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,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TrackRelay
|
|
4
|
+
module Testing
|
|
5
|
+
# Minitest assertions for use against the active
|
|
6
|
+
# {TrackRelay::Subscribers::Test} (the one installed by
|
|
7
|
+
# {TrackRelay.test_mode!}).
|
|
8
|
+
#
|
|
9
|
+
# Designed to be mixed into `ActiveSupport::TestCase` /
|
|
10
|
+
# `Minitest::Test` either directly or via {Helpers}, which also
|
|
11
|
+
# registers per-test setup/teardown to enable test mode automatically.
|
|
12
|
+
#
|
|
13
|
+
# Used directly:
|
|
14
|
+
#
|
|
15
|
+
# class MyTest < ActiveSupport::TestCase
|
|
16
|
+
# include TrackRelay::Testing::MinitestAssertions
|
|
17
|
+
#
|
|
18
|
+
# setup { TrackRelay.test_mode! }
|
|
19
|
+
# teardown { TrackRelay.test_mode_off! }
|
|
20
|
+
#
|
|
21
|
+
# test "fires :foo" do
|
|
22
|
+
# MyService.run!
|
|
23
|
+
# assert_tracked :foo, user_id: 1
|
|
24
|
+
# end
|
|
25
|
+
# end
|
|
26
|
+
#
|
|
27
|
+
# Or via {Helpers}, which wires the setup/teardown for you.
|
|
28
|
+
module MinitestAssertions
|
|
29
|
+
# Return the active Test subscriber, or raise a helpful message
|
|
30
|
+
# if {TrackRelay.test_mode!} has not been called yet.
|
|
31
|
+
#
|
|
32
|
+
# @raise [RuntimeError]
|
|
33
|
+
# @return [TrackRelay::Subscribers::Test]
|
|
34
|
+
def track_relay_test
|
|
35
|
+
TrackRelay.test_subscriber ||
|
|
36
|
+
raise("Call TrackRelay.test_mode! before using assert_tracked / refute_tracked")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Assert at least one event named `name` was captured. When
|
|
40
|
+
# `expected_params` is supplied, also assert at least one matching
|
|
41
|
+
# event has params that include every key/value pair in
|
|
42
|
+
# `expected_params` (subset semantics).
|
|
43
|
+
#
|
|
44
|
+
# @param name [Symbol]
|
|
45
|
+
# @param expected_params [Hash{Symbol => Object}]
|
|
46
|
+
# @raise [Minitest::Assertion] when the assertion fails
|
|
47
|
+
# @return [void]
|
|
48
|
+
def assert_tracked(name, **expected_params)
|
|
49
|
+
events = track_relay_test.find(name)
|
|
50
|
+
assert events.any?,
|
|
51
|
+
"Expected an event :#{name} to be tracked, but found #{track_relay_test.events.map(&:name).inspect}"
|
|
52
|
+
return if expected_params.empty?
|
|
53
|
+
|
|
54
|
+
match = events.find { |e| expected_params.all? { |k, v| e.params[k] == v } }
|
|
55
|
+
assert match,
|
|
56
|
+
"Expected :#{name} with params >= #{expected_params.inspect}, got #{events.map(&:params).inspect}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Assert no event named `name` was captured.
|
|
60
|
+
#
|
|
61
|
+
# @param name [Symbol]
|
|
62
|
+
# @raise [Minitest::Assertion] when an event named `name` was tracked
|
|
63
|
+
# @return [void]
|
|
64
|
+
def refute_tracked(name)
|
|
65
|
+
events = track_relay_test.find(name)
|
|
66
|
+
refute events.any?,
|
|
67
|
+
"Expected no event :#{name} to be tracked, but got #{events.map(&:params).inspect}"
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# RSpec matchers for {TrackRelay}.
|
|
4
|
+
#
|
|
5
|
+
# Loaded conditionally — guarded by `defined?(RSpec)` so requiring
|
|
6
|
+
# `track_relay/testing` is safe even when RSpec is not on the load
|
|
7
|
+
# path. Consumers who use RSpec require this file from their
|
|
8
|
+
# `rails_helper.rb` (or rely on the auto-load below from
|
|
9
|
+
# `track_relay/testing.rb`).
|
|
10
|
+
#
|
|
11
|
+
# Provided matchers:
|
|
12
|
+
#
|
|
13
|
+
# - `have_tracked(name)` — passes when at least one event named
|
|
14
|
+
# `name` was captured by the active Test subscriber. Supports
|
|
15
|
+
# `.with(**params)` to require subset-matching params on at least
|
|
16
|
+
# one captured event.
|
|
17
|
+
# - `have_identified(user)` — Phase-01 placeholder that never
|
|
18
|
+
# matches; identify capture in the Test subscriber is deferred to
|
|
19
|
+
# Phase 02.
|
|
20
|
+
#
|
|
21
|
+
# Usage:
|
|
22
|
+
#
|
|
23
|
+
# require "track_relay/testing"
|
|
24
|
+
#
|
|
25
|
+
# RSpec.describe "checkout" do
|
|
26
|
+
# before { TrackRelay.test_mode! }
|
|
27
|
+
# after { TrackRelay.test_mode_off! }
|
|
28
|
+
#
|
|
29
|
+
# it "fires :purchase" do
|
|
30
|
+
# Checkout.run!
|
|
31
|
+
# expect(track_relay).to have_tracked(:purchase).with(amount_cents: 1999)
|
|
32
|
+
# end
|
|
33
|
+
# end
|
|
34
|
+
#
|
|
35
|
+
# The `track_relay` example-group helper (registered below) returns
|
|
36
|
+
# the active Test subscriber so `expect(track_relay).to ...` reads
|
|
37
|
+
# naturally. The matcher itself reads the global
|
|
38
|
+
# `TrackRelay.test_subscriber`, so the receiver is mostly stylistic.
|
|
39
|
+
if defined?(RSpec)
|
|
40
|
+
RSpec::Matchers.define :have_tracked do |name|
|
|
41
|
+
match do |_actual|
|
|
42
|
+
raise "Call TrackRelay.test_mode! before have_tracked" unless TrackRelay::Testing.active?
|
|
43
|
+
events = TrackRelay.test_subscriber.find(name)
|
|
44
|
+
next false if events.empty?
|
|
45
|
+
next true if @expected_params.nil?
|
|
46
|
+
events.any? { |e| @expected_params.all? { |k, v| e.params[k] == v } }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
chain :with do |params|
|
|
50
|
+
@expected_params = params
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
failure_message do |_actual|
|
|
54
|
+
seen = TrackRelay.test_subscriber.events.map { |e| {name: e.name, params: e.params} }
|
|
55
|
+
msg = "expected an event :#{name} to be tracked"
|
|
56
|
+
msg += " with params >= #{@expected_params.inspect}" if @expected_params
|
|
57
|
+
"#{msg}, but got #{seen.inspect}"
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
RSpec::Matchers.define :have_identified do |_user|
|
|
62
|
+
# Phase 01 placeholder: identify capture is not yet wired into the
|
|
63
|
+
# Test subscriber. Documented as TODO for Phase 02.
|
|
64
|
+
match { |_actual| false }
|
|
65
|
+
failure_message { "have_identified is not yet implemented in Phase 01" }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
if defined?(RSpec.configure)
|
|
69
|
+
RSpec.configure do |config|
|
|
70
|
+
config.include(Module.new do
|
|
71
|
+
# Returns the active Test subscriber so RSpec example groups
|
|
72
|
+
# can write `expect(track_relay).to have_tracked(...)`.
|
|
73
|
+
def track_relay
|
|
74
|
+
TrackRelay.test_subscriber || raise("Call TrackRelay.test_mode! first")
|
|
75
|
+
end
|
|
76
|
+
end)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "track_relay"
|
|
4
|
+
require "track_relay/subscribers/test"
|
|
5
|
+
|
|
6
|
+
module TrackRelay
|
|
7
|
+
# Opt-in testing entry point for {TrackRelay}.
|
|
8
|
+
#
|
|
9
|
+
# `lib/track_relay.rb` does NOT require this file — consumers add
|
|
10
|
+
# `require "track_relay/testing"` themselves in their `test_helper.rb`
|
|
11
|
+
# / `rails_helper.rb`. This keeps the auto setup/teardown helpers and
|
|
12
|
+
# the RSpec matcher hooks out of production runtime. The gem's own
|
|
13
|
+
# `test/test_helper.rb` performs that require explicitly because the
|
|
14
|
+
# gem's tests are themselves consumers of the testing surface.
|
|
15
|
+
#
|
|
16
|
+
# `test_mode!` swaps the configured subscriber list for a single
|
|
17
|
+
# {TrackRelay::Subscribers::Test} for the duration of an example so
|
|
18
|
+
# consumer tests can assert against fired events without sending them
|
|
19
|
+
# to real adapters. `test_mode_off!` restores the previously captured
|
|
20
|
+
# list.
|
|
21
|
+
#
|
|
22
|
+
# `test_mode!` is idempotent: calling twice without restoring returns
|
|
23
|
+
# the same Test instance and does NOT clobber the originally-captured
|
|
24
|
+
# subscriber list. After `test_mode_off!`, calling `test_mode!` again
|
|
25
|
+
# creates a fresh Test subscriber so per-test buffers stay isolated.
|
|
26
|
+
module Testing
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
# Replace `TrackRelay.config.subscribers` with a single
|
|
30
|
+
# {Subscribers::Test} and snapshot the previous list. Idempotent.
|
|
31
|
+
#
|
|
32
|
+
# @return [Subscribers::Test] the active test subscriber
|
|
33
|
+
def test_mode!
|
|
34
|
+
return @test_subscriber if active?
|
|
35
|
+
test_subscriber = Subscribers::Test.new
|
|
36
|
+
@previous_subscribers = TrackRelay.config.replace_subscribers([test_subscriber])
|
|
37
|
+
@test_subscriber = test_subscriber
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Restore the subscriber list captured by {.test_mode!}. No-op when
|
|
41
|
+
# not active.
|
|
42
|
+
#
|
|
43
|
+
# @return [void]
|
|
44
|
+
def test_mode_off!
|
|
45
|
+
return unless active?
|
|
46
|
+
TrackRelay.config.replace_subscribers(@previous_subscribers)
|
|
47
|
+
@previous_subscribers = nil
|
|
48
|
+
@test_subscriber = nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @return [Boolean] whether {.test_mode!} is currently active
|
|
52
|
+
def active?
|
|
53
|
+
!@test_subscriber.nil?
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @return [Subscribers::Test, nil] the active test subscriber, or nil
|
|
57
|
+
def test_subscriber
|
|
58
|
+
@test_subscriber
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
class << self
|
|
63
|
+
# Convenience delegate to {Testing.test_mode!}.
|
|
64
|
+
#
|
|
65
|
+
# @return [Subscribers::Test]
|
|
66
|
+
def test_mode!
|
|
67
|
+
Testing.test_mode!
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Convenience delegate to {Testing.test_mode_off!}.
|
|
71
|
+
#
|
|
72
|
+
# @return [void]
|
|
73
|
+
def test_mode_off!
|
|
74
|
+
Testing.test_mode_off!
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Convenience delegate to {Testing.test_subscriber}.
|
|
78
|
+
#
|
|
79
|
+
# @return [Subscribers::Test, nil]
|
|
80
|
+
def test_subscriber
|
|
81
|
+
Testing.test_subscriber
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Auto-load RSpec matchers when RSpec is on the load path. The
|
|
87
|
+
# matcher file itself is also guarded with `if defined?(RSpec)`, so
|
|
88
|
+
# consumers who require it directly outside an RSpec context are
|
|
89
|
+
# still safe — the file becomes a no-op.
|
|
90
|
+
require "track_relay/testing/rspec_matchers" if defined?(RSpec)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "track_relay/errors"
|
|
4
|
+
require "track_relay/validators/ga4_constraints"
|
|
5
|
+
|
|
6
|
+
module TrackRelay
|
|
7
|
+
module Validators
|
|
8
|
+
# Validates an {EventDefinition} at catalog-load time.
|
|
9
|
+
#
|
|
10
|
+
# Runs both GA4 constraint checks ({Ga4Constraints}) and the
|
|
11
|
+
# reserved-key collision guard. Failures raise immediately so the
|
|
12
|
+
# offending catalog file fails to load — much louder than a silent
|
|
13
|
+
# collision at track time.
|
|
14
|
+
#
|
|
15
|
+
# Two distinct error types are raised by design:
|
|
16
|
+
# - {ReservedKeyError} for params that collide with the runtime
|
|
17
|
+
# context keys ({TrackRelay::RESERVED_KEYS}).
|
|
18
|
+
# - {Ga4ConstraintError} for any GA4 rule violation.
|
|
19
|
+
#
|
|
20
|
+
# This module is invoked by `EventBuilder#event` (DSL) before
|
|
21
|
+
# registering the definition with the catalog. It is also safe to call
|
|
22
|
+
# against externally-built definitions (e.g. tests).
|
|
23
|
+
module CatalogValidator
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
# Run all catalog-load-time checks against a definition.
|
|
27
|
+
#
|
|
28
|
+
# @param definition [TrackRelay::EventDefinition]
|
|
29
|
+
# @raise [TrackRelay::ReservedKeyError] when a param key collides
|
|
30
|
+
# with a reserved context key
|
|
31
|
+
# @raise [TrackRelay::Ga4ConstraintError] when any GA4 rule fails
|
|
32
|
+
# @return [void]
|
|
33
|
+
def validate!(definition)
|
|
34
|
+
Ga4Constraints.validate_event_name!(definition.name)
|
|
35
|
+
Ga4Constraints.validate_param_count!(definition.params)
|
|
36
|
+
|
|
37
|
+
definition.params.each_key do |param_name|
|
|
38
|
+
if TrackRelay::RESERVED_KEYS.include?(param_name)
|
|
39
|
+
raise ReservedKeyError,
|
|
40
|
+
"Param #{param_name.inspect} on event #{definition.name.inspect} collides with a reserved context key — rename to e.g. :actor_user_id, :session_token, :tracking_client_id, or :http_request"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
Ga4Constraints.validate_param_name!(param_name)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "track_relay/errors"
|
|
4
|
+
|
|
5
|
+
module TrackRelay
|
|
6
|
+
module Validators
|
|
7
|
+
# Enforces Google Analytics 4 naming + sizing constraints on catalog
|
|
8
|
+
# entries. Used by {CatalogValidator} at catalog-load time so
|
|
9
|
+
# GA4-incompatible events fail fast in development rather than silently
|
|
10
|
+
# in production where GA4 would just drop them.
|
|
11
|
+
#
|
|
12
|
+
# Rules implemented (per GA4 docs):
|
|
13
|
+
# - Event names: snake_case, start with a lowercase letter, max 40
|
|
14
|
+
# chars, not in {TrackRelay::GA4_RESERVED_NAMES}.
|
|
15
|
+
# - Param names: same regex + max 40 chars.
|
|
16
|
+
# - Per-event param count: <= 25 custom params.
|
|
17
|
+
#
|
|
18
|
+
# All violations raise {TrackRelay::Ga4ConstraintError} with a message
|
|
19
|
+
# naming the offender so error messages stay actionable.
|
|
20
|
+
module Ga4Constraints
|
|
21
|
+
# GA4 names must start with a lowercase letter and contain only
|
|
22
|
+
# lowercase letters, digits, and underscores after that.
|
|
23
|
+
NAME_PATTERN = /\A[a-z][a-z0-9_]*\z/
|
|
24
|
+
|
|
25
|
+
# GA4 caps custom event/param name length at 40 characters.
|
|
26
|
+
MAX_NAME_LENGTH = 40
|
|
27
|
+
|
|
28
|
+
# GA4 caps custom params per event at 25.
|
|
29
|
+
MAX_PARAMS_PER_EVENT = 25
|
|
30
|
+
|
|
31
|
+
module_function
|
|
32
|
+
|
|
33
|
+
# @param name [Symbol, String] event name to validate
|
|
34
|
+
# @raise [TrackRelay::Ga4ConstraintError] when the name violates any
|
|
35
|
+
# GA4 rule (shape, length, or reserved-name list)
|
|
36
|
+
# @return [void]
|
|
37
|
+
def validate_event_name!(name)
|
|
38
|
+
as_string = name.to_s
|
|
39
|
+
|
|
40
|
+
unless as_string.match?(NAME_PATTERN)
|
|
41
|
+
raise Ga4ConstraintError,
|
|
42
|
+
"Event name #{name.inspect} must be snake_case (matches #{NAME_PATTERN.inspect})"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
if as_string.length > MAX_NAME_LENGTH
|
|
46
|
+
raise Ga4ConstraintError,
|
|
47
|
+
"Event name #{name.inspect} exceeds GA4 max length of #{MAX_NAME_LENGTH} chars (got #{as_string.length})"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
if TrackRelay::GA4_RESERVED_NAMES.include?(as_string)
|
|
51
|
+
raise Ga4ConstraintError,
|
|
52
|
+
"Event name #{name.inspect} is reserved by GA4 (https://support.google.com/analytics/answer/9234069) — pick a non-reserved name"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @param params [Hash] params hash from an EventDefinition
|
|
57
|
+
# @raise [TrackRelay::Ga4ConstraintError] when params.size > 25
|
|
58
|
+
# @return [void]
|
|
59
|
+
def validate_param_count!(params)
|
|
60
|
+
if params.size > MAX_PARAMS_PER_EVENT
|
|
61
|
+
raise Ga4ConstraintError,
|
|
62
|
+
"Event has #{params.size} custom params; GA4 caps custom params per event at #{MAX_PARAMS_PER_EVENT}"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @param name [Symbol, String] param name to validate
|
|
67
|
+
# @raise [TrackRelay::Ga4ConstraintError] when the param name
|
|
68
|
+
# violates GA4 shape or length rules
|
|
69
|
+
# @return [void]
|
|
70
|
+
def validate_param_name!(name)
|
|
71
|
+
as_string = name.to_s
|
|
72
|
+
|
|
73
|
+
unless as_string.match?(NAME_PATTERN)
|
|
74
|
+
raise Ga4ConstraintError,
|
|
75
|
+
"Param name #{name.inspect} must be snake_case (matches #{NAME_PATTERN.inspect})"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
if as_string.length > MAX_NAME_LENGTH
|
|
79
|
+
raise Ga4ConstraintError,
|
|
80
|
+
"Param name #{name.inspect} exceeds GA4 max length of #{MAX_NAME_LENGTH} chars (got #{as_string.length})"
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
data/lib/track_relay.rb
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "track_relay/version"
|
|
4
|
+
require "track_relay/errors"
|
|
5
|
+
require "track_relay/event_definition"
|
|
6
|
+
require "track_relay/validators/ga4_constraints"
|
|
7
|
+
require "track_relay/validators/catalog_validator"
|
|
8
|
+
require "track_relay/catalog"
|
|
9
|
+
require "track_relay/dsl/param_builder"
|
|
10
|
+
require "track_relay/dsl/event_builder"
|
|
11
|
+
require "track_relay/current"
|
|
12
|
+
require "track_relay/client_id/ga"
|
|
13
|
+
require "track_relay/client_id/ahoy_visitor"
|
|
14
|
+
require "track_relay/client_id/session"
|
|
15
|
+
require "track_relay/configuration"
|
|
16
|
+
require "track_relay/instrumenter"
|
|
17
|
+
require "track_relay/subscribers/base"
|
|
18
|
+
require "track_relay/subscribers/test"
|
|
19
|
+
require "track_relay/subscribers/logger"
|
|
20
|
+
require "track_relay/subscribers/ga4_measurement_protocol"
|
|
21
|
+
require "track_relay/subscribers/ahoy"
|
|
22
|
+
require "track_relay/delivery_job"
|
|
23
|
+
require "track_relay/dispatcher"
|
|
24
|
+
require "track_relay/controller_tracking"
|
|
25
|
+
require "track_relay/job_tracking"
|
|
26
|
+
require "track_relay/linter"
|
|
27
|
+
|
|
28
|
+
# The Railtie is the only Rails-coupled file pulled in at load time.
|
|
29
|
+
# Load it conditionally so the gem still works in non-Rails contexts
|
|
30
|
+
# (plain `require "track_relay"` from a script). Everything above this
|
|
31
|
+
# line depends on activesupport but not the full Rails stack.
|
|
32
|
+
require "track_relay/railtie" if defined?(Rails::Railtie)
|
|
33
|
+
|
|
34
|
+
module TrackRelay
|
|
35
|
+
# Param keys that cannot appear in a catalog event because they collide
|
|
36
|
+
# with the runtime context that track_relay injects automatically
|
|
37
|
+
# (TrackRelay::Current + reserved-key extraction in `track`).
|
|
38
|
+
#
|
|
39
|
+
# If a catalog event declares any of these as params, the catalog
|
|
40
|
+
# validator raises {ReservedKeyError} at load time so the conflict
|
|
41
|
+
# surfaces during boot rather than at track time.
|
|
42
|
+
RESERVED_KEYS = %i[user visitor_token client_id request].freeze
|
|
43
|
+
|
|
44
|
+
# Event names Google Analytics 4 reserves for its own use. Custom events
|
|
45
|
+
# in the catalog must not use any of these names — GA4 silently drops
|
|
46
|
+
# them on ingestion. Source:
|
|
47
|
+
# https://support.google.com/analytics/answer/9234069
|
|
48
|
+
#
|
|
49
|
+
# Kept here as a frozen Array of Strings so {GA4_RESERVED_NAMES.include?}
|
|
50
|
+
# works against both String and Symbol input via to_s coercion in the
|
|
51
|
+
# validator.
|
|
52
|
+
GA4_RESERVED_NAMES = %w[
|
|
53
|
+
ad_click
|
|
54
|
+
ad_exposure
|
|
55
|
+
ad_query
|
|
56
|
+
ad_reward
|
|
57
|
+
adunit_exposure
|
|
58
|
+
app_clear_data
|
|
59
|
+
app_exception
|
|
60
|
+
app_install
|
|
61
|
+
app_remove
|
|
62
|
+
app_store_refund
|
|
63
|
+
app_store_subscription_cancel
|
|
64
|
+
app_store_subscription_convert
|
|
65
|
+
app_store_subscription_renew
|
|
66
|
+
app_update
|
|
67
|
+
click
|
|
68
|
+
error
|
|
69
|
+
file_download
|
|
70
|
+
first_open
|
|
71
|
+
first_visit
|
|
72
|
+
form_start
|
|
73
|
+
form_submit
|
|
74
|
+
in_app_purchase
|
|
75
|
+
notification_dismiss
|
|
76
|
+
notification_foreground
|
|
77
|
+
notification_open
|
|
78
|
+
notification_receive
|
|
79
|
+
os_update
|
|
80
|
+
page_view
|
|
81
|
+
screen_view
|
|
82
|
+
scroll
|
|
83
|
+
session_start
|
|
84
|
+
session_start_with_rollout
|
|
85
|
+
session_resume_with_rollout
|
|
86
|
+
user_engagement
|
|
87
|
+
video_complete
|
|
88
|
+
video_progress
|
|
89
|
+
video_start
|
|
90
|
+
view_search_results
|
|
91
|
+
].freeze
|
|
92
|
+
|
|
93
|
+
class << self
|
|
94
|
+
# Process-wide {Configuration} singleton. Lazily instantiated on
|
|
95
|
+
# first access. Reset between tests via {reset_config!}.
|
|
96
|
+
#
|
|
97
|
+
# @return [Configuration]
|
|
98
|
+
def config
|
|
99
|
+
@config ||= Configuration.new
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Yield the {Configuration} singleton for host-app setup, then
|
|
103
|
+
# return it so callers can chain.
|
|
104
|
+
#
|
|
105
|
+
# TrackRelay.configure do |c|
|
|
106
|
+
# c.subscribe(MySubscriber.new)
|
|
107
|
+
# c.untyped_events_allowed = false
|
|
108
|
+
# end
|
|
109
|
+
#
|
|
110
|
+
# @yieldparam config [Configuration]
|
|
111
|
+
# @return [Configuration]
|
|
112
|
+
def configure
|
|
113
|
+
yield(config)
|
|
114
|
+
config
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Replace the singleton with a fresh {Configuration}. Used by the
|
|
118
|
+
# test suite's teardown hook so per-test mutations do not leak.
|
|
119
|
+
#
|
|
120
|
+
# @return [Configuration] the new (default) configuration
|
|
121
|
+
def reset_config!
|
|
122
|
+
@config = Configuration.new
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Track an event — typed (catalog-defined) or untyped.
|
|
127
|
+
#
|
|
128
|
+
# Reserved keys (`:user`, `:request`, `:client_id`, `:visitor_token`)
|
|
129
|
+
# are partitioned out of `params` BEFORE catalog lookup so they never
|
|
130
|
+
# appear in `payload.params`. `:user`/`:request`/`:client_id` are
|
|
131
|
+
# bound on {Current} for the duration of the call;
|
|
132
|
+
# `:visitor_token` is merged directly into `payload.context`.
|
|
133
|
+
#
|
|
134
|
+
# See {Instrumenter.track} for full semantics.
|
|
135
|
+
#
|
|
136
|
+
# @param name [Symbol]
|
|
137
|
+
# @param params [Hash]
|
|
138
|
+
# @return [void]
|
|
139
|
+
def self.track(name, **params)
|
|
140
|
+
Instrumenter.track(name, **params)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Identify a user — Phase 01 thin pass-through.
|
|
144
|
+
#
|
|
145
|
+
# See {Instrumenter.identify}.
|
|
146
|
+
#
|
|
147
|
+
# @param user [Object]
|
|
148
|
+
# @param user_properties [Hash]
|
|
149
|
+
# @return [void]
|
|
150
|
+
def self.identify(user, **user_properties)
|
|
151
|
+
Instrumenter.identify(user, **user_properties)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Register a subscriber with optional per-instance event-name filters.
|
|
155
|
+
#
|
|
156
|
+
# TrackRelay.subscribe(MySubscriber)
|
|
157
|
+
# TrackRelay.subscribe(MySubscriber, only: %i[purchase sign_up])
|
|
158
|
+
# TrackRelay.subscribe(MySubscriber, except: %i[page_view])
|
|
159
|
+
# TrackRelay.subscribe(MySubscriber.new, only: %i[purchase])
|
|
160
|
+
#
|
|
161
|
+
# Accepts either a subscriber class (instantiated via `.new`) or a
|
|
162
|
+
# pre-built instance. When `only:` or `except:` is non-nil, the value
|
|
163
|
+
# is coerced to `Set<Symbol>` and stored as a SINGLETON-CLASS override
|
|
164
|
+
# on the registered instance. Other instances of the same class — and
|
|
165
|
+
# the class-level defaults declared via `filter only:` / `filter
|
|
166
|
+
# except:` — are NOT mutated.
|
|
167
|
+
#
|
|
168
|
+
# The instance is appended to {Configuration#subscribers} via
|
|
169
|
+
# {Configuration#subscribe} and returned, so callers can hold a
|
|
170
|
+
# reference (e.g. for tests).
|
|
171
|
+
#
|
|
172
|
+
# @param subscriber_or_class [Class, Subscribers::Base]
|
|
173
|
+
# @param only [Array<Symbol, String>, nil] allow-list override
|
|
174
|
+
# @param except [Array<Symbol, String>, nil] deny-list override
|
|
175
|
+
# @return [Subscribers::Base] the registered subscriber instance
|
|
176
|
+
def self.subscribe(subscriber_or_class, only: nil, except: nil)
|
|
177
|
+
instance = subscriber_or_class.is_a?(Class) ? subscriber_or_class.new : subscriber_or_class
|
|
178
|
+
instance.set_filter_overrides!(only: only, except: except)
|
|
179
|
+
config.subscribe(instance)
|
|
180
|
+
instance
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Top-level entry point for the catalog DSL.
|
|
184
|
+
#
|
|
185
|
+
# Evaluates `block` against a {DSL::EventBuilder} so callers can use
|
|
186
|
+
# `event :name do ... end` and `user_property :name, :type` directly:
|
|
187
|
+
#
|
|
188
|
+
# TrackRelay.catalog do
|
|
189
|
+
# event :article_viewed do
|
|
190
|
+
# integer :article_id, required: true
|
|
191
|
+
# end
|
|
192
|
+
#
|
|
193
|
+
# user_property :plan, :string
|
|
194
|
+
# end
|
|
195
|
+
#
|
|
196
|
+
# Each `event` declaration validates against GA4 + reserved-key rules
|
|
197
|
+
# and registers the resulting {EventDefinition} in {Catalog} before
|
|
198
|
+
# returning. Failures raise {Ga4ConstraintError},
|
|
199
|
+
# {ReservedKeyError}, or {CatalogError} depending on the violation.
|
|
200
|
+
def self.catalog(&block)
|
|
201
|
+
DSL::EventBuilder.new.instance_exec(&block)
|
|
202
|
+
end
|
|
203
|
+
end
|