Module: TrackRelay::Instrumenter
- Defined in:
- lib/track_relay/instrumenter.rb
Overview
Central orchestrator for track and identify.
track is the integration point of:
- {RESERVED_KEYS} extraction (split-routed: some keys land on
{Current}, `:visitor_token` lands directly in `payload.context`)
- {Catalog} lookup (typed vs untyped path)
- {EventPayload} construction + {EventPayload#validate!}
- context snapshot (read at `track` time so async delivery in
Plan 05 has the data after `Current` is reset)
- `ActiveSupport::Notifications.instrument("track_relay.event", ...)`
All four steps happen on the calling thread before any subscriber runs, so reserved-key partitioning and validation are deterministic from the host application's perspective.
Reserved-key split rationale (per Plan 01-04 must_have):
- `:user`, `:request`, `:client_id` are {Current} attributes —
bound via `Current.set(...) { ... }` for the duration of the
instrumentation block.
- `:visitor_token` is intentionally NOT a {Current} attribute.
{Current} carries `:visit` (an Ahoy-style record), not a raw
opaque token. The token is merged directly into
`payload.context[:visitor_token]` so subscribers (and the
downstream DeliveryJob in Plan 05) can read it without
touching {Current}.
identify is a thin pass-through in Phase 01: it instruments
track_relay.identify with {user:, properties:} and performs
no catalog validation against user_property declarations.
Adapter-specific user-property handling is deferred to Phase 02.
Constant Summary collapse
- NOTIFICATION =
AS::Notifications event name for typed/untyped event tracking.
"track_relay.event"- IDENTIFY_NOTIFICATION =
AS::Notifications event name for identify calls.
"track_relay.identify"- CURRENT_ATTR_KEYS =
Reserved keys that must be partitioned onto Current (block-scoped via
Current.set). See DIRECT_CONTEXT_KEYS for keys that bypass Current and land directly onpayload.context. %i[user request client_id].freeze
- DIRECT_CONTEXT_KEYS =
Reserved keys that bypass Current entirely and are merged directly into
payload.contextat build time. %i[visitor_token].freeze
Class Method Summary collapse
-
.build_payload(name:, definition:, params:, extra_context: {}) ⇒ EventPayload
Build either a typed or untyped EventPayload, merging
extra_context(e.g.:visitor_token) into the snapshot of Current taken at instrument time. -
.current_context ⇒ Hash
Snapshot Current at instrument time.
-
.identify(user, **user_properties) ⇒ void
Identify a user — Phase 01 pass-through.
-
.partition_reserved(params) ⇒ Array(Hash, Hash, Hash)
Split params into three buckets:.
-
.track(name, **params) ⇒ void
Track a typed (catalog-defined) or untyped event.
-
.track_gate_allows?(payload) ⇒ Boolean
Evaluate Configuration#track_gate once per track call, inside the
Current.setscope so the gate sees the same request the subscribers would. -
.validate(payload) ⇒ Object?
Run EventPayload#validate! and apply the Configuration#raise_on_validation_error gate.
-
.with_current_attrs(current_attrs) { ... } ⇒ Object
Bind
current_attrson Current for the duration ofblock.
Class Method Details
.build_payload(name:, definition:, params:, extra_context: {}) ⇒ EventPayload
Build either a typed or untyped EventPayload, merging
extra_context (e.g. :visitor_token) into the snapshot of
Current taken at instrument time.
Untyped path is gated by Configuration#untyped_events_allowed — when disallowed, UnknownEventError is raised.
174 175 176 177 178 179 180 181 182 183 184 185 |
# File 'lib/track_relay/instrumenter.rb', line 174 def build_payload(name:, definition:, params:, extra_context: {}) context = current_context.merge(extra_context) if definition EventPayload.new(definition: definition, params: params, context: context) elsif TrackRelay.config.untyped_events_allowed EventPayload.untyped(name: name, params: params, context: context) else raise UnknownEventError, "Unknown event #{name.inspect}; declare it in your catalog or set config.untyped_events_allowed = true" end end |
.current_context ⇒ Hash
Snapshot Current at instrument time. Plan 05's DeliveryJob
depends on this contract: by the time the job runs, the Rails
Executor has already cleared Current, so async subscribers
must read from payload.context, not from Current directly.
Keys snapshot:
- `:user` — Current.user (any object)
- `:controller` — Current.controller&.class&.name (String)
- `:action` — Current.controller&.action_name (String)
- `:client_id` — Current.client_id (String)
- `:visit` — Current.visit (Ahoy-style record or nil)
- `:request_id` — Current.request&.request_id (String)
:controller and :action are required by Plan 05's Logger
JSONL shape.
205 206 207 208 209 210 211 212 213 214 215 216 217 |
# File 'lib/track_relay/instrumenter.rb', line 205 def current_context controller = Current.controller action = controller.respond_to?(:action_name) ? controller.action_name : nil { user: Current.user, controller: controller&.class&.name, action: action, client_id: Current.client_id, visit: Current.visit, request_id: Current.request&.request_id } end |
.identify(user, **user_properties) ⇒ void
This method returns an undefined value.
Identify a user — Phase 01 pass-through.
Instruments track_relay.identify with {user:, properties:}.
No catalog validation happens here; adapter-specific user_property
validation is deferred to Phase 02 where each subscriber decides
how to handle properties (GA4 user_properties, Ahoy User update,
etc.).
TODO(phase-02): wire Catalog.user_properties validation here so
consumers can declare user_property schemas and have them enforced
at identify time.
105 106 107 108 109 110 111 |
# File 'lib/track_relay/instrumenter.rb', line 105 def identify(user, **user_properties) ActiveSupport::Notifications.instrument( IDENTIFY_NOTIFICATION, user: user, properties: user_properties ) end |
.partition_reserved(params) ⇒ Array(Hash, Hash, Hash)
Split params into three buckets:
- `current_attrs` — keys that {Current.set} accepts
(`:user`, `:request`, `:client_id`)
- `direct_context` — keys that bypass {Current} and land
directly on `payload.context` (`:visitor_token`)
- `event_params` — everything else (validated against catalog)
141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 |
# File 'lib/track_relay/instrumenter.rb', line 141 def partition_reserved(params) current_attrs = {} direct_context = {} event_params = {} params.each do |key, value| if CURRENT_ATTR_KEYS.include?(key) current_attrs[key] = value elsif DIRECT_CONTEXT_KEYS.include?(key) direct_context[key] = value else event_params[key] = value end end [current_attrs, direct_context, event_params] end |
.track(name, **params) ⇒ void
This method returns an undefined value.
Track a typed (catalog-defined) or untyped event.
Reserved keys are extracted from params BEFORE catalog lookup so
they never appear in payload.params. Validation gating respects
Configuration#raise_on_validation_error (true re-raises, false
logs and swallows without instrumenting).
74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 |
# File 'lib/track_relay/instrumenter.rb', line 74 def track(name, **params) current_attrs, direct_context, event_params = partition_reserved(params) with_current_attrs(current_attrs) do definition = Catalog.lookup(name) payload = build_payload( name: name, definition: definition, params: event_params, extra_context: direct_context ) return unless validate(payload) return unless track_gate_allows?(payload) ActiveSupport::Notifications.instrument(NOTIFICATION, event: payload) end end |
.track_gate_allows?(payload) ⇒ Boolean
Evaluate Configuration#track_gate once per track call, inside
the Current.set scope so the gate sees the same request the
subscribers would. Runs AFTER validation and BEFORE the
notification fans out — a falsy return means no subscriber ever
sees the event. Unset gate (nil) always allows.
227 228 229 230 231 232 |
# File 'lib/track_relay/instrumenter.rb', line 227 def track_gate_allows?(payload) gate = TrackRelay.config.track_gate return true if gate.nil? !!gate.call(payload: payload, request: Current.request) end |
.validate(payload) ⇒ Object?
Run EventPayload#validate! and apply the Configuration#raise_on_validation_error gate.
Returns truthy when the caller should proceed to instrument and
nil when validation failed and was swallowed (no instrument).
244 245 246 247 248 249 250 251 252 253 254 255 |
# File 'lib/track_relay/instrumenter.rb', line 244 def validate(payload) payload.validate! payload rescue ValidationError => e raise if TrackRelay.config.raise_on_validation_error if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger Rails.logger.error("[track_relay] validation failed for #{payload.name.inspect}: #{e.}") end nil end |
.with_current_attrs(current_attrs) { ... } ⇒ Object
Bind current_attrs on Current for the duration of block.
When the hash is empty, Current.set(**{}) would raise
ArgumentError: wrong number of arguments (given 0, expected 1)
under ActiveSupport 8.x. Skipping the wrapper in that case
preserves the no-reserved-keys path (most calls).
123 124 125 126 127 128 129 |
# File 'lib/track_relay/instrumenter.rb', line 123 def with_current_attrs(current_attrs, &block) if current_attrs.empty? block.call else Current.set(**current_attrs, &block) end end |