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,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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TrackRelay
4
+ VERSION = "1.0.0"
5
+ end
@@ -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