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.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +147 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +458 -0
  5. data/UPGRADING.md +85 -0
  6. data/USAGE.md +192 -0
  7. data/lib/generators/track_relay/event/event_generator.rb +17 -0
  8. data/lib/generators/track_relay/event/templates/event.rb.tt +21 -0
  9. data/lib/generators/track_relay/install/install_generator.rb +49 -0
  10. data/lib/generators/track_relay/install/templates/application_subscriber.rb.tt +31 -0
  11. data/lib/generators/track_relay/install/templates/initializer.rb.tt +42 -0
  12. data/lib/generators/track_relay/install/templates/sample_catalog.rb.tt +17 -0
  13. data/lib/generators/track_relay/subscriber/subscriber_generator.rb +17 -0
  14. data/lib/generators/track_relay/subscriber/templates/subscriber.rb.tt +28 -0
  15. data/lib/tasks/track_relay.rake +80 -0
  16. data/lib/track_relay/catalog.rb +86 -0
  17. data/lib/track_relay/client_id/ahoy_visitor.rb +34 -0
  18. data/lib/track_relay/client_id/ga.rb +48 -0
  19. data/lib/track_relay/client_id/session.rb +46 -0
  20. data/lib/track_relay/configuration.rb +141 -0
  21. data/lib/track_relay/controller_tracking.rb +90 -0
  22. data/lib/track_relay/current.rb +33 -0
  23. data/lib/track_relay/delivery_job.rb +84 -0
  24. data/lib/track_relay/dispatcher.rb +92 -0
  25. data/lib/track_relay/dsl/event_builder.rb +64 -0
  26. data/lib/track_relay/dsl/param_builder.rb +74 -0
  27. data/lib/track_relay/errors.rb +54 -0
  28. data/lib/track_relay/event_definition.rb +74 -0
  29. data/lib/track_relay/event_payload.rb +244 -0
  30. data/lib/track_relay/instrumenter.rb +241 -0
  31. data/lib/track_relay/job_tracking.rb +50 -0
  32. data/lib/track_relay/linter.rb +218 -0
  33. data/lib/track_relay/manifest.rb +85 -0
  34. data/lib/track_relay/railtie.rb +97 -0
  35. data/lib/track_relay/subscribers/ahoy.rb +110 -0
  36. data/lib/track_relay/subscribers/base.rb +231 -0
  37. data/lib/track_relay/subscribers/ga4_measurement_protocol.rb +250 -0
  38. data/lib/track_relay/subscribers/logger.rb +79 -0
  39. data/lib/track_relay/subscribers/test.rb +60 -0
  40. data/lib/track_relay/testing/helpers.rb +44 -0
  41. data/lib/track_relay/testing/minitest_assertions.rb +71 -0
  42. data/lib/track_relay/testing/rspec_matchers.rb +79 -0
  43. data/lib/track_relay/testing.rb +90 -0
  44. data/lib/track_relay/validators/catalog_validator.rb +48 -0
  45. data/lib/track_relay/validators/ga4_constraints.rb +85 -0
  46. data/lib/track_relay/version.rb +5 -0
  47. data/lib/track_relay.rb +203 -0
  48. 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).