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,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "track_relay/event_definition"
|
|
4
|
+
|
|
5
|
+
module TrackRelay
|
|
6
|
+
module DSL
|
|
7
|
+
# The DSL receiver for the body of `event :name do ... end`.
|
|
8
|
+
#
|
|
9
|
+
# Each type method (`integer`, `string`, `float`, `boolean`,
|
|
10
|
+
# `datetime`) records a {EventDefinition::ParamSchema} keyed by param
|
|
11
|
+
# name. `user_property` accumulates user-property declarations
|
|
12
|
+
# separately so they can be passed through to the resulting
|
|
13
|
+
# EventDefinition without polluting `params`.
|
|
14
|
+
#
|
|
15
|
+
# ParamBuilder does NOT validate or register anything itself — that
|
|
16
|
+
# is {EventBuilder}'s job. Keeping the builder side-effect-free makes
|
|
17
|
+
# it trivial to test and lets EventBuilder run validation against the
|
|
18
|
+
# complete definition (so e.g. param-count overflow is reported with
|
|
19
|
+
# the full param set).
|
|
20
|
+
class ParamBuilder
|
|
21
|
+
attr_reader :params, :user_properties
|
|
22
|
+
|
|
23
|
+
# @param event_name [Symbol] the name of the surrounding event;
|
|
24
|
+
# stored for diagnostic context only
|
|
25
|
+
def initialize(event_name)
|
|
26
|
+
@event_name = event_name
|
|
27
|
+
@params = {}
|
|
28
|
+
@user_properties = {}
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @param name [Symbol] param name
|
|
32
|
+
# @param opts [Hash] optional ParamSchema slots (required, max, in,
|
|
33
|
+
# format, sanitize)
|
|
34
|
+
# @return [EventDefinition::ParamSchema]
|
|
35
|
+
def integer(name, **opts)
|
|
36
|
+
record_param(name, :integer, opts)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def string(name, **opts)
|
|
40
|
+
record_param(name, :string, opts)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def float(name, **opts)
|
|
44
|
+
record_param(name, :float, opts)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def boolean(name, **opts)
|
|
48
|
+
record_param(name, :boolean, opts)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def datetime(name, **opts)
|
|
52
|
+
record_param(name, :datetime, opts)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Declare a user property on the surrounding event. Accumulated
|
|
56
|
+
# separately from regular params so EventDefinition can keep them
|
|
57
|
+
# in `user_properties`.
|
|
58
|
+
#
|
|
59
|
+
# @param name [Symbol]
|
|
60
|
+
# @param type [Symbol] one of the type symbols (`:string`,
|
|
61
|
+
# `:integer`, etc.)
|
|
62
|
+
def user_property(name, type)
|
|
63
|
+
@user_properties[name] = type
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def record_param(name, type, opts)
|
|
69
|
+
schema = EventDefinition::ParamSchema.new(name: name, type: type, **opts)
|
|
70
|
+
@params[name] = schema
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TrackRelay
|
|
4
|
+
# Base class for every error track_relay raises.
|
|
5
|
+
#
|
|
6
|
+
# Consumers can rescue `TrackRelay::Error` to catch any failure originating
|
|
7
|
+
# from the gem (validation, catalog load-time checks, GA4 constraint
|
|
8
|
+
# violations, unknown events, etc.) without depending on individual
|
|
9
|
+
# subclasses.
|
|
10
|
+
class Error < StandardError; end
|
|
11
|
+
|
|
12
|
+
# Raised at catalog-load time when an event declares a param whose name
|
|
13
|
+
# collides with one of the reserved context keys (see {RESERVED_KEYS}).
|
|
14
|
+
class ReservedKeyError < Error; end
|
|
15
|
+
|
|
16
|
+
# Raised at catalog-load time when an event or param violates a Google
|
|
17
|
+
# Analytics 4 constraint (reserved event name, illegal characters,
|
|
18
|
+
# length, or > 25 custom params per event).
|
|
19
|
+
class Ga4ConstraintError < Error; end
|
|
20
|
+
|
|
21
|
+
# Raised at track time when an EventPayload's params do not satisfy the
|
|
22
|
+
# corresponding EventDefinition (missing required key, failed coercion,
|
|
23
|
+
# max-length overflow, inclusion-list miss, format mismatch, or undeclared
|
|
24
|
+
# extra param).
|
|
25
|
+
class ValidationError < Error; end
|
|
26
|
+
|
|
27
|
+
# Raised at catalog-load time on registry-level problems such as
|
|
28
|
+
# double-registering the same event name.
|
|
29
|
+
class CatalogError < Error; end
|
|
30
|
+
|
|
31
|
+
# Raised when callers attempt to track an event that is not present in
|
|
32
|
+
# the catalog and untyped events are disabled.
|
|
33
|
+
class UnknownEventError < Error; end
|
|
34
|
+
|
|
35
|
+
# Raised by a subscriber's `#deliver` to signal a *transient* failure
|
|
36
|
+
# that the {DeliveryJob} should retry via ActiveJob's `retry_on`.
|
|
37
|
+
# Examples: HTTP 5xx response, `Net::OpenTimeout`, `Errno::ECONNREFUSED`,
|
|
38
|
+
# `SocketError` against the GA4 Measurement Protocol endpoint.
|
|
39
|
+
#
|
|
40
|
+
# Inherits from `StandardError` (not {Error}) so the
|
|
41
|
+
# {Subscribers::Base#safe_deliver} carve-out can re-raise it without
|
|
42
|
+
# dragging in unrelated track_relay error semantics — and so consumers
|
|
43
|
+
# who rescue `TrackRelay::Error` to log validation failures do not
|
|
44
|
+
# accidentally swallow a retriable network blip.
|
|
45
|
+
class DeliveryRetriableError < StandardError; end
|
|
46
|
+
|
|
47
|
+
# Raised by a subscriber's `#deliver` to signal a *permanent* failure
|
|
48
|
+
# that the {DeliveryJob} should drop via ActiveJob's `discard_on`
|
|
49
|
+
# (HTTP 4xx, malformed credentials, etc.). Defensive: GA4 returns 2xx
|
|
50
|
+
# in practice even on bad payloads, but we map 4xx defensively in case
|
|
51
|
+
# Google ever changes that contract. Same `StandardError` rationale as
|
|
52
|
+
# {DeliveryRetriableError}.
|
|
53
|
+
class DeliveryDiscardableError < StandardError; end
|
|
54
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TrackRelay
|
|
4
|
+
# Catalog metadata for a single event.
|
|
5
|
+
#
|
|
6
|
+
# An EventDefinition is the immutable description of an event as
|
|
7
|
+
# declared in `TrackRelay.catalog do ... end`. It holds the event name,
|
|
8
|
+
# the schema of every declared param (a Hash of {ParamSchema} keyed by
|
|
9
|
+
# param name), and any user_property declarations.
|
|
10
|
+
#
|
|
11
|
+
# EventDefinition is paired with {TrackRelay::EventPayload} at runtime:
|
|
12
|
+
# the definition is the schema, the payload is the data, and
|
|
13
|
+
# `payload.validate!` checks the data against the schema.
|
|
14
|
+
#
|
|
15
|
+
# Instances are deep-frozen at construction so the catalog cannot be
|
|
16
|
+
# mutated after load. Re-defining an event requires {Catalog#clear!}.
|
|
17
|
+
#
|
|
18
|
+
# @example
|
|
19
|
+
# schema = TrackRelay::EventDefinition::ParamSchema.new(
|
|
20
|
+
# name: :article_id, type: :integer, required: true
|
|
21
|
+
# )
|
|
22
|
+
# definition = TrackRelay::EventDefinition.new(
|
|
23
|
+
# name: :article_viewed,
|
|
24
|
+
# params: {article_id: schema}
|
|
25
|
+
# )
|
|
26
|
+
# definition.params[:article_id].type # => :integer
|
|
27
|
+
class EventDefinition
|
|
28
|
+
# The schema of a single param within an event definition.
|
|
29
|
+
#
|
|
30
|
+
# `name` is the param's Symbol key. `type` is one of `:integer`,
|
|
31
|
+
# `:string`, `:float`, `:boolean`, `:datetime`. The remaining slots
|
|
32
|
+
# are optional validator hooks consumed by {EventPayload#validate!}:
|
|
33
|
+
#
|
|
34
|
+
# - `required` (Boolean) — when true, missing values raise
|
|
35
|
+
# {ValidationError}.
|
|
36
|
+
# - `max` (Integer) — for strings, max length; for numbers, max
|
|
37
|
+
# value. Overflows raise (no silent truncation).
|
|
38
|
+
# - `in` (Array) — inclusion list; values not in the list raise.
|
|
39
|
+
# - `format` (Regexp) — applied to coerced strings; mismatches raise.
|
|
40
|
+
# - `sanitize` (Callable) — applied to the raw value BEFORE coercion
|
|
41
|
+
# and validation, so sanitization can preempt max/format checks.
|
|
42
|
+
#
|
|
43
|
+
# `Data.define` (Ruby 3.2+) gives us a frozen value object with a
|
|
44
|
+
# keyword constructor, equality by value, and zero ceremony.
|
|
45
|
+
ParamSchema = Data.define(
|
|
46
|
+
:name,
|
|
47
|
+
:type,
|
|
48
|
+
:required,
|
|
49
|
+
:max,
|
|
50
|
+
:in,
|
|
51
|
+
:format,
|
|
52
|
+
:sanitize
|
|
53
|
+
) do
|
|
54
|
+
# Override `initialize` to default the optional slots so callers
|
|
55
|
+
# only need to pass `name:` and `type:`.
|
|
56
|
+
def initialize(name:, type:, required: false, max: nil, in: nil, format: nil, sanitize: nil)
|
|
57
|
+
super
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
attr_reader :name, :params, :user_properties
|
|
62
|
+
|
|
63
|
+
# @param name [Symbol] event name (e.g. `:article_viewed`)
|
|
64
|
+
# @param params [Hash{Symbol => ParamSchema}] param schemas keyed by name
|
|
65
|
+
# @param user_properties [Hash{Symbol => Symbol}] user-property names
|
|
66
|
+
# mapped to their type (e.g. `{plan: :string}`)
|
|
67
|
+
def initialize(name:, params: {}, user_properties: {})
|
|
68
|
+
@name = name
|
|
69
|
+
@params = params.freeze
|
|
70
|
+
@user_properties = user_properties.freeze
|
|
71
|
+
freeze
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require "track_relay/errors"
|
|
5
|
+
|
|
6
|
+
module TrackRelay
|
|
7
|
+
# Runtime data for a single event in flight.
|
|
8
|
+
#
|
|
9
|
+
# An EventPayload pairs a {EventDefinition} (the schema) with the raw
|
|
10
|
+
# values supplied at the call site (`TrackRelay.track(:foo, ...)`),
|
|
11
|
+
# plus the request-derived context (user, visitor_token, client_id,
|
|
12
|
+
# etc.) and a timestamp. It is the value passed through the
|
|
13
|
+
# `track_relay.event` ActiveSupport::Notifications instrumentation.
|
|
14
|
+
#
|
|
15
|
+
# Two constructors:
|
|
16
|
+
# - `EventPayload.new(definition:, params:, ...)` — typed payload;
|
|
17
|
+
# {validate!} coerces and enforces the definition's schema.
|
|
18
|
+
# - `EventPayload.untyped(name:, params:, ...)` — untyped payload (no
|
|
19
|
+
# matching definition); {validate!} is a no-op so consumers can
|
|
20
|
+
# still ship the raw event for the untyped-events linter (see
|
|
21
|
+
# Plan 04).
|
|
22
|
+
#
|
|
23
|
+
# `validate!` is destructive: it replaces `@params` with the coerced
|
|
24
|
+
# hash so subscribers see post-coercion values. Errors raise
|
|
25
|
+
# {ValidationError} naming the offending key.
|
|
26
|
+
class EventPayload
|
|
27
|
+
# Sentinel values that the strict boolean coercion accepts. Anything
|
|
28
|
+
# else raises {ValidationError}.
|
|
29
|
+
BOOLEAN_TRUE_VALUES = [true, "true", 1].freeze
|
|
30
|
+
BOOLEAN_FALSE_VALUES = [false, "false", 0].freeze
|
|
31
|
+
|
|
32
|
+
attr_reader :definition, :params, :context, :timestamp
|
|
33
|
+
|
|
34
|
+
# @param definition [EventDefinition] schema for typed events
|
|
35
|
+
# @param params [Hash{Symbol => Object}] raw param values
|
|
36
|
+
# @param context [Hash] request-derived metadata (user, visitor,
|
|
37
|
+
# request, etc.)
|
|
38
|
+
# @param timestamp [Time] event timestamp; defaults to now
|
|
39
|
+
def initialize(definition:, params:, context: {}, timestamp: Time.now)
|
|
40
|
+
@definition = definition
|
|
41
|
+
@params = params
|
|
42
|
+
@context = context
|
|
43
|
+
@timestamp = timestamp
|
|
44
|
+
@untyped_name = nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Build an untyped payload — no definition, no schema enforcement.
|
|
48
|
+
# Used when a host application calls `TrackRelay.track(:unknown, ...)`
|
|
49
|
+
# while `untyped_events_allowed = true`.
|
|
50
|
+
#
|
|
51
|
+
# @param name [Symbol] event name (stored separately because there
|
|
52
|
+
# is no definition to read it from)
|
|
53
|
+
# @param params [Hash]
|
|
54
|
+
# @param context [Hash]
|
|
55
|
+
# @param timestamp [Time]
|
|
56
|
+
# @return [EventPayload]
|
|
57
|
+
def self.untyped(name:, params:, context: {}, timestamp: Time.now)
|
|
58
|
+
payload = new(definition: nil, params: params, context: context, timestamp: timestamp)
|
|
59
|
+
payload.instance_variable_set(:@untyped_name, name)
|
|
60
|
+
payload
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Reconstruct an EventPayload from a serialized {to_h} form. Used
|
|
64
|
+
# by Plan 05's {DeliveryJob} to rehydrate a payload after ActiveJob
|
|
65
|
+
# has serialized arguments through the queue adapter.
|
|
66
|
+
#
|
|
67
|
+
# The reconstructed payload is always untyped (`definition: nil`):
|
|
68
|
+
# validation happened at track time on the calling thread, so the
|
|
69
|
+
# async delivery path doesn't need the schema. ActiveJob's argument
|
|
70
|
+
# serialization round-trips Symbols as Strings under the standard
|
|
71
|
+
# adapter, so `String#to_sym` is applied defensively to the name.
|
|
72
|
+
#
|
|
73
|
+
# @param hash [Hash] result of {to_h} (Symbol- or String-keyed)
|
|
74
|
+
# @return [EventPayload]
|
|
75
|
+
def self.from_h(hash)
|
|
76
|
+
payload = allocate
|
|
77
|
+
payload.instance_variable_set(:@definition, nil)
|
|
78
|
+
payload.instance_variable_set(:@params, hash[:params] || hash["params"] || {})
|
|
79
|
+
payload.instance_variable_set(:@context, hash[:context] || hash["context"] || {})
|
|
80
|
+
payload.instance_variable_set(
|
|
81
|
+
:@timestamp,
|
|
82
|
+
Time.iso8601(hash[:timestamp] || hash["timestamp"])
|
|
83
|
+
)
|
|
84
|
+
payload.instance_variable_set(
|
|
85
|
+
:@untyped_name,
|
|
86
|
+
(hash[:name] || hash["name"]).to_sym
|
|
87
|
+
)
|
|
88
|
+
payload
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# @return [Symbol] event name (from definition or untyped store)
|
|
92
|
+
def name
|
|
93
|
+
@definition ? @definition.name : @untyped_name
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# @return [Boolean] whether this payload was built without a
|
|
97
|
+
# matching catalog definition
|
|
98
|
+
def untyped?
|
|
99
|
+
@definition.nil?
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Coerce and validate `@params` against `@definition.params`.
|
|
103
|
+
# Mutates `@params` to the coerced hash.
|
|
104
|
+
#
|
|
105
|
+
# For each schema entry the order of operations is:
|
|
106
|
+
# 1. Apply `sanitize` callable if present (raw -> sanitized).
|
|
107
|
+
# 2. Check `required` against post-sanitize value.
|
|
108
|
+
# 3. Coerce to `type`.
|
|
109
|
+
# 4. Apply `max` (length for strings, value for numbers).
|
|
110
|
+
# 5. Apply `in` inclusion list.
|
|
111
|
+
# 6. Apply `format` regex (strings only).
|
|
112
|
+
#
|
|
113
|
+
# After per-key processing, any incoming param not declared in the
|
|
114
|
+
# schema raises {ValidationError}.
|
|
115
|
+
#
|
|
116
|
+
# No-op for untyped payloads.
|
|
117
|
+
#
|
|
118
|
+
# @raise [ValidationError]
|
|
119
|
+
# @return [Hash{Symbol => Object}] coerced params
|
|
120
|
+
def validate!
|
|
121
|
+
return @params if untyped?
|
|
122
|
+
|
|
123
|
+
coerced = {}
|
|
124
|
+
|
|
125
|
+
@definition.params.each do |key, schema|
|
|
126
|
+
raw_value = @params[key]
|
|
127
|
+
raw_value = schema.sanitize.call(raw_value) if schema.sanitize&.respond_to?(:call) && @params.key?(key)
|
|
128
|
+
|
|
129
|
+
if raw_value.nil?
|
|
130
|
+
if schema.required
|
|
131
|
+
raise ValidationError, "Param #{key.inspect} is required but was not provided"
|
|
132
|
+
end
|
|
133
|
+
next
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
coerced_value = coerce(key, schema.type, raw_value)
|
|
137
|
+
check_max!(key, schema.max, coerced_value) if schema.max
|
|
138
|
+
check_in!(key, schema.in, coerced_value) if schema.in
|
|
139
|
+
check_format!(key, schema.format, coerced_value) if schema.format
|
|
140
|
+
|
|
141
|
+
coerced[key] = coerced_value
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
extras = @params.keys - @definition.params.keys
|
|
145
|
+
unless extras.empty?
|
|
146
|
+
raise ValidationError,
|
|
147
|
+
"Unexpected param(s) #{extras.map(&:inspect).join(", ")} not declared on event #{@definition.name.inspect}"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
@params = coerced
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Serialize to a Hash suitable for ActiveJob arguments / JSON
|
|
154
|
+
# encoding. Used by the DeliveryJob in Plan 05.
|
|
155
|
+
#
|
|
156
|
+
# @return [Hash]
|
|
157
|
+
def to_h
|
|
158
|
+
{
|
|
159
|
+
name: name,
|
|
160
|
+
params: @params,
|
|
161
|
+
context: @context,
|
|
162
|
+
timestamp: @timestamp.respond_to?(:iso8601) ? @timestamp.iso8601 : @timestamp.to_s
|
|
163
|
+
}
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
private
|
|
167
|
+
|
|
168
|
+
def coerce(key, type, value)
|
|
169
|
+
case type
|
|
170
|
+
when :integer then coerce_integer(key, value)
|
|
171
|
+
when :string then coerce_string(value)
|
|
172
|
+
when :float then coerce_float(key, value)
|
|
173
|
+
when :boolean then coerce_boolean(key, value)
|
|
174
|
+
when :datetime then coerce_datetime(key, value)
|
|
175
|
+
else
|
|
176
|
+
raise ValidationError, "Param #{key.inspect} has unsupported type #{type.inspect}"
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def coerce_integer(key, value)
|
|
181
|
+
Integer(value)
|
|
182
|
+
rescue ArgumentError, TypeError
|
|
183
|
+
raise ValidationError, "Param #{key.inspect} cannot be coerced to Integer (got #{value.inspect})"
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def coerce_string(value)
|
|
187
|
+
String(value)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def coerce_float(key, value)
|
|
191
|
+
Float(value)
|
|
192
|
+
rescue ArgumentError, TypeError
|
|
193
|
+
raise ValidationError, "Param #{key.inspect} cannot be coerced to Float (got #{value.inspect})"
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def coerce_boolean(key, value)
|
|
197
|
+
return true if BOOLEAN_TRUE_VALUES.include?(value)
|
|
198
|
+
return false if BOOLEAN_FALSE_VALUES.include?(value)
|
|
199
|
+
|
|
200
|
+
raise ValidationError,
|
|
201
|
+
"Param #{key.inspect} cannot be coerced to Boolean — accepted values are true/false/'true'/'false'/1/0 (got #{value.inspect})"
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def coerce_datetime(key, value)
|
|
205
|
+
case value
|
|
206
|
+
when Time, DateTime
|
|
207
|
+
value
|
|
208
|
+
when String
|
|
209
|
+
begin
|
|
210
|
+
Time.iso8601(value)
|
|
211
|
+
rescue ArgumentError
|
|
212
|
+
raise ValidationError, "Param #{key.inspect} is not a valid ISO8601 datetime (got #{value.inspect})"
|
|
213
|
+
end
|
|
214
|
+
else
|
|
215
|
+
raise ValidationError,
|
|
216
|
+
"Param #{key.inspect} cannot be coerced to datetime — expected Time, DateTime, or ISO8601 String (got #{value.class})"
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def check_max!(key, max, value)
|
|
221
|
+
length_or_value = value.is_a?(String) ? value.length : value
|
|
222
|
+
|
|
223
|
+
if length_or_value > max
|
|
224
|
+
unit = value.is_a?(String) ? "length" : "value"
|
|
225
|
+
raise ValidationError,
|
|
226
|
+
"Param #{key.inspect} #{unit} #{length_or_value} exceeds max #{max}"
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def check_in!(key, allowed, value)
|
|
231
|
+
unless allowed.include?(value)
|
|
232
|
+
raise ValidationError,
|
|
233
|
+
"Param #{key.inspect} value #{value.inspect} is not in inclusion list #{allowed.inspect}"
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def check_format!(key, format, value)
|
|
238
|
+
unless value.is_a?(String) && value.match?(format)
|
|
239
|
+
raise ValidationError,
|
|
240
|
+
"Param #{key.inspect} value #{value.inspect} does not match format #{format.inspect}"
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/notifications"
|
|
4
|
+
require "track_relay/catalog"
|
|
5
|
+
require "track_relay/current"
|
|
6
|
+
require "track_relay/errors"
|
|
7
|
+
require "track_relay/event_payload"
|
|
8
|
+
|
|
9
|
+
module TrackRelay
|
|
10
|
+
# Central orchestrator for {TrackRelay.track} and {TrackRelay.identify}.
|
|
11
|
+
#
|
|
12
|
+
# `track` is the integration point of:
|
|
13
|
+
#
|
|
14
|
+
# - {RESERVED_KEYS} extraction (split-routed: some keys land on
|
|
15
|
+
# {Current}, `:visitor_token` lands directly in `payload.context`)
|
|
16
|
+
# - {Catalog} lookup (typed vs untyped path)
|
|
17
|
+
# - {EventPayload} construction + {EventPayload#validate!}
|
|
18
|
+
# - context snapshot (read at `track` time so async delivery in
|
|
19
|
+
# Plan 05 has the data after `Current` is reset)
|
|
20
|
+
# - `ActiveSupport::Notifications.instrument("track_relay.event", ...)`
|
|
21
|
+
#
|
|
22
|
+
# All four steps happen on the calling thread before any subscriber
|
|
23
|
+
# runs, so reserved-key partitioning and validation are deterministic
|
|
24
|
+
# from the host application's perspective.
|
|
25
|
+
#
|
|
26
|
+
# Reserved-key split rationale (per Plan 01-04 must_have):
|
|
27
|
+
#
|
|
28
|
+
# - `:user`, `:request`, `:client_id` are {Current} attributes —
|
|
29
|
+
# bound via `Current.set(...) { ... }` for the duration of the
|
|
30
|
+
# instrumentation block.
|
|
31
|
+
# - `:visitor_token` is intentionally NOT a {Current} attribute.
|
|
32
|
+
# {Current} carries `:visit` (an Ahoy-style record), not a raw
|
|
33
|
+
# opaque token. The token is merged directly into
|
|
34
|
+
# `payload.context[:visitor_token]` so subscribers (and the
|
|
35
|
+
# downstream DeliveryJob in Plan 05) can read it without
|
|
36
|
+
# touching {Current}.
|
|
37
|
+
#
|
|
38
|
+
# `identify` is a thin pass-through in Phase 01: it instruments
|
|
39
|
+
# `track_relay.identify` with `{user:, properties:}` and performs
|
|
40
|
+
# no catalog validation against `user_property` declarations.
|
|
41
|
+
# Adapter-specific user-property handling is deferred to Phase 02.
|
|
42
|
+
module Instrumenter
|
|
43
|
+
# AS::Notifications event name for typed/untyped event tracking.
|
|
44
|
+
NOTIFICATION = "track_relay.event"
|
|
45
|
+
|
|
46
|
+
# AS::Notifications event name for identify calls.
|
|
47
|
+
IDENTIFY_NOTIFICATION = "track_relay.identify"
|
|
48
|
+
|
|
49
|
+
# Reserved keys that must be partitioned onto {Current} (block-scoped
|
|
50
|
+
# via `Current.set`). See {DIRECT_CONTEXT_KEYS} for keys that bypass
|
|
51
|
+
# {Current} and land directly on `payload.context`.
|
|
52
|
+
CURRENT_ATTR_KEYS = %i[user request client_id].freeze
|
|
53
|
+
|
|
54
|
+
# Reserved keys that bypass {Current} entirely and are merged
|
|
55
|
+
# directly into `payload.context` at build time.
|
|
56
|
+
DIRECT_CONTEXT_KEYS = %i[visitor_token].freeze
|
|
57
|
+
|
|
58
|
+
module_function
|
|
59
|
+
|
|
60
|
+
# Track a typed (catalog-defined) or untyped event.
|
|
61
|
+
#
|
|
62
|
+
# Reserved keys are extracted from `params` BEFORE catalog lookup so
|
|
63
|
+
# they never appear in `payload.params`. Validation gating respects
|
|
64
|
+
# {Configuration#raise_on_validation_error} (`true` re-raises, `false`
|
|
65
|
+
# logs and swallows without instrumenting).
|
|
66
|
+
#
|
|
67
|
+
# @param name [Symbol] event name; looked up in {Catalog}
|
|
68
|
+
# @param params [Hash{Symbol => Object}] event params + reserved keys
|
|
69
|
+
# @return [void]
|
|
70
|
+
# @raise [UnknownEventError] when the event is not in the catalog
|
|
71
|
+
# AND {Configuration#untyped_events_allowed} is false
|
|
72
|
+
# @raise [ValidationError] when a typed event fails validation AND
|
|
73
|
+
# {Configuration#raise_on_validation_error} is true
|
|
74
|
+
def track(name, **params)
|
|
75
|
+
current_attrs, direct_context, event_params = partition_reserved(params)
|
|
76
|
+
with_current_attrs(current_attrs) do
|
|
77
|
+
definition = Catalog.lookup(name)
|
|
78
|
+
payload = build_payload(
|
|
79
|
+
name: name,
|
|
80
|
+
definition: definition,
|
|
81
|
+
params: event_params,
|
|
82
|
+
extra_context: direct_context
|
|
83
|
+
)
|
|
84
|
+
return unless validate(payload)
|
|
85
|
+
ActiveSupport::Notifications.instrument(NOTIFICATION, event: payload)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Identify a user — Phase 01 pass-through.
|
|
90
|
+
#
|
|
91
|
+
# Instruments `track_relay.identify` with `{user:, properties:}`.
|
|
92
|
+
# No catalog validation happens here; adapter-specific user_property
|
|
93
|
+
# validation is deferred to Phase 02 where each subscriber decides
|
|
94
|
+
# how to handle properties (GA4 user_properties, Ahoy User update,
|
|
95
|
+
# etc.).
|
|
96
|
+
#
|
|
97
|
+
# TODO(phase-02): wire `Catalog.user_properties` validation here so
|
|
98
|
+
# consumers can declare user_property schemas and have them enforced
|
|
99
|
+
# at identify time.
|
|
100
|
+
#
|
|
101
|
+
# @param user [Object] user-like object (or id) to identify
|
|
102
|
+
# @param user_properties [Hash] arbitrary properties to attach
|
|
103
|
+
# @return [void]
|
|
104
|
+
def identify(user, **user_properties)
|
|
105
|
+
ActiveSupport::Notifications.instrument(
|
|
106
|
+
IDENTIFY_NOTIFICATION,
|
|
107
|
+
user: user,
|
|
108
|
+
properties: user_properties
|
|
109
|
+
)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Bind `current_attrs` on {Current} for the duration of `block`.
|
|
113
|
+
#
|
|
114
|
+
# When the hash is empty, `Current.set(**{})` would raise
|
|
115
|
+
# `ArgumentError: wrong number of arguments (given 0, expected 1)`
|
|
116
|
+
# under ActiveSupport 8.x. Skipping the wrapper in that case
|
|
117
|
+
# preserves the no-reserved-keys path (most calls).
|
|
118
|
+
#
|
|
119
|
+
# @param current_attrs [Hash]
|
|
120
|
+
# @yield with `Current` bound
|
|
121
|
+
# @return [Object] the block's return value
|
|
122
|
+
def with_current_attrs(current_attrs, &block)
|
|
123
|
+
if current_attrs.empty?
|
|
124
|
+
block.call
|
|
125
|
+
else
|
|
126
|
+
Current.set(**current_attrs, &block)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Split params into three buckets:
|
|
131
|
+
#
|
|
132
|
+
# - `current_attrs` — keys that {Current.set} accepts
|
|
133
|
+
# (`:user`, `:request`, `:client_id`)
|
|
134
|
+
# - `direct_context` — keys that bypass {Current} and land
|
|
135
|
+
# directly on `payload.context` (`:visitor_token`)
|
|
136
|
+
# - `event_params` — everything else (validated against catalog)
|
|
137
|
+
#
|
|
138
|
+
# @param params [Hash]
|
|
139
|
+
# @return [Array(Hash, Hash, Hash)] three-tuple of partitioned hashes
|
|
140
|
+
def partition_reserved(params)
|
|
141
|
+
current_attrs = {}
|
|
142
|
+
direct_context = {}
|
|
143
|
+
event_params = {}
|
|
144
|
+
|
|
145
|
+
params.each do |key, value|
|
|
146
|
+
if CURRENT_ATTR_KEYS.include?(key)
|
|
147
|
+
current_attrs[key] = value
|
|
148
|
+
elsif DIRECT_CONTEXT_KEYS.include?(key)
|
|
149
|
+
direct_context[key] = value
|
|
150
|
+
else
|
|
151
|
+
event_params[key] = value
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
[current_attrs, direct_context, event_params]
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Build either a typed or untyped {EventPayload}, merging
|
|
159
|
+
# `extra_context` (e.g. `:visitor_token`) into the snapshot of
|
|
160
|
+
# {Current} taken at instrument time.
|
|
161
|
+
#
|
|
162
|
+
# Untyped path is gated by
|
|
163
|
+
# {Configuration#untyped_events_allowed} — when disallowed,
|
|
164
|
+
# {UnknownEventError} is raised.
|
|
165
|
+
#
|
|
166
|
+
# @param name [Symbol]
|
|
167
|
+
# @param definition [EventDefinition, nil]
|
|
168
|
+
# @param params [Hash]
|
|
169
|
+
# @param extra_context [Hash] reserved keys that go straight to
|
|
170
|
+
# context (currently just `:visitor_token`)
|
|
171
|
+
# @return [EventPayload]
|
|
172
|
+
# @raise [UnknownEventError]
|
|
173
|
+
def build_payload(name:, definition:, params:, extra_context: {})
|
|
174
|
+
context = current_context.merge(extra_context)
|
|
175
|
+
|
|
176
|
+
if definition
|
|
177
|
+
EventPayload.new(definition: definition, params: params, context: context)
|
|
178
|
+
elsif TrackRelay.config.untyped_events_allowed
|
|
179
|
+
EventPayload.untyped(name: name, params: params, context: context)
|
|
180
|
+
else
|
|
181
|
+
raise UnknownEventError,
|
|
182
|
+
"Unknown event #{name.inspect}; declare it in your catalog or set config.untyped_events_allowed = true"
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Snapshot {Current} at instrument time. Plan 05's DeliveryJob
|
|
187
|
+
# depends on this contract: by the time the job runs, the Rails
|
|
188
|
+
# Executor has already cleared {Current}, so async subscribers
|
|
189
|
+
# must read from `payload.context`, not from `Current` directly.
|
|
190
|
+
#
|
|
191
|
+
# Keys snapshot:
|
|
192
|
+
#
|
|
193
|
+
# - `:user` — Current.user (any object)
|
|
194
|
+
# - `:controller` — Current.controller&.class&.name (String)
|
|
195
|
+
# - `:action` — Current.controller&.action_name (String)
|
|
196
|
+
# - `:client_id` — Current.client_id (String)
|
|
197
|
+
# - `:visit` — Current.visit (Ahoy-style record or nil)
|
|
198
|
+
# - `:request_id` — Current.request&.request_id (String)
|
|
199
|
+
#
|
|
200
|
+
# `:controller` and `:action` are required by Plan 05's Logger
|
|
201
|
+
# JSONL shape.
|
|
202
|
+
#
|
|
203
|
+
# @return [Hash]
|
|
204
|
+
def current_context
|
|
205
|
+
controller = Current.controller
|
|
206
|
+
action = controller.respond_to?(:action_name) ? controller.action_name : nil
|
|
207
|
+
|
|
208
|
+
{
|
|
209
|
+
user: Current.user,
|
|
210
|
+
controller: controller&.class&.name,
|
|
211
|
+
action: action,
|
|
212
|
+
client_id: Current.client_id,
|
|
213
|
+
visit: Current.visit,
|
|
214
|
+
request_id: Current.request&.request_id
|
|
215
|
+
}
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Run {EventPayload#validate!} and apply the
|
|
219
|
+
# {Configuration#raise_on_validation_error} gate.
|
|
220
|
+
#
|
|
221
|
+
# Returns truthy when the caller should proceed to instrument and
|
|
222
|
+
# `nil` when validation failed and was swallowed (no instrument).
|
|
223
|
+
#
|
|
224
|
+
# @param payload [EventPayload]
|
|
225
|
+
# @return [Object, nil] truthy on success, `nil` on swallowed failure
|
|
226
|
+
# @raise [ValidationError] when validation fails AND
|
|
227
|
+
# {Configuration#raise_on_validation_error} is true
|
|
228
|
+
def validate(payload)
|
|
229
|
+
payload.validate!
|
|
230
|
+
payload
|
|
231
|
+
rescue ValidationError => e
|
|
232
|
+
raise if TrackRelay.config.raise_on_validation_error
|
|
233
|
+
|
|
234
|
+
if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
235
|
+
Rails.logger.error("[track_relay] validation failed for #{payload.name.inspect}: #{e.message}")
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
nil
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|