track_relay 1.0.0 → 1.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0ef792bdf08a7044b2505ba7d864b6efd9e52300227bd90ba6d4556833ea4317
4
- data.tar.gz: d5fa187c552b77560ccb373546702ae563b692708ac0e3aebee4b8ee96d9a90f
3
+ metadata.gz: aaa3f984be840d9f657d23e543aae04eccac6c45da4877fcdaad30e8f3dc7f7a
4
+ data.tar.gz: ed659de0a4cd9e7a63d50d8d6ddeb32c962c667471e4c9b56f143fcf16f0b26b
5
5
  SHA512:
6
- metadata.gz: d092e315a251dd34b3d29f2b61042dd6b05deadd4bd1b99cad515b28502bdb049bc4009ca2e71a3b6c3375791dc37a81e93c451ad90756d0e846142525d2fa84
7
- data.tar.gz: d9c406cdda1cf1517fc568b6f0589ce3d3655c0402c33151cc520fe2f2f054f242f4e35cd3d98b9f911919bb25da3be6dca07f80a192f084d404bab20c56542b
6
+ metadata.gz: 19dd615e0a99f54df3bedaf1eb01b61ace7a338fa04d4d77373cbb43b9e786e221ade99ea3fd52e13b82a64be705a5ec2674bdc3fb69223d84e2b25157da272b
7
+ data.tar.gz: eabf9d5db4cb4da4643815b628cc7c065b6b93b4f4ad8345b2d4525a93c6ea982f7877db25fae2df294bef512a222cdfb3b25966988bd5f13f9e54ad58b70646
data/CHANGELOG.md CHANGED
@@ -7,6 +7,69 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.2.0] - 2026-07-31
11
+
12
+ ### Added
13
+ - `ClientId::SignedToken` — first-party, HMAC-signed analytics token
14
+ (ActiveSupport::MessageVerifier, SHA256) as a cryptographic
15
+ proof-of-browser replacement for `_ga`-cookie sniffing. Carries
16
+ `client_id`, `session_id`, `session_started_at`, and `last_seen_at`;
17
+ `mint`/`read`/`from_request` reject tampered, forged, wrong-secret,
18
+ and expired (`ttl:`) values by returning `nil`.
19
+ - `TrackRelay::HandshakeEndpoint` — routable Rack endpoint
20
+ (`post "/analytics/handshakes", to: TrackRelay::HandshakeEndpoint`)
21
+ that mints/refreshes the signed token cookie (HttpOnly,
22
+ SameSite=Lax, secure on SSL). Preserves an existing token's
23
+ client_id, rotates the session after 30 minutes of inactivity
24
+ (`SESSION_INACTIVITY_TIMEOUT`), and raises `ConfigurationError`
25
+ when `signed_client_token_secret` is unset.
26
+ - Config: `signed_client_token_secret` (default `nil` — feature off)
27
+ and `signed_client_token_ttl` (default 30 days).
28
+ - `ConfigurationError` error class.
29
+
30
+ ### Changed
31
+ - `Subscribers::Ga4MeasurementProtocol#prepare` now prefers the signed
32
+ first-party token — for both the browser-proof client_id and
33
+ page-context session enrichment — falling back to the `_ga` /
34
+ `_ga_<stream>` cookies so hosts can migrate incrementally. Behavior
35
+ is unchanged for hosts that don't configure a signed-token secret.
36
+
37
+ ## [1.1.0] - 2026-07-07
38
+
39
+ ### Added
40
+ - `config.ga4_require_browser_client_id` (default `false`) — browser-proof
41
+ delivery gate for `Subscribers::Ga4MeasurementProtocol`. When enabled,
42
+ events are delivered only when the current request carries a genuine
43
+ `_ga` cookie (set by gtag JS, which bots don't execute). No cookie — or a
44
+ malformed one with fewer than four dot-segments — enqueues no
45
+ `DeliveryJob` at all; the random per-event client_id fallback never
46
+ fires. The cookie-derived client_id rides in the delivered payload's
47
+ context.
48
+ - `config.ga4_enrich_page_context` (default `false`) — notification-time
49
+ page/session enrichment for GA4 MP events: `page_location` (request
50
+ URL), `page_referrer` (when a referer exists), and `session_id` +
51
+ `engagement_time_msec` (nominal 100) when the gtag `_ga_<stream>`
52
+ session cookie is parseable. Captured while the request is in scope and
53
+ serialized through the payload, so async deliveries carry it. Without
54
+ these params GA4 files server-side events under a blank page path.
55
+ - `config.track_gate` (default `nil`) — host-level fan-out gate. An
56
+ optional callable receiving `payload:` and `request:` keywords,
57
+ evaluated once per `TrackRelay.track` call before the event notification
58
+ fires; a falsy return drops the event for every subscriber. Enables
59
+ e.g. "only track requests that prove a real browser" across Ahoy, GA4,
60
+ and Logger at once.
61
+ - `Subscribers::Base#prepare(payload)` — public notification-time hook
62
+ (filter → prepare → sync/async) for subclasses to gate delivery
63
+ (return `nil`) or swap in an enriched copy of the payload while the
64
+ request is still in scope.
65
+ - `ClientId::Ga.parse` / `ClientId::Ga.from_request` — the `_ga` parse
66
+ rule as reusable class methods (the resolver-chain `#call` now
67
+ delegates to them).
68
+
69
+ ### Changed
70
+ - All new options default off: an unconfigured upgrade is byte-identical
71
+ to 1.0.0 (the pre-existing test suite passes unmodified).
72
+
10
73
  ## [1.0.0] - 2026-05-07
11
74
 
12
75
  ### Added
@@ -141,6 +204,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
141
204
  - Privacy: untyped JSONL captures param NAMES only (never VALUES) to avoid leaking PII.
142
205
  - Naming: `track_relay` availability on RubyGems will be re-validated before 1.0.
143
206
 
207
+ [1.1.0]: https://github.com/dchuk/track_relay/compare/v1.0.0...v1.1.0
144
208
  [1.0.0]: https://github.com/dchuk/track_relay/compare/v0.3.0...v1.0.0
145
209
  [0.3.0]: https://github.com/dchuk/track_relay/releases/tag/v0.3.0
146
210
  [0.2.0]: https://github.com/dchuk/track_relay/releases/tag/v0.2.0
data/README.md CHANGED
@@ -334,6 +334,46 @@ TrackRelay.subscribe(
334
334
  )
335
335
  ```
336
336
 
337
+ ### Browser-proof gating + page context (opt-in)
338
+
339
+ Server-side GA4 events have two chronic problems: bots with browser user agents inflate user counts (the Measurement Protocol accepts any client_id, and the subscriber's fallback mints a random one per event), and events land under a blank page path because nothing sends `page_location`. Two opt-in flags fix both:
340
+
341
+ ```ruby
342
+ TrackRelay.configure do |c|
343
+ # Deliver ONLY when the current request carries a genuine `_ga`
344
+ # cookie — set by gtag JS, which bots don't execute. No cookie (or a
345
+ # malformed one) means no DeliveryJob is enqueued at all; the random
346
+ # client_id fallback never fires. The cookie-derived client_id is
347
+ # used for the delivery.
348
+ c.ga4_require_browser_client_id = true
349
+
350
+ # Capture page context from the request at notification time and
351
+ # send it with every GA4 event: `page_location` (request URL),
352
+ # `page_referrer` (when a referer exists), and `session_id` +
353
+ # `engagement_time_msec` when the gtag `_ga_<stream>` session cookie
354
+ # is present.
355
+ c.ga4_enrich_page_context = true
356
+ end
357
+ ```
358
+
359
+ Both run inside the subscriber's notification-time `prepare` hook — the request is still in scope there, and the captured values ride through the serialized payload to the async `DeliveryJob`. Other subscribers always receive the original, unmodified payload.
360
+
361
+ ### Host-level track gate (opt-in)
362
+
363
+ `config.track_gate` is evaluated once per `TrackRelay.track` call, before the event fans out to **any** subscriber. A falsy return drops the event everywhere (Ahoy, GA4, Logger, …). It receives `payload:` and `request:` keywords:
364
+
365
+ ```ruby
366
+ TrackRelay.configure do |c|
367
+ # Only track requests that prove a real browser — aligns first-party
368
+ # analytics (e.g. Ahoy) with what GA4 sees under the browser gate.
369
+ c.track_gate = ->(payload:, request:) {
370
+ TrackRelay::ClientId::Ga.from_request(request)
371
+ }
372
+ end
373
+ ```
374
+
375
+ Unset (`nil`, the default) disables the gate entirely.
376
+
337
377
  ### `client_id` resolver chain
338
378
 
339
379
  `TrackRelay::Current.client_id` is resolved via a configurable chain of `client_id_resolvers`. The default chain checks the GA `_ga` cookie, then any Ahoy visitor token, then mints a session-stable UUID into `session[:track_relay_client_id]` so visitors without a `_ga` cookie still get a stable identifier. First non-nil wins; per-resolver exceptions are isolated so a single buggy resolver cannot block the chain.
@@ -28,6 +28,35 @@ module TrackRelay
28
28
  # and yield `nil` (callers should fall through to the next resolver
29
29
  # in the chain).
30
30
  class Ga
31
+ # Parse a raw `_ga` cookie value into a GA4 client_id.
32
+ #
33
+ # This is THE canonical `_ga` parse rule — the resolver chain
34
+ # ({#call}) and the browser-proof delivery gate
35
+ # ({Subscribers::Ga4MeasurementProtocol#prepare}) both delegate
36
+ # here so the "fewer than four segments = malformed = nil"
37
+ # posture is defined exactly once.
38
+ #
39
+ # @param cookie_value [String, nil] raw `_ga` cookie value
40
+ # @return [String, nil] the parsed GA4 client_id, or `nil` when
41
+ # the value is missing/empty/malformed.
42
+ def self.parse(cookie_value)
43
+ return nil if cookie_value.nil? || cookie_value.empty?
44
+
45
+ parts = cookie_value.split(".")
46
+ return nil if parts.size < 4
47
+
48
+ "#{parts[-2]}.#{parts[-1]}"
49
+ end
50
+
51
+ # Extract and parse the `_ga` cookie from a request-like object.
52
+ #
53
+ # @param request [#cookies, nil] any object exposing
54
+ # `request.cookies["_ga"]` (e.g. an `ActionDispatch::Request`).
55
+ # @return [String, nil]
56
+ def self.from_request(request)
57
+ parse(request&.cookies&.[]("_ga"))
58
+ end
59
+
31
60
  # @param controller [#request] any object exposing
32
61
  # `controller.request.cookies["_ga"]`. Typically an
33
62
  # `ActionController::Base` instance from
@@ -35,13 +64,7 @@ module TrackRelay
35
64
  # @return [String, nil] the parsed GA4 client_id, or `nil` when the
36
65
  # cookie is missing/empty/malformed.
37
66
  def call(controller:, **)
38
- ga_cookie = controller&.request&.cookies&.[]("_ga")
39
- return nil if ga_cookie.nil? || ga_cookie.empty?
40
-
41
- parts = ga_cookie.split(".")
42
- return nil if parts.size < 4
43
-
44
- "#{parts[-2]}.#{parts[-1]}"
67
+ self.class.from_request(controller&.request)
45
68
  end
46
69
  end
47
70
  end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/message_verifier"
4
+
5
+ module TrackRelay
6
+ module ClientId
7
+ # First-party, HMAC-signed client token — the spoof-proof
8
+ # replacement for `_ga`-cookie browser proof.
9
+ #
10
+ # The cookie value is generated and verified with
11
+ # {ActiveSupport::MessageVerifier} (SHA256), so any tampering,
12
+ # truncation, or wrong-secret forgery verifies to `nil` rather
13
+ # than raising.
14
+ class SignedToken
15
+ COOKIE_NAME = "_tr_cid"
16
+
17
+ # Verified token contents. `session_started_at` is a Time (UTC);
18
+ # `session_id`/`client_id` are strings shaped for GA4 payloads.
19
+ Token = Struct.new(:client_id, :session_id, :session_started_at, :last_seen_at, keyword_init: true)
20
+
21
+ # Generate a signed cookie value embedding the client_id and
22
+ # optional session fields.
23
+ #
24
+ # @param secret [String] HMAC signing secret
25
+ # @param client_id [String] GA4-shaped client id to embed
26
+ # @param session_id [String, nil] GA4 session id
27
+ # @param session_started_at [Time, nil] start of the embedded session
28
+ # @param ttl [ActiveSupport::Duration, nil] validity window;
29
+ # `nil` mints a non-expiring token
30
+ # @return [String] opaque signed cookie value
31
+ def self.mint(secret:, client_id:, session_id: nil, session_started_at: nil, last_seen_at: nil, ttl: nil)
32
+ payload = {
33
+ "cid" => client_id,
34
+ "sid" => session_id,
35
+ "sst" => session_started_at&.to_i,
36
+ "lsa" => last_seen_at&.to_i
37
+ }.compact
38
+ verifier(secret).generate(payload, expires_in: ttl)
39
+ end
40
+
41
+ # Read and verify the signed cookie into a {Token}.
42
+ #
43
+ # @param request [#cookies, nil] any object exposing
44
+ # `request.cookies[COOKIE_NAME]`
45
+ # @param secret [String] HMAC signing secret
46
+ # @return [Token, nil] verified token contents, or `nil` when the
47
+ # cookie is missing or unverifiable.
48
+ def self.read(request, secret:)
49
+ value = request&.cookies&.[](COOKIE_NAME)
50
+ return nil if value.nil? || value.empty?
51
+
52
+ payload = verifier(secret).verified(value)
53
+ return nil unless payload
54
+
55
+ Token.new(
56
+ client_id: payload["cid"],
57
+ session_id: payload["sid"],
58
+ session_started_at: payload["sst"] && Time.at(payload["sst"]).utc,
59
+ last_seen_at: payload["lsa"] && Time.at(payload["lsa"]).utc
60
+ )
61
+ end
62
+
63
+ # Convenience for gate/client-id use: just the verified client_id.
64
+ #
65
+ # @param request [#cookies, nil] any object exposing
66
+ # `request.cookies[COOKIE_NAME]`
67
+ # @param secret [String] HMAC signing secret
68
+ # @return [String, nil] the embedded client_id, or `nil` when the
69
+ # cookie is missing or unverifiable.
70
+ def self.from_request(request, secret:)
71
+ read(request, secret: secret)&.client_id
72
+ end
73
+
74
+ def self.verifier(secret)
75
+ ActiveSupport::MessageVerifier.new(secret, digest: "SHA256", serializer: JSON)
76
+ end
77
+ private_class_method :verifier
78
+ end
79
+ end
80
+ end
@@ -50,6 +50,30 @@ module TrackRelay
50
50
  # When `true`, the GA4 subscriber posts to
51
51
  # `https://region1.google-analytics.com/mp/collect` instead of
52
52
  # the global endpoint. Defaults to `false`.
53
+ # @!attribute [rw] ga4_require_browser_client_id
54
+ # When `true`, the GA4 subscriber delivers ONLY when the current
55
+ # request carries a genuine `_ga` cookie (set by gtag JS, which
56
+ # bots don't execute). No cookie — or a malformed one — means no
57
+ # DeliveryJob is enqueued at all; the random client_id fallback
58
+ # never fires. The cookie-derived client_id is merged into the
59
+ # delivered payload's context. Defaults to `false` (deliver
60
+ # everything, as before).
61
+ # @!attribute [rw] ga4_enrich_page_context
62
+ # When `true`, the GA4 subscriber captures page context from the
63
+ # current request at notification time and merges it into the
64
+ # delivered event's params: `page_location` (request URL),
65
+ # `page_referrer` (when a referer exists), and — when the gtag
66
+ # session cookie `_ga_<stream>` is parseable — `session_id` plus
67
+ # a nominal `engagement_time_msec`. Without these GA4 files
68
+ # server-side events under a blank page path. Defaults to
69
+ # `false`.
70
+ # @!attribute [rw] track_gate
71
+ # Optional callable evaluated once per {TrackRelay.track} call,
72
+ # BEFORE the event notification fans out to any subscriber.
73
+ # Receives `payload:` (the built {EventPayload}) and `request:`
74
+ # ({Current.request}, possibly `nil`) keywords; a falsy return
75
+ # drops the event for every subscriber. `nil` (the default)
76
+ # disables the gate entirely — current behavior.
53
77
  attr_accessor :swallow_subscriber_errors,
54
78
  :untyped_log_path,
55
79
  :untyped_events_allowed,
@@ -58,7 +82,12 @@ module TrackRelay
58
82
  :client_id_resolvers,
59
83
  :ga4_measurement_id,
60
84
  :ga4_api_secret,
61
- :ga4_use_eu_endpoint
85
+ :ga4_use_eu_endpoint,
86
+ :ga4_require_browser_client_id,
87
+ :ga4_enrich_page_context,
88
+ :track_gate,
89
+ :signed_client_token_secret,
90
+ :signed_client_token_ttl
62
91
 
63
92
  # @return [Array] registered subscriber instances, in insertion order
64
93
  attr_reader :subscribers
@@ -82,6 +111,11 @@ module TrackRelay
82
111
  @ga4_measurement_id = nil
83
112
  @ga4_api_secret = nil
84
113
  @ga4_use_eu_endpoint = false
114
+ @ga4_require_browser_client_id = false
115
+ @ga4_enrich_page_context = false
116
+ @track_gate = nil
117
+ @signed_client_token_secret = nil
118
+ @signed_client_token_ttl = 30.days
85
119
  end
86
120
 
87
121
  # Append a subscriber to the registry.
@@ -32,6 +32,11 @@ module TrackRelay
32
32
  # the catalog and untyped events are disabled.
33
33
  class UnknownEventError < Error; end
34
34
 
35
+ # Raised when a feature is used without its required configuration —
36
+ # e.g. mounting {HandshakeEndpoint} without setting
37
+ # `config.signed_client_token_secret`.
38
+ class ConfigurationError < Error; end
39
+
35
40
  # Raised by a subscriber's `#deliver` to signal a *transient* failure
36
41
  # that the {DeliveryJob} should retry via ActiveJob's `retry_on`.
37
42
  # Examples: HTTP 5xx response, `Net::OpenTimeout`, `Errno::ECONNREFUSED`,
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module TrackRelay
6
+ # Routable Rack endpoint that mints the first-party signed analytics
7
+ # token ({ClientId::SignedToken}). Hosts mount it directly:
8
+ #
9
+ # post "/analytics/handshakes", to: TrackRelay::HandshakeEndpoint
10
+ #
11
+ # A small host-side JS module calls it from the browser; because only
12
+ # JS-executing clients ever do, possession of a valid token is
13
+ # proof-of-browser for the host's `track_gate`.
14
+ class HandshakeEndpoint
15
+ # GA4 semantics: a session ends after this much inactivity, however
16
+ # long it has been running. Activity = any handshake call.
17
+ SESSION_INACTIVITY_TIMEOUT = 30 * 60
18
+
19
+ def self.call(env)
20
+ new.call(env)
21
+ end
22
+
23
+ def call(env)
24
+ request = ActionDispatch::Request.new(env)
25
+ config = TrackRelay.config
26
+ now = Time.now.utc
27
+
28
+ if config.signed_client_token_secret.nil? || config.signed_client_token_secret.empty?
29
+ raise ConfigurationError,
30
+ "TrackRelay::HandshakeEndpoint requires config.signed_client_token_secret to be set"
31
+ end
32
+
33
+ existing = ClientId::SignedToken.read(request, secret: config.signed_client_token_secret)
34
+ session_alive = existing && !session_stale?(existing, now)
35
+
36
+ cookie_value = ClientId::SignedToken.mint(
37
+ secret: config.signed_client_token_secret,
38
+ client_id: existing&.client_id || fresh_client_id(now),
39
+ session_id: session_alive ? existing.session_id : now.to_i.to_s,
40
+ session_started_at: session_alive ? existing.session_started_at : now,
41
+ last_seen_at: now,
42
+ ttl: config.signed_client_token_ttl
43
+ )
44
+
45
+ [204, {"set-cookie" => set_cookie(cookie_value, request, now, config)}, []]
46
+ end
47
+
48
+ private
49
+
50
+ # A session with no recorded activity falls back to its start time,
51
+ # so pre-`last_seen_at` tokens still rotate sensibly.
52
+ def session_stale?(token, now)
53
+ reference = token.last_seen_at || token.session_started_at
54
+ reference.nil? || (now - reference) > SESSION_INACTIVITY_TIMEOUT
55
+ end
56
+
57
+ # GA4-shaped client_id: `<random_int31>.<unix_ts>`.
58
+ def fresh_client_id(now)
59
+ "#{SecureRandom.random_number(0x7FFFFFFF)}.#{now.to_i}"
60
+ end
61
+
62
+ def set_cookie(value, request, now, config)
63
+ attributes = {
64
+ value: value,
65
+ path: "/",
66
+ httponly: true,
67
+ same_site: :lax,
68
+ secure: request.ssl?
69
+ }
70
+ attributes[:expires] = now + config.signed_client_token_ttl if config.signed_client_token_ttl
71
+
72
+ Rack::Utils.set_cookie_header(ClientId::SignedToken::COOKIE_NAME, attributes)
73
+ end
74
+ end
75
+ end
@@ -82,6 +82,7 @@ module TrackRelay
82
82
  extra_context: direct_context
83
83
  )
