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
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ module TrackRelay
6
+ # Job-side tracking helper.
7
+ #
8
+ # Host applications include this concern in `ApplicationJob` (or any
9
+ # job) to expose a `track(name, **params)` instance method that
10
+ # delegates to {TrackRelay.track}.
11
+ #
12
+ # Unlike {ControllerTracking}, this concern is intentionally minimal
13
+ # — it does NOT auto-populate {Current}. The reason is the Rails
14
+ # Executor: ActiveJob wraps every `perform` with the Executor, which
15
+ # calls `ActiveSupport::CurrentAttributes.clear_all` BEFORE the job
16
+ # runs. So any `Current.user` set in the request that enqueued the
17
+ # job is gone by the time `perform` runs — even under the inline /
18
+ # test queue adapter. Auto-populating from constructor args would be
19
+ # wrong: the args are serialized through the queue, but the in-memory
20
+ # context (visit, request, etc.) is not.
21
+ #
22
+ # Job authors are responsible for restoring whatever context they
23
+ # care about. The documented pattern is `Current.set(user: u, ...)
24
+ # { track :foo, ... }`. The block form binds attributes for the
25
+ # duration of the block, then unwinds — perfect for a single `track`
26
+ # call inside `perform`.
27
+ #
28
+ # @example Documented usage
29
+ # class WelcomeEmailJob < ApplicationJob
30
+ # include TrackRelay::JobTracking
31
+ #
32
+ # def perform(user)
33
+ # TrackRelay::Current.set(user: user, visitor_token: user.last_visitor_token) do
34
+ # track :welcome_email_sent, template: "v3"
35
+ # end
36
+ # end
37
+ # end
38
+ module JobTracking
39
+ extend ActiveSupport::Concern
40
+
41
+ # Delegate to {TrackRelay.track}. Sugar for in-job call sites.
42
+ #
43
+ # @param name [Symbol]
44
+ # @param params [Hash]
45
+ # @return [void]
46
+ def track(name, **params)
47
+ TrackRelay.track(name, **params)
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,218 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "track_relay/validators/ga4_constraints"
5
+
6
+ module TrackRelay
7
+ # Audits the JSONL untyped-event sink written by
8
+ # {Subscribers::Logger} and produces a deduped report grouped by
9
+ # event name + sorted-param-name signature.
10
+ #
11
+ # ## Input contract (locked in Plan 05 / 01-CONTEXT.md)
12
+ #
13
+ # The JSONL sink contains one event per line. Each line is JSON with
14
+ # the canonical shape:
15
+ #
16
+ # {"event":"...", "params":["a","b"], "controller":"...", "action":"...", "timestamp":"..."}
17
+ #
18
+ # `params` carries only sorted, stringified parameter NAMES — values
19
+ # are never written to the sink (privacy contract from
20
+ # 01-CONTEXT.md). The linter reads the same shape and dedupes only on
21
+ # `event` + sorted `params`; `controller`, `action`, and `timestamp`
22
+ # are accepted but ignored for grouping (they are useful breadcrumbs
23
+ # for the human reading the JSONL directly, not signal for dedup).
24
+ #
25
+ # ## Output
26
+ #
27
+ # - {#report} → Array of {Report} structs, sorted by total occurrences
28
+ # descending. Each Report bundles every distinct param signature
29
+ # seen for that event name.
30
+ # - {#print} → human-readable summary written to an IO.
31
+ # - {#to_json} → machine-readable JSON with stable keys
32
+ # `{event, total, signatures: [{params, count}]}`. Plan 09's
33
+ # CHANGELOG references this contract.
34
+ #
35
+ # ## Resilience
36
+ #
37
+ # - Missing files return an empty report (the JSONL may legitimately
38
+ # not exist yet on a fresh app).
39
+ # - Lines that are not valid JSON are skipped and counted in
40
+ # {#malformed_lines}.
41
+ # - Blank lines are silently skipped (not malformed).
42
+ class Linter
43
+ # One report entry per distinct event name.
44
+ #
45
+ # @!attribute [rw] event_name
46
+ # The event name as it appeared in the JSONL `event` field.
47
+ # @!attribute [rw] signatures
48
+ # Array of {Signature} structs, sorted by `count` descending.
49
+ # @!attribute [rw] total
50
+ # Sum of all signature counts for this event.
51
+ Report = Struct.new(:event_name, :signatures, :total, keyword_init: true)
52
+
53
+ # One signature per distinct sorted-param-name shape under an event.
54
+ #
55
+ # @!attribute [rw] params
56
+ # Sorted array of parameter NAMES (no values).
57
+ # @!attribute [rw] count
58
+ # How many times this exact signature was seen.
59
+ Signature = Struct.new(:params, :count, keyword_init: true)
60
+
61
+ # @return [Integer] count of lines that failed JSON parsing
62
+ attr_reader :malformed_lines
63
+
64
+ def initialize(jsonl_path)
65
+ @jsonl_path = jsonl_path
66
+ @malformed_lines = 0
67
+ end
68
+
69
+ # Build the deduped report.
70
+ #
71
+ # @return [Array<Report>] sorted by total occurrences descending
72
+ def report
73
+ groups = Hash.new { |h, k| h[k] = Hash.new(0) }
74
+ read_lines do |entry|
75
+ event = entry["event"]
76
+ signature = Array(entry["params"]).sort
77
+ groups[event][signature] += 1
78
+ end
79
+
80
+ groups.map { |event, signatures|
81
+ sig_list = signatures.map { |params, count| Signature.new(params: params, count: count) }
82
+ total = sig_list.sum(&:count)
83
+ Report.new(
84
+ event_name: event,
85
+ signatures: sig_list.sort_by { |s| -s.count },
86
+ total: total
87
+ )
88
+ }.sort_by { |r| -r.total }
89
+ end
90
+
91
+ # Write a human-readable summary to `io`.
92
+ #
93
+ # @param io [IO] writer; defaults to `$stdout`
94
+ # @return [void]
95
+ def print(io = $stdout)
96
+ reports = report
97
+ io.puts "# track_relay untyped event audit"
98
+ io.puts "# source: #{@jsonl_path}"
99
+ io.puts "# events: #{reports.size}; total occurrences: #{reports.sum(&:total)}"
100
+ io.puts ""
101
+ reports.each do |r|
102
+ io.puts "event :#{r.event_name} (#{r.total} total)"
103
+ r.signatures.each do |sig|
104
+ io.puts " - params=[#{sig.params.join(", ")}] count=#{sig.count}"
105
+ end
106
+ io.puts ""
107
+ end
108
+ io.puts "# #{@malformed_lines} malformed line(s) skipped" if @malformed_lines.positive?
109
+ end
110
+
111
+ # Emit machine-readable JSON.
112
+ #
113
+ # Keys (`event`, `total`, `signatures`, `params`, `count`) are
114
+ # stable — Plan 09's CHANGELOG references this contract.
115
+ #
116
+ # @return [String] JSON
117
+ def to_json(*)
118
+ JSON.generate(report.map { |r|
119
+ {
120
+ event: r.event_name,
121
+ total: r.total,
122
+ signatures: r.signatures.map { |s| {params: s.params, count: s.count} }
123
+ }
124
+ })
125
+ end
126
+
127
+ # One row in the GA4 lint report.
128
+ #
129
+ # @!attribute [rw] event_name
130
+ # The event name as it appeared in the JSONL `event` field.
131
+ # @!attribute [rw] reason
132
+ # Human-readable description of the GA4 rule that was violated.
133
+ # @!attribute [rw] count
134
+ # How many JSONL lines contained this event name (regardless of
135
+ # param signature — the GA4 lint groups purely by event name).
136
+ Ga4Violation = Struct.new(:event_name, :reason, :count, keyword_init: true)
137
+
138
+ # Audit each unique event name in the JSONL sink against
139
+ # {Validators::Ga4Constraints} (REQ-28, Plan 02-04 / Scout §8).
140
+ #
141
+ # Only the event-name shape is checked here:
142
+ #
143
+ # - snake_case regex (`/\A[a-z][a-z0-9_]*\z/`)
144
+ # - max 40 chars
145
+ # - not in `GA4_RESERVED_NAMES`
146
+ #
147
+ # Param-name validation is intentionally OUT OF SCOPE for this
148
+ # method — `params` in the JSONL is a sorted-NAMES-only privacy
149
+ # snapshot, but param-name shape is a per-line fact (each occurrence
150
+ # could fail differently) and the linter's grouping model is built
151
+ # around event names. Use the call-time validation in
152
+ # {Subscribers::Ga4MeasurementProtocol#deliver} for per-payload
153
+ # checks; this method is the audit trail for "what event names did
154
+ # we ship that GA4 will silently drop?".
155
+ #
156
+ # @return [Array<Ga4Violation>] sorted by `count` descending. Empty
157
+ # when every event name in the JSONL passes.
158
+ def ga4_violations
159
+ counts = Hash.new(0)
160
+ read_lines do |entry|
161
+ name = entry["event"]
162
+ next if name.nil? || name.empty?
163
+ counts[name] += 1
164
+ end
165
+
166
+ counts.map { |name, count|
167
+ begin
168
+ Validators::Ga4Constraints.validate_event_name!(name)
169
+ nil
170
+ rescue Ga4ConstraintError => e
171
+ Ga4Violation.new(event_name: name, reason: e.message, count: count)
172
+ end
173
+ }.compact.sort_by { |v| -v.count }
174
+ end
175
+
176
+ # Write the GA4 violation report to `io`.
177
+ #
178
+ # Returns `true` when there were no violations (rake task should
179
+ # exit 0), `false` otherwise (rake task exits non-zero).
180
+ #
181
+ # @param io [IO] writer; defaults to `$stdout`
182
+ # @return [Boolean] `true` ⇒ clean, `false` ⇒ violations found
183
+ def print_ga4(io = $stdout)
184
+ violations = ga4_violations
185
+ io.puts "# track_relay GA4 event-name audit"
186
+ io.puts "# source: #{@jsonl_path}"
187
+ io.puts "# violations: #{violations.size}"
188
+ io.puts ""
189
+
190
+ if violations.empty?
191
+ io.puts "# clean — every event name passes GA4 constraints"
192
+ return true
193
+ end
194
+
195
+ violations.each do |v|
196
+ io.puts "event :#{v.event_name} (#{v.count} occurrence#{"s" unless v.count == 1})"
197
+ io.puts " reason: #{v.reason}"
198
+ io.puts ""
199
+ end
200
+ false
201
+ end
202
+
203
+ private
204
+
205
+ def read_lines
206
+ return unless File.exist?(@jsonl_path.to_s)
207
+ File.foreach(@jsonl_path.to_s) do |line|
208
+ line = line.strip
209
+ next if line.empty?
210
+ begin
211
+ yield JSON.parse(line)
212
+ rescue JSON::ParserError
213
+ @malformed_lines += 1
214
+ end
215
+ end
216
+ end
217
+ end
218
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "time"
6
+ require "track_relay/version"
7
+ require "track_relay/catalog"
8
+
9
+ module TrackRelay
10
+ # Generate a typed JSON manifest of the loaded catalog for the
11
+ # `@track_relay/client` JS package (Plan 02-05) to fetch and validate
12
+ # events client-side. The on-disk artifact is written by either:
13
+ #
14
+ # * `rake track_relay:manifest` (production / CI), or
15
+ # * `config.to_prepare` in development (regenerated on every reload),
16
+ #
17
+ # both of which delegate to {.write!}. The generated shape is stable
18
+ # and consumed by the JS client:
19
+ #
20
+ # {
21
+ # "version": "<gem version>",
22
+ # "generated_at": "<ISO8601 timestamp>",
23
+ # "events": {
24
+ # "<event_name>": {
25
+ # "params": {"<param>" => "<type>"}, # all 5 ParamSchema types
26
+ # "required": ["<required_param_name>"] # may be []
27
+ # }
28
+ # }
29
+ # }
30
+ #
31
+ # Phase 2 ships `params` (types) + `required[]` only — richer
32
+ # constraints (max/in/format) land in Phase 4 alongside generators.
33
+ module Manifest
34
+ DEFAULT_FILENAME = "track_relay_catalog.json"
35
+
36
+ class << self
37
+ # Build the manifest Hash from a catalog-like object.
38
+ #
39
+ # @param catalog [#all] anything responding to `all` and returning
40
+ # an Array of {EventDefinition}; defaults to {TrackRelay::Catalog}
41
+ # @return [Hash] frozen-shape manifest (NOT frozen — callers may
42
+ # mutate before serialization if needed)
43
+ def generate(catalog: Catalog)
44
+ {
45
+ version: TrackRelay::VERSION,
46
+ generated_at: Time.now.utc.iso8601,
47
+ events: catalog.all.each_with_object({}) do |defn, h|
48
+ h[defn.name.to_s] = {
49
+ params: defn.params.transform_keys(&:to_s).transform_values { |s| s.type.to_s },
50
+ required: defn.params.select { |_, s| s.required }.keys.map(&:to_s)
51
+ }
52
+ end
53
+ }
54
+ end
55
+
56
+ # Write the manifest to `path` as pretty-printed JSON.
57
+ #
58
+ # `FileUtils.mkdir_p(File.dirname(path))` is called first so a fresh
59
+ # checkout (e.g. the Combustion dummy app at `test/internal/`, which
60
+ # has no `public/` directory) does NOT crash with `Errno::ENOENT` on
61
+ # the first call.
62
+ #
63
+ # @param path [String, Pathname] target file; defaults to
64
+ # `Rails.root.join("public", "track_relay_catalog.json")` when
65
+ # Rails is loaded
66
+ # @param catalog [#all] forwarded to {.generate}
67
+ # @return [String, Pathname] the path that was written
68
+ def write!(path: default_path, catalog: Catalog)
69
+ FileUtils.mkdir_p(File.dirname(path))
70
+ File.write(path, JSON.pretty_generate(generate(catalog: catalog)))
71
+ path
72
+ end
73
+
74
+ private
75
+
76
+ def default_path
77
+ unless defined?(Rails) && Rails.respond_to?(:root) && Rails.root
78
+ raise ArgumentError,
79
+ "TrackRelay::Manifest.write! requires a `path:` argument when Rails.root is unavailable"
80
+ end
81
+ Rails.root.join("public", DEFAULT_FILENAME)
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+ require "track_relay/catalog"
5
+ require "track_relay/dispatcher"
6
+ # Direct require of Manifest (rather than going through `lib/track_relay.rb`)
7
+ # keeps this plan file-disjoint with Plan 02-02 in the same wave —
8
+ # Plan 02-02 owns the umbrella file's require list.
9
+ require "track_relay/manifest"
10
+
11
+ module TrackRelay
12
+ # Rails integration boundary for the gem.
13
+ #
14
+ # Three responsibilities, all wired in initializer blocks:
15
+ #
16
+ # 1. Tell Zeitwerk to ignore `Rails.root/config/track_relay` so that
17
+ # DSL files in that directory (which call {TrackRelay.catalog}
18
+ # rather than defining constants) don't trip Rails autoloading.
19
+ #
20
+ # 2. Register a `config.to_prepare` callback that calls
21
+ # {Catalog.clear!} and then `Dir.glob/load`s every `*.rb` file
22
+ # under `config/track_relay/`. {Catalog.clear!} runs FIRST so
23
+ # editing a catalog file in dev produces a clean rebuild rather
24
+ # than a duplicate-registration error from {Catalog#register}.
25
+ # `to_prepare` runs once at boot in test/production and before
26
+ # every request reload in development — exactly the hot-reload
27
+ # contract this gem needs.
28
+ #
29
+ # 3. Call {Dispatcher.start!} exactly once via
30
+ # `config.after_initialize` so the AS::Notifications fan-out
31
+ # subscription is registered before the host app handles its
32
+ # first request. {Dispatcher.start!} is idempotent so this is
33
+ # safe even when the Railtie is loaded multiple times.
34
+ #
35
+ # The Railtie is required from `lib/track_relay.rb` only when
36
+ # `Rails::Railtie` is defined, so the gem still loads cleanly in
37
+ # non-Rails contexts (plain `require "track_relay"` from a script).
38
+ class Railtie < Rails::Railtie
39
+ initializer "track_relay.catalog_autoload" do |app|
40
+ catalog_dir = app.root.join("config", "track_relay")
41
+
42
+ # Conditional ignore avoids a Zeitwerk warning when the directory
43
+ # doesn't exist yet (host app hasn't created it).
44
+ Rails.autoloaders.main.ignore(catalog_dir) if catalog_dir.exist?
45
+
46
+ # Clear before reload so editing config/track_relay/foo.rb in dev
47
+ # produces a clean catalog rebuild rather than double-registration
48
+ # errors from Catalog.register's defensive duplicate guard.
49
+ #
50
+ # In development, regenerate `public/track_relay_catalog.json`
51
+ # after the catalog rebuild so the JS client picks up DSL changes
52
+ # without a server restart. The test env is excluded explicitly to
53
+ # avoid every-test churn — production builds the manifest via
54
+ # `assets:precompile` (see the next initializer).
55
+ app.config.to_prepare do
56
+ TrackRelay::Catalog.clear!
57
+ Dir.glob("#{catalog_dir}/**/*.rb").sort.each do |file|
58
+ load file
59
+ end
60
+
61
+ if Rails.env.development? && TrackRelay::Catalog.all.any?
62
+ TrackRelay::Manifest.write!
63
+ end
64
+ end
65
+ end
66
+
67
+ initializer "track_relay.start_dispatcher" do |app|
68
+ app.config.after_initialize do
69
+ TrackRelay::Dispatcher.start!
70
+ end
71
+ end
72
+
73
+ # Chain `track_relay:manifest` as a prerequisite of
74
+ # `assets:precompile` so production / CI builds always ship a fresh
75
+ # `public/track_relay_catalog.json`. The conditional avoids a
76
+ # Rake::Task-not-defined error in non-asset apps (API-only Rails
77
+ # without Sprockets/Propshaft). Mirrors cssbundling-rails /
78
+ # jsbundling-rails patterns.
79
+ initializer "track_relay.enhance_assets_precompile" do
80
+ # `defined?(Rake)` guards against API-only Rails apps that have
81
+ # never `require "rake"`-d at boot — the initializer is a no-op
82
+ # there. When Rake IS loaded, the `task_defined?` check then
83
+ # silently skips when the host app uses neither Sprockets nor
84
+ # Propshaft.
85
+ if defined?(Rake) && Rake::Task.task_defined?("assets:precompile")
86
+ Rake::Task["assets:precompile"].enhance(["track_relay:manifest"])
87
+ end
88
+ end
89
+
90
+ # Make `rake track_relay:lint` and `rake track_relay:lint:json`
91
+ # available in any consumer app. `__dir__` is `lib/track_relay`;
92
+ # `..` walks to `lib`; the rake file lives at `lib/tasks/track_relay.rake`.
93
+ rake_tasks do
94
+ load File.expand_path("../tasks/track_relay.rake", __dir__)
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "track_relay/current"
4
+ require "track_relay/subscribers/base"
5
+
6
+ module TrackRelay
7
+ module Subscribers
8
+ # Server-side Ahoy subscriber (REQ-09).
9
+ #
10
+ # Routes catalog events through Ahoy's only public tracking surface
11
+ # — `controller.ahoy.track(name, properties)` — by reading
12
+ # {TrackRelay::Current.controller} on the synchronous request
13
+ # thread. When no controller is in scope (background job, rake
14
+ # task, console), or the host application does not include
15
+ # `Ahoy::Controller` in its `ApplicationController`, the subscriber
16
+ # logs a warning and skips delivery — it does NOT raise, does NOT
17
+ # enqueue a {DeliveryJob}, and does NOT touch any internal Ahoy
18
+ # API.
19
+ #
20
+ # ## Why synchronous (not async like GA4)
21
+ #
22
+ # `Ahoy::Tracker` is bound to the live request — it wraps the
23
+ # controller's cookie jar and visit lifecycle. By the time a
24
+ # {DeliveryJob} runs, `Rails.application.executor.wrap` has
25
+ # already cleared {ActiveSupport::CurrentAttributes}, so
26
+ # `Current.controller` is nil and the live tracker instance is
27
+ # unreachable. {.synchronous!} therefore opts the subscriber into
28
+ # inline delivery on the request thread — `#handle` calls
29
+ # `safe_deliver(payload)` directly instead of enqueueing a job.
30
+ #
31
+ # `tracker.track` is an in-process database write (it calls
32
+ # `@store.track_event(data)` which does `event_model.create!` or
33
+ # equivalent), not a network call, so synchronous delivery adds
34
+ # negligible request overhead and matches how Ahoy itself works
35
+ # (Ahoy::Trackable wires `track` as an inline before_action helper).
36
+ #
37
+ # ## Why no `require "ahoy"`
38
+ #
39
+ # The subscriber must load cleanly in non-Ahoy host applications
40
+ # (the gem ships with the file in `lib/track_relay.rb`'s require
41
+ # manifest unconditionally). Duck-typing via
42
+ # `controller.respond_to?(:ahoy, true)` handles the absent-Ahoy
43
+ # case without a top-level require — the same pattern used by
44
+ # {ClientId::AhoyVisitor}.
45
+ #
46
+ # ## Why no `Ahoy::Event.create!` / `Ahoy::Tracker.new`
47
+ #
48
+ # `Ahoy::Tracker` is the sole public tracking surface. Internal
49
+ # APIs (`Ahoy::Event.create!`, `Ahoy::Visit#track` — which does NOT
50
+ # exist on the visit model) are off-limits because they bypass
51
+ # Ahoy's bot-exclusion store, user-method config, and visit
52
+ # association logic. The subscriber dispatches via
53
+ # `controller.ahoy.track(name, properties)` only.
54
+ #
55
+ # ## Skip conditions
56
+ #
57
+ # All three skip paths log a `Rails.logger.warn` line of the form
58
+ # `[track_relay] Ahoy subscriber skipping delivery — <reason>` and
59
+ # `return` from {#deliver}. They MUST NOT raise — host applications
60
+ # that boot without Ahoy or call `TrackRelay.track` from a job
61
+ # must not crash.
62
+ #
63
+ # 1. `Current.controller` is nil — job, rake, or console context.
64
+ # 2. The controller does not `respond_to?(:ahoy, true)` —
65
+ # `Ahoy::Controller` was never `include`d.
66
+ # 3. `controller.ahoy` returns nil — defensive coverage for a
67
+ # controller that has the helper but no live tracker yet.
68
+ class Ahoy < Base
69
+ synchronous!
70
+
71
+ # Dispatch `payload` to `controller.ahoy.track` when a live
72
+ # controller with an Ahoy tracker is in scope. Skip-and-warn
73
+ # otherwise.
74
+ #
75
+ # @param payload [TrackRelay::EventPayload]
76
+ # @return [void]
77
+ def deliver(payload)
78
+ controller = TrackRelay::Current.controller
79
+
80
+ unless controller&.respond_to?(:ahoy, true)
81
+ log_skip("no controller or ahoy tracker in context")
82
+ return
83
+ end
84
+
85
+ tracker = controller.ahoy
86
+
87
+ unless tracker
88
+ log_skip("controller.ahoy returned nil")
89
+ return
90
+ end
91
+
92
+ tracker.track(payload.name.to_s, payload.params)
93
+ end
94
+
95
+ private
96
+
97
+ # Mirror of {Subscribers::Ga4MeasurementProtocol#warn_missing_credentials}
98
+ # — guarded `Rails.logger.warn` so the subscriber stays callable
99
+ # in non-Rails contexts (e.g. plain `require "track_relay"` from
100
+ # a script).
101
+ #
102
+ # @param reason [String]
103
+ # @return [void]
104
+ def log_skip(reason)
105
+ return unless defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
106
+ Rails.logger.warn("[track_relay] Ahoy subscriber skipping delivery — #{reason}")
107
+ end
108
+ end
109
+ end
110
+ end