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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 0ef792bdf08a7044b2505ba7d864b6efd9e52300227bd90ba6d4556833ea4317
|
|
4
|
+
data.tar.gz: d5fa187c552b77560ccb373546702ae563b692708ac0e3aebee4b8ee96d9a90f
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: d092e315a251dd34b3d29f2b61042dd6b05deadd4bd1b99cad515b28502bdb049bc4009ca2e71a3b6c3375791dc37a81e93c451ad90756d0e846142525d2fa84
|
|
7
|
+
data.tar.gz: d9c406cdda1cf1517fc568b6f0589ce3d3655c0402c33151cc520fe2f2f054f242f4e35cd3d98b9f911919bb25da3be6dca07f80a192f084d404bab20c56542b
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [1.0.0] - 2026-05-07
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- `rails g track_relay:install` — opinionated scaffold: richly commented
|
|
14
|
+
initializer (`config/initializers/track_relay.rb`), sample catalog
|
|
15
|
+
(`config/track_relay/sample.rb`), ApplicationSubscriber base class
|
|
16
|
+
(`app/track_relay/subscribers/application_subscriber.rb`), and
|
|
17
|
+
`include TrackRelay::ControllerTracking` injected idempotently into
|
|
18
|
+
ApplicationController. `bundle exec rake test` passes cleanly
|
|
19
|
+
immediately after running this generator.
|
|
20
|
+
- `rails g track_relay:event NAME` — scaffolds a typed catalog entry
|
|
21
|
+
stub at `config/track_relay/<name>.rb`. Each event is its own file;
|
|
22
|
+
the Railtie merges them at boot.
|
|
23
|
+
- `rails g track_relay:subscriber NAME` — scaffolds a subscriber class
|
|
24
|
+
stub at `app/track_relay/subscribers/<name>_subscriber.rb`.
|
|
25
|
+
- Getting-started guide at [USAGE.md](USAGE.md) (now shipped inside the gem).
|
|
26
|
+
- Migration notes at [UPGRADING.md](UPGRADING.md) (now shipped inside the gem).
|
|
27
|
+
- E2E happy-path test exercising the install generator's output through
|
|
28
|
+
the live Combustion harness (controller call → Test subscriber capture).
|
|
29
|
+
- README sections: Generators, Ahoy subscriber, Public API stability.
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
- Public-API stability is established for this release. See
|
|
33
|
+
[UPGRADING.md](UPGRADING.md) for migration paths from 0.1.0 / 0.2.0
|
|
34
|
+
/ 0.3.0 to 1.0.0.
|
|
35
|
+
|
|
36
|
+
### Fixed
|
|
37
|
+
- `TrackRelay::ClientId::AhoyVisitor` now reads
|
|
38
|
+
`controller.ahoy.visitor_token` directly. The previous chain
|
|
39
|
+
(`controller.ahoy.current_visit.visitor_token`) raised
|
|
40
|
+
`NoMethodError` on `ahoy_matey >= 5.0` because `Ahoy::Tracker` does
|
|
41
|
+
not define `current_visit`. The error was rescued by the resolver
|
|
42
|
+
chain so the gem did not crash, but the resolver always fell through
|
|
43
|
+
to a random GA4 `client_id` instead of a stable Ahoy visitor token.
|
|
44
|
+
|
|
45
|
+
### Documentation
|
|
46
|
+
- README and USAGE: note that `ahoy_matey` silently drops events from
|
|
47
|
+
non-browser user-agents (`[ahoy] Event excluded`) — visible when
|
|
48
|
+
smoke-testing via `curl`/Postman.
|
|
49
|
+
|
|
50
|
+
### Notes
|
|
51
|
+
- **Public API stability:** Public-API stability for `TrackRelay.track`, `.configure`,
|
|
52
|
+
`.catalog`, `.subscribe`, `.identify`, `.test_mode!`,
|
|
53
|
+
`TrackRelay::Subscribers::Base` (and the `synchronous!`,
|
|
54
|
+
`filter only:`, `filter except:` macros),
|
|
55
|
+
`TrackRelay::Subscribers::{Test,Logger,Ga4MeasurementProtocol,Ahoy}`,
|
|
56
|
+
`TrackRelay::ControllerTracking`, `TrackRelay::JobTracking`,
|
|
57
|
+
`TrackRelay::Testing::Helpers`, the catalog DSL
|
|
58
|
+
(`event`, `integer`, `string`, `float`, `boolean`, `datetime`,
|
|
59
|
+
`user_property` + `required:`/`max:`/`in:`/`format:`/`sanitize:`
|
|
60
|
+
validators), the three generators, and the four rake tasks
|
|
61
|
+
(`track_relay:lint`, `track_relay:lint:json`,
|
|
62
|
+
`track_relay:lint:ga4`, `track_relay:manifest`) is established for
|
|
63
|
+
the 1.0.0 release. Internal classes (`EventPayload`, `Instrumenter`,
|
|
64
|
+
`Dispatcher`, `Catalog`, `Current`, `DeliveryJob`, `ClientId::*`)
|
|
65
|
+
are not part of the public API contract.
|
|
66
|
+
- No breaking changes from 0.3.0. The `init({ manifestUrl })`
|
|
67
|
+
JS-client breaking change recorded in 0.3.0 still applies — see the
|
|
68
|
+
[0.3.0] entry below.
|
|
69
|
+
|
|
70
|
+
## [0.3.0] - 2026-05-06
|
|
71
|
+
|
|
72
|
+
### Added
|
|
73
|
+
|
|
74
|
+
- `TrackRelay::Subscribers::Ahoy` — server-side synchronous subscriber that routes catalog events through `controller.ahoy.track(payload.name.to_s, payload.params)` (Ahoy's only public tracking surface — there is no `Ahoy::Visit#track`). Duck-types `controller.respond_to?(:ahoy, true)` so the gem loads cleanly in non-Ahoy host apps; calls without a controller in scope (background jobs, rake tasks, console) skip-log via `Rails.logger.warn` rather than fabricate a write. Stateless — no constructor args; reads `TrackRelay::Current.controller` directly at deliver time (safe on the synchronous path, mandatory because `payload.context[:controller]` is the controller class name as a String, not the live instance).
|
|
75
|
+
- `AhoyJs` named export in `@track_relay/client` — client-side mirror of `Subscribers::Ahoy`. `Object.freeze({ name: "AhoyJs", handle(eventName, params) })` shape matches the existing `Ga4Gtag` export. Validates against the manifest (typed events: dev-throws / prod-warns-and-drops per REQ-05; untyped passes through per REQ-06), then dispatches via `window.ahoy.track(eventName, params)`. Guards on `typeof window.ahoy?.track === "function"` and emits `console.warn` + drops when missing — never throws when `ahoy.js` is absent.
|
|
76
|
+
- `ahoy_matey` added as a development dependency in `track_relay.gemspec`. Resolves to 5.4.2 under Rails 7.1 and 5.5.0 under Rails 7.2 / 8.0; lockfiles in `gemfiles/` confirm. The gem is required by the unit/integration test suites only; runtime hosts pull `ahoy_matey` themselves via their own Gemfile.
|
|
77
|
+
|
|
78
|
+
### Changed (BREAKING)
|
|
79
|
+
|
|
80
|
+
- `init({ manifestUrl })` no longer requires `measurementId`. Hosts using only `AhoyJs` (no GA4 subscriber in use) can now omit the GA4 measurement ID and call `init({ manifestUrl: "/track_relay_catalog.json" })`. Previously this threw synchronously; now it succeeds and leaves the GA4 dispatch surface dormant (`_flushConfigOnce()` already short-circuits on missing `_measurementId`, so `track()` and `Ga4Gtag.handle()` continue to validate but do not emit `gtag('config', ...)`). Hosts that relied on the missing-`measurementId` throw to detect misconfiguration must migrate — assert their own `measurementId` (or any other host-app-side invariant) before calling `init`. The error message thrown when `manifestUrl` is missing is also reworded to mention `manifestUrl` only.
|
|
81
|
+
- `client/src/index.d.ts`: `InitOptions.measurementId` typed as `measurementId?: string` (was required `string`). Existing TypeScript hosts that pass `measurementId` continue to typecheck unchanged; AhoyJs-only hosts can now omit it without a type error.
|
|
82
|
+
|
|
83
|
+
### Notes
|
|
84
|
+
|
|
85
|
+
- REQ-09 success criteria mention `Ahoy::Visit#track` as a fallback dispatch path. This method does not exist on `Ahoy::Visit` (the ActiveRecord model). The `Ahoy::Tracker` is the sole public tracking surface, and it is bound to the request lifecycle (it wraps the controller cookie jar / visit auto-create). The Ahoy subscriber therefore routes through `controller.ahoy.track` only; the no-controller skip path (with a `Rails.logger.warn` line) is the substitute for the missing `visit.track` route. See `phases/03-ahoy-subscribers/03-RESEARCH.md` §"The visit.track question" for full rationale.
|
|
86
|
+
- Cross-subscriber name parity: server `TrackRelay::Subscribers::Ahoy.name` returns `"TrackRelay::Subscribers::Ahoy"` (Ruby `Class#name`); client `AhoyJs.name` returns `"AhoyJs"`. The names differ but the dispatched event-name strings are byte-identical on both sides — server `payload.name.to_s` → `tracker.track`, client `eventName` → `window.ahoy.track`. REQ-09's "same event names as the server" criterion is about the event-name string, not the subscriber class name.
|
|
87
|
+
|
|
88
|
+
## [0.2.0] - 2026-05-06
|
|
89
|
+
|
|
90
|
+
### Added
|
|
91
|
+
|
|
92
|
+
- `@track_relay/client` v0.2.0 — companion JS package living at `client/` in the repo. Ships dual ESM (`dist/index.mjs`) + real CommonJS (`dist/index.cjs`) builds via `tsup` so both `import "@track_relay/client"` and `require("@track_relay/client")` work; the `package.json` `exports` map points at the built artifacts (not the unbuilt source). Hand-written `src/index.d.ts` documents the public types.
|
|
93
|
+
- `init({measurementId, manifestUrl, env, onValidationError})` is the single entry point — both `measurementId` and `manifestUrl` are REQUIRED and the function throws synchronously (not via a rejected promise) when either is nullish or empty-string, so misconfiguration is loud at the call site. The Rails layer is the source of truth: `measurementId` from `TrackRelay.config.ga4_measurement_id`, `manifestUrl` from `asset_path('track_relay_catalog.json')` — wire both via an inline ERB `<script type="module">` block in the layout (see `client/README.md`).
|
|
94
|
+
- `track(name, params)` validates against the manifest entry and dispatches via `window.gtag("event", name, params)`. Untyped events pass through unchanged (REQ-06). Missing `window.gtag` warns and drops the event without throwing. `Ga4Gtag.handle(name, params)` is a server-subscriber-shaped wrapper around `track()` for hosts that prefer object dispatch — covers REQ-08's client-side half.
|
|
95
|
+
- JS-side validation mirrors REQ-05: `env: "development"` throws on validation failure (missing required, wrong type), `env: "production"` calls `console.warn` and silently drops. The optional `onValidationError(errors)` callback fires before the throw/warn branch so hosts can route errors to a logging pipeline.
|
|
96
|
+
- `setClientId(id)` updates the resolved `client_id`; the next `track()` re-emits `gtag("config", measurementId, {client_id})` so GA4 attributes events to the right user. The `config` call fires once per page lifecycle until the client_id changes.
|
|
97
|
+
- CI: new `js-test` job in `.github/workflows/ci.yml` runs `npm ci && npm run build && npm test` on Node 22 (build BEFORE test so `build_smoke.test.js` sees a populated `dist/`). Vitest + happy-dom test harness with 31 test cases covering init/track/validation/Ga4Gtag.
|
|
98
|
+
- `TrackRelay::Subscribers::Ga4MeasurementProtocol` — async server-side GA4 Measurement Protocol subscriber. POSTs to `https://www.google-analytics.com/mp/collect?measurement_id=...&api_secret=...` with the canonical Scout §2 web-stream body shape (`{client_id, user_id?, timestamp_micros, events: [{name, params}]}`). EU-region toggle via `config.ga4_use_eu_endpoint = true` switches to `region1.google-analytics.com`. Net::HTTP from Ruby stdlib (no new gem dependency). Default 5s open / 10s read timeout. Async-by-default; hosts opt in inline via `Ga4MeasurementProtocol.synchronous!` per REQ-11. When `client_id` is missing from `payload.context`, falls back to a synthesized `<rand>.<unix_ts>` value so server-only events without a `_ga` cookie still post.
|
|
99
|
+
- `TrackRelay::DeliveryRetriableError` and `TrackRelay::DeliveryDiscardableError` — typed exceptions raised by `Ga4MeasurementProtocol#deliver` to signal retriable (HTTP 5xx, `Net::OpenTimeout`, `Net::ReadTimeout`, `Errno::ECONNREFUSED`, `SocketError`) vs. permanent (HTTP 4xx — defensive coverage) failures. `DeliveryJob` declares `retry_on TrackRelay::DeliveryRetriableError, wait: :polynomially_longer, attempts: TrackRelay::DeliveryJob::DEFAULT_GA4_DELIVERY_ATTEMPTS` (`= 5`) and `discard_on TrackRelay::DeliveryDiscardableError`. The attempt cap is a class-local constant (NOT `config.ga4_delivery_attempts`) because `retry_on` runs at class-body load time before any host initializer fires — runtime configurability is deferred to a future minor.
|
|
100
|
+
- `Subscribers::Base#safe_deliver` carve-out for the typed retry/discard exceptions: those two classes RE-RAISE through the rescue boundary even when `config.swallow_subscriber_errors = true` (the production default). Without this narrow exception to the REQ-23 blanket-rescue, ActiveJob's `retry_on`/`discard_on` macros would never see the typed exceptions because `safe_deliver` would catch them and return them as values. Arbitrary `StandardError`s still flow through the existing log-and-return path — REQ-23's contract is preserved for everything outside the carve-out.
|
|
101
|
+
- Configurable `config.ga4_measurement_id` / `config.ga4_api_secret` (read at delivery time so credentials lambdas / late-bound configs work) and `config.ga4_use_eu_endpoint` (default `false`). When either credential is `nil` at delivery time the subscriber emits a single `Rails.logger.warn` and returns — gem-loaded-but-not-configured apps must not crash. `config.ga4_delivery_attempts` is INTENTIONALLY NOT shipped in 0.2.0 (load-order hazard documented above).
|
|
102
|
+
- Call-time GA4 payload validation in `Ga4MeasurementProtocol#deliver` (REQ-27 split, call-time half): `payload.params.size <= 25` and param-name reserved-prefix check (`firebase_`, `ga_`, `google_`). Honors `config.raise_on_validation_error` — raises `Ga4ConstraintError` in dev/test, `Rails.logger.warn` + skip-POST in prod. Boot-time event-name validation (snake_case + reserved-name list) is the existing `Validators::Ga4Constraints` check at catalog load — Plan 02-04 does not duplicate it.
|
|
103
|
+
- `rake track_relay:lint:ga4` audits the JSONL untyped sink for GA4 event-name violations (snake_case shape, max length, reserved names) and exits non-zero when any are found, so CI can gate on it. The new `TrackRelay::Linter#ga4_violations` and `#print_ga4` methods power the task; both honor the same `untyped_log_path`-must-be-set abort contract as the existing `track_relay:lint` tasks.
|
|
104
|
+
- Subscriber-side `only:` / `except:` event-name filters via the `filter` class DSL or the new `TrackRelay.subscribe(klass_or_instance, only:, except:)` registration helper. Filters short-circuit at the top of `Subscribers::Base#handle` BEFORE the sync/async branch and BEFORE `safe_deliver`'s rescue boundary, so a filtered event with a buggy `#deliver` neither runs nor logs. Per-instance overrides on `TrackRelay.subscribe` are stored on the singleton class so they do not mutate either the class-level defaults or other instances of the same subscriber.
|
|
105
|
+
- `webmock ~> 3.23` as a development dependency. `test_helper.rb` requires `webmock/minitest` and calls `WebMock.disable_net_connect!(allow_localhost: true)` so HTTP-stubbed subscriber tests (Phase 02 GA4 measurement protocol) can register expected calls without leaking to the live network.
|
|
106
|
+
- JSON manifest generation: `rake track_relay:manifest` writes a typed `public/track_relay_catalog.json` (version + `generated_at` + `events: { name => { params: {key => type}, required: [...] } }`) for the `@track_relay/client` JS package to validate events client-side. The task aborts with a nonzero exit when the catalog is empty (RISK-04 footgun guard). The Railtie auto-runs `track_relay:manifest` before `assets:precompile` for production / CI builds and regenerates the file on every `to_prepare` reload in development, so editing `config/track_relay/*.rb` produces a fresh manifest without a server restart. `Manifest.write!` `mkdir_p`s the parent directory so a fresh checkout without a `public/` directory does not crash on first run.
|
|
107
|
+
- Configurable `config.client_id_resolvers` chain (`ClientId::Ga`, `ClientId::AhoyVisitor`, `ClientId::Session` defaults). First non-nil wins; resolved once per request inside `ControllerTracking#_resolve_client_id`; resolver exceptions are isolated (each `#call` is wrapped in `rescue StandardError` and logged via `Rails.logger.warn`, so a single buggy resolver cannot block the chain). `ClientId::Ga` reproduces Phase 01's `_ga`-cookie parser bit-for-bit, preserving existing behavior; the new `Session` fallback mints a `SecureRandom.uuid` into `session[:track_relay_client_id]` so visitors without a `_ga` cookie still get a session-stable identifier. Hosts can prepend custom resolvers via `TrackRelay.config.client_id_resolvers.unshift(...)` for native-app traffic, request-header overrides, etc.
|
|
108
|
+
|
|
109
|
+
## [0.1.0] - 2026-05-06
|
|
110
|
+
|
|
111
|
+
### Added
|
|
112
|
+
|
|
113
|
+
- Catalog DSL with `event` blocks and typed params (`integer`, `string`, `float`, `boolean`, `datetime`) plus validators (`required`, `max`, `in`, `format`, `sanitize`).
|
|
114
|
+
- `TrackRelay::EventDefinition` (catalog metadata) and `TrackRelay::EventPayload` (runtime instance) as separate classes.
|
|
115
|
+
- `TrackRelay::Current` via `ActiveSupport::CurrentAttributes` with `:user, :request, :visit, :controller, :client_id`.
|
|
116
|
+
- `TrackRelay::Configuration` with `subscribe`, `swallow_subscriber_errors`, `untyped_log_path`, `untyped_events_allowed`, `force_synchronous`, `raise_on_validation_error`.
|
|
117
|
+
- `TrackRelay.track(name, **params)` — validates against catalog (when defined) or accepts untyped, instruments `track_relay.event` via `ActiveSupport::Notifications`.
|
|
118
|
+
- `TrackRelay.identify(user, **properties)` — instruments `track_relay.identify`.
|
|
119
|
+
- Reserved-key partitioning: `:user, :visitor_token, :client_id, :request` are routed to `Current` / payload context, never appear in `payload.params`.
|
|
120
|
+
- Reserved-key collision detection at catalog load (`TrackRelay::ReservedKeyError`).
|
|
121
|
+
- GA4 constraint enforcement: snake_case event names, max 40 chars, max 25 custom params, refusal of GA4-reserved names.
|
|
122
|
+
- `TrackRelay::Subscribers::Base` with `synchronous!` macro and per-subscriber rescue (`safe_deliver` returns the StandardError on failure rather than re-raising inline).
|
|
123
|
+
- `TrackRelay::Subscribers::Test` — in-memory capture, synchronous, per-instance state.
|
|
124
|
+
- `TrackRelay::Subscribers::Logger` — writes to `Rails.logger`; appends untyped-event JSONL (`{event, params, controller, action, timestamp}` — param NAMES only, never values) to `config.untyped_log_path`.
|
|
125
|
+
- `TrackRelay::DeliveryJob < ActiveJob::Base` for async subscriber delivery.
|
|
126
|
+
- `TrackRelay::Dispatcher` — single `ActiveSupport::Notifications` subscription that fans out to `config.subscribers` with collect-then-reraise semantics; idempotent `start!` / `stop!`.
|
|
127
|
+
- `TrackRelay::Railtie` — autoloads `config/track_relay/**/*.rb` via `Rails.autoloaders.main.ignore` + `config.to_prepare` + `Dir.glob`/`load`; clears the catalog before each reload for hot-reload safety; starts the Dispatcher on `after_initialize`; loads rake tasks via the `rake_tasks` block.
|
|
128
|
+
- `TrackRelay::ControllerTracking` concern — `track` instance method + `before_action` that sets `Current.controller` / `Current.request` / `Current.client_id` (from the `_ga` cookie).
|
|
129
|
+
- `TrackRelay::JobTracking` concern — `track` instance method (job authors use `Current.set` block form for context, since the Rails Executor clears `CurrentAttributes` before every job).
|
|
130
|
+
- `TrackRelay.test_mode!` / `test_mode_off!` — atomic subscriber swap for test isolation. **Opt-in**: load via `require "track_relay/testing"` (not auto-required by `lib/track_relay.rb`).
|
|
131
|
+
- `TrackRelay::Testing::Helpers` (Minitest assertions) — `assert_tracked`, `refute_tracked`, with auto setup/teardown.
|
|
132
|
+
- RSpec matchers — `have_tracked(:event).with(**params)` (gated by `defined?(RSpec)`).
|
|
133
|
+
- `TrackRelay::Linter` + `rake track_relay:lint` and `rake track_relay:lint:json` — audit untyped events from the JSONL sink with dedupe by event name + sorted-param-signature. Both rake tasks abort with a nonzero exit when `config.untyped_log_path` is unset (footgun prevention).
|
|
134
|
+
- Collect-then-reraise dispatcher: peer subscribers always receive each event; in dev/test (`swallow_subscriber_errors=false`) the first failed subscriber's exception is re-raised AFTER fan-out, so loudness is preserved without breaking the bus.
|
|
135
|
+
- CI matrix: Ruby 3.2/3.3/3.4 × Rails 7.1/7.2/8.0 (9 combinations) via Appraisal + `ruby/setup-ruby@v1` + `bundler-cache: true`.
|
|
136
|
+
- Combustion-backed Minitest test harness; `ActiveSupport::CurrentAttributes::TestHelper` mixed into `ActiveSupport::TestCase` for automatic `Current` reset between tests.
|
|
137
|
+
- StandardRB linting via `bundle exec standardrb` (and `bundle exec rake` default = standard + test).
|
|
138
|
+
|
|
139
|
+
### Notes
|
|
140
|
+
|
|
141
|
+
- Privacy: untyped JSONL captures param NAMES only (never VALUES) to avoid leaking PII.
|
|
142
|
+
- Naming: `track_relay` availability on RubyGems will be re-validated before 1.0.
|
|
143
|
+
|
|
144
|
+
[1.0.0]: https://github.com/dchuk/track_relay/compare/v0.3.0...v1.0.0
|
|
145
|
+
[0.3.0]: https://github.com/dchuk/track_relay/releases/tag/v0.3.0
|
|
146
|
+
[0.2.0]: https://github.com/dchuk/track_relay/releases/tag/v0.2.0
|
|
147
|
+
[0.1.0]: https://github.com/dchuk/track_relay/releases/tag/v0.1.0
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Darrin Demchuk
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
# track_relay
|
|
2
|
+
|
|
3
|
+
Unified, typed event tracking for Rails apps. One catalog, multiple destinations, built on `ActiveSupport::Notifications`.
|
|
4
|
+
|
|
5
|
+
## Status
|
|
6
|
+
|
|
7
|
+
**Version:** 1.0.0 (pending release) — public API is being stabilized for the 1.0.0 cut. See [CHANGELOG.md](CHANGELOG.md) for release history and [UPGRADING.md](UPGRADING.md) for migration notes.
|
|
8
|
+
|
|
9
|
+
## Why
|
|
10
|
+
|
|
11
|
+
Modern Rails apps that want both marketing analytics (GA4) and product analytics (your DB) end up with two parallel event vocabularies. `track_relay` defines events once in a typed catalog and fans them out to every destination, server-side and client-side, without copy-paste.
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
Add to your Gemfile:
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
gem "track_relay", "~> 1.0"
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Then `bundle install`.
|
|
22
|
+
|
|
23
|
+
Then run the install generator to scaffold a working configuration:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
bin/rails generate track_relay:install
|
|
27
|
+
bundle exec rake test # passes cleanly out of the box
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
See [USAGE.md](USAGE.md) for a full walkthrough.
|
|
31
|
+
|
|
32
|
+
Requires Ruby 3.2+ and Rails 7.1, 7.2, or 8.0.
|
|
33
|
+
|
|
34
|
+
For client-side tracking, also install the companion JS package:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
npm install @track_relay/client
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
See [GA4 + client-side tracking](#ga4--client-side-tracking) below.
|
|
41
|
+
|
|
42
|
+
## Quick start
|
|
43
|
+
|
|
44
|
+
> **Tip:** `bin/rails g track_relay:install` scaffolds the five files below for you. Read on if you'd rather wire them up by hand.
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
# config/initializers/track_relay.rb
|
|
48
|
+
TrackRelay.configure do |c|
|
|
49
|
+
c.untyped_log_path = Rails.root.join("tmp/track_relay_untyped.jsonl")
|
|
50
|
+
c.subscribe TrackRelay::Subscribers::Logger.new
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# config/track_relay/articles.rb
|
|
54
|
+
TrackRelay.catalog do
|
|
55
|
+
event :article_viewed do
|
|
56
|
+
integer :article_id, required: true
|
|
57
|
+
string :slug, required: true
|
|
58
|
+
string :category
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# app/controllers/application_controller.rb
|
|
63
|
+
class ApplicationController < ActionController::Base
|
|
64
|
+
include TrackRelay::ControllerTracking
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# app/controllers/articles_controller.rb
|
|
68
|
+
class ArticlesController < ApplicationController
|
|
69
|
+
def show
|
|
70
|
+
@article = Article.find(params[:id])
|
|
71
|
+
track :article_viewed, article_id: @article.id, slug: @article.slug
|
|
72
|
+
# ...
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
That is the full path from `bundle install` to a fired event — five files, no generators required.
|
|
78
|
+
|
|
79
|
+
## Catalog DSL
|
|
80
|
+
|
|
81
|
+
Declare events in `config/track_relay/*.rb`. The Railtie autoloads the directory at boot and reloads it on every code reload in development (Zeitwerk-friendly: the directory is explicitly ignored by the autoloader so DSL files never look like constant definitions).
|
|
82
|
+
|
|
83
|
+
| Type | Example |
|
|
84
|
+
|------|---------|
|
|
85
|
+
| integer | `integer :count, required: true` |
|
|
86
|
+
| string | `string :name, max: 100` |
|
|
87
|
+
| float | `float :amount` |
|
|
88
|
+
| boolean | `boolean :flag` |
|
|
89
|
+
| datetime | `datetime :occurred_at` |
|
|
90
|
+
|
|
91
|
+
Validators: `required:`, `max:`, `in:`, `format:`, `sanitize:` (callable, runs before validation — no silent truncation). Validation runs at `track` time on the calling thread; failures raise `TrackRelay::ValidationError` when `config.raise_on_validation_error` is true (the default in dev/test).
|
|
92
|
+
|
|
93
|
+
Each catalog entry produces a `TrackRelay::EventDefinition` (the schema) which is used at `track` time to build a `TrackRelay::EventPayload` (the runtime instance). `EventDefinition` and `EventPayload` are intentionally separate classes so the schema is shareable and immutable across calls while the payload owns the per-call params, context, and timestamp.
|
|
94
|
+
|
|
95
|
+
### GA4 constraints (applied automatically)
|
|
96
|
+
|
|
97
|
+
- snake_case event names
|
|
98
|
+
- max 40 characters per event name
|
|
99
|
+
- max 25 custom params per event
|
|
100
|
+
- GA4 reserved names (`page_view`, `session_start`, `screen_view`, etc.) are refused at catalog load with `TrackRelay::Ga4ConstraintError`
|
|
101
|
+
|
|
102
|
+
### Reserved keys
|
|
103
|
+
|
|
104
|
+
Four keys are reserved and partitioned out of `params` automatically by `TrackRelay.track`:
|
|
105
|
+
|
|
106
|
+
- `:user`, `:request`, `:client_id` — bound on `TrackRelay::Current` for the duration of the call (block-scoped via `Current.set`).
|
|
107
|
+
- `:visitor_token` — written directly to `payload.context[:visitor_token]`. It is intentionally **not** a `Current` attribute: `Current` carries `:visit` (an Ahoy-style visit record), not a raw token.
|
|
108
|
+
|
|
109
|
+
Defining any of these four as a catalog param raises `TrackRelay::ReservedKeyError` at boot, so the conflict surfaces before any event is fired.
|
|
110
|
+
|
|
111
|
+
### Identify
|
|
112
|
+
|
|
113
|
+
```ruby
|
|
114
|
+
TrackRelay.identify(current_user, plan: "pro", country: "US")
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
`identify` is a thin pass-through in 0.1.0: it instruments `track_relay.identify` with `{user:, properties:}` so subscribers can route the user property update wherever they need to. Per-adapter user-property validation (GA4 `user_properties`, etc.) lands in 0.2.0.
|
|
118
|
+
|
|
119
|
+
## Subscribers
|
|
120
|
+
|
|
121
|
+
`TrackRelay::Subscribers::Base` is the base class for every subscriber. It exposes a `synchronous!` macro (opts the subclass out of the async `DeliveryJob` path) and a per-subscriber `safe_deliver` rescue that returns the exception instead of re-raising — so one bad subscriber never blocks peers from receiving the event.
|
|
122
|
+
|
|
123
|
+
```ruby
|
|
124
|
+
class MySubscriber < TrackRelay::Subscribers::Base
|
|
125
|
+
synchronous! # opt out of async DeliveryJob
|
|
126
|
+
|
|
127
|
+
def deliver(payload)
|
|
128
|
+
# payload.name, payload.params, payload.context, payload.timestamp
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Async subscribers automatically dispatch via `TrackRelay::DeliveryJob` (an `ActiveJob::Base` subclass). Use Solid Queue, Sidekiq, or any other ActiveJob adapter as your backend.
|
|
134
|
+
|
|
135
|
+
`TrackRelay::Dispatcher` is the single `ActiveSupport::Notifications` subscription that fans `track_relay.event` notifications out to `config.subscribers`. Its **collect-then-reraise** error contract means: every peer receives the payload, then if `config.swallow_subscriber_errors` is `false` (the default in dev/test), the first collected exception is re-raised after fan-out completes. In production (`swallow_subscriber_errors=true`), exceptions are logged and swallowed so a single broken adapter doesn't take the application down. The Dispatcher is started automatically by the Railtie on `after_initialize`.
|
|
136
|
+
|
|
137
|
+
Built-in subscribers:
|
|
138
|
+
|
|
139
|
+
- `Subscribers::Test` — in-memory capture for specs. Per-instance state, no class-level globals.
|
|
140
|
+
- `Subscribers::Logger` — writes a one-line summary to `Rails.logger`; appends untyped events to `config.untyped_log_path` JSONL with the canonical shape `{event, params, controller, action, timestamp}` (param NAMES only — values are never written, by design, to avoid leaking PII).
|
|
141
|
+
|
|
142
|
+
### Ahoy subscriber (server-side)
|
|
143
|
+
|
|
144
|
+
`TrackRelay::Subscribers::Ahoy` routes events through the host app's
|
|
145
|
+
ahoy_matey instrumentation using only the public Ahoy API
|
|
146
|
+
(`controller.ahoy.track`). It never calls `Ahoy::Event.create!`
|
|
147
|
+
directly.
|
|
148
|
+
|
|
149
|
+
Requires the `ahoy_matey` gem in your Gemfile. Wire it in the
|
|
150
|
+
initializer:
|
|
151
|
+
|
|
152
|
+
```ruby
|
|
153
|
+
TrackRelay.configure do |config|
|
|
154
|
+
config.subscribe TrackRelay::Subscribers::Ahoy.new
|
|
155
|
+
end
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Job-context calls (no controller, no visit) are logged and skipped;
|
|
159
|
+
the Ahoy subscriber will never fabricate a write without a real visit.
|
|
160
|
+
|
|
161
|
+
> **Heads up — Ahoy bot exclusion.** ahoy_matey silently drops events
|
|
162
|
+
> from requests whose user-agent doesn't look like a real browser
|
|
163
|
+
> (logged as `[ahoy] Event excluded`). If you're smoke-testing via
|
|
164
|
+
> `curl` or Postman and no row appears in `ahoy_events`, pass a real
|
|
165
|
+
> browser User-Agent header. This is Ahoy's default behavior — see
|
|
166
|
+
> ahoy_matey's `exclude_method` config to customize.
|
|
167
|
+
|
|
168
|
+
### Subscribing directly to AS::Notifications
|
|
169
|
+
|
|
170
|
+
Because every event is published through `ActiveSupport::Notifications.instrument("track_relay.event", event: payload)`, host apps can subscribe directly without writing a `Subscribers::Base` subclass at all:
|
|
171
|
+
|
|
172
|
+
```ruby
|
|
173
|
+
ActiveSupport::Notifications.subscribe("track_relay.event") do |*, payload|
|
|
174
|
+
Rails.logger.tagged("analytics") { Rails.logger.info(payload[:event].name) }
|
|
175
|
+
end
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
This is useful for one-off integrations and for debugging — your existing `ActiveSupport::Notifications` tooling (lograge, the Rails event reporter, etc.) just works.
|
|
179
|
+
|
|
180
|
+
## Generators
|
|
181
|
+
|
|
182
|
+
`track_relay` ships three Rails generators.
|
|
183
|
+
|
|
184
|
+
- `bin/rails g track_relay:install` — opinionated scaffold: richly
|
|
185
|
+
commented initializer (`config/initializers/track_relay.rb`),
|
|
186
|
+
sample catalog (`config/track_relay/sample.rb`),
|
|
187
|
+
ApplicationSubscriber base class
|
|
188
|
+
(`app/track_relay/subscribers/application_subscriber.rb`), and
|
|
189
|
+
`include TrackRelay::ControllerTracking` injected into
|
|
190
|
+
ApplicationController (idempotent — no-ops if the include already
|
|
191
|
+
exists).
|
|
192
|
+
|
|
193
|
+
- `bin/rails g track_relay:event NAME` — scaffolds a typed catalog
|
|
194
|
+
entry stub at `config/track_relay/<name>.rb` with a
|
|
195
|
+
`TrackRelay.catalog do event :name do ... end end` block. Each
|
|
196
|
+
event lives in its own file; the Railtie merges them at boot.
|
|
197
|
+
|
|
198
|
+
- `bin/rails g track_relay:subscriber NAME` — scaffolds a subscriber
|
|
199
|
+
class stub at
|
|
200
|
+
`app/track_relay/subscribers/<name>_subscriber.rb`.
|
|
201
|
+
|
|
202
|
+
See [USAGE.md](USAGE.md) for a full walkthrough.
|
|
203
|
+
|
|
204
|
+
### Controller and Job helpers
|
|
205
|
+
|
|
206
|
+
```ruby
|
|
207
|
+
class ApplicationController < ActionController::Base
|
|
208
|
+
include TrackRelay::ControllerTracking
|
|
209
|
+
# adds a `track` instance method + a before_action that populates
|
|
210
|
+
# Current.controller / Current.request / Current.client_id (from the _ga cookie)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
class WelcomeEmailJob < ApplicationJob
|
|
214
|
+
include TrackRelay::JobTracking
|
|
215
|
+
# adds a `track` instance method; use Current.set { ... } block form
|
|
216
|
+
# inside `perform` to populate context (the Rails Executor clears
|
|
217
|
+
# CurrentAttributes before every job, by design).
|
|
218
|
+
|
|
219
|
+
def perform(user)
|
|
220
|
+
TrackRelay::Current.set(user: user) do
|
|
221
|
+
track :welcome_email_sent, template_version: "v3"
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
## Test helpers
|
|
228
|
+
|
|
229
|
+
The testing surface is **opt-in**. Add `require "track_relay/testing"` to your `test_helper.rb` (Minitest) or `rails_helper.rb` (RSpec) — `lib/track_relay.rb` does NOT require it automatically, so the `Subscribers::Test` swap and RSpec matchers stay out of production runtime.
|
|
230
|
+
|
|
231
|
+
`TrackRelay.test_mode!` atomically replaces the configured subscriber list with a single `Subscribers::Test` instance and forces synchronous delivery; `TrackRelay.test_mode_off!` restores the previous list. Tests assert against the captured events without spinning up real adapters or external services.
|
|
232
|
+
|
|
233
|
+
### Minitest
|
|
234
|
+
|
|
235
|
+
```ruby
|
|
236
|
+
# test/test_helper.rb
|
|
237
|
+
require "track_relay/testing"
|
|
238
|
+
# OR (just the Minitest helpers)
|
|
239
|
+
require "track_relay/testing/helpers"
|
|
240
|
+
|
|
241
|
+
class MyTest < ActiveSupport::TestCase
|
|
242
|
+
include TrackRelay::Testing::Helpers # auto test_mode! / test_mode_off! per test
|
|
243
|
+
|
|
244
|
+
test "fires article_viewed" do
|
|
245
|
+
get article_path(@article)
|
|
246
|
+
assert_tracked :article_viewed, article_id: @article.id
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
test "does not double-fire" do
|
|
250
|
+
refute_tracked :article_viewed, article_id: 99
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
### RSpec
|
|
256
|
+
|
|
257
|
+
```ruby
|
|
258
|
+
# spec/rails_helper.rb
|
|
259
|
+
require "track_relay/testing"
|
|
260
|
+
|
|
261
|
+
RSpec.configure do |c|
|
|
262
|
+
c.before(:each) { TrackRelay.test_mode! }
|
|
263
|
+
c.after(:each) { TrackRelay.test_mode_off! }
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
it "fires outbound_click" do
|
|
267
|
+
click_link "External"
|
|
268
|
+
expect(track_relay).to have_tracked(:outbound_click).with(destination_domain: "example.com")
|
|
269
|
+
end
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
The RSpec matchers are loaded only when `RSpec` is already defined, so the gem stays test-framework-agnostic.
|
|
273
|
+
|
|
274
|
+
## Untyped events + linter
|
|
275
|
+
|
|
276
|
+
Untyped events (events that aren't in the catalog) are allowed by default — `config.untyped_events_allowed = true` — so teams can adopt the catalog incrementally. Set `config.untyped_log_path` to capture every untyped fire to a JSONL file:
|
|
277
|
+
|
|
278
|
+
```ruby
|
|
279
|
+
TrackRelay.configure do |c|
|
|
280
|
+
c.untyped_log_path = Rails.root.join("tmp/track_relay_untyped.jsonl")
|
|
281
|
+
end
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
Then audit with the bundled rake tasks:
|
|
285
|
+
|
|
286
|
+
```bash
|
|
287
|
+
$ bundle exec rake track_relay:lint
|
|
288
|
+
# track_relay untyped event audit
|
|
289
|
+
# events: 3; total occurrences: 47
|
|
290
|
+
event :outbound_click (32 total)
|
|
291
|
+
- params=[destination_url, link_text, source_path] count=32
|
|
292
|
+
event :search_executed (12 total)
|
|
293
|
+
- params=[filters, query] count=12
|
|
294
|
+
event :modal_dismissed (3 total)
|
|
295
|
+
- params=[modal_id] count=3
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
`bundle exec rake track_relay:lint:json` emits the same data as JSON for consumption by external tooling (Slack notifiers, dashboards, CI gates).
|
|
299
|
+
|
|
300
|
+
The linter (`TrackRelay::Linter`) dedupes by event name + sorted-param-name signature: two firings of `:outbound_click` with the same param names collapse into one row, while different param shapes count separately so you can spot drift.
|
|
301
|
+
|
|
302
|
+
If `config.untyped_log_path` is unset, both rake tasks abort with a nonzero exit code and a configuration message — by design, so a misconfigured audit pipeline doesn't silently exit 0.
|
|
303
|
+
|
|
304
|
+
The JSONL captures only sorted, stringified parameter NAMES (never values) for the same privacy reason.
|
|
305
|
+
|
|
306
|
+
## GA4 + client-side tracking
|
|
307
|
+
|
|
308
|
+
0.2.0 ships a complete GA4 path — server-side via `Subscribers::Ga4MeasurementProtocol`, client-side via the [`@track_relay/client`](client/README.md) JS package. They share one catalog and one validation contract.
|
|
309
|
+
|
|
310
|
+
### Server-side: GA4 Measurement Protocol subscriber
|
|
311
|
+
|
|
312
|
+
```ruby
|
|
313
|
+
# config/initializers/track_relay.rb
|
|
314
|
+
TrackRelay.configure do |c|
|
|
315
|
+
c.ga4_measurement_id = ENV.fetch("GA4_MEASUREMENT_ID")
|
|
316
|
+
c.ga4_api_secret = ENV.fetch("GA4_API_SECRET")
|
|
317
|
+
# c.ga4_use_eu_endpoint = true # opt-in for EU residency
|
|
318
|
+
|
|
319
|
+
# Send all events that need server-side fan-out
|
|
320
|
+
c.subscribe TrackRelay::Subscribers::Ga4MeasurementProtocol.new
|
|
321
|
+
end
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
The subscriber POSTs to `https://www.google-analytics.com/mp/collect` with the canonical `{client_id, user_id?, timestamp_micros, events: [{name, params}]}` body. Async-by-default through `TrackRelay::DeliveryJob` (an `ActiveJob::Base` subclass) — typed `DeliveryRetriableError` / `DeliveryDiscardableError` exceptions wire `retry_on :polynomially_longer, attempts: 5` and `discard_on` so 5xx errors retry and 4xx errors are dropped without retrying. Hosts can opt the subscriber into synchronous delivery for in-process consistency: `Ga4MeasurementProtocol.synchronous!`.
|
|
325
|
+
|
|
326
|
+
When either credential is `nil` at delivery time the subscriber emits a single `Rails.logger.warn` and returns — gem-loaded-but-not-configured apps must not crash.
|
|
327
|
+
|
|
328
|
+
Subscriber-side filters via `only:` / `except:` keep noisy events out of GA4:
|
|
329
|
+
|
|
330
|
+
```ruby
|
|
331
|
+
TrackRelay.subscribe(
|
|
332
|
+
TrackRelay::Subscribers::Ga4MeasurementProtocol.new,
|
|
333
|
+
only: %i[purchase signup outbound_click]
|
|
334
|
+
)
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
### `client_id` resolver chain
|
|
338
|
+
|
|
339
|
+
`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.
|
|
340
|
+
|
|
341
|
+
```ruby
|
|
342
|
+
TrackRelay.configure do |c|
|
|
343
|
+
# Prepend a custom resolver for native-app traffic
|
|
344
|
+
c.client_id_resolvers.unshift(->(req) { req.headers["X-Native-App-Id"] })
|
|
345
|
+
end
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
### JSON manifest
|
|
349
|
+
|
|
350
|
+
`rake track_relay:manifest` writes a typed JSON snapshot of the catalog to `public/track_relay_catalog.json`:
|
|
351
|
+
|
|
352
|
+
```json
|
|
353
|
+
{
|
|
354
|
+
"version": "0.2.0",
|
|
355
|
+
"generated_at": "2026-05-06T12:00:00Z",
|
|
356
|
+
"events": {
|
|
357
|
+
"purchase": {
|
|
358
|
+
"params": {"value": "float", "currency": "string", "coupon": "string"},
|
|
359
|
+
"required": ["value", "currency"]
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
The Railtie auto-runs `track_relay:manifest` before `assets:precompile` (production / CI) and regenerates the file on every `to_prepare` reload in development. The manifest is the contract the JS package consumes for client-side validation.
|
|
366
|
+
|
|
367
|
+
### Client-side: `@track_relay/client`
|
|
368
|
+
|
|
369
|
+
The JS package fetches the manifest at boot and dispatches events via `window.gtag` after validating against the same typed schema as the server. The Rails layer owns the configuration; the layout wires both `measurementId` and `manifestUrl`:
|
|
370
|
+
|
|
371
|
+
```erb
|
|
372
|
+
<%# app/views/layouts/application.html.erb %>
|
|
373
|
+
<script type="module">
|
|
374
|
+
import { init } from "@track_relay/client";
|
|
375
|
+
init({
|
|
376
|
+
measurementId: "<%= TrackRelay.config.ga4_measurement_id %>",
|
|
377
|
+
manifestUrl: "<%= asset_path('track_relay_catalog.json') %>"
|
|
378
|
+
});
|
|
379
|
+
</script>
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
Then track events from anywhere in your JS:
|
|
383
|
+
|
|
384
|
+
```javascript
|
|
385
|
+
import { track } from "@track_relay/client";
|
|
386
|
+
|
|
387
|
+
document.querySelector("#buy-button").addEventListener("click", () => {
|
|
388
|
+
track("purchase", { value: 9.99, currency: "USD" });
|
|
389
|
+
});
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
Validation behavior mirrors REQ-05: in development a missing required field or wrong type throws an Error; in production it calls `console.warn` and silently drops the event. Untyped events (not in the manifest) pass through unchanged. See [`client/README.md`](client/README.md) for the full API and the `Ga4Gtag` named export.
|
|
393
|
+
|
|
394
|
+
## Compatibility
|
|
395
|
+
|
|
396
|
+
- Ruby 3.2, 3.3, 3.4
|
|
397
|
+
- Rails 7.1, 7.2, 8.0
|
|
398
|
+
- Test framework: any (gem ships matchers for both Minitest and RSpec; gem itself uses Minitest)
|
|
399
|
+
|
|
400
|
+
CI runs the full Ruby × Rails matrix (9 combinations) on every push via Appraisal + GitHub Actions.
|
|
401
|
+
|
|
402
|
+
## Roadmap
|
|
403
|
+
|
|
404
|
+
### Shipped
|
|
405
|
+
- 0.1.0 — Core (catalog DSL, dispatch, Test + Logger subscribers, Minitest/RSpec helpers)
|
|
406
|
+
- 0.2.0 — GA4 (server-side Measurement Protocol subscriber, client-side `Ga4Gtag`, JSON manifest)
|
|
407
|
+
- 0.3.0 — Ahoy (server-side `Subscribers::Ahoy`, client-side `AhoyJs`)
|
|
408
|
+
|
|
409
|
+
### Pending release
|
|
410
|
+
- 1.0.0 (pending release) — Polish: generators, doc audit, public-API stability guarantee
|
|
411
|
+
|
|
412
|
+
### Future (post-1.0.0)
|
|
413
|
+
- Additional v2 subscribers: PostHog, Mixpanel, Plausible, Webhook, Segment
|
|
414
|
+
- Optional engine mount for `/track_relay/events` POST endpoint (ad-blocker resilience)
|
|
415
|
+
- Performance benchmarks
|
|
416
|
+
- Companion `rubocop-track_relay` cop for raw `gtag` / `ahoy.track` call detection
|
|
417
|
+
|
|
418
|
+
## Public API stability
|
|
419
|
+
|
|
420
|
+
As of 1.0.0, the following surface is covered by SemVer guarantees:
|
|
421
|
+
|
|
422
|
+
- Module entry points: `TrackRelay.track`, `.configure`, `.catalog`,
|
|
423
|
+
`.subscribe`, `.identify`, `.test_mode!`, `.test_mode_off!`
|
|
424
|
+
- Subscriber base class and class macros: `TrackRelay::Subscribers::Base`,
|
|
425
|
+
`synchronous!`, `filter only:`, `filter except:`
|
|
426
|
+
- Built-in subscribers: `TrackRelay::Subscribers::Test`, `Logger`,
|
|
427
|
+
`Ga4MeasurementProtocol`, `Ahoy`
|
|
428
|
+
- Concerns: `TrackRelay::ControllerTracking`, `TrackRelay::JobTracking`
|
|
429
|
+
- Test helpers: `TrackRelay::Testing::Helpers`, `assert_tracked`,
|
|
430
|
+
`refute_tracked`
|
|
431
|
+
- Catalog DSL keywords (`event`, `integer`, `string`, `float`,
|
|
432
|
+
`boolean`, `datetime`, `user_property`) and validators
|
|
433
|
+
(`required:`, `max:`, `in:`, `format:`, `sanitize:`)
|
|
434
|
+
- Generators: `track_relay:install`, `track_relay:event`,
|
|
435
|
+
`track_relay:subscriber`
|
|
436
|
+
- Rake tasks: `track_relay:lint`, `track_relay:lint:json`,
|
|
437
|
+
`track_relay:lint:ga4`, `track_relay:manifest`
|
|
438
|
+
|
|
439
|
+
Internal classes (`TrackRelay::EventPayload`, `Instrumenter`,
|
|
440
|
+
`Dispatcher`, `Catalog`, `Current`, `DeliveryJob`, `ClientId::*`)
|
|
441
|
+
are not part of the public API contract and may change without a
|
|
442
|
+
major version bump.
|
|
443
|
+
|
|
444
|
+
See [UPGRADING.md](UPGRADING.md) for migration notes from 0.x.
|
|
445
|
+
|
|
446
|
+
## Contributing
|
|
447
|
+
|
|
448
|
+
```bash
|
|
449
|
+
bundle install
|
|
450
|
+
bundle exec rake # default = standard + test
|
|
451
|
+
bundle exec appraisal install # one-time, generates gemfiles/*.gemfile
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
The test harness boots a minimal Combustion-backed dummy app under `test/internal/`. CI runs Ruby 3.2/3.3/3.4 × Rails 7.1/7.2/8.0 (9 combinations) via Appraisal. Linting uses StandardRB (`bundle exec standardrb`).
|
|
455
|
+
|
|
456
|
+
## License
|
|
457
|
+
|
|
458
|
+
MIT — see [LICENSE.txt](LICENSE.txt).
|