Rails_ai_agents event-tracking
git clone https://github.com/ThibautBaissac/rails_ai_agents
T=$(mktemp -d) && git clone --depth=1 https://github.com/ThibautBaissac/rails_ai_agents "$T" && mkdir -p ~/.claude/skills && cp -r "$T/.claude_37signals/skills/event-tracking" ~/.claude/skills/thibautbaissac-rails-ai-agents-event-tracking && rm -rf "$T"
.claude_37signals/skills/event-tracking/SKILL.mdEvent Tracking
Philosophy: Generic Event Model + Eventable Concern
- One
model withEvent
string andaction
polymorphic associationeventable - An
concern mixed into models that need trackingEventable - Models call
in their domain methodstrack_event("closed", particulars: {...})
JSON field stores action-specific metadataparticulars- 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:
is a plain string likeaction
,"card_closed"
,"comment_created"
. Calling"card_triaged"
lets you do.inquiry
.event.action.card_closed?
points to the model that triggered the event (Card, Comment, etc.).eventable
is a JSON column for action-specific metadata (old title, new board name, assignee IDs, column name, etc.).particulars
(notafter_create
) calls back into the eventable so it can create system comments or touch timestamps within the same transaction.after_create_commit
dispatches webhooks asynchronously.after_create_commit
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 |
|---|---|
| |
| |
| |
| |
| |
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
model withEvent
string andaction
polymorphiceventable - Create an
concern -- models callEventable
in domain methodstrack_event - Store action-specific data in
JSON columnparticulars - Dispatch webhooks via background jobs (Solid Queue)
- Include HMAC signature in webhook headers (
)X-Webhook-Signature - Scope events to account and board
- Use
for webhook dispatch (async after transaction)after_create_commit - Use
(sync) for side effects that need the transaction (system comments)after_create
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
models)CommentAdded - 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