#
Slimmetry-Ruby v2.0 — Wide Events Revamp
Slimmetry-Ruby today emits a firehose: every `trace`d method emits its own event, **and** the Rails subscribers (`EventSubscriber`, `NotificationsSubscriber`) emit a separate event for each AS::Notifications fire — `sql.active_record`, `render_template.action_view`, `cache_read.active_support`, etc. A single web request can produce dozens-to-hundreds of narrow events, all linked only by `_request_id` and `_parent_trace_id`. Server-side queries have to JOIN events together to reconstruct what one request actually did, and there's no single record that says "this request, this controller, ran this SQL, took this long."
The revamp adopts the **wide events** philosophy: one fat self-contained event per unit of work. Specifically:
1.
`web_request` — emitted once per HTTP request, contains everything that happened during it.
2.
`job_perform` — emitted once per ActiveJob run, same idea.
3.
`trace` — emitted once per `trace`d method call, same idea.
AS::Notifications fires no longer emit events of their own. Instead, they **accumulate into the active frame's context** and ship with that frame's wide event when the frame closes.
This is a clean v2.0 break — no compatibility shim. Downstream consumers (`discode2`, `games`) will need to be updated.
###
Core abstraction: Frames
A **frame** is a unit of work that:
-
Opens on entry (HTTP request received, job perform start, traced method called).
-
Has a `trace_id` (UUID) and optional `parent_trace_id`.
-
Accumulates context during its lifetime (timing, notifications, custom fields).
-
Emits exactly one wide event on close.
Frames form a thread-local stack: `Thread.current[:slimmetry_frame_stack]`. The current `Slimmetry::Context` module handles trace_id propagation today; it gets generalized into a frame stack.
When an AS::Notifications event fires, the gem walks **every frame in the active stack** and appends the (filtered) notification to each frame's notification buffer. Each frame's emitted event is fully self-contained — the `web_request` event contains every SQL query that happened under it, even those inside nested traced methods. The same SQL row appears in multiple events (request, trace_A, trace_B). Server-side dedup/reconstruction is possible via `_trace_id` linkage if desired, but each event stands on its own.
Notifications that fire when **no frame is active** are dropped silently. Manual `Slimmetry.frame("name") { ... }` is the escape hatch for Rake tasks, scripts, REPL sessions.
All three event types share the same envelope keys (current `_name`, `_ts`, `_trace_id`, `_parent_trace_id`, `_request_id` convention preserved). Additions:
| Key | Type | Notes |
|---|
`_name` | String | `web_request`, `job_perform`, or `trace` |
`_trace_id` | UUID | Frame ID |
`_parent_trace_id` | UUID? | Enclosing frame's id |
`_started_at` | ISO 8601 | Frame open time |
`_completed_at` | ISO 8601 | Frame close time |
`duration_ms` | Float | `_completed_at - _started_at` |
`notifications` | Array<Hash> | All AS::Notifications captured under this frame (filtered) |
`fields` | Hash | Custom fields added via `Slimmetry.add_field` |
`exception` | Hash? | `{ class:, message:, backtrace: }` if the frame raised |
-
`controller`, `action`, `method`, `path`, `status`, `format`
-
Sourced from `start_processing.action_controller` (open) and `process_action.action_controller` (close).
-
`job_class`, `job_id`, `queue`, `adapter`, `executions`
-
Sourced from `perform_start.active_job` (open) and `perform.active_job` (close).
-
`class`, `method` (e.g. `"#create"` for instance, `".find"` for class)
-
`arguments`, `result` when configured per-method (current `args:`/`result:` knobs preserved).
Each `notifications[]` entry preserves the AS event shape after filtering:
ruby
{ name: "sql.active_record", started_at:, duration_ms:, payload: {...filtered...} }
###
Filter/ignore pipeline — retained, repurposed
The current `EventSubscriber` filter/ignore registry (SQL obfuscation, params sanitization, exclusion of `:request`/`:response`/`:headers`, SolidQueue/SolidCable polling drops, etc.) is **kept verbatim** but its terminal action changes: instead of `Slimmetry.emit(name, payload)`, it appends to the active frames' notification arrays via the new `Slimmetry::Frame::Accumulator`.
Every emitted event (all three types) is merged with a process-wide **global context** hash. This is where service identity, environment, hostname, deployment SHA, etc. live so they don't have to be re-added per frame.
ruby
Slimmetry.configure do |c|
c.global_fields = {
service: "discode2",
environment: Rails.env,
hostname: Socket.gethostname,
deployment_sha: ENV["DEPLOY_SHA"]
}
end
Slimmetry.add_global_field(:deployment_sha, "abc123")
Slimmetry.add_global_field(region: "us-east-1", instance_id: ENV["INSTANCE_ID"])
**Merge order at emit time** (later wins on key collision):
1.
`Slimmetry.configuration.global_fields` (process-wide constants)
2.
Frame envelope keys (`_name`, `_trace_id`, `_started_at`, etc.)
3.
Frame-type keys (`controller`, `action`, `job_class`, ...)
4.
Per-frame custom fields from `Slimmetry.add_field`
So a custom `add_field(:environment, "staging")` overrides a global `environment: Rails.env`. The reserved `_*` keys cannot be overridden.
Implemented in `Slimmetry::Frame#to_event` (single merge point) — backend layer stays oblivious. Values may be procs/lambdas; they're evaluated lazily on emit so things like `-> { Time.current.hour }` work.
ruby
Slimmetry.add_field(:user_id, current_user.id)
Slimmetry.add_field(feature_flag: "experiment_a", tenant: "acme")
Slimmetry.frame("import_users") do
end
Slimmetry.add_global_field(:deployment_sha, "abc123")
Slimmetry.configuration.global_fields
###
Backend interface — unchanged
Backends still implement `emit(event_name, payload)` / `with_context` / `current_context`. They just receive fewer, fatter events. `SlimmetryBackend`'s buffer + flush thread continues to work; we'll bump the default `buffer_size` down (100 → 25) since each event is heavier, and document the tradeoff. `Logger`, `EventReporter`, `Honeybadger`, `Null` need no changes.
-
The `tracer.<class>.<method>` event-name scheme — replaced by flat `trace` name with `class`/`method` in payload.
-
`config.rails_event_subscriber_enabled` and `config.rails_notifications_subscriber_enabled` — subscribers are no longer optional in Rails environments; they only feed frames. We add `config.rails_integration_enabled` (default `true`) as the single switch.
-
The notion that AS notifications can emit standalone events.
-
`lib/slimmetry/frame.rb` — Frame class (open/close/accumulate, trace_id, notification array, custom fields).
-
`lib/slimmetry/frame/stack.rb` — Thread-local frame stack with push/pop/current/each.
-
`lib/slimmetry/frame/accumulator.rb` — Receives filtered notifications, cascades into stack.
-
`lib/slimmetry/plugins/rails/request_frame_subscriber.rb` — Subscribes to `start_processing.action_controller` (open frame) and `process_action.action_controller` (close + emit). Captures controller/action/status/etc.
-
`lib/slimmetry/plugins/rails/job_frame_subscriber.rb` — Same for `perform_start.active_job` / `perform.active_job`.
###
Substantially rewritten
-
`lib/slimmetry.rb` — Add `Slimmetry.add_field`, `Slimmetry.frame`. Wire up frame stack.
-
`lib/slimmetry/context.rb` — Generalize from "parent trace id" to "frame stack head". Keep `with_trace_id` as a thin shim during the rewrite, then remove.
-
`lib/slimmetry/tracer.rb` — `trace_method_execution` (lines 142-187) opens a `trace` frame, runs method, closes frame, emits wide event. Notifications fire into this frame's accumulator via cascade.
-
`lib/slimmetry/plugins/rails/notifications_subscriber.rb` — Becomes a pure feeder for the accumulator. No longer calls `Slimmetry.emit`.
-
`lib/slimmetry/plugins/rails/event_subscriber.rb` — Filter/ignore registry preserved; terminal action becomes `Accumulator.append`.
-
`lib/slimmetry/plugins/rails/railtie.rb` — Replace per-subscriber toggles with `rails_integration_enabled`. Wire new subscribers.
-
`lib/slimmetry/configuration.rb` — Drop `rails_event_subscriber_enabled` / `rails_notifications_subscriber_enabled`; add `rails_integration_enabled`. Add `global_fields` (Hash, default `{}`) and `add_global_field` helper. Add `default_notification_filters` if we want to expose tuning.
-
`lib/slimmetry/backends/slimmetry_backend.rb` — Bump `MAX_BUFFER_SIZE` default downward; keep everything else.
-
`test/frame_test.rb` (new) — frame open/close, stack semantics, cascade behavior.
-
`test/slimmetry_test.rb` — exercise `Slimmetry.add_field` / `Slimmetry.frame`.
-
`test/tracer_test.rb` — assert traces now emit a single `trace` event with `notifications` array.
-
`test/plugins/rails/notifications_subscriber_test.rb` — assert notifications no longer emit, instead populate active frame.
-
`test/plugins/rails/request_frame_subscriber_test.rb` (new) — synthesize controller AS events, assert one `web_request` emission with accumulated notifications.
-
`test/plugins/rails/job_frame_subscriber_test.rb` (new) — same for jobs.
-
`test/support/test_backend.rb` — unchanged.
-
`README.md` — rewrite the "Event Payload" and "Rails event subscription" sections. Add migration note pointing v1 users at the upgrade path.
-
`CHANGELOG.md` — v2.0.0 entry: "Wide events redesign — see README for migration."
-
`lib/slimmetry/version.rb` — bump to `2.0.0`.
1.
**Frame infrastructure** (`Frame`, `Frame::Stack`, `Frame::Accumulator`) + tests with synthetic notifications, no Rails dependency. Land this first so the rest can build on it.
2.
**Tracer rewrite** — convert `trace_method_execution` to open/close a frame. Confirm existing tracer tests pass (with payload-shape updates).
3.
**Global context + `Slimmetry.add_field` + `Slimmetry.frame`** — `Configuration#global_fields`, `Slimmetry.add_global_field`, frame-level field accumulator, and the `Frame#to_event` merge. Lazy proc evaluation for global values.
4.
**`NotificationsSubscriber` retarget** — feed the accumulator instead of emitting. Confirm filter/ignore pipeline still scrubs SQL, params, etc.
5.
**`RequestFrameSubscriber`** — handle controller open/close + emit.
6.
**`JobFrameSubscriber`** — handle job open/close + emit.
7.
**Configuration cleanup** — drop old toggles, add `rails_integration_enabled`.
8.
**README + CHANGELOG + version bump.**
Each step can ship as its own commit; the gem is usable after step 2 even if Rails integration is mid-rewrite.
End-to-end checks before tagging v2.0:
1.
**Unit tests**: `rake test` — all green. New frame/accumulator tests included.
2.
**Rubocop**: `bundle exec rubocop` clean.
3.
**Trace-only smoke test** (no Rails):
ruby
class Foo; extend Slimmetry; def bar; end; trace :bar; end
Slimmetry.configure do |c|
c.backend = Slimmetry::Backends::Logger.new(Logger.new($stdout))
c.global_fields = { service: "test", env: "dev" }
end
Slimmetry.add_field(:request_path, "/foo")
Foo.new.bar
4.
**Rails request smoke test**: install the gem in `slimmetry` (server) or `games`, hit a real endpoint, confirm one `web_request` event hits `POST /api/v1/events` carrying the full SQL/render array in `notifications[]`. Use `bin/rails server` in the Rails project's Docker container.
5.
**Nested cascade**: in the same Rails app, trace a service-object method that runs SQL. Confirm three resulting events (`web_request`, `trace`, plus any others) and that the SQL row appears in both the `web_request` and the nested `trace` event.
6.
**Job smoke test**: enqueue and run an ActiveJob (Solid Queue). Confirm one `job_perform` event with the job's notifications.
7.
**Out-of-frame drop**: from `rails runner`, run code that triggers a SQL query without wrapping in `Slimmetry.frame`. Confirm `TestBackend` (or Logger) receives zero events.
8.
**Manual frame**: wrap the same `runner` snippet in `Slimmetry.frame("rake")` and confirm one event emitted with the SQL captured.
9.
**Global context + override**: configure `global_fields = { environment: "prod" }`, then in a frame call `Slimmetry.add_field(:environment, "test")`. Confirm the emitted event has `environment: "test"` (frame wins). Confirm a sibling frame without the override has `environment: "prod"`.
##
Open questions deferred to implementation
-
**Notification array hard cap**: do we cap `notifications[]` at e.g. 5000 entries to bound payload size? Default to uncapped, add `config.max_notifications_per_frame = nil`. Decide during step 1.
-
**Async ActiveJob over multiple threads**: confirm Solid Queue's worker model — each perform happens on a dedicated thread, so thread-local frame stack is correct. Validate during step 6.
-
**Exception path**: ensure frame closes and emits even when the wrapped method/controller/job raises. `ensure` block around frame body. Covered in step 2.