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,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TrackRelay
4
+ module ClientId
5
+ # Resolver that returns the current Ahoy visitor token, when the
6
+ # host application has the [ahoy](https://github.com/ankane/ahoy)
7
+ # gem installed AND the controller exposes its `ahoy` helper.
8
+ #
9
+ # Default position-1 entry in {Configuration#client_id_resolvers}
10
+ # — between {Ga} (cookie-based) and {Session} (UUID fallback).
11
+ #
12
+ # ## Duck-typed integration
13
+ #
14
+ # This resolver does NOT `require "ahoy"`. It probes the controller
15
+ # via `respond_to?(:ahoy, true)` so the gem boots cleanly in apps
16
+ # without Ahoy. When Ahoy is absent, `#call` returns `nil` and the
17
+ # next resolver in the chain takes over.
18
+ #
19
+ # When Ahoy IS present, `controller.ahoy` returns an `Ahoy::Tracker`
20
+ # whose `#visitor_token` returns the visitor cookie value (no DB
21
+ # query). We use that public API only; nothing internal.
22
+ class AhoyVisitor
23
+ # @param controller [Object] any controller-like object that may
24
+ # or may not include `Ahoy::Trackable`.
25
+ # @return [String, nil] `ahoy.visitor_token` if available, else
26
+ # `nil`.
27
+ def call(controller:, **)
28
+ return nil unless controller&.respond_to?(:ahoy, true)
29
+
30
+ controller.ahoy&.visitor_token
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TrackRelay
4
+ module ClientId
5
+ # Resolver that extracts the GA4-shaped `client_id` from the host
6
+ # app's `_ga` cookie.
7
+ #
8
+ # Default position-0 entry in {Configuration#client_id_resolvers}.
9
+ # Reproduces Phase 01's `_track_relay_client_id_from_cookie` parser
10
+ # bit-for-bit so existing behavior is preserved when the resolver
11
+ # chain is enabled.
12
+ #
13
+ # ## `_ga` cookie format
14
+ #
15
+ # The `_ga` cookie ships with format
16
+ # `GA1.<version>.<random_int>.<unix_ts>` (four dot-separated
17
+ # segments). The GA4 Measurement Protocol expects the client_id to
18
+ # be the last two segments joined with a dot — e.g. cookie
19
+ # `"GA1.2.860784081.1732738496"` yields client_id
20
+ # `"860784081.1732738496"`.
21
+ #
22
+ # The parser always takes the last two segments (`parts[-2..]`)
23
+ # rather than the 3rd/4th, so it is robust against:
24
+ # - Custom server-side cookie writers that prepend extra segments
25
+ # - Future Google rollouts that change the prefix segment count
26
+ #
27
+ # Cookies with fewer than four segments are treated as malformed
28
+ # and yield `nil` (callers should fall through to the next resolver
29
+ # in the chain).
30
+ class Ga
31
+ # @param controller [#request] any object exposing
32
+ # `controller.request.cookies["_ga"]`. Typically an
33
+ # `ActionController::Base` instance from
34
+ # {ControllerTracking#_resolve_client_id}.
35
+ # @return [String, nil] the parsed GA4 client_id, or `nil` when the
36
+ # cookie is missing/empty/malformed.
37
+ def call(controller:, **)
38
+ ga_cookie = controller&.request&.cookies&.[]("_ga")
39
+ return nil if ga_cookie.nil? || ga_cookie.empty?
40
+
41
+ parts = ga_cookie.split(".")
42
+ return nil if parts.size < 4
43
+
44
+ "#{parts[-2]}.#{parts[-1]}"
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module TrackRelay
6
+ module ClientId
7
+ # Last-resort resolver that mints a session-stable UUID when no
8
+ # other resolver in the chain produced a client_id.
9
+ #
10
+ # Default position-2 (final) entry in
11
+ # {Configuration#client_id_resolvers}. Stores the generated UUID at
12
+ # `session[:track_relay_client_id]` so subsequent requests on the
13
+ # same Rails session reuse the same value — every visitor gets a
14
+ # consistent client_id even if they have no `_ga` cookie and no
15
+ # Ahoy visit record.
16
+ #
17
+ # ## Storage
18
+ #
19
+ # Uses the host app's standard session store (cookie store, Redis,
20
+ # ActiveRecord, etc.) — whatever `controller.session` provides.
21
+ # The value is a plain UUID string; no signing or encryption is
22
+ # required (it has no security implications).
23
+ #
24
+ # ## Sessionless contexts
25
+ #
26
+ # API-only controllers without session middleware (and any
27
+ # controller-less context) have `controller.session == nil`. The
28
+ # resolver returns `nil` in that case and the chain falls through
29
+ # to whatever follows — or to `nil` overall, leaving
30
+ # {Current.client_id} unset (the same outcome as Phase 01).
31
+ class Session
32
+ SESSION_KEY = :track_relay_client_id
33
+
34
+ # @param controller [#session] any controller-like object with a
35
+ # Rails-style session hash.
36
+ # @return [String, nil] a stable UUID, or `nil` when no session
37
+ # is available.
38
+ def call(controller:, **)
39
+ session = controller&.session
40
+ return nil unless session
41
+
42
+ session[SESSION_KEY] ||= SecureRandom.uuid
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TrackRelay
4
+ # Process-wide configuration for track_relay.
5
+ #
6
+ # Holds every Phase-01 knob: the subscriber list, defaults for
7
+ # untyped-event handling, and the test-mode synchronous override.
8
+ # The host application configures it via {TrackRelay.configure}; the
9
+ # rest of the gem reads it via {TrackRelay.config}.
10
+ #
11
+ # @example Host-app initializer
12
+ # TrackRelay.configure do |c|
13
+ # c.subscribe(MyAnalyticsSubscriber.new)
14
+ # c.untyped_events_allowed = false
15
+ # c.untyped_log_path = Rails.root.join("log/untyped_events.log")
16
+ # end
17
+ class Configuration
18
+ # @!attribute [rw] swallow_subscriber_errors
19
+ # Whether subscriber exceptions are caught and logged instead of
20
+ # re-raised. Defaults to `true` in production, `false` elsewhere.
21
+ # @!attribute [rw] untyped_log_path
22
+ # Optional path for logging untyped (non-catalog) events.
23
+ # `nil` disables the untyped log.
24
+ # @!attribute [rw] untyped_events_allowed
25
+ # Whether {TrackRelay.track} accepts events not in the catalog.
26
+ # @!attribute [rw] force_synchronous
27
+ # When `true`, subscribers run inline regardless of their async
28
+ # preference. Used by `test_mode!` (Plan 07) and integration tests.
29
+ # @!attribute [rw] raise_on_validation_error
30
+ # Whether catalog/payload validation errors raise (dev/test) or
31
+ # are merely logged (prod). Defaults to true in dev/test.
32
+ # @!attribute [rw] client_id_resolvers
33
+ # Ordered chain of `#call(controller:, **)`-callables consulted
34
+ # by {ControllerTracking#_resolve_client_id} to populate
35
+ # {Current.client_id}. First non-nil result wins. Defaults to
36
+ # `[ClientId::Ga.new, ClientId::AhoyVisitor.new,
37
+ # ClientId::Session.new]` (Plan 02-02 / REQ-26).
38
+ # @!attribute [rw] ga4_measurement_id
39
+ # GA4 Measurement Protocol `measurement_id` query parameter
40
+ # (`G-XXXXXXXXXX`). Read at delivery time so credentials
41
+ # lambdas / late-bound configs work. Defaults to `nil`; when
42
+ # `nil` at delivery time the GA4 subscriber emits a
43
+ # `Rails.logger.warn` and skips the POST without raising.
44
+ # @!attribute [rw] ga4_api_secret
45
+ # GA4 Measurement Protocol `api_secret` query parameter, scoped
46
+ # per data stream. Read at delivery time. Defaults to `nil`;
47
+ # same warn-and-skip behavior as {#ga4_measurement_id} when
48
+ # missing. Treat as a credential — never commit to source.
49
+ # @!attribute [rw] ga4_use_eu_endpoint
50
+ # When `true`, the GA4 subscriber posts to
51
+ # `https://region1.google-analytics.com/mp/collect` instead of
52
+ # the global endpoint. Defaults to `false`.
53
+ attr_accessor :swallow_subscriber_errors,
54
+ :untyped_log_path,
55
+ :untyped_events_allowed,
56
+ :force_synchronous,
57
+ :raise_on_validation_error,
58
+ :client_id_resolvers,
59
+ :ga4_measurement_id,
60
+ :ga4_api_secret,
61
+ :ga4_use_eu_endpoint
62
+
63
+ # @return [Array] registered subscriber instances, in insertion order
64
+ attr_reader :subscribers
65
+
66
+ def initialize
67
+ reset!
68
+ end
69
+
70
+ # Restore every setting to its environment-aware default and clear
71
+ # the subscriber list. Used by tests and by {TrackRelay.reset_config!}.
72
+ #
73
+ # @return [void]
74
+ def reset!
75
+ @subscribers = []
76
+ @swallow_subscriber_errors = production_env?
77
+ @untyped_log_path = nil
78
+ @untyped_events_allowed = true
79
+ @force_synchronous = false
80
+ @raise_on_validation_error = development_or_test_env?
81
+ @client_id_resolvers = default_client_id_resolvers
82
+ @ga4_measurement_id = nil
83
+ @ga4_api_secret = nil
84
+ @ga4_use_eu_endpoint = false
85
+ end
86
+
87
+ # Append a subscriber to the registry.
88
+ #
89
+ # @param subscriber [#call, Object] any object conforming to the
90
+ # subscriber protocol (Plan 05 defines the contract).
91
+ # @return [Object] the subscriber, so the call is chainable / usable
92
+ # as `sub = config.subscribe(MySubscriber.new)`.
93
+ def subscribe(subscriber)
94
+ @subscribers << subscriber
95
+ subscriber
96
+ end
97
+
98
+ # Atomically replace the subscriber list and return the previous
99
+ # one. Plan 07's `TrackRelay.test_mode!` uses this to swap in a
100
+ # capturing subscriber for the duration of a test block and then
101
+ # restore the original list.
102
+ #
103
+ # @param list [Array, nil] new subscribers (`nil` is coerced to `[]`)
104
+ # @return [Array] the previous subscriber list (caller's snapshot)
105
+ def replace_subscribers(list)
106
+ previous = @subscribers
107
+ @subscribers = Array(list)
108
+ previous
109
+ end
110
+
111
+ private
112
+
113
+ # Build a fresh default resolver chain. Each call returns a new
114
+ # array with new resolver instances so a mutated chain in one test
115
+ # cannot leak into another (the {Session} resolver is otherwise
116
+ # stateless, but the array container itself must be per-config).
117
+ def default_client_id_resolvers
118
+ [
119
+ ClientId::Ga.new,
120
+ ClientId::AhoyVisitor.new,
121
+ ClientId::Session.new
122
+ ]
123
+ end
124
+
125
+ def production_env?
126
+ current_env == "production"
127
+ end
128
+
129
+ def development_or_test_env?
130
+ %w[development test].include?(current_env)
131
+ end
132
+
133
+ def current_env
134
+ if defined?(Rails) && Rails.respond_to?(:env)
135
+ Rails.env.to_s
136
+ else
137
+ ENV.fetch("RACK_ENV", "development")
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+ require "track_relay/current"
5
+
6
+ module TrackRelay
7
+ # Controller-side tracking helper.
8
+ #
9
+ # Host applications include this concern in `ApplicationController` (or
10
+ # any controller) to get:
11
+ #
12
+ # - A `before_action` that populates {Current.controller},
13
+ # {Current.request}, and {Current.client_id} (the latter derived
14
+ # from the `_ga` cookie if present) before any action runs. This
15
+ # means {TrackRelay.track} called inside an action automatically
16
+ # captures the originating controller / request in
17
+ # `payload.context`.
18
+ #
19
+ # - An instance method `track(name, **params)` that delegates to
20
+ # {TrackRelay.track}. The delegate is sugar — host code can also
21
+ # call `TrackRelay.track(...)` directly.
22
+ #
23
+ # The concern is NOT auto-included. Host apps include it explicitly,
24
+ # which keeps the gem opt-in and avoids surprising behavior in
25
+ # controllers that don't want tracking. The Phase 4 install generator
26
+ # will wire the include into ApplicationController.
27
+ #
28
+ # ## `client_id` resolver chain
29
+ #
30
+ # The before_action runs the ordered chain at
31
+ # {TrackRelay::Configuration#client_id_resolvers} (default
32
+ # `[ClientId::Ga, ClientId::AhoyVisitor, ClientId::Session]`). The
33
+ # FIRST resolver to return a non-nil value wins; later resolvers are
34
+ # not invoked. Each resolver call is wrapped in `rescue StandardError`
35
+ # so a single misbehaving resolver cannot block client_id resolution
36
+ # — the chain skips it and continues.
37
+ #
38
+ # The default first resolver ({ClientId::Ga}) reproduces Phase 1's
39
+ # `_ga`-cookie parser bit-for-bit, so existing behavior is preserved.
40
+ # Hosts can prepend custom resolvers (e.g. a request-header reader
41
+ # for native-app traffic) via `TrackRelay.config.client_id_resolvers.unshift(...)`.
42
+ module ControllerTracking
43
+ extend ActiveSupport::Concern
44
+
45
+ included do
46
+ before_action :_track_relay_set_current
47
+ end
48
+
49
+ # Delegate to {TrackRelay.track}. Sugar for in-controller call sites
50
+ # so the host doesn't have to spell `TrackRelay.track` explicitly.
51
+ #
52
+ # @param name [Symbol]
53
+ # @param params [Hash]
54
+ # @return [void]
55
+ def track(name, **params)
56
+ TrackRelay.track(name, **params)
57
+ end
58
+
59
+ private
60
+
61
+ def _track_relay_set_current
62
+ TrackRelay::Current.controller = self
63
+ TrackRelay::Current.request = request
64
+ TrackRelay::Current.client_id = _resolve_client_id
65
+ end
66
+
67
+ # Run the resolver chain in order; return the first non-nil result.
68
+ # Each resolver's `#call` is wrapped in `rescue StandardError` so a
69
+ # broken resolver cannot poison the chain — it simply yields nil and
70
+ # the iteration continues to the next resolver.
71
+ #
72
+ # @return [String, nil]
73
+ def _resolve_client_id
74
+ TrackRelay.config.client_id_resolvers.each do |resolver|
75
+ result = begin
76
+ resolver.call(controller: self)
77
+ rescue => e
78
+ Rails.logger&.warn(
79
+ "[track_relay] client_id resolver #{resolver.class} raised " \
80
+ "#{e.class}: #{e.message} — skipping"
81
+ )
82
+ nil
83
+ end
84
+
85
+ return result unless result.nil?
86
+ end
87
+ nil
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support"
4
+ require "active_support/current_attributes"
5
+
6
+ module TrackRelay
7
+ # Per-request / per-job ambient context for track_relay.
8
+ #
9
+ # Subclasses {ActiveSupport::CurrentAttributes} so the host application
10
+ # (or this gem's controller/job middleware) can stash request-scoped
11
+ # values that {TrackRelay.track} reads back at event time without
12
+ # threading them through every call site.
13
+ #
14
+ # All attributes are auto-reset between requests, jobs, and (in the
15
+ # test suite) between tests via
16
+ # `ActiveSupport::CurrentAttributes::TestHelper`, which is mixed into
17
+ # `ActiveSupport::TestCase` in `test/test_helper.rb`.
18
+ #
19
+ # @example Setting context in a controller before-filter
20
+ # before_action do
21
+ # TrackRelay::Current.user = current_user
22
+ # TrackRelay::Current.request = request
23
+ # TrackRelay::Current.controller = self
24
+ # end
25
+ #
26
+ # @example Block-scoped override (e.g. impersonation, replay)
27
+ # TrackRelay::Current.set(user: other_user) do
28
+ # TrackRelay.track(:article_viewed, article_id: 42)
29
+ # end
30
+ class Current < ActiveSupport::CurrentAttributes
31
+ attribute :user, :request, :visit, :controller, :client_id
32
+ end
33
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_job"
4
+
5
+ module TrackRelay
6
+ # ActiveJob-backed async delivery for non-synchronous subscribers.
7
+ #
8
+ # The job receives `[subscriber_class_name, payload_hash]` (because
9
+ # ActiveJob arguments must be GlobalID-serializable, not raw
10
+ # subscriber instances or {EventPayload} objects). On `perform` it
11
+ # reconstructs the {EventPayload} via {EventPayload.from_h} and
12
+ # dispatches to a fresh subscriber instance.
13
+ #
14
+ # **Async loudness mirrors the sync {Dispatcher} contract:** when
15
+ # `safe_deliver` returns a StandardError AND
16
+ # {Configuration#swallow_subscriber_errors} is `false`, the job
17
+ # re-raises after `safe_deliver` has already logged. This lets
18
+ # ActiveJob's normal error path (retry / discard / failed-job queue)
19
+ # surface the failure. Under the `:test` adapter this propagates
20
+ # synchronously through `perform_now`/`perform_later`.
21
+ #
22
+ # The job receives a serialized hash, **not** {Current} state —
23
+ # ActiveJob's Executor clears CurrentAttributes before `perform`, so
24
+ # any context the subscriber needs must already be on
25
+ # `payload.context` (snapshotted at track time by the Instrumenter).
26
+ #
27
+ # ## GA4 retry / discard policy (Plan 02-04)
28
+ #
29
+ # `retry_on TrackRelay::DeliveryRetriableError` covers transient GA4
30
+ # failures (HTTP 5xx, network timeouts, ECONNREFUSED, SocketError).
31
+ # The wait algorithm `:polynomially_longer` produces ~3s, ~18s, ~83s,
32
+ # ~258s with 15% default jitter — appropriate for GA4 since the
33
+ # Measurement Protocol has no strict ordering requirement, sends are
34
+ # idempotent enough for analytics, and GA4's 72-hour event-backdating
35
+ # window means late retries still arrive correctly.
36
+ #
37
+ # `discard_on TrackRelay::DeliveryDiscardableError` covers HTTP 4xx
38
+ # (defensive — Scout §2 confirms GA4 returns 2xx in practice even on
39
+ # malformed payloads, but mapping 4xx to discard is correct in case
40
+ # Google ever changes that contract).
41
+ #
42
+ # ### Why `DEFAULT_GA4_DELIVERY_ATTEMPTS` is a class-local constant
43
+ #
44
+ # `retry_on` runs at class-body load time, **before** any
45
+ # `TrackRelay.configure` block in a host's initializer has had a
46
+ # chance to mutate {TrackRelay.config}. Reading `TrackRelay.config.
47
+ # ga4_delivery_attempts` here would either crash (singleton not yet
48
+ # built) or capture a stale default that the host's initializer is
49
+ # about to overwrite. Pinning the value to a class-local constant
50
+ # sidesteps the load-order hazard entirely. A future minor (Phase 4)
51
+ # can introduce runtime configurability via `self.inherited` /
52
+ # `after_initialize` machinery without breaking this contract.
53
+ class DeliveryJob < ActiveJob::Base
54
+ queue_as :track_relay
55
+
56
+ # GA4 retry attempt cap (Plan 02-04). Class-local constant — see
57
+ # the rationale in the class docstring above. Future Phase 4 work
58
+ # may introduce `config.ga4_delivery_attempts` once a safe
59
+ # late-binding path exists; until then, `5` is the contract.
60
+ DEFAULT_GA4_DELIVERY_ATTEMPTS = 5
61
+
62
+ retry_on TrackRelay::DeliveryRetriableError,
63
+ wait: :polynomially_longer,
64
+ attempts: DEFAULT_GA4_DELIVERY_ATTEMPTS
65
+
66
+ discard_on TrackRelay::DeliveryDiscardableError
67
+
68
+ # @param subscriber_class_name [String] fully-qualified subscriber
69
+ # class name (e.g. `"TrackRelay::Subscribers::Logger"`)
70
+ # @param payload_hash [Hash] result of {EventPayload#to_h}, possibly
71
+ # round-tripped through JSON (string keys)
72
+ # @return [void]
73
+ def perform(subscriber_class_name, payload_hash)
74
+ subscriber = subscriber_class_name.constantize.new
75
+ payload = EventPayload.from_h(payload_hash)
76
+ result = subscriber.safe_deliver(payload)
77
+
78
+ # `safe_deliver` already logged via Rails.logger.error. If the
79
+ # host opts out of swallowing, re-raise so ActiveJob's normal
80
+ # error path surfaces the failure.
81
+ raise result if result.is_a?(StandardError) && !TrackRelay.config.swallow_subscriber_errors
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/notifications"
4
+
5
+ module TrackRelay
6
+ # Single ActiveSupport::Notifications subscription that fans
7
+ # `track_relay.event` notifications out to every subscriber in
8
+ # {Configuration#subscribers}.
9
+ #
10
+ # **Error contract — collect-then-reraise (locked in 01-CONTEXT.md):**
11
+ #
12
+ # 1. Iterate every configured subscriber, calling `handle(payload)`.
13
+ # 2. {Subscribers::Base#handle} returns `nil` on success or the
14
+ # StandardError on a sync failure (it never re-raises inline). For
15
+ # non-Base subscribers that ignore the contract and raise inline,
16
+ # a defensive rescue here preserves the "peers still run"
17
+ # invariant — exceptions from those rogues are also collected.
18
+ # 3. AFTER fan-out completes, if
19
+ # {Configuration#swallow_subscriber_errors} is `false` AND any
20
+ # exception was collected, re-raise the **first** one. This is
21
+ # the locked dev/test loudness rule: surface failures, but only
22
+ # after every peer has had its chance to receive the event.
23
+ #
24
+ # **Lifecycle:** {.start!} registers a single subscription block;
25
+ # {.stop!} unsubscribes. Both are idempotent so the Plan 06 Railtie
26
+ # can call `start!` once at boot without worrying about
27
+ # double-subscription. {.started?} reports the current state.
28
+ #
29
+ # `lib/track_relay.rb` requires this file but does NOT call `start!`
30
+ # — only the Railtie does, so non-Rails environments can opt in.
31
+ module Dispatcher
32
+ NOTIFICATION = "track_relay.event"
33
+
34
+ class << self
35
+ # Register the AS::Notifications subscription. Idempotent: calling
36
+ # twice does not double-subscribe.
37
+ #
38
+ # @param notifier [#subscribe] defaults to ActiveSupport::Notifications
39
+ # @return [Object] the subscription handle (opaque AS object)
40
+ def start!(notifier = ActiveSupport::Notifications)
41
+ return @subscription if @subscription
42
+ @subscription = notifier.subscribe(NOTIFICATION) do |*, payload|
43
+ dispatch(payload[:event])
44
+ end
45
+ end
46
+
47
+ # Unsubscribe the AS::Notifications subscription. Idempotent — safe
48
+ # to call when no subscription has been registered.
49
+ #
50
+ # @param notifier [#unsubscribe] defaults to ActiveSupport::Notifications
51
+ # @return [void]
52
+ def stop!(notifier = ActiveSupport::Notifications)
53
+ return unless @subscription
54
+ notifier.unsubscribe(@subscription)
55
+ @subscription = nil
56
+ end
57
+
58
+ # @return [Boolean] whether a subscription is currently registered
59
+ def started?
60
+ !@subscription.nil?
61
+ end
62
+
63
+ private
64
+
65
+ def dispatch(payload)
66
+ errors = []
67
+ TrackRelay.config.subscribers.each do |subscriber|
68
+ # Subscribers::Base#handle returns nil on success or the
69
+ # StandardError on failure (it never re-raises inline). For
70
+ # non-Base subscribers that ignore that contract, the inline
71
+ # rescue below preserves the "peers still run" invariant.
72
+ result =
73
+ begin
74
+ subscriber.handle(payload)
75
+ rescue => e
76
+ if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
77
+ Rails.logger.error(
78
+ "[track_relay] non-Base subscriber #{subscriber.class} raised inline: #{e.class}: #{e.message}"
79
+ )
80
+ end
81
+ e
82
+ end
83
+ errors << result if result.is_a?(StandardError)
84
+ end
85
+
86
+ return if errors.empty?
87
+ return if TrackRelay.config.swallow_subscriber_errors
88
+ raise errors.first
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "track_relay/event_definition"
4
+ require "track_relay/dsl/param_builder"
5
+ require "track_relay/validators/catalog_validator"
6
+
7
+ module TrackRelay
8
+ module DSL
9
+ # The DSL receiver for the body of `TrackRelay.catalog do ... end`.
10
+ #
11
+ # Provides two top-level methods:
12
+ # - `event(name, &block)` — defines an event. The block is
13
+ # instance_exec'd against a {ParamBuilder} so type DSL methods
14
+ # (integer, string, float, boolean, datetime) work without any
15
+ # explicit receiver.
16
+ # - `user_property(name, type)` — declares a catalog-wide user
17
+ # property (registered globally on {Catalog}, not attached to a
18
+ # single event).
19
+ #
20
+ # `event` is the layer where validation runs. After the block
21
+ # populates a ParamBuilder, EventBuilder builds the
22
+ # {EventDefinition}, calls {Validators::CatalogValidator.validate!}
23
+ # to enforce GA4 + reserved-key rules, and registers the result.
24
+ # That way the *first* time a definition exists, it is already
25
+ # validated and immutable.
26
+ class EventBuilder
27
+ # Define a single event in the catalog.
28
+ #
29
+ # @param name [Symbol] event name
30
+ # @yield no-args block evaluated in a {ParamBuilder} context
31
+ # @raise [TrackRelay::Ga4ConstraintError] when the event or any
32
+ # param violates GA4 rules
33
+ # @raise [TrackRelay::ReservedKeyError] when any param collides
34
+ # with a reserved context key
35
+ # @raise [TrackRelay::CatalogError] when the event is already
36
+ # registered
37
+ # @return [EventDefinition] the registered, frozen definition
38
+ def event(name, &block)
39
+ param_builder = ParamBuilder.new(name)
40
+ param_builder.instance_exec(&block) if block
41
+
42
+ definition = EventDefinition.new(
43
+ name: name,
44
+ params: param_builder.params,
45
+ user_properties: param_builder.user_properties
46
+ )
47
+
48
+ Validators::CatalogValidator.validate!(definition)
49
+ Catalog.register(definition)
50
+ definition
51
+ end
52
+
53
+ # Register a catalog-wide user property (independent of any event).
54
+ #
55
+ # @param name [Symbol]
56
+ # @param type [Symbol] one of `:string`, `:integer`, `:float`,
57
+ # `:boolean`, `:datetime`
58
+ # @return [void]
59
+ def user_property(name, type)
60
+ Catalog.register_user_property(name, type)
61
+ end
62
+ end
63
+ end
64
+ end