84
84
  return unless validate(payload)
85
+ return unless track_gate_allows?(payload)
85
86
  ActiveSupport::Notifications.instrument(NOTIFICATION, event: payload)
86
87
  end
87
88
  end
@@ -215,6 +216,21 @@ module TrackRelay
215
216
  }
216
217
  end
217
218
 
219
+ # Evaluate {Configuration#track_gate} once per track call, inside
220
+ # the `Current.set` scope so the gate sees the same request the
221
+ # subscribers would. Runs AFTER validation and BEFORE the
222
+ # notification fans out — a falsy return means no subscriber ever
223
+ # sees the event. Unset gate (`nil`) always allows.
224
+ #
225
+ # @param payload [EventPayload]
226
+ # @return [Boolean]
227
+ def track_gate_allows?(payload)
228
+ gate = TrackRelay.config.track_gate
229
+ return true if gate.nil?
230
+
231
+ !!gate.call(payload: payload, request: Current.request)
232
+ end
233
+
218
234
  # Run {EventPayload#validate!} and apply the
219
235
  # {Configuration#raise_on_validation_error} gate.
220
236
  #
@@ -109,6 +109,9 @@ module TrackRelay
109
109
  def handle(payload)
110
110
  return nil if filtered?(payload.name.to_sym)
111
111
 
