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
data/UPGRADING.md ADDED
@@ -0,0 +1,85 @@
1
+ # Upgrading track_relay
2
+
3
+ This document summarizes breaking changes between `track_relay` releases
4
+ and how to migrate. For the full release history, see
5
+ [CHANGELOG.md](CHANGELOG.md).
6
+
7
+ ## 0.1.0 → 0.2.0
8
+
9
+ No breaking changes to the Ruby gem surface. New features added:
10
+
11
+ - `TrackRelay::Subscribers::Ga4MeasurementProtocol` — wire with
12
+ `config.ga4_measurement_id` and `config.ga4_api_secret`.
13
+ - `config.client_id_resolvers` — ordered chain (GA cookie, Ahoy
14
+ visitor, session fallback). Default chain preserves existing
15
+ `_ga` cookie behavior.
16
+ - Subscriber-side `only:` / `except:` filters added to
17
+ `TrackRelay.subscribe(klass, only:, except:)`.
18
+ - New rake task: `track_relay:lint:ga4` — audits your catalog
19
+ against GA4 constraints (snake_case, max 40 chars per event name,
20
+ max 25 custom params per event, reserved-name refusal).
21
+
22
+ No host-app code changes are required. Optional adoption:
23
+ `bundle exec rake track_relay:lint:ga4` and add the GA4 subscriber if
24
+ you use Google Analytics.
25
+
26
+ ## 0.2.0 → 0.3.0
27
+
28
+ **One BREAKING change (JavaScript client only). Ruby gem surface is
29
+ unaffected.**
30
+
31
+ ### BREAKING: `init({ manifestUrl })` no longer requires `measurementId`
32
+
33
+ In `@track_relay/client`, the `init({ manifestUrl })` call no longer
34
+ requires a `measurementId` parameter. If your code relied on the
35
+ missing-`measurementId` throw to detect misconfiguration, add an
36
+ explicit assertion before calling `init`:
37
+
38
+ ```javascript
39
+ import { init } from "@track_relay/client";
40
+
41
+ const measurementId = process.env.GA4_MEASUREMENT_ID;
42
+ if (!measurementId) {
43
+ throw new Error("GA4_MEASUREMENT_ID is required");
44
+ }
45
+
46
+ init({ manifestUrl: "/track_relay_catalog.json", measurementId });
47
+ ```
48
+
49
+ ### New features in 0.3.0
50
+
51
+ - `TrackRelay::Subscribers::Ahoy` — server-side subscriber that uses
52
+ only the public Ahoy API (`controller.ahoy.track` /
53
+ `current_visit.track`). Wire with
54
+ `config.subscribe TrackRelay::Subscribers::Ahoy.new` (requires the
55
+ `ahoy_matey` gem).
56
+ - `AhoyJs` export added in `@track_relay/client` — wraps
57
+ `window.ahoy.track` using the same event names as the server.
58
+
59
+ ## 0.3.0 → 1.0.0
60
+
61
+ **No breaking changes.** 1.0.0 adds:
62
+
63
+ - Three Rails generators: `track_relay:install`, `track_relay:event`,
64
+ `track_relay:subscriber`. Run
65
+ `bin/rails generate track_relay:install` in your existing app —
66
+ the inject step is idempotent and skips if
67
+ `include TrackRelay::ControllerTracking` is already present.
68
+ - Public-API stability guarantee. See [README.md](README.md#public-api-stability)
69
+ for the stable surface; classes outside that list (`EventPayload`,
70
+ `Instrumenter`, `Dispatcher`, `Catalog`, `Current`, `DeliveryJob`,
71
+ `ClientId::*`) are internal and may change without a major bump.
72
+ - E2E test coverage proving the install generator's output works
73
+ end-to-end through a real controller call.
74
+ - Documentation: getting-started guide at [USAGE.md](USAGE.md).
75
+
76
+ To upgrade:
77
+
78
+ 1. Bump your Gemfile pin to `gem "track_relay", "~> 1.0"`.
79
+ 2. `bundle update track_relay`.
80
+ 3. (Optional but recommended) Run
81
+ `bin/rails generate track_relay:install` to refresh your initializer
82
+ with the latest comments and ApplicationSubscriber base class.
83
+ Existing files will trigger a Thor "overwrite?" prompt; the inject
84
+ step is idempotent regardless.
85
+ 4. `bundle exec rake test` — should pass without further changes.
data/USAGE.md ADDED
@@ -0,0 +1,192 @@
1
+ # track_relay — Getting Started
2
+
3
+ A walkthrough of the typical install path, from `bundle install` to your
4
+ first asserted-tracked event.
5
+
6
+ ## 1. Install
7
+
8
+ Add to your Gemfile:
9
+
10
+ ```ruby
11
+ gem "track_relay", "~> 1.0"
12
+ ```
13
+
14
+ Then run:
15
+
16
+ ```bash
17
+ bundle install
18
+ bin/rails generate track_relay:install
19
+ ```
20
+
21
+ The install generator scaffolds:
22
+ - `config/initializers/track_relay.rb` — richly commented; one Logger
23
+ subscriber active by default, plus commented-out scaffolds for Test,
24
+ GA4, and Ahoy.
25
+ - `config/track_relay/sample.rb` — a working `event :hello_world`
26
+ declaration to prove your install works.
27
+ - `app/track_relay/subscribers/application_subscriber.rb` — base class
28
+ for your custom subscribers.
29
+ - `include TrackRelay::ControllerTracking` injected into
30
+ `app/controllers/application_controller.rb` (idempotent — no-ops if
31
+ already present).
32
+
33
+ Verify it works:
34
+
35
+ ```bash
36
+ bundle exec rake test
37
+ ```
38
+
39
+ This should pass cleanly.
40
+
41
+ ## 2. Define your first event
42
+
43
+ The install generator created `config/track_relay/sample.rb`:
44
+
45
+ ```ruby
46
+ TrackRelay.catalog do
47
+ event :hello_world do
48
+ string :message, required: true
49
+ end
50
+ end
51
+ ```
52
+
53
+ You can add more events to the same file, or generate one per file:
54
+
55
+ ```bash
56
+ bin/rails generate track_relay:event ArticleViewed
57
+ ```
58
+
59
+ That writes `config/track_relay/article_viewed.rb`:
60
+
61
+ ```ruby
62
+ TrackRelay.catalog do
63
+ event :article_viewed do
64
+ # integer :id, required: true
65
+ # string :slug, required: true
66
+ end
67
+ end
68
+ ```
69
+
70
+ Uncomment and edit the typed param stubs as needed. Supported types:
71
+ `integer`, `string`, `float`, `boolean`, `datetime`. Validators:
72
+ `required:`, `max:`, `in:`, `format:`, `sanitize:`.
73
+
74
+ ## 3. Track from a controller
75
+
76
+ The install generator already added the `ControllerTracking` concern to
77
+ your `ApplicationController`. Inside any controller action:
78
+
79
+ ```ruby
80
+ class ArticlesController < ApplicationController
81
+ def show
82
+ track :article_viewed, id: params[:id].to_i, slug: "the-slug"
83
+ # ... render normally
84
+ end
85
+ end
86
+ ```
87
+
88
+ Tracking is fire-and-forget; subscribers run via ActiveJob unless they
89
+ opt into `synchronous!`.
90
+
91
+ ## 4. Add subscribers
92
+
93
+ The default install enables `TrackRelay::Subscribers::Logger` so every
94
+ event hits `Rails.logger`. Edit `config/initializers/track_relay.rb` to
95
+ wire additional destinations.
96
+
97
+ For a custom subscriber:
98
+
99
+ ```bash
100
+ bin/rails generate track_relay:subscriber Slack
101
+ ```
102
+
103
+ That writes `app/track_relay/subscribers/slack_subscriber.rb`. Edit the
104
+ `#deliver(payload)` body, then register in
105
+ `config/initializers/track_relay.rb`:
106
+
107
+ ```ruby
108
+ TrackRelay.configure do |config|
109
+ config.subscribe SlackSubscriber.new
110
+ end
111
+ ```
112
+
113
+ Built-in subscribers:
114
+ - `TrackRelay::Subscribers::Test` — in-memory capture for tests
115
+ - `TrackRelay::Subscribers::Logger` — Rails.logger sink
116
+ - `TrackRelay::Subscribers::Ga4MeasurementProtocol` — GA4 server-side
117
+ (requires `ga4_measurement_id` + `ga4_api_secret` config)
118
+ - `TrackRelay::Subscribers::Ahoy` — ahoy_matey integration (requires
119
+ the `ahoy_matey` gem in your Gemfile)
120
+
121
+ > **Heads up — Ahoy bot exclusion.** ahoy_matey silently drops events
122
+ > from requests that look bot-like (logged as `[ahoy] Event excluded`).
123
+ > If you're testing via `curl`, Postman, or anything with a non-browser
124
+ > User-Agent, no row will land in `ahoy_events`. Use a real browser or
125
+ > pass a Chrome/Firefox UA header. This is Ahoy's behavior, not
126
+ > track_relay's.
127
+
128
+ ## 5. Test your events
129
+
130
+ `track_relay` ships Minitest and RSpec test helpers. In Minitest:
131
+
132
+ ```ruby
133
+ require "track_relay/testing/helpers"
134
+
135
+ class ArticlesControllerTest < ActionDispatch::IntegrationTest
136
+ include TrackRelay::Testing::Helpers
137
+
138
+ test "show tracks article_viewed" do
139
+ get article_path(42)
140
+ assert_tracked :article_viewed, id: 42, slug: "the-slug"
141
+ end
142
+ end
143
+ ```
144
+
145
+ The helpers auto-call `TrackRelay.test_mode!` in setup and
146
+ `test_mode_off!` in teardown.
147
+
148
+ For RSpec, use the `have_tracked(:event).with(params)` matcher exposed
149
+ by `TrackRelay::Testing::RSpec` — see the README for full details.
150
+
151
+ ## 6. Untyped events and the linter
152
+
153
+ By default, `untyped_events_allowed = true` — `track :anything` works
154
+ without a catalog entry. To audit untyped events, enable the JSONL log:
155
+
156
+ ```ruby
157
+ config.untyped_log_path = Rails.root.join("tmp/track_relay_untyped.jsonl")
158
+ ```
159
+
160
+ Then run:
161
+
162
+ ```bash
163
+ bundle exec rake track_relay:lint
164
+ ```
165
+
166
+ This reports every untyped event seen, with file and line context, so
167
+ you can promote them to typed catalog entries.
168
+
169
+ ## 7. GA4 + client-side
170
+
171
+ For GA4 server-side, set the credentials and subscribe:
172
+
173
+ ```ruby
174
+ config.ga4_measurement_id = ENV.fetch("GA4_MEASUREMENT_ID")
175
+ config.ga4_api_secret = ENV.fetch("GA4_API_SECRET")
176
+ config.subscribe TrackRelay::Subscribers::Ga4MeasurementProtocol.new
177
+ ```
178
+
179
+ Then run `bundle exec rake track_relay:lint:ga4` to audit your catalog
180
+ against GA4 constraints (snake_case names, max 40 chars, max 25 custom
181
+ params per event, reserved-name refusal).
182
+
183
+ For client-side, install `@track_relay/client` from npm and call
184
+ `init({ manifestUrl: "/track_relay_catalog.json" })` after the manifest
185
+ is generated by `bundle exec rake track_relay:manifest` (or
186
+ automatically by the Railtie's asset-precompile hook).
187
+
188
+ ## Next
189
+
190
+ - [README.md](README.md) — full feature reference
191
+ - [UPGRADING.md](UPGRADING.md) — migration notes if upgrading from 0.x
192
+ - [CHANGELOG.md](CHANGELOG.md) — release history
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module TrackRelay
6
+ module Generators
7
+ class EventGenerator < Rails::Generators::NamedBase
8
+ source_root File.expand_path("templates", __dir__)
9
+
10
+ desc "Creates a typed catalog entry stub at config/track_relay/<name>.rb."
11
+
12
+ def create_event_file
13
+ template "event.rb.tt", "config/track_relay/#{file_name}.rb"
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ # <%= class_name %> event — defined here so the track_relay Railtie
4
+ # autoloads it from config/track_relay/ at boot.
5
+ #
6
+ # Track this event from a controller or job:
7
+ # track :<%= file_name %>, # required + optional params here
8
+ #
9
+ # Run `bundle exec rake track_relay:lint:ga4` to audit GA4 constraints.
10
+
11
+ TrackRelay.catalog do
12
+ event :<%= file_name %> do
13
+ # Uncomment and edit:
14
+ # integer :id, required: true
15
+ # string :label, required: true
16
+ # string :category
17
+ # float :value
18
+ # boolean :active
19
+ # datetime :occurred_at
20
+ end
21
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module TrackRelay
6
+ module Generators
7
+ class InstallGenerator < Rails::Generators::Base
8
+ source_root File.expand_path("templates", __dir__)
9
+
10
+ desc "Creates a TrackRelay initializer, sample catalog, and ApplicationSubscriber, and includes TrackRelay::ControllerTracking in ApplicationController."
11
+
12
+ def create_initializer
13
+ template "initializer.rb.tt", "config/initializers/track_relay.rb"
14
+ end
15
+
16
+ def create_sample_catalog
17
+ template "sample_catalog.rb.tt", "config/track_relay/sample.rb"
18
+ end
19
+
20
+ def create_application_subscriber
21
+ template "application_subscriber.rb.tt", "app/track_relay/subscribers/application_subscriber.rb"
22
+ end
23
+
24
+ def inject_controller_tracking
25
+ controller_path = "app/controllers/application_controller.rb"
26
+ unless File.exist?(File.join(destination_root, controller_path))
27
+ say_status :skip, "#{controller_path} not found; add `include TrackRelay::ControllerTracking` manually", :yellow
28
+ return
29
+ end
30
+
31
+ existing = File.read(File.join(destination_root, controller_path))
32
+ if existing.include?("TrackRelay::ControllerTracking")
33
+ say_status :identical, "#{controller_path} already includes TrackRelay::ControllerTracking", :blue
34
+ return
35
+ end
36
+
37
+ inject_into_class controller_path, "ApplicationController", " include TrackRelay::ControllerTracking\n"
38
+ end
39
+
40
+ def post_install_message
41
+ say ""
42
+ say "TrackRelay installed.", :green
43
+ say " Edit config/initializers/track_relay.rb to wire subscribers."
44
+ say " Edit config/track_relay/sample.rb (or rails g track_relay:event NAME) to define events."
45
+ say " Run `bundle exec rake test` — it should pass cleanly out of the box."
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ # ApplicationSubscriber — base class for your custom track_relay subscribers.
4
+ #
5
+ # Subclass this to add your own destinations:
6
+ #
7
+ # class MyAnalyticsSubscriber < ApplicationSubscriber
8
+ # def deliver(payload)
9
+ # # send payload.name + payload.params somewhere
10
+ # end
11
+ # end
12
+ #
13
+ # Register in config/initializers/track_relay.rb:
14
+ #
15
+ # TrackRelay.configure do |config|
16
+ # config.subscribe MyAnalyticsSubscriber.new
17
+ # end
18
+ #
19
+ # Run `rails g track_relay:subscriber NAME` to scaffold a subscriber.
20
+ class ApplicationSubscriber < TrackRelay::Subscribers::Base
21
+ # Uncomment to run inline instead of via DeliveryJob:
22
+ # synchronous!
23
+
24
+ # payload.name => :event_name (Symbol)
25
+ # payload.params => { key: value, ... } (typed and coerced)
26
+ # payload.context => { controller:, action:, client_id:, user:, ... }
27
+ # payload.timestamp => Time
28
+ def deliver(payload)
29
+ raise NotImplementedError, "#{self.class.name} must implement #deliver(payload)"
30
+ end
31
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ # TrackRelay configuration
4
+ # See https://github.com/dchuk/track_relay#readme for full documentation.
5
+ # Run `bundle exec rake track_relay:lint` to audit untyped events.
6
+
7
+ TrackRelay.configure do |config|
8
+ # ----- Subscribers --------------------------------------------------------
9
+ # Logger subscriber: writes every event to Rails.logger and (optionally)
10
+ # captures untyped events to a JSONL file for audit.
11
+ config.subscribe TrackRelay::Subscribers::Logger.new
12
+
13
+ # Test subscriber: in-memory capture for use with `assert_tracked`.
14
+ # Prefer `TrackRelay.test_mode!` in test setup over registering this here.
15
+ # config.subscribe TrackRelay::Subscribers::Test.new if Rails.env.test?
16
+
17
+ # ----- GA4 Measurement Protocol (server-side) ----------------------------
18
+ # config.ga4_measurement_id = ENV.fetch("GA4_MEASUREMENT_ID", nil)
19
+ # config.ga4_api_secret = ENV.fetch("GA4_API_SECRET", nil)
20
+ # config.ga4_use_eu_endpoint = false
21
+ # config.subscribe TrackRelay::Subscribers::Ga4MeasurementProtocol.new
22
+
23
+ # ----- Ahoy (server-side) ------------------------------------------------
24
+ # Requires the `ahoy_matey` gem in your Gemfile.
25
+ # config.subscribe TrackRelay::Subscribers::Ahoy.new
26
+
27
+ # ----- Untyped events ----------------------------------------------------
28
+ # Allow tracking calls for events not yet declared in the catalog.
29
+ # When false, untyped events raise in dev/test and log in production.
30
+ # config.untyped_events_allowed = true
31
+
32
+ # Path for the untyped-events audit log (used by `rake track_relay:lint`).
33
+ # config.untyped_log_path = Rails.root.join("tmp/track_relay_untyped.jsonl")
34
+
35
+ # ----- Validation behavior -----------------------------------------------
36
+ # In production, subscriber errors are swallowed and logged (default true).
37
+ # In development/test, they are re-raised after fan-out (default false).
38
+ # config.swallow_subscriber_errors = false
39
+
40
+ # Raise on schema validation errors (default true in dev/test, false in prod).
41
+ # config.raise_on_validation_error = true
42
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Sample catalog — define your events here.
4
+ #
5
+ # The Railtie autoloads every *.rb file under config/track_relay/ at boot
6
+ # and reloads them on every code reload in development.
7
+ #
8
+ # Run `rails g track_relay:event NAME` to scaffold a new event in its own file.
9
+
10
+ TrackRelay.catalog do
11
+ # Tutorial event: prove your install works.
12
+ # In a controller action: `track :hello_world, message: "hi"`
13
+ # In a test: `assert_tracked :hello_world, message: "hi"`
14
+ event :hello_world do
15
+ string :message, required: true
16
+ end
17
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module TrackRelay
6
+ module Generators
7
+ class SubscriberGenerator < Rails::Generators::NamedBase
8
+ source_root File.expand_path("templates", __dir__)
9
+
10
+ desc "Creates a subscriber class stub at app/track_relay/subscribers/<name>_subscriber.rb."
11
+
12
+ def create_subscriber_file
13
+ template "subscriber.rb.tt", "app/track_relay/subscribers/#{file_name}_subscriber.rb"
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ # <%= class_name %>Subscriber — custom track_relay subscriber.
4
+ #
5
+ # Register in config/initializers/track_relay.rb:
6
+ #
7
+ # TrackRelay.configure do |config|
8
+ # config.subscribe <%= class_name %>Subscriber.new
9
+ # end
10
+ #
11
+ # If you ran `rails g track_relay:install`, you can subclass
12
+ # ApplicationSubscriber instead of TrackRelay::Subscribers::Base
13
+ # to share behavior across your subscribers.
14
+ class <%= class_name %>Subscriber < TrackRelay::Subscribers::Base
15
+ # Uncomment to run inline instead of via DeliveryJob:
16
+ # synchronous!
17
+
18
+ # Filter to specific events only:
19
+ # filter only: %i[<%= file_name %>]
20
+
21
+ # payload.name => :event_name (Symbol)
22
+ # payload.params => { key: value, ... } (typed and coerced)
23
+ # payload.context => { controller:, action:, client_id:, user:, ... }
24
+ # payload.timestamp => Time
25
+ def deliver(payload)
26
+ # TODO: send payload to your destination
27
+ end
28
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Audit untyped (non-catalog) events captured in the JSONL sink written
4
+ # by {TrackRelay::Subscribers::Logger}.
5
+ #
6
+ # Both tasks ABORT WITH NONZERO exit when
7
+ # `TrackRelay.config.untyped_log_path` is unset. From 01-CONTEXT.md /
8
+ # the plan's must_haves: a silent exit-0 on a misconfigured audit task
9
+ # is a footgun (the user thinks the audit "passed" when in fact nothing
10
+ # was ever recorded). When the path IS set, the task exits 0 — lint is
11
+ # a report, not a gate.
12
+
13
+ # Loaded via require_relative (not the gem's umbrella `require
14
+ # "track_relay"`) so this rake file stays file-disjoint with Plan 02-02
15
+ # — `lib/track_relay.rb` is owned by that plan in the same wave.
16
+ require_relative "../track_relay/manifest"
17
+
18
+ namespace :track_relay do
19
+ desc "Audit untyped events captured in the JSONL sink (config.untyped_log_path)"
20
+ task lint: :environment do
21
+ path = TrackRelay.config.untyped_log_path
22
+ if path.nil?
23
+ abort <<~MSG
24
+ track_relay: untyped_log_path is not configured.
25
+ Set it in config/initializers/track_relay.rb:
26
+
27
+ TrackRelay.configure do |c|
28
+ c.untyped_log_path = Rails.root.join("tmp/track_relay_untyped.jsonl")
29
+ c.subscribe TrackRelay::Subscribers::Logger.new
30
+ end
31
+ MSG
32
+ end
33
+
34
+ TrackRelay::Linter.new(path).print
35
+ end
36
+
37
+ desc "Audit untyped events and emit JSON report to stdout"
38
+ task "lint:json" => :environment do
39
+ path = TrackRelay.config.untyped_log_path
40
+ abort "track_relay: untyped_log_path is not configured" if path.nil?
41
+
42
+ puts TrackRelay::Linter.new(path).to_json
43
+ end
44
+
45
+ desc "Audit untyped events for GA4 event-name constraint violations"
46
+ task "lint:ga4" => :environment do
47
+ path = TrackRelay.config.untyped_log_path
48
+ if path.nil?
49
+ abort <<~MSG
50
+ track_relay: untyped_log_path is not configured.
51
+ Set it in config/initializers/track_relay.rb:
52
+
53
+ TrackRelay.configure do |c|
54
+ c.untyped_log_path = Rails.root.join("tmp/track_relay_untyped.jsonl")
55
+ c.subscribe TrackRelay::Subscribers::Logger.new
56
+ end
57
+ MSG
58
+ end
59
+
60
+ clean = TrackRelay::Linter.new(path).print_ga4
61
+ # Exit non-zero when violations exist, zero when clean. Lets CI
62
+ # pipelines gate on `rake track_relay:lint:ga4` without parsing
63
+ # output.
64
+ exit(clean ? 0 : 1)
65
+ end
66
+
67
+ desc "Generate public/track_relay_catalog.json from the loaded catalog"
68
+ task manifest: :environment do
69
+ # Footgun guard (RISK-04): an empty manifest tells the JS client
70
+ # "no schema, accept everything" — silently. Abort loudly so the
71
+ # operator notices the catalog never loaded.
72
+ if TrackRelay::Catalog.all.empty?
73
+ abort "[track_relay] aborting: catalog is empty (no events registered — check config/track_relay/**/*.rb)"
74
+ end
75
+
76
+ path = TrackRelay::Manifest.write!
77
+ count = TrackRelay::Catalog.all.size
78
+ puts "[track_relay] manifest written to #{path} (#{count} #{(count == 1) ? "event" : "events"})"
79
+ end
80
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "track_relay/errors"
4
+
5
+ module TrackRelay
6
+ # Process-wide registry of event definitions and user properties.
7
+ #
8
+ # The {DSL::EventBuilder} pushes definitions in via {register}; the
9
+ # rest of the gem (and host applications) read them out via {lookup},
10
+ # {defined?}, and {all}.
11
+ #
12
+ # State is module-level by design — the gem assumes one catalog per
13
+ # Ruby process, populated during boot. Tests that need isolation call
14
+ # {clear!} in `setup` / `teardown` to reset between cases.
15
+ #
16
+ # @example Registering and looking up an event
17
+ # TrackRelay.catalog do
18
+ # event :article_viewed do
19
+ # integer :article_id, required: true
20
+ # end
21
+ # end
22
+ #
23
+ # TrackRelay::Catalog.lookup(:article_viewed)
24
+ # # => #<TrackRelay::EventDefinition name=:article_viewed ...>
25
+ module Catalog
26
+ @definitions = {}
27
+ @user_properties = {}
28
+
29
+ class << self
30
+ # @return [Hash{Symbol => Symbol}] catalog-wide user properties
31
+ attr_reader :user_properties
32
+
33
+ # Register a new {EventDefinition} in the catalog.
34
+ #
35
+ # @param definition [EventDefinition]
36
+ # @raise [TrackRelay::CatalogError] when an event with the same
37
+ # name is already registered (defensive guard against catalog
38
+ # bugs that could silently shadow events)
39
+ # @return [EventDefinition]
40
+ def register(definition)
41
+ if @definitions.key?(definition.name)
42
+ raise CatalogError,
43
+ "Event #{definition.name.inspect} is already registered. Call TrackRelay::Catalog.clear! before re-registering (e.g. in tests)."
44
+ end
45
+ @definitions[definition.name] = definition
46
+ end
47
+
48
+ # Register a catalog-wide user property.
49
+ #
50
+ # @param name [Symbol]
51
+ # @param type [Symbol]
52
+ # @return [Symbol] the type, for chaining
53
+ def register_user_property(name, type)
54
+ @user_properties[name] = type
55
+ end
56
+
57
+ # @param name [Symbol]
58
+ # @return [EventDefinition, nil]
59
+ def lookup(name)
60
+ @definitions[name]
61
+ end
62
+
63
+ # @param name [Symbol]
64
+ # @return [Boolean] whether an event with the given name is
65
+ # registered
66
+ def defined?(name)
67
+ @definitions.key?(name)
68
+ end
69
+
70
+ # @return [Array<EventDefinition>] frozen array of all registered
71
+ # definitions
72
+ def all
73
+ @definitions.values.freeze
74
+ end
75
+
76
+ # Reset the registry. Intended for test isolation; do not call in
77
+ # production code.
78
+ #
79
+ # @return [void]
80
+ def clear!
81
+ @definitions = {}
82
+ @user_properties = {}
83
+ end
84
+ end
85
+ end
86
+ end