Agent Skills: Event Tracking

>-

UncategorizedID: thibautbaissac/rails_ai_agents/event-tracking

Install this agent skill to your local

pnpm dlx add-skill https://github.com/ThibautBaissac/rails_ai_agents/tree/HEAD/.claude_37signals/skills/event-tracking

Skill Files

Browse the full folder contents for event-tracking.

Download Skill

Loading file tree…

.claude_37signals/skills/event-tracking/SKILL.md

Skill Metadata

Name
event-tracking
Description
>-

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:

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

  • 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:

# 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 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