112
+ payload = prepare(payload)
113
+ return nil if payload.nil?
114
+
112
115
  if self.class.synchronous || TrackRelay.config.force_synchronous
113
116
  safe_deliver(payload)
114
117
  else
@@ -117,6 +120,23 @@ module TrackRelay
117
120
  end
118
121
  end
119
122
 
123
+ # Notification-time hook, called by {#handle} after the event-name
124
+ # filter and BEFORE the sync/async branch — i.e. while the request
125
+ # ({Current.request}) is still in scope, which is gone by the time
126
+ # an async {DeliveryJob} runs.
127
+ #
128
+ # Subclasses override this to gate delivery (return `nil` to drop
129
+ # the event silently — no job, no `deliver`) or to swap in an
130
+ # enriched **copy** of the payload. The same payload object fans
131
+ # out to every subscriber, so implementations must never mutate
132
+ # `payload` — build a new {EventPayload} instead.
133
+ #
134
+ # @param payload [EventPayload]
135
+ # @return [EventPayload, nil] the payload to deliver, or `nil` to drop
136
+ def prepare(payload)
137
+ payload
138
+ end
139
+
120
140
  # Wrap {#deliver} with the per-subscriber rescue.
121
141
  #
122
142
  # Returns `nil` on success or the StandardError on failure. ALWAYS
