track_relay
Unified, typed event tracking for Rails apps. One catalog, multiple destinations, built on ActiveSupport::Notifications.
Status
Version: 1.0.0 (pending release) — public API is being stabilized for the 1.0.0 cut. See CHANGELOG.md for release history and UPGRADING.md for migration notes.
Why
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.
Installation
Add to your Gemfile:
gem "track_relay", "~> 1.0"
Then bundle install.
Then run the install generator to scaffold a working configuration:
bin/rails generate track_relay:install
bundle exec rake test # passes cleanly out of the box
See USAGE.md for a full walkthrough.
Requires Ruby 3.2+ and Rails 7.1, 7.2, or 8.0.
For client-side tracking, also install the companion JS package:
npm install @track_relay/client
See GA4 + client-side tracking below.
Quick start
Tip:
bin/rails g track_relay:installscaffolds the five files below for you. Read on if you'd rather wire them up by hand.
# config/initializers/track_relay.rb
TrackRelay.configure do |c|
c.untyped_log_path = Rails.root.join("tmp/track_relay_untyped.jsonl")
c.subscribe TrackRelay::Subscribers::Logger.new
end
# config/track_relay/articles.rb
TrackRelay.catalog do
event :article_viewed do
integer :article_id, required: true
string :slug, required: true
string :category
end
end
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
include TrackRelay::ControllerTracking
end
# app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
def show
@article = Article.find(params[:id])
track :article_viewed, article_id: @article.id, slug: @article.slug
# ...
end
end
That is the full path from bundle install to a fired event — five files, no generators required.
Catalog DSL
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).
| Type | Example |
|---|---|
| integer | integer :count, required: true |
| string | string :name, max: 100 |
| float | float :amount |
| boolean | boolean :flag |
| datetime | datetime :occurred_at |
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).
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.
GA4 constraints (applied automatically)
- snake_case event names
- max 40 characters per event name
- max 25 custom params per event
- GA4 reserved names (
page_view,session_start,screen_view, etc.) are refused at catalog load withTrackRelay::Ga4ConstraintError
Reserved keys
Four keys are reserved and partitioned out of params automatically by TrackRelay.track:
:user,:request,:client_id— bound onTrackRelay::Currentfor the duration of the call (block-scoped viaCurrent.set).:visitor_token— written directly topayload.context[:visitor_token]. It is intentionally not aCurrentattribute:Currentcarries:visit(an Ahoy-style visit record), not a raw token.
Defining any of these four as a catalog param raises TrackRelay::ReservedKeyError at boot, so the conflict surfaces before any event is fired.
Identify
TrackRelay.identify(current_user, plan: "pro", country: "US")
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.
Subscribers
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.
class MySubscriber < TrackRelay::Subscribers::Base
synchronous! # opt out of async DeliveryJob
def deliver(payload)
# payload.name, payload.params, payload.context, payload.timestamp
end
end
Async subscribers automatically dispatch via TrackRelay::DeliveryJob (an ActiveJob::Base subclass). Use Solid Queue, Sidekiq, or any other ActiveJob adapter as your backend.
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.
Built-in subscribers:
Subscribers::Test— in-memory capture for specs. Per-instance state, no class-level globals.Subscribers::Logger— writes a one-line summary toRails.logger; appends untyped events toconfig.untyped_log_pathJSONL with the canonical shape{event, params, controller, action, timestamp}(param NAMES only — values are never written, by design, to avoid leaking PII).
Ahoy subscriber (server-side)
TrackRelay::Subscribers::Ahoy routes events through the host app's
ahoy_matey instrumentation using only the public Ahoy API
(controller.ahoy.track). It never calls Ahoy::Event.create!
directly.
Requires the ahoy_matey gem in your Gemfile. Wire it in the
initializer:
TrackRelay.configure do |config|
config.subscribe TrackRelay::Subscribers::Ahoy.new
end
Job-context calls (no controller, no visit) are logged and skipped; the Ahoy subscriber will never fabricate a write without a real visit.
Heads up — Ahoy bot exclusion. ahoy_matey silently drops events from requests whose user-agent doesn't look like a real browser (logged as
[ahoy] Event excluded). If you're smoke-testing viacurlor Postman and no row appears inahoy_events, pass a real browser User-Agent header. This is Ahoy's default behavior — see ahoy_matey'sexclude_methodconfig to customize.
Subscribing directly to AS::Notifications
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:
ActiveSupport::Notifications.subscribe("track_relay.event") do |*, payload|
Rails.logger.tagged("analytics") { Rails.logger.info(payload[:event].name) }
end
This is useful for one-off integrations and for debugging — your existing ActiveSupport::Notifications tooling (lograge, the Rails event reporter, etc.) just works.
Generators
track_relay ships three Rails generators.
bin/rails g track_relay:install— opinionated scaffold: richly commented initializer (config/initializers/track_relay.rb), sample catalog (config/track_relay/sample.rb), ApplicationSubscriber base class (app/track_relay/subscribers/application_subscriber.rb), andinclude TrackRelay::ControllerTrackinginjected into ApplicationController (idempotent — no-ops if the include already exists).bin/rails g track_relay:event NAME— scaffolds a typed catalog entry stub atconfig/track_relay/<name>.rbwith aTrackRelay.catalog do event :name do ... end endblock. Each event lives in its own file; the Railtie merges them at boot.bin/rails g track_relay:subscriber NAME— scaffolds a subscriber class stub atapp/track_relay/subscribers/<name>_subscriber.rb.
See USAGE.md for a full walkthrough.
Controller and Job helpers
class ApplicationController < ActionController::Base
include TrackRelay::ControllerTracking
# adds a `track` instance method + a before_action that populates
# Current.controller / Current.request / Current.client_id (from the _ga cookie)
end
class WelcomeEmailJob < ApplicationJob
include TrackRelay::JobTracking
# adds a `track` instance method; use Current.set { ... } block form
# inside `perform` to populate context (the Rails Executor clears
# CurrentAttributes before every job, by design).
def perform(user)
TrackRelay::Current.set(user: user) do
track :welcome_email_sent, template_version: "v3"
end
end
end
Test helpers
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.
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.
Minitest
# test/test_helper.rb
require "track_relay/testing"
# OR (just the Minitest helpers)
require "track_relay/testing/helpers"
class MyTest < ActiveSupport::TestCase
include TrackRelay::Testing::Helpers # auto test_mode! / test_mode_off! per test
test "fires article_viewed" do
get article_path(@article)
assert_tracked :article_viewed, article_id: @article.id
end
test "does not double-fire" do
refute_tracked :article_viewed, article_id: 99
end
end
RSpec
# spec/rails_helper.rb
require "track_relay/testing"
RSpec.configure do |c|
c.before(:each) { TrackRelay.test_mode! }
c.after(:each) { TrackRelay.test_mode_off! }
end
it "fires outbound_click" do
click_link "External"
expect(track_relay).to have_tracked(:outbound_click).with(destination_domain: "example.com")
end
The RSpec matchers are loaded only when RSpec is already defined, so the gem stays test-framework-agnostic.
Untyped events + linter
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:
TrackRelay.configure do |c|
c.untyped_log_path = Rails.root.join("tmp/track_relay_untyped.jsonl")
end
Then audit with the bundled rake tasks:
$ bundle exec rake track_relay:lint
# track_relay untyped event audit
# events: 3; total occurrences: 47
event :outbound_click (32 total)
- params=[destination_url, link_text, source_path] count=32
event :search_executed (12 total)
- params=[filters, query] count=12
event :modal_dismissed (3 total)
- params=[modal_id] count=3
bundle exec rake track_relay:lint:json emits the same data as JSON for consumption by external tooling (Slack notifiers, dashboards, CI gates).
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.
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.
The JSONL captures only sorted, stringified parameter NAMES (never values) for the same privacy reason.
GA4 + client-side tracking
0.2.0 ships a complete GA4 path — server-side via Subscribers::Ga4MeasurementProtocol, client-side via the @track_relay/client JS package. They share one catalog and one validation contract.
Server-side: GA4 Measurement Protocol subscriber
# config/initializers/track_relay.rb
TrackRelay.configure do |c|
c.ga4_measurement_id = ENV.fetch("GA4_MEASUREMENT_ID")
c.ga4_api_secret = ENV.fetch("GA4_API_SECRET")
# c.ga4_use_eu_endpoint = true # opt-in for EU residency
# Send all events that need server-side fan-out
c.subscribe TrackRelay::Subscribers::Ga4MeasurementProtocol.new
end
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!.
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.
Subscriber-side filters via only: / except: keep noisy events out of GA4:
TrackRelay.subscribe(
TrackRelay::Subscribers::Ga4MeasurementProtocol.new,
only: %i[purchase signup outbound_click]
)
client_id resolver chain
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.
TrackRelay.configure do |c|
# Prepend a custom resolver for native-app traffic
c.client_id_resolvers.unshift(->(req) { req.headers["X-Native-App-Id"] })
end
JSON manifest
rake track_relay:manifest writes a typed JSON snapshot of the catalog to public/track_relay_catalog.json:
{
"version": "0.2.0",
"generated_at": "2026-05-06T12:00:00Z",
"events": {
"purchase": {
"params": {"value": "float", "currency": "string", "coupon": "string"},
"required": ["value", "currency"]
}
}
}
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.
Client-side: @track_relay/client
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:
<%# app/views/layouts/application.html.erb %>
<script type="module">
import { init } from "@track_relay/client";
init({
measurementId: "<%= TrackRelay.config.ga4_measurement_id %>",
manifestUrl: "<%= asset_path('track_relay_catalog.json') %>"
});
</script>
Then track events from anywhere in your JS:
import { track } from "@track_relay/client";
document.querySelector("#buy-button").addEventListener("click", () => {
track("purchase", { value: 9.99, currency: "USD" });
});
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 for the full API and the Ga4Gtag named export.
Compatibility
- Ruby 3.2, 3.3, 3.4
- Rails 7.1, 7.2, 8.0
- Test framework: any (gem ships matchers for both Minitest and RSpec; gem itself uses Minitest)
CI runs the full Ruby × Rails matrix (9 combinations) on every push via Appraisal + GitHub Actions.
Roadmap
Shipped
- 0.1.0 — Core (catalog DSL, dispatch, Test + Logger subscribers, Minitest/RSpec helpers)
- 0.2.0 — GA4 (server-side Measurement Protocol subscriber, client-side
Ga4Gtag, JSON manifest) - 0.3.0 — Ahoy (server-side
Subscribers::Ahoy, client-sideAhoyJs)
Pending release
- 1.0.0 (pending release) — Polish: generators, doc audit, public-API stability guarantee
Future (post-1.0.0)
- Additional v2 subscribers: PostHog, Mixpanel, Plausible, Webhook, Segment
- Optional engine mount for
/track_relay/eventsPOST endpoint (ad-blocker resilience) - Performance benchmarks
- Companion
rubocop-track_relaycop for rawgtag/ahoy.trackcall detection
Public API stability
As of 1.0.0, the following surface is covered by SemVer guarantees:
- Module entry points:
TrackRelay.track,.configure,.catalog,.subscribe,.identify,.test_mode!,.test_mode_off! - Subscriber base class and class macros:
TrackRelay::Subscribers::Base,synchronous!,filter only:,filter except: - Built-in subscribers:
TrackRelay::Subscribers::Test,Logger,Ga4MeasurementProtocol,Ahoy - Concerns:
TrackRelay::ControllerTracking,TrackRelay::JobTracking - Test helpers:
TrackRelay::Testing::Helpers,assert_tracked,refute_tracked - Catalog DSL keywords (
event,integer,string,float,boolean,datetime,user_property) and validators (required:,max:,in:,format:,sanitize:) - Generators:
track_relay:install,track_relay:event,track_relay:subscriber - Rake tasks:
track_relay:lint,track_relay:lint:json,track_relay:lint:ga4,track_relay:manifest
Internal classes (TrackRelay::EventPayload, Instrumenter,
Dispatcher, Catalog, Current, DeliveryJob, ClientId::*)
are not part of the public API contract and may change without a
major version bump.
See UPGRADING.md for migration notes from 0.x.
Contributing
bundle install
bundle exec rake # default = standard + test
bundle exec appraisal install # one-time, generates gemfiles/*.gemfile
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).
License
MIT — see LICENSE.txt.