# Event Tracking

> Builds event tracking, activity feeds, and webhook systems following 37signals patterns with a generic Event model and Eventable concern. Use when implementing audit trails, activity feeds, event recording, webhooks, or when user mentions events, tracking, webhooks, or activity logs. WHEN NOT: For state changes as records (use state-records), for background job patterns (use job-patterns), for mailer delivery (use mailer-patterns).

- Skill: `thibautbaissac/event-tracking` (Agent Skill, multi-file: 4 files)
- Install (CLI): `npx skillmds@latest add thibautbaissac/event-tracking`
- Raw SKILL.md: https://api.skillmd.com/api/skills/thibautbaissac/event-tracking/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: AI & ML
- License: MIT
- Author: thibautbaissac (https://skillmd.com/u/thibautbaissac)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/thibautbaissac/event-tracking

---


# Event Tracking

## Philosophy: Generic Event Model + Eventable Concern

- One `Event` model with `action` string and `eventable` polymorphic association
- An `Eventable` concern mixed into models that need tracking
- Models call `track_event("closed", particulars: {...})` in their domain methods
- `particulars` JSON 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:**
```bash
# 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:

```ruby
# 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:
- `action` is a plain string like `"card_closed"`, `"comment_created"`,
  `"card_triaged"`. Calling `.inquiry` lets you do `event.action.card_closed?`.
- `eventable` points to the model that triggered the event (Card, Comment, etc.).
- `particulars` is a JSON column for action-specific metadata (old title, new
  board name, assignee IDs, column name, etc.).
- `after_create` (not `after_create_commit`) calls back into the eventable so
  it can create system comments or touch timestamps within the same transaction.
- `after_create_commit` dispatches 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:

```ruby
# 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:

```ruby
# 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:

```ruby
# 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:

```ruby
# 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:

```ruby
# 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`:

```erb
<%# 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:

```ruby
# 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.

```ruby
# 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:

```ruby
# 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:

```ruby
# 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 `Event` model with `action` string and `eventable` polymorphic
- Create an `Eventable` concern -- models call `track_event` in domain methods
- Store action-specific data in `particulars` JSON 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_commit` for 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`, `CommentAdded` models)
- 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