@@ -35,6 +35,12 @@ module TrackRelay
35
35
  # - `config.ga4_api_secret` — per-stream MP secret
36
36
  # - `config.ga4_use_eu_endpoint` — when `true`, post to
37
37
  # `https://region1.google-analytics.com/mp/collect`
38
+ # - `config.ga4_require_browser_client_id` — when `true`, deliver
39
+ # only for requests carrying a genuine `_ga` cookie (see
40
+ # {#prepare})
41
+ # - `config.ga4_enrich_page_context` — when `true`, merge
42
+ # `page_location` / `page_referrer` / `session_id` /
43
+ # `engagement_time_msec` into delivered params (see {#prepare})
38
44
  #
39
45
  # When `ga4_measurement_id` or `ga4_api_secret` is `nil`, `#deliver`
40
46
  # emits a single `Rails.logger.warn` and returns without raising —
@@ -85,6 +91,47 @@ module TrackRelay
85
91
  # drops them.
86
92
  RESERVED_PARAM_PREFIXES = %w[firebase_ ga_ google_].freeze
87
93
 
94
+ # Nominal engagement time attached alongside `session_id` when
95
+ # page-context enrichment finds a gtag session cookie. GA4 needs
96
+ # a nonzero value for server events to count toward session
97
+ # activity; the number itself is nominal (Google's Measurement
98
+ # Protocol examples use 100).
99
+ ENGAGEMENT_TIME_MSEC = 100
100
+
101
+ # Browser-proof gate + page-context enrichment (issue #1), both
102
+ # opt-in via configuration. Runs at notification time —
103
+ # {Current.request} is gone by the time an async {DeliveryJob}
104
+ # performs, so gating and enrichment MUST happen here and ride
105
+ # through the serialized payload.
106
+ #
107
+ # - `config.ga4_require_browser_client_id`: deliver only when the
108
+ # current request carries a genuine `_ga` cookie; the
109
+ # cookie-derived client_id lands in the delivered context.
110
+ # - `config.ga4_enrich_page_context`: merge `page_location`,
111
+ # conditional `page_referrer`, and conditional `session_id` /
112
+ # `engagement_time_msec` into the delivered params.
113
+ #
114
+ # Either path swaps in a **copy** — the shared payload other
115
+ # subscribers receive during fan-out is never mutated.
116
+ #
117
+ # @param payload [EventPayload]
118
+ # @return [EventPayload, nil] `nil` drops the event (no job)
119
+ def prepare(payload)
120
+ config = TrackRelay.config
121
+ return payload unless config.ga4_require_browser_client_id || config.ga4_enrich_page_context
122
+
123
+ request = Current.request
124
+ token = signed_token(request, config)
125
+ client_id = token&.client_id || ClientId::Ga.from_request(request)
126
+ return nil if config.ga4_require_browser_client_id && client_id.nil?
127
+ return payload if request.nil?
128
+
129
+ hash = payload.to_h
130
+ hash[:params] = hash[:params].merge(page_params(request, token)) if config.ga4_enrich_page_context
131
+ hash[:context] = hash[:context].merge(client_id: client_id) if client_id
132
+ EventPayload.from_h(hash)
133
+ end
134
+
88
135
  # POST `payload` to the GA4 Measurement Protocol endpoint.
