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,231 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support"
4
+ require "active_support/core_ext/class/attribute"
5
+
6
+ module TrackRelay
7
+ module Subscribers
8
+ # Base class for all track_relay subscribers.
9
+ #
10
+ # Each subscriber receives an {EventPayload} via {#handle}, which
11
+ # routes to one of two paths:
12
+ #
13
+ # - **sync** — `safe_deliver(payload)` is invoked inline on the
14
+ # calling thread. Used when the subclass calls {.synchronous!}
15
+ # or when {Configuration#force_synchronous} is `true`.
16
+ # - **async** — {DeliveryJob} is enqueued with the subscriber's
17
+ # class name and the payload's serialized form. The job calls
18
+ # `safe_deliver` on a fresh instance when it eventually runs.
19
+ #
20
+ # **Error contract (locked in 01-CONTEXT.md, 01-05-PLAN.md):**
21
+ # `safe_deliver` returns `nil` on success or the StandardError on
22
+ # failure — it NEVER re-raises inline. The {Dispatcher} collects
23
+ # those returns during fan-out and re-raises the first one
24
+ # afterwards, but only when {Configuration#swallow_subscriber_errors}
25
+ # is `false`. This guarantees that one bad subscriber never blocks
26
+ # peers, while still letting dev/test surface failures loudly once
27
+ # everyone has had their chance.
28
+ class Base
29
+ class_attribute :synchronous, default: false
30
+
31
+ # Subscriber-side event-name filters (Plan 02-01). Both default to
32
+ # `nil`, which means "no filter — receive every event" (Phase 1
33
+ # behavior). When set, they are stored as `Set<Symbol>` by the
34
+ # {.filter} class DSL and consulted by {#filtered?} at the top of
35
+ # {#handle}, BEFORE the sync/async branch.
36
+ #
37
+ # The class-level value is the default for instances of this
38
+ # subscriber; {TrackRelay.subscribe} (Plan 02-01) overrides per
39
+ # instance via the singleton-class accessors so two instances of
40
+ # the same subscriber can carry different filters without
41
+ # cross-talk.
42
+ class_attribute :only_events, default: nil
43
+ class_attribute :except_events, default: nil
44
+
45
+ # Mark this subclass as synchronous. Calls to {#handle} will run
46
+ # `safe_deliver` inline rather than enqueueing a {DeliveryJob}.
47
+ #
48
+ # @return [Boolean] `true`
49
+ def self.synchronous!
50
+ self.synchronous = true
51
+ end
52
+
53
+ # Class-level DSL for declaring an event-name filter.
54
+ #
55
+ # class MySubscriber < TrackRelay::Subscribers::Base
56
+ # filter only: %i[purchase sign_up]
57
+ # end
58
+ #
59
+ # `only:` and `except:` are mutually exclusive in spirit but not
60
+ # enforced as such — if both are set, `only:` wins (an event must
61
+ # be in the allow-list AND not in the deny-list to pass). Pass
62
+ # `nil` to clear a previously set filter.
63
+ #
64
+ # @param only [Array<Symbol, String>, nil] allow-list; if non-nil,
65
+ # only events whose name is in this set are delivered.
66
+ # @param except [Array<Symbol, String>, nil] deny-list; events in
67
+ # this set are dropped.
68
+ # @return [void]
69
+ def self.filter(only: nil, except: nil)
70
+ self.only_events = coerce_event_set(only)
71
+ self.except_events = coerce_event_set(except)
72
+ end
73
+
74
+ # Coerce a filter input (Array<Symbol|String>, Set, single Symbol,
75
+ # or nil) into a `Set<Symbol>` or `nil`. Internal helper shared by
76
+ # the class-level {.filter} DSL and the per-instance override path
77
+ # ({Base#set_filter_overrides!}, used by {TrackRelay.subscribe}).
78
+ #
79
+ # @param value [Array, Set, Symbol, String, nil]
80
+ # @return [Set<Symbol>, nil]
81
+ def self.coerce_event_set(value)
82
+ return nil if value.nil?
83
+ Set.new(Array(value).map(&:to_sym))
84
+ end
85
+
86
+ # Implement in subclasses to receive an {EventPayload}.
87
+ #
88
+ # @param payload [EventPayload]
89
+ # @raise [NotImplementedError] when not overridden
90
+ # @return [void]
91
+ def deliver(payload)
92
+ raise NotImplementedError, "#{self.class.name} must implement #deliver(payload)"
93
+ end
94
+
95
+ # Route `payload` to the sync or async path.
96
+ #
97
+ # **Returns:** `nil` on success, the StandardError on a sync
98
+ # failure, or `nil` on the async path (the job runs later — its
99
+ # eventual failure mode is handled inside {DeliveryJob#perform}).
100
+ #
101
+ # **Filter gate (Plan 02-01):** if `only_events` / `except_events`
102
+ # exclude `payload.name`, return `nil` immediately — BEFORE the
103
+ # sync/async branch and BEFORE `safe_deliver`'s rescue boundary.
104
+ # A filtered event with a buggy `#deliver` therefore neither runs
105
+ # nor logs.
106
+ #
107
+ # @param payload [EventPayload]
108
+ # @return [nil, StandardError]
109
+ def handle(payload)
110
+ return nil if filtered?(payload.name.to_sym)
111
+
112
+ if self.class.synchronous || TrackRelay.config.force_synchronous
113
+ safe_deliver(payload)
114
+ else
115
+ DeliveryJob.perform_later(self.class.name, payload.to_h)
116
+ nil
117
+ end
118
+ end
119
+
120
+ # Wrap {#deliver} with the per-subscriber rescue.
121
+ #
122
+ # Returns `nil` on success or the StandardError on failure. ALWAYS
123
+ # logs the failure (via `Rails.logger.error`) when running under
124
+ # Rails. NEVER re-raises arbitrary `StandardError`s — the
125
+ # Dispatcher (or {DeliveryJob}) makes the loudness decision based
126
+ # on {Configuration#swallow_subscriber_errors}.
127
+ #
128
+ # **REQ-23 carve-out (Plan 02-04):**
129
+ # {TrackRelay::DeliveryRetriableError} and
130
+ # {TrackRelay::DeliveryDiscardableError} are RE-RAISED unconditionally
131
+ # — even when `swallow_subscriber_errors = true` (the production
132
+ # default). ActiveJob's `retry_on` / `discard_on` macros only fire
133
+ # on raised exceptions; without this carve-out the GA4 retry/discard
134
+ # policy in {DeliveryJob} would be silently broken in production
135
+ # because `safe_deliver` would catch the exception, return it as a
136
+ # value, and the job would think delivery succeeded.
137
+ #
138
+ # The carve-out is INTENTIONALLY NARROW: arbitrary `StandardError`s
139
+ # still flow through the existing log-and-return path — REQ-23's
140
+ # blanket-rescue contract is preserved for everything outside of
141
+ # the typed retry/discard exception classes.
142
+ #
143
+ # @param payload [EventPayload]
144
+ # @return [nil, StandardError]
145
+ def safe_deliver(payload)
146
+ deliver(payload)
147
+ nil
148
+ rescue TrackRelay::DeliveryRetriableError, TrackRelay::DeliveryDiscardableError
149
+ # Carve-out: ActiveJob retry_on/discard_on must see these.
150
+ # Do NOT log here — the DeliveryJob's retry path will log on
151
+ # eventual exhaustion, and the discard path is an intentional
152
+ # drop. Logging on every retry attempt would spam the log with
153
+ # transient blips that resolve on retry.
154
+ raise
155
+ rescue => e
156
+ log_failure(e)
157
+ e
158
+ end
159
+
160
+ # Set per-instance `only:` / `except:` filter overrides on this
161
+ # subscriber. Used by {TrackRelay.subscribe} so a single subscriber
162
+ # class can be registered multiple times with different filters.
163
+ #
164
+ # Each non-nil argument is coerced via {Base.coerce_event_set} and
165
+ # stored on the instance's singleton class so it does not bleed
166
+ # across instances or mutate the class-level defaults declared via
167
+ # {.filter}. Passing `nil` for either argument leaves that override
168
+ # untouched (the instance falls through to the class default).
169
+ #
170
+ # @param only [Array<Symbol, String>, Set, nil]
171
+ # @param except [Array<Symbol, String>, Set, nil]
172
+ # @return [self]
173
+ def set_filter_overrides!(only: nil, except: nil)
174
+ unless only.nil?
175
+ singleton_class.instance_variable_set(:@only_events_override, self.class.coerce_event_set(only))
176
+ end
177
+ unless except.nil?
178
+ singleton_class.instance_variable_set(:@except_events_override, self.class.coerce_event_set(except))
179
+ end
180
+ self
181
+ end
182
+
183
+ # Read the effective `only:` filter for this instance — the
184
+ # singleton override (set by {TrackRelay.subscribe}) when present,
185
+ # otherwise the class-level default declared via {.filter}.
186
+ #
187
+ # @return [Set<Symbol>, nil]
188
+ def only_events
189
+ if singleton_class.instance_variable_defined?(:@only_events_override)
190
+ singleton_class.instance_variable_get(:@only_events_override)
191
+ else
192
+ self.class.only_events
193
+ end
194
+ end
195
+
196
+ # Read the effective `except:` filter for this instance — the
197
+ # singleton override (set by {TrackRelay.subscribe}) when present,
198
+ # otherwise the class-level default declared via {.filter}.
199
+ #
200
+ # @return [Set<Symbol>, nil]
201
+ def except_events
202
+ if singleton_class.instance_variable_defined?(:@except_events_override)
203
+ singleton_class.instance_variable_get(:@except_events_override)
204
+ else
205
+ self.class.except_events
206
+ end
207
+ end
208
+
209
+ private
210
+
211
+ # @param event_name [Symbol] coerced from `payload.name`
212
+ # @return [Boolean] true ⇒ drop this event before delivery
213
+ def filtered?(event_name)
214
+ only = only_events
215
+ except = except_events
216
+ return false if only.nil? && except.nil?
217
+ return true if only && !only.include?(event_name)
218
+ return true if except&.include?(event_name)
219
+ false
220
+ end
221
+
222
+ def log_failure(e)
223
+ return unless defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
224
+ Rails.logger.error(
225
+ "[track_relay] subscriber=#{self.class.name} failed: #{e.class}: #{e.message}\n" \
226
+ "#{Array(e.backtrace).first(5).join("\n")}"
227
+ )
228
+ end
229
+ end
230
+ end
231
+ end
@@ -0,0 +1,250 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+ require "track_relay/errors"
7
+ require "track_relay/subscribers/base"
8
+ require "track_relay/validators/ga4_constraints"
9
+
10
+ module TrackRelay
11
+ module Subscribers
12
+ # GA4 Measurement Protocol server-side subscriber (REQ-08, REQ-11).
13
+ #
14
+ # POSTs each event to Google Analytics 4 via the Measurement Protocol
15
+ # endpoint:
16
+ #
17
+ # POST https://www.google-analytics.com/mp/collect
18
+ # ?measurement_id=G-XXXXXXXXXX
19
+ # &api_secret=<secret>
20
+ # Content-Type: application/json
21
+ # Body: { client_id:, user_id:, timestamp_micros:, events: [{name:, params:}] }
22
+ #
23
+ # Async by default — `#deliver` runs inside a {DeliveryJob} (loadbalanced
24
+ # via ActiveJob). Hosts that need inline delivery (e.g. unit-test
25
+ # determinism, low-traffic ingestion) can call {.synchronous!} per
26
+ # REQ-11.
27
+ #
28
+ # ## Configuration
29
+ #
30
+ # Read from {TrackRelay::Configuration} at *delivery time* (NOT at
31
+ # class-body load time) so credentials lambdas / late-bound configs
32
+ # work:
33
+ #
34
+ # - `config.ga4_measurement_id` — `G-XXXXXXXXXX`
35
+ # - `config.ga4_api_secret` — per-stream MP secret
36
+ # - `config.ga4_use_eu_endpoint` — when `true`, post to
37
+ # `https://region1.google-analytics.com/mp/collect`
38
+ #
39
+ # When `ga4_measurement_id` or `ga4_api_secret` is `nil`, `#deliver`
40
+ # emits a single `Rails.logger.warn` and returns without raising —
41
+ # gem-loaded-but-not-configured apps must not crash.
42
+ #
43
+ # ## Error contract
44
+ #
45
+ # `#deliver` raises:
46
+ #
47
+ # - {TrackRelay::DeliveryRetriableError} on transient failures
48
+ # (HTTP 5xx, `Net::OpenTimeout`, `Net::ReadTimeout`,
49
+ # `Errno::ECONNREFUSED`, `SocketError`). Mapped to
50
+ # `retry_on` in {DeliveryJob}.
51
+ # - {TrackRelay::DeliveryDiscardableError} on permanent failures
52
+ # (HTTP 4xx — defensive: GA4 returns 2xx in practice even on
53
+ # malformed payloads). Mapped to `discard_on` in {DeliveryJob}.
54
+ # - {TrackRelay::Ga4ConstraintError} when call-time payload
55
+ # validation fails AND `config.raise_on_validation_error` is
56
+ # `true` (dev/test). In production (`raise_on_validation_error
57
+ # = false`) the violation is logged via `Rails.logger.warn` and
58
+ # the POST is skipped.
59
+ #
60
+ # ## Why typed retriable/discardable exceptions?
61
+ #
62
+ # ActiveJob's `retry_on`/`discard_on` macros only fire on raised
63
+ # exceptions, not returned values. {Subscribers::Base#safe_deliver}
64
+ # normally rescues any `StandardError` and returns it (the REQ-23
65
+ # blanket-rescue contract), so a 5xx retry would never reach the
66
+ # job's retry policy. {Subscribers::Base} therefore carves these two
67
+ # exception classes out of the rescue: it re-raises them so
68
+ # {DeliveryJob} can map them to `retry_on`/`discard_on`. See
69
+ # `test/unit/subscribers/base_retry_passthrough_test.rb`.
70
+ class Ga4MeasurementProtocol < Base
71
+ # GA4 production endpoint (US/global region).
72
+ ENDPOINT_URL = "https://www.google-analytics.com/mp/collect"
73
+
74
+ # GA4 EU-region endpoint, selected via `config.ga4_use_eu_endpoint = true`.
75
+ ENDPOINT_URL_EU = "https://region1.google-analytics.com/mp/collect"
76
+
77
+ # Net::HTTP open timeout (TCP connect).
78
+ OPEN_TIMEOUT_SECONDS = 5
79
+
80
+ # Net::HTTP read timeout (response wait).
81
+ READ_TIMEOUT_SECONDS = 10
82
+
83
+ # GA4-reserved param-name prefixes. Per Scout §2 / REQ-27, params
84
+ # starting with these prefixes must not be sent — GA4 silently
85
+ # drops them.
86
+ RESERVED_PARAM_PREFIXES = %w[firebase_ ga_ google_].freeze
87
+
88
+ # POST `payload` to the GA4 Measurement Protocol endpoint.
89
+ #
90
+ # See class docs for the full configuration / error contract.
91
+ #
92
+ # @param payload [TrackRelay::EventPayload]
93
+ # @raise [TrackRelay::DeliveryRetriableError] on 5xx or network blip
94
+ # @raise [TrackRelay::DeliveryDiscardableError] on 4xx
95
+ # @raise [TrackRelay::Ga4ConstraintError] on call-time payload
96
+ # violation when `raise_on_validation_error` is true
97
+ # @return [void]
98
+ def deliver(payload)
99
+ config = TrackRelay.config
100
+ measurement_id = config.ga4_measurement_id
101
+ api_secret = config.ga4_api_secret
102
+
103
+ if measurement_id.nil? || api_secret.nil?
104
+ warn_missing_credentials(measurement_id, api_secret)
105
+ return
106
+ end
107
+
108
+ return unless validate_ga4_payload!(payload)
109
+
110
+ post_to_ga4(payload, measurement_id, api_secret, config.ga4_use_eu_endpoint)
111
+ end
112
+
113
+ private
114
+
115
+ # Run call-time payload constraint checks (REQ-27 split). Returns
116
+ # `true` when delivery should proceed, `false` when a constraint
117
+ # was violated AND `raise_on_validation_error` is `false` (the
118
+ # subscriber logs and skips the POST). Raises
119
+ # {Ga4ConstraintError} when `raise_on_validation_error` is `true`.
120
+ def validate_ga4_payload!(payload)
121
+ if payload.params.size > Validators::Ga4Constraints::MAX_PARAMS_PER_EVENT
122
+ msg = "GA4 payload for #{payload.name.inspect} has #{payload.params.size} params; GA4 max is #{Validators::Ga4Constraints::MAX_PARAMS_PER_EVENT}"
123
+ return handle_constraint_violation(msg)
124
+ end
125
+
126
+ payload.params.each_key do |key|
127
+ as_str = key.to_s
128
+ next unless RESERVED_PARAM_PREFIXES.any? { |prefix| as_str.start_with?(prefix) }
129
+
130
+ msg = "Param #{key.inspect} on event #{payload.name.inspect} uses a GA4-reserved prefix (one of #{RESERVED_PARAM_PREFIXES.inspect})"
131
+ return false unless handle_constraint_violation(msg)
132
+ end
133
+
134
+ true
135
+ end
136
+
137
+ # Honor the {Configuration#raise_on_validation_error} gate.
138
+ # Returns `false` (skip the POST) when the violation is logged;
139
+ # raises {Ga4ConstraintError} otherwise. Mirrors the pattern in
140
+ # {Instrumenter#validate}.
141
+ def handle_constraint_violation(msg)
142
+ if TrackRelay.config.raise_on_validation_error
143
+ raise Ga4ConstraintError, msg
144
+ end
145
+
146
+ Rails.logger&.warn("[track_relay] #{msg}")
147
+ false
148
+ end
149
+
150
+ def post_to_ga4(payload, measurement_id, api_secret, use_eu)
151
+ uri = build_endpoint_uri(measurement_id, api_secret, use_eu)
152
+ body = build_request_body(payload)
153
+ response = http_post(uri, body)
154
+ map_response_to_exception!(response)
155
+ end
156
+
157
+ def build_endpoint_uri(measurement_id, api_secret, use_eu)
158
+ base = use_eu ? ENDPOINT_URL_EU : ENDPOINT_URL
159
+ uri = URI(base)
160
+ uri.query = URI.encode_www_form(
161
+ measurement_id: measurement_id,
162
+ api_secret: api_secret
163
+ )
164
+ uri
165
+ end
166
+
167
+ def build_request_body(payload)
168
+ body = {
169
+ client_id: client_id_for(payload),
170
+ timestamp_micros: timestamp_micros(payload),
171
+ events: [{
172
+ name: payload.name.to_s,
173
+ params: stringify_params(payload.params)
174
+ }]
175
+ }
176
+
177
+ user_id = payload.context[:user_id] || payload.context["user_id"]
178
+ body[:user_id] = user_id.to_s if user_id
179
+
180
+ JSON.generate(body)
181
+ end
182
+
183
+ # GA4 requires a `client_id` even when `payload.context[:client_id]`
184
+ # is nil (e.g. server-side events with no `_ga` cookie). Generate a
185
+ # deterministic-shaped fallback (`<rand>.<unix_ts>`) so the POST
186
+ # still goes through — the `client_id` is the cohort identifier in
187
+ # GA4, not a true per-user key.
188
+ def client_id_for(payload)
189
+ explicit = payload.context[:client_id] || payload.context["client_id"]
190
+ return explicit if explicit && !explicit.to_s.empty?
191
+
192
+ "#{SecureRandom.random_number(2_147_483_647)}.#{Time.now.to_i}"
193
+ end
194
+
195
+ def timestamp_micros(payload)
196
+ ts = payload.timestamp || Time.now
197
+ (ts.to_f * 1_000_000).to_i
198
+ end
199
+
200
+ def stringify_params(params)
201
+ out = {}
202
+ params.each { |k, v| out[k.to_s] = v }
203
+ out
204
+ end
205
+
206
+ def http_post(uri, body)
207
+ http = Net::HTTP.new(uri.host, uri.port)
208
+ http.use_ssl = (uri.scheme == "https")
209
+ http.open_timeout = OPEN_TIMEOUT_SECONDS
210
+ http.read_timeout = READ_TIMEOUT_SECONDS
211
+
212
+ request = Net::HTTP::Post.new(uri.request_uri)
213
+ request["Content-Type"] = "application/json"
214
+ request.body = body
215
+
216
+ http.request(request)
217
+ rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNREFUSED, SocketError => e
218
+ raise DeliveryRetriableError, "GA4 network error: #{e.class}: #{e.message}"
219
+ end
220
+
221
+ # Map the response code to retry/discard semantics. GA4 returns 2xx
222
+ # in practice for both successful and malformed payloads (Scout §2
223
+ # line 211), so 4xx is defensive coverage in case Google ever
224
+ # changes the contract.
225
+ def map_response_to_exception!(response)
226
+ code = response.code.to_i
227
+ return if code.between?(200, 299)
228
+
229
+ message = "GA4 returned HTTP #{code}: #{response.body.to_s[0, 200]}"
230
+
231
+ if code.between?(500, 599)
232
+ raise DeliveryRetriableError, message
233
+ else
234
+ raise DeliveryDiscardableError, message
235
+ end
236
+ end
237
+
238
+ def warn_missing_credentials(measurement_id, api_secret)
239
+ return unless defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
240
+
241
+ missing = []
242
+ missing << "ga4_measurement_id" if measurement_id.nil?
243
+ missing << "ga4_api_secret" if api_secret.nil?
244
+ Rails.logger.warn(
245
+ "[track_relay] Ga4MeasurementProtocol skipping delivery — missing config: #{missing.join(", ")}"
246
+ )
247
+ end
248
+ end
249
+ end
250
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "track_relay/subscribers/base"
5
+
6
+ module TrackRelay
7
+ module Subscribers
8
+ # Two-output subscriber that surfaces every event in development /
9
+ # production logs and persists untyped (non-catalog) events to a
10
+ # JSONL sidecar for the linter (Plan 08) and the end-to-end test
11
+ # (Plan 09) to consume.
12
+ #
13
+ # **Outputs:**
14
+ #
15
+ # 1. **Always** writes a human-readable line to `Rails.logger.info`:
16
+ # `[track_relay] event=<name> kind=<typed|untyped> params=[...]`
17
+ #
18
+ # 2. **Only when** {Configuration#untyped_log_path} is set AND the
19
+ # payload is untyped (`payload.definition.nil?`), appends a JSONL
20
+ # line to that path. The line shape is locked at:
21
+ #
22
+ # ```json
23
+ # {
24
+ # "event": "<event_name>",
25
+ # "params": ["param_a", "param_b"],
26
+ # "controller": "ArticlesController",
27
+ # "action": "show",
28
+ # "timestamp": "2026-05-06T12:00:00Z"
29
+ # }
30
+ # ```
31
+ #
32
+ # **Privacy contract (locked in 01-CONTEXT.md):** param VALUES are
33
+ # NEVER written. Only sorted, stringified param NAMES. The
34
+ # JSONL is a "what events fired" audit trail, not a payload log.
35
+ #
36
+ # The same line shape is read by `TrackRelay::Linter` (Plan 08) and
37
+ # asserted by the end-to-end test (Plan 09). Keep these three sites
38
+ # in sync if the shape ever needs to change.
39
+ class Logger < Base
40
+ synchronous!
41
+
42
+ def deliver(payload)
43
+ log_human(payload)
44
+ log_untyped_jsonl(payload) if untyped?(payload) && jsonl_path
45
+ end
46
+
47
+ private
48
+
49
+ def log_human(payload)
50
+ return unless defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
51
+ marker = untyped?(payload) ? "untyped" : "typed"
52
+ Rails.logger.info(
53
+ "[track_relay] event=#{payload.name} kind=#{marker} params=#{payload.params.keys.sort.inspect}"
54
+ )
55
+ end
56
+
57
+ def log_untyped_jsonl(payload)
58
+ line = {
59
+ event: payload.name.to_s,
60
+ params: payload.params.keys.map(&:to_s).sort,
61
+ controller: payload.context[:controller],
62
+ action: payload.context[:action],
63
+ timestamp: payload.timestamp.iso8601
64
+ }
65
+ File.open(jsonl_path, "a") do |f|
66
+ f.puts(JSON.generate(line))
67
+ end
68
+ end
69
+
70
+ def untyped?(payload)
71
+ payload.definition.nil?
72
+ end
73
+
74
+ def jsonl_path
75
+ TrackRelay.config.untyped_log_path
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "track_relay/subscribers/base"
4
+
5
+ module TrackRelay
6
+ module Subscribers
7
+ # In-memory capture subscriber for use in test suites.
8
+ #
9
+ # Plan 07 will wire this into `TrackRelay.test_mode!`, which swaps
10
+ # the configured subscriber list for a single Test instance for the
11
+ # duration of an example so consumer tests can assert against fired
12
+ # events without sending them to real adapters.
13
+ #
14
+ # Per-instance state — no class-level globals — so multiple
15
+ # instances do not crosstalk. {#clear!} resets the buffer; {#find}
16
+ # filters by event name.
17
+ #
18
+ # @example
19
+ # sub = TrackRelay::Subscribers::Test.new
20
+ # sub.handle(payload)
21
+ # sub.events # => [payload]
22
+ # sub.find(:foo) # => [payload] if payload.name == :foo
23
+ # sub.clear!
24
+ class Test < Base
25
+ synchronous!
26
+
27
+ # @return [Array<EventPayload>] captured payloads in insertion order
28
+ attr_reader :events
29
+
30
+ def initialize
31
+ super
32
+ @events = []
33
+ end
34
+
35
+ # Append the payload to {#events}. Called inline by {Base#handle}
36
+ # because {.synchronous!} is set.
37
+ #
38
+ # @param payload [EventPayload]
39
+ # @return [Array<EventPayload>] the events buffer (after append)
40
+ def deliver(payload)
41
+ @events << payload
42
+ end
43
+
44
+ # Clear the captured events buffer.
45
+ #
46
+ # @return [Array] the empty buffer
47
+ def clear!
48
+ @events.clear
49
+ end
50
+
51
+ # Return only the captured payloads whose `name` equals `name`.
52
+ #
53
+ # @param name [Symbol]
54
+ # @return [Array<EventPayload>]
55
+ def find(name)
56
+ @events.select { |e| e.name == name }
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "track_relay/testing"
4
+ require "track_relay/testing/minitest_assertions"
5
+
6
+ module TrackRelay
7
+ module Testing
8
+ # Minitest mix-in that combines {MinitestAssertions} with auto
9
+ # setup/teardown for {TrackRelay.test_mode!}.
10
+ #
11
+ # Including this module into `ActiveSupport::TestCase` /
12
+ # `Minitest::Test`:
13
+ # 1. mixes in `assert_tracked` / `refute_tracked` /
14
+ # `track_relay_test`;
15
+ # 2. registers `setup` / `teardown` blocks that auto-enable
16
+ # `TrackRelay.test_mode!` per test, so each example gets a fresh
17
+ # Test subscriber.
18
+ #
19
+ # class MyTest < ActiveSupport::TestCase
20
+ # include TrackRelay::Testing::Helpers
21
+ #
22
+ # test "fires :foo" do
23
+ # MyService.run!
24
+ # assert_tracked :foo, user_id: 1
25
+ # end
26
+ # end
27
+ #
28
+ # The `setup` / `teardown` blocks only register when the including
29
+ # class supports them (i.e. when included into a Minitest test
30
+ # class). Including the module elsewhere is a no-op for the hooks;
31
+ # `MinitestAssertions` is still mixed in so consumers can wire
32
+ # `test_mode!` themselves.
33
+ module Helpers
34
+ def self.included(base)
35
+ base.include(MinitestAssertions)
36
+
37
+ if base.respond_to?(:setup) && base.respond_to?(:teardown)
38
+ base.setup { TrackRelay.test_mode! }
39
+ base.teardown { TrackRelay.test_mode_off! }
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end