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,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
|