89
136
  #
90
137
  # See class docs for the full configuration / error contract.
@@ -112,6 +159,49 @@ module TrackRelay
112
159
 
113
160
  private
114
161
 
162
+ # Verified first-party token, when the host has configured a
163
+ # signed-token secret. The token is the preferred (cryptographic)
164
+ # source for both client_id and session_id; the `_ga`/`_ga_<stream>`
165
+ # cookies remain as fallbacks so hosts can migrate incrementally.
166
+ #
167
+ # @return [ClientId::SignedToken::Token, nil]
168
+ def signed_token(request, config)
169
+ return nil unless config.signed_client_token_secret
170
+
171
+ ClientId::SignedToken.read(request, secret: config.signed_client_token_secret)
172
+ end
173
+
174
+ # Page-context params captured from the live request (only when
175
+ # `config.ga4_enrich_page_context` is enabled).
176
+ def page_params(request, token = nil)
177
+ params = {page_location: request.original_url}
178
+ params[:page_referrer] = request.referer if request.referer && !request.referer.empty?
179
+
180
+ if (session_id = token&.session_id || ga_session_id(request))
181
+ params[:session_id] = session_id
182
+ params[:engagement_time_msec] = ENGAGEMENT_TIME_MSEC
183
+ end
184
+ params
185
+ end
186
+
187
+ # The gtag session cookie is `_ga_<stream>` (measurement id minus
188
+ # the "G-" prefix), shaped `GS1.1.<session_id>.<session_number>...`.
189
+ # The format is Google-controlled, so parse defensively — too few
190
+ # segments or a blank session segment yields nil and the delivery
191
+ # proceeds without session params.
192
+ def ga_session_id(request)
193
+ stream = TrackRelay.config.ga4_measurement_id.to_s.delete_prefix("G-")
194
+ return nil if stream.empty?
195
+
196
+ cookie = request.cookies["_ga_#{stream}"]
197
+ return nil if cookie.nil? || cookie.empty?
198
+
199
+ parts = cookie.split(".")
200
+ return nil if parts.size < 4 || parts[2].nil? || parts[2].empty?
201
+
202
+ parts[2]
203
+ end
204
+
115
205
  # Run call-time payload constraint checks (REQ-27 split). Returns
