track_relay 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +147 -0
- data/LICENSE.txt +21 -0
- data/README.md +458 -0
- data/UPGRADING.md +85 -0
- data/USAGE.md +192 -0
- data/lib/generators/track_relay/event/event_generator.rb +17 -0
- data/lib/generators/track_relay/event/templates/event.rb.tt +21 -0
- data/lib/generators/track_relay/install/install_generator.rb +49 -0
- data/lib/generators/track_relay/install/templates/application_subscriber.rb.tt +31 -0
- data/lib/generators/track_relay/install/templates/initializer.rb.tt +42 -0
- data/lib/generators/track_relay/install/templates/sample_catalog.rb.tt +17 -0
- data/lib/generators/track_relay/subscriber/subscriber_generator.rb +17 -0
- data/lib/generators/track_relay/subscriber/templates/subscriber.rb.tt +28 -0
- data/lib/tasks/track_relay.rake +80 -0
- data/lib/track_relay/catalog.rb +86 -0
- data/lib/track_relay/client_id/ahoy_visitor.rb +34 -0
- data/lib/track_relay/client_id/ga.rb +48 -0
- data/lib/track_relay/client_id/session.rb +46 -0
- data/lib/track_relay/configuration.rb +141 -0
- data/lib/track_relay/controller_tracking.rb +90 -0
- data/lib/track_relay/current.rb +33 -0
- data/lib/track_relay/delivery_job.rb +84 -0
- data/lib/track_relay/dispatcher.rb +92 -0
- data/lib/track_relay/dsl/event_builder.rb +64 -0
- data/lib/track_relay/dsl/param_builder.rb +74 -0
- data/lib/track_relay/errors.rb +54 -0
- data/lib/track_relay/event_definition.rb +74 -0
- data/lib/track_relay/event_payload.rb +244 -0
- data/lib/track_relay/instrumenter.rb +241 -0
- data/lib/track_relay/job_tracking.rb +50 -0
- data/lib/track_relay/linter.rb +218 -0
- data/lib/track_relay/manifest.rb +85 -0
- data/lib/track_relay/railtie.rb +97 -0
- data/lib/track_relay/subscribers/ahoy.rb +110 -0
- data/lib/track_relay/subscribers/base.rb +231 -0
- data/lib/track_relay/subscribers/ga4_measurement_protocol.rb +250 -0
- data/lib/track_relay/subscribers/logger.rb +79 -0
- data/lib/track_relay/subscribers/test.rb +60 -0
- data/lib/track_relay/testing/helpers.rb +44 -0
- data/lib/track_relay/testing/minitest_assertions.rb +71 -0
- data/lib/track_relay/testing/rspec_matchers.rb +79 -0
- data/lib/track_relay/testing.rb +90 -0
- data/lib/track_relay/validators/catalog_validator.rb +48 -0
- data/lib/track_relay/validators/ga4_constraints.rb +85 -0
- data/lib/track_relay/version.rb +5 -0
- data/lib/track_relay.rb +203 -0
- metadata +248 -0
|
@@ -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
|