track_relay 1.1.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 +4 -4
- data/CHANGELOG.md +27 -0
- data/lib/track_relay/client_id/signed_token.rb +80 -0
- data/lib/track_relay/configuration.rb +5 -1
- data/lib/track_relay/errors.rb +5 -0
- data/lib/track_relay/handshake_endpoint.rb +75 -0
- data/lib/track_relay/subscribers/ga4_measurement_protocol.rb +17 -4
- data/lib/track_relay/version.rb +1 -1
- data/lib/track_relay.rb +2 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: aaa3f984be840d9f657d23e543aae04eccac6c45da4877fcdaad30e8f3dc7f7a
|
|
4
|
+
data.tar.gz: ed659de0a4cd9e7a63d50d8d6ddeb32c962c667471e4c9b56f143fcf16f0b26b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 19dd615e0a99f54df3bedaf1eb01b61ace7a338fa04d4d77373cbb43b9e786e221ade99ea3fd52e13b82a64be705a5ec2674bdc3fb69223d84e2b25157da272b
|
|
7
|
+
data.tar.gz: eabf9d5db4cb4da4643815b628cc7c065b6b93b4f4ad8345b2d4525a93c6ea982f7877db25fae2df294bef512a222cdfb3b25966988bd5f13f9e54ad58b70646
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,33 @@ 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
|
+
|
|
10
37
|
## [1.1.0] - 2026-07-07
|
|
11
38
|
|
|
12
39
|
### 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.
|
data/lib/track_relay/errors.rb
CHANGED
|
@@ -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
|
|
@@ -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
|
-
|
|
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
|
data/lib/track_relay/version.rb
CHANGED
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,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: track_relay
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.2.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,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
|