116
206
  # `true` when delivery should proceed, `false` when a constraint
117
207
  # was violated AND `raise_on_validation_error` is `false` (the
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TrackRelay
4
- VERSION = "1.0.0"
4
+ VERSION = "1.2.0"
5
5
  end
data/lib/track_relay.rb CHANGED
@@ -12,6 +12,8 @@ require "track_relay/current"
12
12
  require "track_relay/client_id/ga"
13
13
  require "track_relay/client_id/ahoy_visitor"
14
14
  require "track_relay/client_id/session"
15
+ require "track_relay/client_id/signed_token"
16
+ require "track_relay/handshake_endpoint"
15
17
  require "track_relay/configuration"
16
18
  require "track_relay/instrumenter"
17
19
  require "track_relay/subscribers/base"
metadata CHANGED
@@ -1,10 +1,10 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: track_relay
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
- - Darrin Demchuk
7
+ - dchuk
8
8
  bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
@@ -193,6 +193,7 @@ files:
193
193
  - lib/track_relay/client_id/ahoy_visitor.rb
194
194
  - lib/track_relay/client_id/ga.rb
195
195
  - lib/track_relay/client_id/session.rb
196
+ - lib/track_relay/client_id/signed_token.rb
196
197
  - lib/track_relay/configuration.rb
197
198
  - lib/track_relay/controller_tracking.rb
198
199
  - lib/track_relay/current.rb
@@ -203,6 +204,7 @@ files:
203
204
  - lib/track_relay/errors.rb
204
205
  - lib/track_relay/event_definition.rb
205
206
  - lib/track_relay/event_payload.rb
207
+ - lib/track_relay/handshake_endpoint.rb
206
208
  - lib/track_relay/instrumenter.rb
207
209
  - lib/track_relay/job_tracking.rb
208
210
  - lib/track_relay/linter.rb