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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +147 -0
- data/LICENSE.txt +21 -0
- data/README.md +458 -0
- data/UPGRADING.md +85 -0
- data/USAGE.md +192 -0
- data/lib/generators/track_relay/event/event_generator.rb +17 -0
- data/lib/generators/track_relay/event/templates/event.rb.tt +21 -0
- data/lib/generators/track_relay/install/install_generator.rb +49 -0
- data/lib/generators/track_relay/install/templates/application_subscriber.rb.tt +31 -0
- data/lib/generators/track_relay/install/templates/initializer.rb.tt +42 -0
- data/lib/generators/track_relay/install/templates/sample_catalog.rb.tt +17 -0
- data/lib/generators/track_relay/subscriber/subscriber_generator.rb +17 -0
- data/lib/generators/track_relay/subscriber/templates/subscriber.rb.tt +28 -0
- data/lib/tasks/track_relay.rake +80 -0
- data/lib/track_relay/catalog.rb +86 -0
- data/lib/track_relay/client_id/ahoy_visitor.rb +34 -0
- data/lib/track_relay/client_id/ga.rb +48 -0
- data/lib/track_relay/client_id/session.rb +46 -0
- data/lib/track_relay/configuration.rb +141 -0
- data/lib/track_relay/controller_tracking.rb +90 -0
- data/lib/track_relay/current.rb +33 -0
- data/lib/track_relay/delivery_job.rb +84 -0
- data/lib/track_relay/dispatcher.rb +92 -0
- data/lib/track_relay/dsl/event_builder.rb +64 -0
- data/lib/track_relay/dsl/param_builder.rb +74 -0
- data/lib/track_relay/errors.rb +54 -0
- data/lib/track_relay/event_definition.rb +74 -0
- data/lib/track_relay/event_payload.rb +244 -0
- data/lib/track_relay/instrumenter.rb +241 -0
- data/lib/track_relay/job_tracking.rb +50 -0
- data/lib/track_relay/linter.rb +218 -0
- data/lib/track_relay/manifest.rb +85 -0
- data/lib/track_relay/railtie.rb +97 -0
- data/lib/track_relay/subscribers/ahoy.rb +110 -0
- data/lib/track_relay/subscribers/base.rb +231 -0
- data/lib/track_relay/subscribers/ga4_measurement_protocol.rb +250 -0
- data/lib/track_relay/subscribers/logger.rb +79 -0
- data/lib/track_relay/subscribers/test.rb +60 -0
- data/lib/track_relay/testing/helpers.rb +44 -0
- data/lib/track_relay/testing/minitest_assertions.rb +71 -0
- data/lib/track_relay/testing/rspec_matchers.rb +79 -0
- data/lib/track_relay/testing.rb +90 -0
- data/lib/track_relay/validators/catalog_validator.rb +48 -0
- data/lib/track_relay/validators/ga4_constraints.rb +85 -0
- data/lib/track_relay/version.rb +5 -0
- data/lib/track_relay.rb +203 -0
- 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
|