track_relay 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +147 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +458 -0
  5. data/UPGRADING.md +85 -0
  6. data/USAGE.md +192 -0
  7. data/lib/generators/track_relay/event/event_generator.rb +17 -0
  8. data/lib/generators/track_relay/event/templates/event.rb.tt +21 -0
  9. data/lib/generators/track_relay/install/install_generator.rb +49 -0
  10. data/lib/generators/track_relay/install/templates/application_subscriber.rb.tt +31 -0
  11. data/lib/generators/track_relay/install/templates/initializer.rb.tt +42 -0
  12. data/lib/generators/track_relay/install/templates/sample_catalog.rb.tt +17 -0
  13. data/lib/generators/track_relay/subscriber/subscriber_generator.rb +17 -0
  14. data/lib/generators/track_relay/subscriber/templates/subscriber.rb.tt +28 -0
  15. data/lib/tasks/track_relay.rake +80 -0
  16. data/lib/track_relay/catalog.rb +86 -0
  17. data/lib/track_relay/client_id/ahoy_visitor.rb +34 -0
  18. data/lib/track_relay/client_id/ga.rb +48 -0
  19. data/lib/track_relay/client_id/session.rb +46 -0
  20. data/lib/track_relay/configuration.rb +141 -0
  21. data/lib/track_relay/controller_tracking.rb +90 -0
  22. data/lib/track_relay/current.rb +33 -0
  23. data/lib/track_relay/delivery_job.rb +84 -0
  24. data/lib/track_relay/dispatcher.rb +92 -0
  25. data/lib/track_relay/dsl/event_builder.rb +64 -0
  26. data/lib/track_relay/dsl/param_builder.rb +74 -0
  27. data/lib/track_relay/errors.rb +54 -0
  28. data/lib/track_relay/event_definition.rb +74 -0
  29. data/lib/track_relay/event_payload.rb +244 -0
  30. data/lib/track_relay/instrumenter.rb +241 -0
  31. data/lib/track_relay/job_tracking.rb +50 -0
  32. data/lib/track_relay/linter.rb +218 -0
  33. data/lib/track_relay/manifest.rb +85 -0
  34. data/lib/track_relay/railtie.rb +97 -0
  35. data/lib/track_relay/subscribers/ahoy.rb +110 -0
  36. data/lib/track_relay/subscribers/base.rb +231 -0
  37. data/lib/track_relay/subscribers/ga4_measurement_protocol.rb +250 -0
  38. data/lib/track_relay/subscribers/logger.rb +79 -0
  39. data/lib/track_relay/subscribers/test.rb +60 -0
  40. data/lib/track_relay/testing/helpers.rb +44 -0
  41. data/lib/track_relay/testing/minitest_assertions.rb +71 -0
  42. data/lib/track_relay/testing/rspec_matchers.rb +79 -0
  43. data/lib/track_relay/testing.rb +90 -0
  44. data/lib/track_relay/validators/catalog_validator.rb +48 -0
  45. data/lib/track_relay/validators/ga4_constraints.rb +85 -0
  46. data/lib/track_relay/version.rb +5 -0
  47. data/lib/track_relay.rb +203 -0
  48. metadata +248 -0
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "track_relay/event_definition"
4
+
5
+ module TrackRelay
6
+ module DSL
7
+ # The DSL receiver for the body of `event :name do ... end`.
8
+ #
9
+ # Each type method (`integer`, `string`, `float`, `boolean`,
10
+ # `datetime`) records a {EventDefinition::ParamSchema} keyed by param
11
+ # name. `user_property` accumulates user-property declarations
12
+ # separately so they can be passed through to the resulting
13
+ # EventDefinition without polluting `params`.
14
+ #
15
+ # ParamBuilder does NOT validate or register anything itself — that
16
+ # is {EventBuilder}'s job. Keeping the builder side-effect-free makes
17
+ # it trivial to test and lets EventBuilder run validation against the
18
+ # complete definition (so e.g. param-count overflow is reported with
19
+ # the full param set).
20
+ class ParamBuilder
21
+ attr_reader :params, :user_properties
22
+
23
+ # @param event_name [Symbol] the name of the surrounding event;
24
+ # stored for diagnostic context only
25
+ def initialize(event_name)
26
+ @event_name = event_name
27
+ @params = {}
28
+ @user_properties = {}
29
+ end
30
+
31
+ # @param name [Symbol] param name
32
+ # @param opts [Hash] optional ParamSchema slots (required, max, in,
33
+ # format, sanitize)
34
+ # @return [EventDefinition::ParamSchema]
35
+ def integer(name, **opts)
36
+ record_param(name, :integer, opts)
37
+ end
38
+
39
+ def string(name, **opts)
40
+ record_param(name, :string, opts)
41
+ end
42
+
43
+ def float(name, **opts)
44
+ record_param(name, :float, opts)
45
+ end
46
+
47
+ def boolean(name, **opts)
48
+ record_param(name, :boolean, opts)
49
+ end
50
+
51
+ def datetime(name, **opts)
52
+ record_param(name, :datetime, opts)
53
+ end
54
+
55
+ # Declare a user property on the surrounding event. Accumulated
56
+ # separately from regular params so EventDefinition can keep them
57
+ # in `user_properties`.
58
+ #
59
+ # @param name [Symbol]
60
+ # @param type [Symbol] one of the type symbols (`:string`,
61
+ # `:integer`, etc.)
62
+ def user_property(name, type)
63
+ @user_properties[name] = type
64
+ end
65
+
66
+ private
67
+
68
+ def record_param(name, type, opts)
69
+ schema = EventDefinition::ParamSchema.new(name: name, type: type, **opts)
70
+ @params[name] = schema
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TrackRelay
4
+ # Base class for every error track_relay raises.
5
+ #
6
+ # Consumers can rescue `TrackRelay::Error` to catch any failure originating
7
+ # from the gem (validation, catalog load-time checks, GA4 constraint
8
+ # violations, unknown events, etc.) without depending on individual
9
+ # subclasses.
10
+ class Error < StandardError; end
11
+
12
+ # Raised at catalog-load time when an event declares a param whose name
13
+ # collides with one of the reserved context keys (see {RESERVED_KEYS}).
14
+ class ReservedKeyError < Error; end
15
+
16
+ # Raised at catalog-load time when an event or param violates a Google
17
+ # Analytics 4 constraint (reserved event name, illegal characters,
18
+ # length, or > 25 custom params per event).
19
+ class Ga4ConstraintError < Error; end
20
+
21
+ # Raised at track time when an EventPayload's params do not satisfy the
22
+ # corresponding EventDefinition (missing required key, failed coercion,
23
+ # max-length overflow, inclusion-list miss, format mismatch, or undeclared
24
+ # extra param).
25
+ class ValidationError < Error; end
26
+
27
+ # Raised at catalog-load time on registry-level problems such as
28
+ # double-registering the same event name.
29
+ class CatalogError < Error; end
30
+
31
+ # Raised when callers attempt to track an event that is not present in
32
+ # the catalog and untyped events are disabled.
33
+ class UnknownEventError < Error; end
34
+
35
+ # Raised by a subscriber's `#deliver` to signal a *transient* failure
36
+ # that the {DeliveryJob} should retry via ActiveJob's `retry_on`.
37
+ # Examples: HTTP 5xx response, `Net::OpenTimeout`, `Errno::ECONNREFUSED`,
38
+ # `SocketError` against the GA4 Measurement Protocol endpoint.
39
+ #
40
+ # Inherits from `StandardError` (not {Error}) so the
41
+ # {Subscribers::Base#safe_deliver} carve-out can re-raise it without
42
+ # dragging in unrelated track_relay error semantics — and so consumers
43
+ # who rescue `TrackRelay::Error` to log validation failures do not
44
+ # accidentally swallow a retriable network blip.
45
+ class DeliveryRetriableError < StandardError; end
46
+
47
+ # Raised by a subscriber's `#deliver` to signal a *permanent* failure
48
+ # that the {DeliveryJob} should drop via ActiveJob's `discard_on`
49
+ # (HTTP 4xx, malformed credentials, etc.). Defensive: GA4 returns 2xx
50
+ # in practice even on bad payloads, but we map 4xx defensively in case
51
+ # Google ever changes that contract. Same `StandardError` rationale as
52
+ # {DeliveryRetriableError}.
53
+ class DeliveryDiscardableError < StandardError; end
54
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TrackRelay
4
+ # Catalog metadata for a single event.
5
+ #
6
+ # An EventDefinition is the immutable description of an event as
7
+ # declared in `TrackRelay.catalog do ... end`. It holds the event name,
8
+ # the schema of every declared param (a Hash of {ParamSchema} keyed by
9
+ # param name), and any user_property declarations.
10
+ #
11
+ # EventDefinition is paired with {TrackRelay::EventPayload} at runtime:
12
+ # the definition is the schema, the payload is the data, and
13
+ # `payload.validate!` checks the data against the schema.
14
+ #
15
+ # Instances are deep-frozen at construction so the catalog cannot be
16
+ # mutated after load. Re-defining an event requires {Catalog#clear!}.
17
+ #
18
+ # @example
19
+ # schema = TrackRelay::EventDefinition::ParamSchema.new(
20
+ # name: :article_id, type: :integer, required: true
21
+ # )
22
+ # definition = TrackRelay::EventDefinition.new(
23
+ # name: :article_viewed,
24
+ # params: {article_id: schema}
25
+ # )
26
+ # definition.params[:article_id].type # => :integer
27
+ class EventDefinition
28
+ # The schema of a single param within an event definition.
29
+ #
30
+ # `name` is the param's Symbol key. `type` is one of `:integer`,
31
+ # `:string`, `:float`, `:boolean`, `:datetime`. The remaining slots
32
+ # are optional validator hooks consumed by {EventPayload#validate!}:
33
+ #
34
+ # - `required` (Boolean) — when true, missing values raise
35
+ # {ValidationError}.
36
+ # - `max` (Integer) — for strings, max length; for numbers, max
37
+ # value. Overflows raise (no silent truncation).
38
+ # - `in` (Array) — inclusion list; values not in the list raise.
39
+ # - `format` (Regexp) — applied to coerced strings; mismatches raise.
40
+ # - `sanitize` (Callable) — applied to the raw value BEFORE coercion
41
+ # and validation, so sanitization can preempt max/format checks.
42
+ #
43
+ # `Data.define` (Ruby 3.2+) gives us a frozen value object with a
44
+ # keyword constructor, equality by value, and zero ceremony.
45
+ ParamSchema = Data.define(
46
+ :name,
47
+ :type,
48
+ :required,
49
+ :max,
50
+ :in,
51
+ :format,
52
+ :sanitize
53
+ ) do
54
+ # Override `initialize` to default the optional slots so callers
55
+ # only need to pass `name:` and `type:`.
56
+ def initialize(name:, type:, required: false, max: nil, in: nil, format: nil, sanitize: nil)
57
+ super
58
+ end
59
+ end
60
+
61
+ attr_reader :name, :params, :user_properties
62
+
63
+ # @param name [Symbol] event name (e.g. `:article_viewed`)
64
+ # @param params [Hash{Symbol => ParamSchema}] param schemas keyed by name
65
+ # @param user_properties [Hash{Symbol => Symbol}] user-property names
66
+ # mapped to their type (e.g. `{plan: :string}`)
67
+ def initialize(name:, params: {}, user_properties: {})
68
+ @name = name
69
+ @params = params.freeze
70
+ @user_properties = user_properties.freeze
71
+ freeze
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,244 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require "track_relay/errors"
5
+
6
+ module TrackRelay
7
+ # Runtime data for a single event in flight.
8
+ #
9
+ # An EventPayload pairs a {EventDefinition} (the schema) with the raw
10
+ # values supplied at the call site (`TrackRelay.track(:foo, ...)`),
11
+ # plus the request-derived context (user, visitor_token, client_id,
12
+ # etc.) and a timestamp. It is the value passed through the
13
+ # `track_relay.event` ActiveSupport::Notifications instrumentation.
14
+ #
15
+ # Two constructors:
16
+ # - `EventPayload.new(definition:, params:, ...)` — typed payload;
17
+ # {validate!} coerces and enforces the definition's schema.
18
+ # - `EventPayload.untyped(name:, params:, ...)` — untyped payload (no
19
+ # matching definition); {validate!} is a no-op so consumers can
20
+ # still ship the raw event for the untyped-events linter (see
21
+ # Plan 04).
22
+ #
23
+ # `validate!` is destructive: it replaces `@params` with the coerced
24
+ # hash so subscribers see post-coercion values. Errors raise
25
+ # {ValidationError} naming the offending key.
26
+ class EventPayload
27
+ # Sentinel values that the strict boolean coercion accepts. Anything
28
+ # else raises {ValidationError}.
29
+ BOOLEAN_TRUE_VALUES = [true, "true", 1].freeze
30
+ BOOLEAN_FALSE_VALUES = [false, "false", 0].freeze
31
+
32
+ attr_reader :definition, :params, :context, :timestamp
33
+
34
+ # @param definition [EventDefinition] schema for typed events
35
+ # @param params [Hash{Symbol => Object}] raw param values
36
+ # @param context [Hash] request-derived metadata (user, visitor,
37
+ # request, etc.)
38
+ # @param timestamp [Time] event timestamp; defaults to now
39
+ def initialize(definition:, params:, context: {}, timestamp: Time.now)
40
+ @definition = definition
41
+ @params = params
42
+ @context = context
43
+ @timestamp = timestamp
44
+ @untyped_name = nil
45
+ end
46
+
47
+ # Build an untyped payload — no definition, no schema enforcement.
48
+ # Used when a host application calls `TrackRelay.track(:unknown, ...)`
49
+ # while `untyped_events_allowed = true`.
50
+ #
51
+ # @param name [Symbol] event name (stored separately because there
52
+ # is no definition to read it from)
53
+ # @param params [Hash]
54
+ # @param context [Hash]
55
+ # @param timestamp [Time]
56
+ # @return [EventPayload]
57
+ def self.untyped(name:, params:, context: {}, timestamp: Time.now)
58
+ payload = new(definition: nil, params: params, context: context, timestamp: timestamp)
59
+ payload.instance_variable_set(:@untyped_name, name)
60
+ payload
61
+ end
62
+
63
+ # Reconstruct an EventPayload from a serialized {to_h} form. Used
64
+ # by Plan 05's {DeliveryJob} to rehydrate a payload after ActiveJob
65
+ # has serialized arguments through the queue adapter.
66
+ #
67
+ # The reconstructed payload is always untyped (`definition: nil`):
68
+ # validation happened at track time on the calling thread, so the
69
+ # async delivery path doesn't need the schema. ActiveJob's argument
70
+ # serialization round-trips Symbols as Strings under the standard
71
+ # adapter, so `String#to_sym` is applied defensively to the name.
72
+ #
73
+ # @param hash [Hash] result of {to_h} (Symbol- or String-keyed)
74
+ # @return [EventPayload]
75
+ def self.from_h(hash)
76
+ payload = allocate
77
+ payload.instance_variable_set(:@definition, nil)
78
+ payload.instance_variable_set(:@params, hash[:params] || hash["params"] || {})
79
+ payload.instance_variable_set(:@context, hash[:context] || hash["context"] || {})
80
+ payload.instance_variable_set(
81
+ :@timestamp,
82
+ Time.iso8601(hash[:timestamp] || hash["timestamp"])
83
+ )
84
+ payload.instance_variable_set(
85
+ :@untyped_name,
86
+ (hash[:name] || hash["name"]).to_sym
87
+ )
88
+ payload
89
+ end
90
+
91
+ # @return [Symbol] event name (from definition or untyped store)
92
+ def name
93
+ @definition ? @definition.name : @untyped_name
94
+ end
95
+
96
+ # @return [Boolean] whether this payload was built without a
97
+ # matching catalog definition
98
+ def untyped?
99
+ @definition.nil?
100
+ end
101
+
102
+ # Coerce and validate `@params` against `@definition.params`.
103
+ # Mutates `@params` to the coerced hash.
104
+ #
105
+ # For each schema entry the order of operations is:
106
+ # 1. Apply `sanitize` callable if present (raw -> sanitized).
107
+ # 2. Check `required` against post-sanitize value.
108
+ # 3. Coerce to `type`.
109
+ # 4. Apply `max` (length for strings, value for numbers).
110
+ # 5. Apply `in` inclusion list.
111
+ # 6. Apply `format` regex (strings only).
112
+ #
113
+ # After per-key processing, any incoming param not declared in the
114
+ # schema raises {ValidationError}.
115
+ #
116
+ # No-op for untyped payloads.
117
+ #
118
+ # @raise [ValidationError]
119
+ # @return [Hash{Symbol => Object}] coerced params
120
+ def validate!
121
+ return @params if untyped?
122
+
123
+ coerced = {}
124
+
125
+ @definition.params.each do |key, schema|
126
+ raw_value = @params[key]
127
+ raw_value = schema.sanitize.call(raw_value) if schema.sanitize&.respond_to?(:call) && @params.key?(key)
128
+
129
+ if raw_value.nil?
130
+ if schema.required
131
+ raise ValidationError, "Param #{key.inspect} is required but was not provided"
132
+ end
133
+ next
134
+ end
135
+
136
+ coerced_value = coerce(key, schema.type, raw_value)
137
+ check_max!(key, schema.max, coerced_value) if schema.max
138
+ check_in!(key, schema.in, coerced_value) if schema.in
139
+ check_format!(key, schema.format, coerced_value) if schema.format
140
+
141
+ coerced[key] = coerced_value
142
+ end
143
+
144
+ extras = @params.keys - @definition.params.keys
145
+ unless extras.empty?
146
+ raise ValidationError,
147
+ "Unexpected param(s) #{extras.map(&:inspect).join(", ")} not declared on event #{@definition.name.inspect}"
148
+ end
149
+
150
+ @params = coerced
151
+ end
152
+
153
+ # Serialize to a Hash suitable for ActiveJob arguments / JSON
154
+ # encoding. Used by the DeliveryJob in Plan 05.
155
+ #
156
+ # @return [Hash]
157
+ def to_h
158
+ {
159
+ name: name,
160
+ params: @params,
161
+ context: @context,
162
+ timestamp: @timestamp.respond_to?(:iso8601) ? @timestamp.iso8601 : @timestamp.to_s
163
+ }
164
+ end
165
+
166
+ private
167
+
168
+ def coerce(key, type, value)
169
+ case type
170
+ when :integer then coerce_integer(key, value)
171
+ when :string then coerce_string(value)
172
+ when :float then coerce_float(key, value)
173
+ when :boolean then coerce_boolean(key, value)
174
+ when :datetime then coerce_datetime(key, value)
175
+ else
176
+ raise ValidationError, "Param #{key.inspect} has unsupported type #{type.inspect}"
177
+ end
178
+ end
179
+
180
+ def coerce_integer(key, value)
181
+ Integer(value)
182
+ rescue ArgumentError, TypeError
183
+ raise ValidationError, "Param #{key.inspect} cannot be coerced to Integer (got #{value.inspect})"
184
+ end
185
+
186
+ def coerce_string(value)
187
+ String(value)
188
+ end
189
+
190
+ def coerce_float(key, value)
191
+ Float(value)
192
+ rescue ArgumentError, TypeError
193
+ raise ValidationError, "Param #{key.inspect} cannot be coerced to Float (got #{value.inspect})"
194
+ end
195
+
196
+ def coerce_boolean(key, value)
197
+ return true if BOOLEAN_TRUE_VALUES.include?(value)
198
+ return false if BOOLEAN_FALSE_VALUES.include?(value)
199
+
200
+ raise ValidationError,
201
+ "Param #{key.inspect} cannot be coerced to Boolean — accepted values are true/false/'true'/'false'/1/0 (got #{value.inspect})"
202
+ end
203
+
204
+ def coerce_datetime(key, value)
205
+ case value
206
+ when Time, DateTime
207
+ value
208
+ when String
209
+ begin
210
+ Time.iso8601(value)
211
+ rescue ArgumentError
212
+ raise ValidationError, "Param #{key.inspect} is not a valid ISO8601 datetime (got #{value.inspect})"
213
+ end
214
+ else
215
+ raise ValidationError,
216
+ "Param #{key.inspect} cannot be coerced to datetime — expected Time, DateTime, or ISO8601 String (got #{value.class})"
217
+ end
218
+ end
219
+
220
+ def check_max!(key, max, value)
221
+ length_or_value = value.is_a?(String) ? value.length : value
222
+
223
+ if length_or_value > max
224
+ unit = value.is_a?(String) ? "length" : "value"
225
+ raise ValidationError,
226
+ "Param #{key.inspect} #{unit} #{length_or_value} exceeds max #{max}"
227
+ end
228
+ end
229
+
230
+ def check_in!(key, allowed, value)
231
+ unless allowed.include?(value)
232
+ raise ValidationError,
233
+ "Param #{key.inspect} value #{value.inspect} is not in inclusion list #{allowed.inspect}"
234
+ end
235
+ end
236
+
237
+ def check_format!(key, format, value)
238
+ unless value.is_a?(String) && value.match?(format)
239
+ raise ValidationError,
240
+ "Param #{key.inspect} value #{value.inspect} does not match format #{format.inspect}"
241
+ end
242
+ end
243
+ end
244
+ end
@@ -0,0 +1,241 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/notifications"
4
+ require "track_relay/catalog"
5
+ require "track_relay/current"
6
+ require "track_relay/errors"
7
+ require "track_relay/event_payload"
8
+
9
+ module TrackRelay
10
+ # Central orchestrator for {TrackRelay.track} and {TrackRelay.identify}.
11
+ #
12
+ # `track` is the integration point of:
13
+ #
14
+ # - {RESERVED_KEYS} extraction (split-routed: some keys land on
15
+ # {Current}, `:visitor_token` lands directly in `payload.context`)
16
+ # - {Catalog} lookup (typed vs untyped path)
17
+ # - {EventPayload} construction + {EventPayload#validate!}
18
+ # - context snapshot (read at `track` time so async delivery in
19
+ # Plan 05 has the data after `Current` is reset)
20
+ # - `ActiveSupport::Notifications.instrument("track_relay.event", ...)`
21
+ #
22
+ # All four steps happen on the calling thread before any subscriber
23
+ # runs, so reserved-key partitioning and validation are deterministic
24
+ # from the host application's perspective.
25
+ #
26
+ # Reserved-key split rationale (per Plan 01-04 must_have):
27
+ #
28
+ # - `:user`, `:request`, `:client_id` are {Current} attributes —
29
+ # bound via `Current.set(...) { ... }` for the duration of the
30
+ # instrumentation block.
31
+ # - `:visitor_token` is intentionally NOT a {Current} attribute.
32
+ # {Current} carries `:visit` (an Ahoy-style record), not a raw
33
+ # opaque token. The token is merged directly into
34
+ # `payload.context[:visitor_token]` so subscribers (and the
35
+ # downstream DeliveryJob in Plan 05) can read it without
36
+ # touching {Current}.
37
+ #
38
+ # `identify` is a thin pass-through in Phase 01: it instruments
39
+ # `track_relay.identify` with `{user:, properties:}` and performs
40
+ # no catalog validation against `user_property` declarations.
41
+ # Adapter-specific user-property handling is deferred to Phase 02.
42
+ module Instrumenter
43
+ # AS::Notifications event name for typed/untyped event tracking.
44
+ NOTIFICATION = "track_relay.event"
45
+
46
+ # AS::Notifications event name for identify calls.
47
+ IDENTIFY_NOTIFICATION = "track_relay.identify"
48
+
49
+ # Reserved keys that must be partitioned onto {Current} (block-scoped
50
+ # via `Current.set`). See {DIRECT_CONTEXT_KEYS} for keys that bypass
51
+ # {Current} and land directly on `payload.context`.
52
+ CURRENT_ATTR_KEYS = %i[user request client_id].freeze
53
+
54
+ # Reserved keys that bypass {Current} entirely and are merged
55
+ # directly into `payload.context` at build time.
56
+ DIRECT_CONTEXT_KEYS = %i[visitor_token].freeze
57
+
58
+ module_function
59
+
60
+ # Track a typed (catalog-defined) or untyped event.
61
+ #
62
+ # Reserved keys are extracted from `params` BEFORE catalog lookup so
63
+ # they never appear in `payload.params`. Validation gating respects
64
+ # {Configuration#raise_on_validation_error} (`true` re-raises, `false`
65
+ # logs and swallows without instrumenting).
66
+ #
67
+ # @param name [Symbol] event name; looked up in {Catalog}
68
+ # @param params [Hash{Symbol => Object}] event params + reserved keys
69
+ # @return [void]
70
+ # @raise [UnknownEventError] when the event is not in the catalog
71
+ # AND {Configuration#untyped_events_allowed} is false
72
+ # @raise [ValidationError] when a typed event fails validation AND
73
+ # {Configuration#raise_on_validation_error} is true
74
+ def track(name, **params)
75
+ current_attrs, direct_context, event_params = partition_reserved(params)
76
+ with_current_attrs(current_attrs) do
77
+ definition = Catalog.lookup(name)
78
+ payload = build_payload(
79
+ name: name,
80
+ definition: definition,
81
+ params: event_params,
82
+ extra_context: direct_context
83
+ )
84
+ return unless validate(payload)
85
+ ActiveSupport::Notifications.instrument(NOTIFICATION, event: payload)
86
+ end
87
+ end
88
+
89
+ # Identify a user — Phase 01 pass-through.
90
+ #
91
+ # Instruments `track_relay.identify` with `{user:, properties:}`.
92
+ # No catalog validation happens here; adapter-specific user_property
93
+ # validation is deferred to Phase 02 where each subscriber decides
94
+ # how to handle properties (GA4 user_properties, Ahoy User update,
95
+ # etc.).
96
+ #
97
+ # TODO(phase-02): wire `Catalog.user_properties` validation here so
98
+ # consumers can declare user_property schemas and have them enforced
99
+ # at identify time.
100
+ #
101
+ # @param user [Object] user-like object (or id) to identify
102
+ # @param user_properties [Hash] arbitrary properties to attach
103
+ # @return [void]
104
+ def identify(user, **user_properties)
105
+ ActiveSupport::Notifications.instrument(
106
+ IDENTIFY_NOTIFICATION,
107
+ user: user,
108
+ properties: user_properties
109
+ )
110
+ end
111
+
112
+ # Bind `current_attrs` on {Current} for the duration of `block`.
113
+ #
114
+ # When the hash is empty, `Current.set(**{})` would raise
115
+ # `ArgumentError: wrong number of arguments (given 0, expected 1)`
116
+ # under ActiveSupport 8.x. Skipping the wrapper in that case
117
+ # preserves the no-reserved-keys path (most calls).
118
+ #
119
+ # @param current_attrs [Hash]
120
+ # @yield with `Current` bound
121
+ # @return [Object] the block's return value
122
+ def with_current_attrs(current_attrs, &block)
123
+ if current_attrs.empty?
124
+ block.call
125
+ else
126
+ Current.set(**current_attrs, &block)
127
+ end
128
+ end
129
+
130
+ # Split params into three buckets:
131
+ #
132
+ # - `current_attrs` — keys that {Current.set} accepts
133
+ # (`:user`, `:request`, `:client_id`)
134
+ # - `direct_context` — keys that bypass {Current} and land
135
+ # directly on `payload.context` (`:visitor_token`)
136
+ # - `event_params` — everything else (validated against catalog)
137
+ #
138
+ # @param params [Hash]
139
+ # @return [Array(Hash, Hash, Hash)] three-tuple of partitioned hashes
140
+ def partition_reserved(params)
141
+ current_attrs = {}
142
+ direct_context = {}
143
+ event_params = {}
144
+
145
+ params.each do |key, value|
146
+ if CURRENT_ATTR_KEYS.include?(key)
147
+ current_attrs[key] = value
148
+ elsif DIRECT_CONTEXT_KEYS.include?(key)
149
+ direct_context[key] = value
150
+ else
151
+ event_params[key] = value
152
+ end
153
+ end
154
+
155
+ [current_attrs, direct_context, event_params]
156
+ end
157
+
158
+ # Build either a typed or untyped {EventPayload}, merging
159
+ # `extra_context` (e.g. `:visitor_token`) into the snapshot of
160
+ # {Current} taken at instrument time.
161
+ #
162
+ # Untyped path is gated by
163
+ # {Configuration#untyped_events_allowed} — when disallowed,
164
+ # {UnknownEventError} is raised.
165
+ #
166
+ # @param name [Symbol]
167
+ # @param definition [EventDefinition, nil]
168
+ # @param params [Hash]
169
+ # @param extra_context [Hash] reserved keys that go straight to
170
+ # context (currently just `:visitor_token`)
171
+ # @return [EventPayload]
172
+ # @raise [UnknownEventError]
173
+ def build_payload(name:, definition:, params:, extra_context: {})
174
+ context = current_context.merge(extra_context)
175
+
176
+ if definition
177
+ EventPayload.new(definition: definition, params: params, context: context)
178
+ elsif TrackRelay.config.untyped_events_allowed
179
+ EventPayload.untyped(name: name, params: params, context: context)
180
+ else
181
+ raise UnknownEventError,
182
+ "Unknown event #{name.inspect}; declare it in your catalog or set config.untyped_events_allowed = true"
183
+ end
184
+ end
185
+
186
+ # Snapshot {Current} at instrument time. Plan 05's DeliveryJob
187
+ # depends on this contract: by the time the job runs, the Rails
188
+ # Executor has already cleared {Current}, so async subscribers
189
+ # must read from `payload.context`, not from `Current` directly.
190
+ #
191
+ # Keys snapshot:
192
+ #
193
+ # - `:user` — Current.user (any object)
194
+ # - `:controller` — Current.controller&.class&.name (String)
195
+ # - `:action` — Current.controller&.action_name (String)
196
+ # - `:client_id` — Current.client_id (String)
197
+ # - `:visit` — Current.visit (Ahoy-style record or nil)
198
+ # - `:request_id` — Current.request&.request_id (String)
199
+ #
200
+ # `:controller` and `:action` are required by Plan 05's Logger
201
+ # JSONL shape.
202
+ #
203
+ # @return [Hash]
204
+ def current_context
205
+ controller = Current.controller
206
+ action = controller.respond_to?(:action_name) ? controller.action_name : nil
207
+
208
+ {
209
+ user: Current.user,
210
+ controller: controller&.class&.name,
211
+ action: action,
212
+ client_id: Current.client_id,
213
+ visit: Current.visit,
214
+ request_id: Current.request&.request_id
215
+ }
216
+ end
217
+
218
+ # Run {EventPayload#validate!} and apply the
219
+ # {Configuration#raise_on_validation_error} gate.
220
+ #
221
+ # Returns truthy when the caller should proceed to instrument and
222
+ # `nil` when validation failed and was swallowed (no instrument).
223
+ #
224
+ # @param payload [EventPayload]
225
+ # @return [Object, nil] truthy on success, `nil` on swallowed failure
226
+ # @raise [ValidationError] when validation fails AND
227
+ # {Configuration#raise_on_validation_error} is true
228
+ def validate(payload)
229
+ payload.validate!
230
+ payload
231
+ rescue ValidationError => e
232
+ raise if TrackRelay.config.raise_on_validation_error
233
+
234
+ if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
235
+ Rails.logger.error("[track_relay] validation failed for #{payload.name.inspect}: #{e.message}")
236
+ end
237
+
238
+ nil
239
+ end
240
+ end
241
+ end