Event Tracking
Philosophy: Generic Event Model + Eventable Concern
- One
Eventmodel withactionstring andeventablepolymorphic association - An
Eventableconcern mixed into models that need tracking - Models call
track_event("closed", particulars: {...})in their domain methods particularsJSON field stores action-specific metadata- Events drive activity feeds, notifications, and webhook deliveries
- Everything is database-backed (Solid Queue for webhooks, no Redis/Kafka)
Project Knowledge
Stack: Solid Queue for background jobs, Turbo Streams for real-time activity feed updates, UUIDs for all primary keys, MySQL (SaaS) / SQLite (OSS).
Multi-tenancy: All events scoped to account via account_id. Events also
scoped to board via board_id.
Commands:
# Generate Event model
rails generate model Event action:string eventable:references{polymorphic} \
board:references creator:references account:references particulars:json
# Generate Webhook models
rails generate model Webhook url:text name:string board:references \
account:references subscribed_actions:text signing_secret:string active:boolean
rails generate model Webhook::Delivery webhook:references event:references \
account:references state:string request:text response:text
rails generate model Webhook::DelinquencyTracker webhook:references \
account:references consecutive_failures_count:integer first_failure_at:datetime
Pattern 1: Event Model
See @references/domain-events.md for full implementation details.
A single generic Event model records all business events:
# app/models/event.rb
class Event < ApplicationRecord
include Notifiable, Particulars
belongs_to :account, default: -> { board.account }
belongs_to :board
belongs_to :creator, class_name: "User"
belongs_to :eventable, polymorphic: true
has_many :webhook_deliveries, class_name: "Webhook::Delivery", dependent: :delete_all
scope :chronologically, -> { order created_at: :asc, id: :desc }
scope :preloaded, -> {
includes(:creator, :board, {
eventable: [
:closure, :image_attachment,
{ rich_text_body: :embeds_attachments },
{ card: [ :closure, :image_attachment ] }
]
})
}
after_create -> { eventable.event_was_created(self) }
after_create_commit :dispatch_webhooks
delegate :card, to: :eventable
def action
super.inquiry
end
def description_for(user)
Event::Description.new(self, user)
end
private
def dispatch_webhooks
Event::WebhookDispatchJob.perform_later(self)
end
end
Key design decisions:
actionis a plain string like"card_closed","comment_created","card_triaged". Calling.inquirylets you doevent.action.card_closed?.eventablepoints to the model that triggered the event (Card, Comment, etc.).particularsis a JSON column for action-specific metadata (old title, new board name, assignee IDs, column name, etc.).after_create(notafter_create_commit) calls back into the eventable so it can create system comments or touch timestamps within the same transaction.after_create_commitdispatches webhooks asynchronously.
Pattern 2: Eventable Concern
See @references/domain-events.md for the full concern hierarchy.
The base Eventable concern provides track_event to any model:
# app/models/concerns/eventable.rb
module Eventable
extend ActiveSupport::Concern
included do
has_many :events, as: :eventable, dependent: :destroy
end
def track_event(action, creator: Current.user, board: self.board, **particulars)
if should_track_event?
board.events.create!(
action: "#{eventable_prefix}_#{action}",
creator:, board:, eventable: self, particulars:
)
end
end
def event_was_created(event)
end
private
def should_track_event?
true
end
def eventable_prefix
self.class.name.demodulize.underscore
end
end
Models override Eventable with model-specific concerns that customize behavior:
# app/models/card/eventable.rb
module Card::Eventable
extend ActiveSupport::Concern
include ::Eventable
included do
after_save :track_title_change, if: :saved_change_to_title?
end
def event_was_created(event)
transaction do
create_system_comment_for(event)
touch_last_active_at
end
end
private
def should_track_event?
published?
end
def track_title_change
if title_before_last_save.present?
track_event "title_changed",
particulars: { old_title: title_before_last_save, new_title: title }
end
end
end
Usage in domain methods -- models call track_event directly:
# app/models/card/closeable.rb
def close(user: Current.user)
unless closed?
transaction do
create_closure! user: user
track_event :closed, creator: user
end
end
end
def reopen(user: Current.user)
if closed?
transaction do
closure&.destroy
track_event :reopened, creator: user
end
end
end
# app/models/card/assignable.rb
def assign(user)
assignment = assignments.create assignee: user, assigner: Current.user
if assignment.persisted?
track_event :assigned, assignee_ids: [ user.id ]
end
end
# app/models/card/triageable.rb
def triage_into(column)
transaction do
update! column: column
track_event "triaged", particulars: { column: column.name }
end
end
# app/models/card/postponable.rb
def postpone(user: Current.user, event_name: :postponed)
transaction do
create_not_now!(user: user) unless postponed?
track_event event_name, creator: user
end
end
Pattern 3: Particulars (Event Metadata)
The particulars JSON column stores action-specific data. Use store_accessor
to provide typed access to common fields:
# app/models/event/particulars.rb
module Event::Particulars
extend ActiveSupport::Concern
included do
store_accessor :particulars, :assignee_ids
end
def assignees
@assignees ||= User.where id: assignee_ids
end
end
Examples of particulars stored per action:
| Action | Particulars |
|--------|-------------|
| card_title_changed | { old_title: "...", new_title: "..." } |
| card_board_changed | { old_board: "...", new_board: "..." } |
| card_assigned | { assignee_ids: [uuid] } |
| card_unassigned | { assignee_ids: [uuid] } |
| card_triaged | { column: "In Progress" } |
Pattern 4: Activity Feed
See @references/activity-feeds.md for view templates and pagination.
Events ARE the activity feed. No separate Activity model needed:
# app/controllers/events_controller.rb
class EventsController < ApplicationController
def index
@events = Current.account.boards
.accessible_by(Current.user)
.events.preloaded.chronologically
end
end
Events are rendered via partials that dispatch on action or eventable_type:
<%# app/views/events/_event.html.erb %>
<% cache event do %>
<% if lookup_context.exists?("events/event/eventable/_#{event.action}") %>
<%= render "events/event/eventable/#{event.action}", event: event %>
<% else %>
<%= render "events/event/eventable/#{event.eventable_type.demodulize.underscore}",
event: event %>
<% end %>
<% end %>
Event descriptions are handled by a dedicated class:
# app/models/event/description.rb
class Event::Description
def initialize(event, user)
@event = event
@user = user
end
def to_html
# Renders "You closed \"Card title\"" or "Alice closed \"Card title\""
# depending on whether user == creator
end
def to_plain_text
# Plain text version for webhooks, emails, etc.
end
end
Pattern 5: Webhook System
See @references/webhooks.md for full implementation details.
# app/models/webhook.rb
class Webhook < ApplicationRecord
include Triggerable
PERMITTED_ACTIONS = %w[
card_assigned card_closed card_postponed card_published
card_reopened card_triaged card_unassigned comment_created
].freeze
has_secure_token :signing_secret
has_many :deliveries, dependent: :delete_all
has_one :delinquency_tracker, dependent: :delete
belongs_to :account, default: -> { board.account }
belongs_to :board
serialize :subscribed_actions, type: Array, coder: JSON
scope :active, -> { where(active: true) }
normalizes :subscribed_actions,
with: ->(value) { Array.wrap(value).map(&:to_s).uniq & PERMITTED_ACTIONS }
validates :name, presence: true
validate :validate_url
def activate
update! active: true unless active?
end
def deactivate
update! active: false
end
end
# app/models/webhook/triggerable.rb
module Webhook::Triggerable
extend ActiveSupport::Concern
included do
scope :triggered_by, ->(event) {
where(board: event.board).triggered_by_action(event.action)
}
scope :triggered_by_action, ->(action) {
where("subscribed_actions LIKE ?", "%\"#{action}\"%")
}
end
def trigger(event)
deliveries.create!(event: event) unless account.cancelled?
end
end
Webhook dispatch uses ActiveJob::Continuable for resumable processing:
# app/jobs/event/webhook_dispatch_job.rb
class Event::WebhookDispatchJob < ApplicationJob
include ActiveJob::Continuable
queue_as :webhooks
discard_on ActiveJob::DeserializationError
def perform(event)
step :dispatch do |step|
Webhook.active.triggered_by(event).find_each(start: step.cursor) do |webhook|
webhook.trigger(event)
step.advance! from: webhook.id
end
end
end
end
Deliveries handle SSRF protection, format detection, and delinquency tracking:
# app/models/webhook/delivery.rb
class Webhook::Delivery < ApplicationRecord
belongs_to :webhook
belongs_to :event
belongs_to :account, default: -> { webhook.account }
store :request, coder: JSON
store :response, coder: JSON
enum :state, %w[ pending in_progress completed errored ].index_by(&:itself),
default: :pending
after_create_commit :deliver_later
def deliver
in_progress!
self.request[:headers] = headers
self.response = perform_request
self.state = :completed
save!
webhook.delinquency_tracker.record_delivery_of(self)
rescue
errored!
raise
end
private
def headers
{ "User-Agent" => "app/1.0.0 Webhook",
"Content-Type" => content_type,
"X-Webhook-Signature" => signature,
"X-Webhook-Timestamp" => event.created_at.utc.iso8601 }
end
def signature
OpenSSL::HMAC.hexdigest("SHA256", webhook.signing_secret, payload)
end
end
# app/models/webhook/delinquency_tracker.rb
class Webhook::DelinquencyTracker < ApplicationRecord
DELINQUENCY_THRESHOLD = 10
DELINQUENCY_DURATION = 1.hour
belongs_to :webhook
def record_delivery_of(delivery)
if delivery.succeeded?
reset
else
mark_first_failure_time if consecutive_failures_count.zero?
increment!(:consecutive_failures_count, touch: true)
webhook.deactivate if delinquent?
end
end
private
def delinquent?
failing_for_too_long? && too_many_consecutive_failures?
end
def failing_for_too_long?
first_failure_at&.before?(DELINQUENCY_DURATION.ago)
end
def too_many_consecutive_failures?
consecutive_failures_count >= DELINQUENCY_THRESHOLD
end
end
Boundaries
Always
- Use a single generic
Eventmodel withactionstring andeventablepolymorphic - Create an
Eventableconcern -- models calltrack_eventin domain methods - Store action-specific data in
particularsJSON column - Dispatch webhooks via background jobs (Solid Queue)
- Include HMAC signature in webhook headers (
X-Webhook-Signature) - Scope events to account and board
- Use
after_create_commitfor webhook dispatch (async after transaction) - Use
after_create(sync) for side effects that need the transaction (system comments)
Ask First
- Which business events/actions to track
- Webhook retry strategy and delinquency threshold
- Activity feed pagination and filtering requirements
- Whether events should create system comments on the eventable
- SSRF protection requirements for webhook delivery
Never
- Create separate model classes per event type (no
CardMoved,CommentAddedmodels) - Use external event bus (Kafka, RabbitMQ)
- Track boolean flags instead of event records
- Deliver webhooks synchronously
- Skip SSRF protection on webhook URLs
- Store events without account + board scoping