track_relay 1.1.0 → 1.3.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: 918577fceae3974911227a1b988f8d17ecf636c086b7f91ad9b8e36ac6d2b9fd
4
- data.tar.gz: 1dcd1c4f9bb900b0b3945a40ae0222b3205212f52477e10640baa0facc39ae40
3
+ metadata.gz: 4d71fbb5a7101c3f407ba9fc7c05bf032a5718447a7cacd341647b9b794c47b2
4
+ data.tar.gz: b36598739beda0519f71b98cf906159b3857234dd9510d7aff35d1f33ecd9808
5
5
  SHA512:
6
- metadata.gz: c95d0181afcad7811afaff649155f78681f41a2d54fc3f3ebaf1057f56e4b146d5704a6954e83183d9ef78269a807b648545cc493b42e22c9b93dd53084c9fd2
7
- data.tar.gz: 9d0c93c207fbdfa07237d9f25657053626711ac0eedf576fd0bb36db84203b67e716bd0f382face10074cbacb4a24842a7e56ac1c7b7adc82ac59aed66036fd3
6
+ metadata.gz: f71946bad98b203c57850991846de8e3fb95828bc825f5d9ccb2d6b5a205d6531253087a65e77c0e4eb4981f5e95129439ecf081fc719939618daeb93b611ca0
7
+ data.tar.gz: 1b05f9d685e43a6c97e37f306aa6f0a970f1b7fe6ccb774a037a4a26043295d7464a22ec2d821425aad6eb9ac6e1e07461420f7fc8ce6875f3bec1919e3d95e1
data/CHANGELOG.md CHANGED
@@ -7,6 +7,53 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.3.0] - 2026-07-31
11
+
12
+ ### Added
13
+ - `TrackRelay::PageViewTracking` — opt-in automatic server-side
14
+ `page_view` emission for hosts delivering GA4 purely via the
15
+ Measurement Protocol (no client gtag). `include
16
+ TrackRelay::PageViewTracking` + `track_page_views` emits exactly one
17
+ `page_view` per full HTML page render — never for JSON/non-HTML,
18
+ turbo-frame fetches, turbo-stream responses, redirects, non-GETs, or
19
+ unsuccessful responses. A host suppression hook (`track_page_views
20
+ if: :method_name` or a proc, instance-exec'd per request) is where
21
+ tracking policy (opt-out, geo, bot checks) plugs in. Events ride the
22
+ normal `TrackRelay.track` path, so `track_gate`, subscribers, and
23
+ GA4 page-context enrichment apply unchanged.
24
+ - The builtin `page_view` event is registered directly with the
25
+ catalog (and therefore the manifest), deliberately bypassing the
26
+ DSL's GA4 reserved-name validation: that guard protects *custom*
27
+ events from shadowing gtag auto-collection, but the MP-standard
28
+ `page_view` is exactly what a server-side host must send.
29
+
30
+ ## [1.2.0] - 2026-07-31
31
+
32
+ ### Added
33
+ - `ClientId::SignedToken` — first-party, HMAC-signed analytics token
34
+ (ActiveSupport::MessageVerifier, SHA256) as a cryptographic
35
+ proof-of-browser replacement for `_ga`-cookie sniffing. Carries
36
+ `client_id`, `session_id`, `session_started_at`, and `last_seen_at`;
37
+ `mint`/`read`/`from_request` reject tampered, forged, wrong-secret,
38
+ and expired (`ttl:`) values by returning `nil`.
39
+ - `TrackRelay::HandshakeEndpoint` — routable Rack endpoint
40
+ (`post "/analytics/handshakes", to: TrackRelay::HandshakeEndpoint`)
41
+ that mints/refreshes the signed token cookie (HttpOnly,
42
+ SameSite=Lax, secure on SSL). Preserves an existing token's
43
+ client_id, rotates the session after 30 minutes of inactivity
44
+ (`SESSION_INACTIVITY_TIMEOUT`), and raises `ConfigurationError`
45
+ when `signed_client_token_secret` is unset.
46
+ - Config: `signed_client_token_secret` (default `nil` — feature off)
47
+ and `signed_client_token_ttl` (default 30 days).
48
+ - `ConfigurationError` error class.
49
+
50
+ ### Changed
51
+ - `Subscribers::Ga4MeasurementProtocol#prepare` now prefers the signed
52
+ first-party token — for both the browser-proof client_id and
53
+ page-context session enrichment — falling back to the `_ga` /
54
+ `_ga_<stream>` cookies so hosts can migrate incrementally. Behavior
55
+ is unchanged for hosts that don't configure a signed-token secret.
56
+
10
57
  ## [1.1.0] - 2026-07-07
11
58
 
12
59
  ### Added
@@ -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
@@ -85,7 +85,9 @@ module TrackRelay
85
85
  :ga4_use_eu_endpoint,
86
86
  :ga4_require_browser_client_id,
87
87
  :ga4_enrich_page_context,
88
- :track_gate
88
+ :track_gate,
89
+ :signed_client_token_secret,
90
+ :signed_client_token_ttl
89
91
 
90
92
  # @return [Array] registered subscriber instances, in insertion order
91
93
  attr_reader :subscribers
@@ -112,6 +114,8 @@ module TrackRelay
112
114
  @ga4_require_browser_client_id = false
113
115
  @ga4_enrich_page_context = false
114
116
  @track_gate = nil
117
+ @signed_client_token_secret = nil
118
+ @signed_client_token_ttl = 30.days
115
119
  end
116
120
 
117
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
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ module TrackRelay
6
+ # Opt-in automatic server-side `page_view` emission.
7
+ #
8
+ # Hosts that deliver GA4 purely via the Measurement Protocol (no
9
+ # client-side gtag) still need standard `page_view` events for GA4's
10
+ # page and engagement reports. Include this concern and call the
11
+ # macro:
12
+ #
13
+ # class ApplicationController < ActionController::Base
14
+ # include TrackRelay::PageViewTracking
15
+ # track_page_views
16
+ # end
17
+ #
18
+ # Events go through the normal {TrackRelay.track} path, so the host's
19
+ # `track_gate`, subscribers, and GA4 page-context enrichment apply
20
+ # unchanged.
21
+ #
22
+ # ## The builtin `page_view` catalog entry
23
+ #
24
+ # `page_view` sits in {TrackRelay::GA4_RESERVED_NAMES}, which guards
25
+ # *custom* catalog events from shadowing gtag's auto-collected ones.
26
+ # A server-side host has no auto-collection — the MP-standard
27
+ # `page_view` is exactly what it must send — so the concern registers
28
+ # the definition directly with the catalog, bypassing the DSL's
29
+ # reserved-name validation on purpose.
30
+ module PageViewTracking
31
+ extend ActiveSupport::Concern
32
+
33
+ include ControllerTracking
34
+
35
+ # Register the builtin `page_view` {EventDefinition} (no params —
36
+ # page context is attached by GA4 enrichment at delivery time).
37
+ # Idempotent so it can be re-run after test-suite `Catalog.clear!`.
38
+ #
39
+ # @return [void]
40
+ def self.register_builtin_event!
41
+ return if Catalog.defined?(:page_view)
42
+
43
+ Catalog.register(EventDefinition.new(name: :page_view))
44
+ end
45
+
46
+ included do
47
+ class_attribute :_track_relay_page_view_condition,
48
+ instance_accessor: false, instance_predicate: false, default: nil
49
+ end
50
+
51
+ class_methods do
52
+ # Enable automatic page_view emission for this controller (and
53
+ # subclasses).
54
+ #
55
+ # @param if [Symbol, Proc, nil] host-side suppression hook —
56
+ # a method name or proc evaluated in the controller instance
57
+ # per request; a falsy result suppresses the page_view.
58
+ # Tracking *policy* (opt-out, geo, bot checks) belongs to the
59
+ # host; this is where it plugs in.
60
+ # @return [void]
61
+ def track_page_views(if: nil)
62
+ self._track_relay_page_view_condition = binding.local_variable_get(:if)
63
+ PageViewTracking.register_builtin_event!
64
+ after_action :_track_relay_emit_page_view
65
+ end
66
+ end
67
+
68
+ private
69
+
70
+ def _track_relay_emit_page_view
71
+ return unless _track_relay_page_view_response?
72
+ return unless _track_relay_page_view_allowed?
73
+
74
+ PageViewTracking.register_builtin_event!
75
+ track(:page_view)
76
+ end
77
+
78
+ def _track_relay_page_view_allowed?
79
+ condition = self.class._track_relay_page_view_condition
80
+ case condition
81
+ when nil then true
82
+ when Symbol then !!send(condition)
83
+ else !!instance_exec(&condition)
84
+ end
85
+ end
86
+
87
+ # Exactly one page_view per full HTML page render:
88
+ #
89
+ # - GET only (a POST that re-renders a form is not a page view)
90
+ # - successful (2xx) only — redirects land on a page that fires its
91
+ # own page_view; error pages are noise
92
+ # - `text/html` only (excludes JSON and turbo-stream's
93
+ # `text/vnd.turbo-stream.html`)
94
+ # - not a turbo-frame fetch (partial navigation within a page)
95
+ def _track_relay_page_view_response?
96
+ request.get? &&
97
+ response.successful? &&
98
+ response.media_type == "text/html" &&
99
+ request.headers["Turbo-Frame"].nil?
100
+ end
101
+ end
102
+ end
@@ -121,12 +121,13 @@ module TrackRelay
121
121
  return payload unless config.ga4_require_browser_client_id || config.ga4_enrich_page_context
122
122
 
123
123
  request = Current.request
124
- client_id = ClientId::Ga.from_request(request)
124
+ token = signed_token(request, config)
125
+ client_id = token&.client_id || ClientId::Ga.from_request(request)
125
126
  return nil if config.ga4_require_browser_client_id && client_id.nil?
126
127
  return payload if request.nil?
127
128
 
128
129
  hash = payload.to_h
129
- hash[:params] = hash[:params].merge(page_params(request)) if config.ga4_enrich_page_context
130
+ hash[:params] = hash[:params].merge(page_params(request, token)) if config.ga4_enrich_page_context
130
131
  hash[:context] = hash[:context].merge(client_id: client_id) if client_id
131
132
  EventPayload.from_h(hash)
132
133
  end
@@ -158,13 +159,25 @@ module TrackRelay
158
159
 
159
160
  private
160
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
+
161
174
  # Page-context params captured from the live request (only when
162
175
  # `config.ga4_enrich_page_context` is enabled).
163
- def page_params(request)
176
+ def page_params(request, token = nil)
164
177
  params = {page_location: request.original_url}
165
178
  params[:page_referrer] = request.referer if request.referer && !request.referer.empty?
166
179
 
167
- if (session_id = ga_session_id(request))
180
+ if (session_id = token&.session_id || ga_session_id(request))
168
181
  params[:session_id] = session_id
169
182
  params[:engagement_time_msec] = ENGAGEMENT_TIME_MSEC
170
183
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TrackRelay
4
- VERSION = "1.1.0"
4
+ VERSION = "1.3.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"
@@ -22,6 +24,7 @@ require "track_relay/subscribers/ahoy"
22
24
  require "track_relay/delivery_job"
23
25
  require "track_relay/dispatcher"
24
26
  require "track_relay/controller_tracking"
27
+ require "track_relay/page_view_tracking"
25
28
  require "track_relay/job_tracking"
26
29
  require "track_relay/linter"
27
30
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: track_relay
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.0
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - dchuk
@@ -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,10 +204,12 @@ 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
209
211
  - lib/track_relay/manifest.rb
212
+ - lib/track_relay/page_view_tracking.rb
210
213
  - lib/track_relay/railtie.rb
211
214
  - lib/track_relay/subscribers/ahoy.rb
212
215
  - lib/track_relay/subscribers/base.rb