wide_events 0.1.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.
@@ -0,0 +1,69 @@
1
+ require "yaml"
2
+
3
+ module WideEvent
4
+ # The attribute schema: every wide-event attribute is declared in a YAML
5
+ # registry (gem defaults merged with the app's registry.yml). Keys
6
+ # containing `*` are globs for dynamic attributes (feature_flag.*).
7
+ # In strict mode the accumulator reports every key it sees via #track;
8
+ # unregistered keys collect in #violations for the test suite to assert on.
9
+ class Registry
10
+ DEFAULTS_PATH = File.expand_path("registry/defaults.yml", __dir__)
11
+
12
+ class << self
13
+ def defaults
14
+ new(load_file(DEFAULTS_PATH))
15
+ end
16
+
17
+ def load(path)
18
+ new(load_file(DEFAULTS_PATH).merge(load_file(path)))
19
+ end
20
+
21
+ private
22
+
23
+ def load_file(path)
24
+ YAML.safe_load_file(path) || {}
25
+ end
26
+ end
27
+
28
+ attr_reader :entries
29
+
30
+ def initialize(entries)
31
+ @entries = entries
32
+ @exact = entries.keys.reject { |k| k.include?("*") }.to_set
33
+ @globs = entries.keys.select { |k| k.include?("*") }
34
+ @violations = Set.new
35
+ @mutex = Mutex.new
36
+ end
37
+
38
+ def registered?(key)
39
+ @exact.include?(key) || @globs.any? { |glob| File.fnmatch(glob, key) }
40
+ end
41
+
42
+ def track(key)
43
+ return if registered?(key)
44
+ @mutex.synchronize { @violations << key }
45
+ nil
46
+ end
47
+
48
+ def violations
49
+ @violations.to_a
50
+ end
51
+
52
+ def validate
53
+ entries.filter_map do |key, entry|
54
+ unless entry.is_a?(Hash) && entry["type"]
55
+ "#{key}: entry must be a mapping with at least a `type` field"
56
+ end
57
+ end
58
+ end
59
+
60
+ def to_markdown
61
+ lines = [ "| Attribute | Type | Set by | PII | Notes |", "|---|---|---|---|---|" ]
62
+ entries.each do |key, entry|
63
+ entry = {} unless entry.is_a?(Hash)
64
+ lines << "| `#{key}` | #{Array(entry["type"]).join(", ")} | #{entry["set_by"]} | #{entry["pii"]} | #{entry["notes"]} |"
65
+ end
66
+ lines.join("\n") + "\n"
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,27 @@
1
+ require "json"
2
+ require "logger"
3
+
4
+ module WideEvent
5
+ module Sinks
6
+ # Emits the wide event as one JSON log line: the canonical-log-line
7
+ # deployment for apps that don't run tracing.
8
+ class LogLine
9
+ def initialize(logger = nil)
10
+ @logger = logger
11
+ end
12
+
13
+ def flush(attrs)
14
+ line = { "timestamp" => Time.now.utc.iso8601(3) }.merge(WideEvent.sanitize(attrs))
15
+ logger.info(JSON.generate(line))
16
+ end
17
+
18
+ private
19
+
20
+ def logger
21
+ @logger ||= WideEvent.config.logger ||
22
+ (defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger) ||
23
+ Logger.new($stdout)
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,20 @@
1
+ module WideEvent
2
+ module Sinks
3
+ # Captures flushed wide events in memory, for tests.
4
+ class Memory
5
+ attr_reader :events
6
+
7
+ def initialize
8
+ @events = []
9
+ end
10
+
11
+ def flush(attrs)
12
+ @events << attrs.dup
13
+ end
14
+
15
+ def clear
16
+ @events.clear
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,12 @@
1
+ module WideEvent
2
+ module Sinks
3
+ # Writes the wide event onto the current OTel span: the root span of
4
+ # the unit of work, since flush happens as the request/job unwinds.
5
+ class OtelSpan
6
+ def flush(attrs)
7
+ return unless defined?(OpenTelemetry::Trace)
8
+ WideEvent.flush_hash(attrs, OpenTelemetry::Trace.current_span)
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,29 @@
1
+ module WideEvent
2
+ # Rolls finished dependency child spans up into the active wide event's
3
+ # stats.* attributes: what makes "this request ran 742 queries" visible
4
+ # without opening a trace. Instrumentation scopes map to dependency names
5
+ # via config.span_scopes. Registered by the railtie when OTel is active.
6
+ class SpanCounterProcessor
7
+ def on_start(span, parent_context); end
8
+
9
+ def on_finish(span)
10
+ return unless WideEvent.active?
11
+
12
+ dep = WideEvent.config.span_scopes[span.instrumentation_scope&.name]
13
+ return unless dep
14
+
15
+ duration_ms = (span.end_timestamp - span.start_timestamp) / 1_000_000.0
16
+ WideEvent.count(dep, duration_ms.round(2))
17
+ rescue StandardError => e
18
+ WideEvent.handle_error(e, "span counter")
19
+ end
20
+
21
+ def force_flush(timeout: nil)
22
+ OpenTelemetry::SDK::Trace::Export::SUCCESS
23
+ end
24
+
25
+ def shutdown(timeout: nil)
26
+ OpenTelemetry::SDK::Trace::Export::SUCCESS
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,40 @@
1
+ module WideEvent
2
+ # Copies measurements Rails already makes into the active wide event via
3
+ # ActiveSupport::Notifications. Subscribed once by the railtie.
4
+ module Subscribers
5
+ def self.subscribe!
6
+ return if @subscribed
7
+
8
+ @subscribed = true
9
+
10
+ ActiveSupport::Notifications.subscribe("process_action.action_controller") do |event|
11
+ next unless WideEvent.active?
12
+
13
+ payload = event.payload
14
+ WideEvent.set("http.route.controller" => "#{payload[:controller]}##{payload[:action]}")
15
+ WideEvent.set("db.duration_ms" => payload[:db_runtime].round(2)) if payload[:db_runtime]
16
+ WideEvent.set("view.duration_ms" => payload[:view_runtime].round(2)) if payload[:view_runtime]
17
+ rescue StandardError => e
18
+ WideEvent.handle_error(e, "process_action")
19
+ end
20
+
21
+ ActiveSupport::Notifications.subscribe("cache_read.active_support") do |event|
22
+ next unless WideEvent.active?
23
+
24
+ prefix = event.payload[:key].to_s.split(%r{[/:]}).first.to_s.gsub(/[^a-z0-9_]+/i, "_")[0, 30].downcase
25
+ next if prefix.empty?
26
+
27
+ key = "cache.#{prefix}"
28
+ attrs = WideEvent.peek
29
+ # Cap distinct cache namespaces, but keep refreshing ones already
30
+ # tracked: otherwise a prefix freezes at whatever value it had
31
+ # when the cap-th distinct namespace appeared.
32
+ next if !attrs.key?(key) && attrs.keys.count { |k| k.start_with?("cache.") } >= WideEvent.config.max_cache_attrs
33
+
34
+ WideEvent.set(key => event.payload[:hit] ? true : false)
35
+ rescue StandardError => e
36
+ WideEvent.handle_error(e, "cache_read")
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,26 @@
1
+ namespace :wide_events do
2
+ namespace :registry do
3
+ desc "Validate the wide-event attribute registry"
4
+ task check: :environment do
5
+ registry = WideEvent.config.registry
6
+ abort("No wide-event registry at #{WideEvent.config.registry_path.inspect}: run `bin/rails generate wide_events:install`") if registry.nil?
7
+ errors = registry.validate
8
+ abort("wide-event registry invalid:\n #{errors.join("\n ")}") unless errors.empty?
9
+ puts "wide-event registry OK (#{registry.entries.size} entries)"
10
+ end
11
+
12
+ desc "Generate the human-readable registry doc (default: docs/wide-events-registry.md)"
13
+ task docs: :environment do
14
+ registry = WideEvent.config.registry
15
+ abort("No wide-event registry at #{WideEvent.config.registry_path.inspect}: run `bin/rails generate wide_events:install`") if registry.nil?
16
+ path = ENV["WIDE_EVENTS_DOCS_PATH"] || "docs/wide-events-registry.md"
17
+ require "fileutils"
18
+ FileUtils.mkdir_p(File.dirname(path))
19
+ header = "# Wide-events attribute registry\n\n" \
20
+ "Generated from `#{WideEvent.config.registry_path}` by `rake wide_events:registry:docs`. " \
21
+ "Do not edit by hand.\n\n"
22
+ File.write(path, header + registry.to_markdown)
23
+ puts "wrote #{path}"
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,55 @@
1
+ module WideEvent
2
+ # Minitest assertions for wide-event instrumentation. Require from your
3
+ # test helper and include in ActiveSupport::TestCase:
4
+ #
5
+ # require "wide_event/test_helper"
6
+ # class ActiveSupport::TestCase
7
+ # include WideEvent::TestHelper
8
+ # end
9
+ module TestHelper
10
+ # Runs the block inside an open wide event and asserts on the
11
+ # accumulated attributes. Bare string arguments assert presence;
12
+ # keyword-style pairs assert equality. Returns the attributes for
13
+ # further assertions.
14
+ #
15
+ # assert_wide_event("pdf_render.duration_ms", "report.format" => "pdf") do
16
+ # report.render
17
+ # end
18
+ def assert_wide_event(*keys, **pairs, &block)
19
+ attrs = WideEvent.with do |store|
20
+ block.call
21
+ store.dup
22
+ end
23
+ keys.each do |key|
24
+ assert attrs.key?(key), "expected wide event to have #{key.inspect}, got keys #{attrs.keys.inspect}"
25
+ end
26
+ pairs.each do |key, value|
27
+ assert_equal value, attrs[key], "wide event attribute #{key.inspect}"
28
+ end
29
+ attrs
30
+ end
31
+
32
+ # Swaps the sink for an in-memory one for the duration of the block and
33
+ # returns every wide event flushed inside it. Use around code that runs
34
+ # its own unit of work (jobs, full request dispatch).
35
+ def capture_wide_events(&block)
36
+ original = WideEvent.config.sink
37
+ sink = Sinks::Memory.new
38
+ WideEvent.config.sink = sink
39
+ block.call
40
+ sink.events
41
+ ensure
42
+ WideEvent.config.sink = original
43
+ end
44
+
45
+ # Fails if strict mode saw any attribute that isn't declared in the
46
+ # registry. Call from a dedicated test (or a teardown hook) with
47
+ # config.strict = true to make the registry an enforced schema.
48
+ def assert_registered_wide_event_attributes
49
+ registry = WideEvent.config.registry
50
+ violations = registry ? registry.violations : []
51
+ assert violations.empty?,
52
+ "unregistered wide-event attributes #{violations.sort.inspect} - declare them in #{WideEvent.config.registry_path}"
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,3 @@
1
+ module WideEvent
2
+ VERSION = "0.1.0"
3
+ end
data/lib/wide_event.rb ADDED
@@ -0,0 +1,188 @@
1
+ require "time"
2
+ require "active_support"
3
+ require "active_support/isolated_execution_state"
4
+ require "active_support/core_ext/object/blank"
5
+
6
+ require "wide_event/version"
7
+ require "wide_event/configuration"
8
+ require "wide_event/registry"
9
+ require "wide_event/sinks/otel_span"
10
+ require "wide_event/sinks/log_line"
11
+ require "wide_event/sinks/memory"
12
+ require "wide_event/middleware"
13
+ require "wide_event/span_counter_processor"
14
+ require "wide_event/subscribers"
15
+ require "wide_event/job_instrumentation"
16
+
17
+ # Wide-event accumulator: one flat attribute hash per unit of work (HTTP
18
+ # request or job execution), flushed to the configured sink by
19
+ # WideEvent::Middleware / WideEvent::JobInstrumentation. Safe no-op when no
20
+ # unit of work is open.
21
+ module WideEvent
22
+ KEY = :wide_event_attributes
23
+ PROCESS_START = Process.clock_gettime(Process::CLOCK_MONOTONIC)
24
+
25
+ class << self
26
+ def config
27
+ @config ||= Configuration.new
28
+ end
29
+
30
+ def configure
31
+ yield config
32
+ config
33
+ end
34
+
35
+ def reset_configuration!
36
+ @config = Configuration.new
37
+ end
38
+
39
+ def enabled?
40
+ config.enabled
41
+ end
42
+
43
+ def with
44
+ previous = store
45
+ current = {}
46
+ ActiveSupport::IsolatedExecutionState[KEY] = current
47
+ yield current
48
+ ensure
49
+ ActiveSupport::IsolatedExecutionState[KEY] = previous
50
+ end
51
+
52
+ def active?
53
+ !store.nil?
54
+ end
55
+
56
+ def set(attrs)
57
+ registry_check(attrs.keys)
58
+ store&.merge!(attrs)
59
+ nil
60
+ rescue StandardError => e
61
+ handle_error(e, "set")
62
+ nil
63
+ end
64
+
65
+ def count(dep, duration_ms)
66
+ s = store
67
+ return if s.nil?
68
+ registry_check([ "stats.#{dep}_count", "stats.#{dep}_duration_ms" ])
69
+ s["stats.#{dep}_count"] = s.fetch("stats.#{dep}_count", 0) + 1
70
+ s["stats.#{dep}_duration_ms"] = (s.fetch("stats.#{dep}_duration_ms", 0.0) + duration_ms).round(2)
71
+ nil
72
+ rescue StandardError => e
73
+ handle_error(e, "count")
74
+ nil
75
+ end
76
+
77
+ def phase(name)
78
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
79
+ yield
80
+ ensure
81
+ elapsed_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000.0
82
+ begin
83
+ set("#{name}.duration_ms" => elapsed_ms.round(2))
84
+ rescue StandardError => e
85
+ handle_error(e, "phase")
86
+ nil
87
+ end
88
+ end
89
+
90
+ def error!(slug:, exception: nil, expected: false)
91
+ attrs = { "error" => true, "exception.slug" => slug, "exception.expected" => expected }
92
+ if exception
93
+ attrs["exception.type"] = exception.class.name
94
+ attrs["exception.message"] = exception.message.to_s[0, 500]
95
+ end
96
+ set(attrs)
97
+ rescue StandardError => e
98
+ handle_error(e, "error!")
99
+ nil
100
+ end
101
+
102
+ def attributes
103
+ store&.dup
104
+ end
105
+
106
+ # Read-only view of the live store for hot-path handlers that would
107
+ # otherwise dup per event (e.g. every cache read). Callers must not
108
+ # mutate: writes go through set/count/error!.
109
+ def peek
110
+ store
111
+ end
112
+
113
+ def uptime_attributes
114
+ uptime = Process.clock_gettime(Process::CLOCK_MONOTONIC) - PROCESS_START
115
+ { "uptime_sec" => uptime.round, "uptime_sec_log10" => Math.log10([ uptime, 1 ].max).round(3) }
116
+ end
117
+
118
+ # Hands a finished wide event to the configured sink. Never raises into
119
+ # the caller.
120
+ def flush(attrs)
121
+ return if attrs.nil? || attrs.empty?
122
+ config.resolved_sink&.flush(attrs)
123
+ nil
124
+ rescue StandardError => e
125
+ handle_error(e, "flush")
126
+ nil
127
+ end
128
+
129
+ # Wires the pieces that need the host's OpenTelemetry SDK configured:
130
+ # notification subscribers and, for the OTel sink, the span counter on
131
+ # the global tracer provider. Called by the railtie after the app's
132
+ # initializers; call it manually from a non-Rails setup.
133
+ def install!
134
+ Subscribers.subscribe!
135
+ if config.sink == :otel && defined?(OpenTelemetry) &&
136
+ OpenTelemetry.respond_to?(:tracer_provider) &&
137
+ OpenTelemetry.tracer_provider.respond_to?(:add_span_processor)
138
+ OpenTelemetry.tracer_provider.add_span_processor(SpanCounterProcessor.new)
139
+ end
140
+ nil
141
+ end
142
+
143
+ # Sanitize and write a wide-event hash onto an OTel span. `span` may be
144
+ # nil (OTel inactive). Never raises into the caller.
145
+ def flush_hash(hash, span)
146
+ return if hash.nil? || hash.empty? || span.nil?
147
+ span.add_attributes(sanitize(hash))
148
+ rescue StandardError => e
149
+ handle_error(e, "flush")
150
+ nil
151
+ end
152
+
153
+ def sanitize(attrs)
154
+ attrs.each_with_object({}) do |(k, v), out|
155
+ key = k.to_s
156
+ case v
157
+ when String, Integer, Float, true, false then out[key] = v
158
+ when Symbol then out[key] = v.to_s
159
+ when Time, DateTime, ActiveSupport::TimeWithZone then out[key] = v.to_time.utc.iso8601(3)
160
+ when nil then nil # drop
161
+ else out[key] = v.to_s[0, 300]
162
+ end
163
+ end
164
+ end
165
+
166
+ # Telemetry must never raise into app code: every public entry point
167
+ # funnels its rescues here, and a broken handler is itself swallowed.
168
+ def handle_error(exception, message)
169
+ config.error_handler&.call(exception, message)
170
+ nil
171
+ rescue StandardError
172
+ nil
173
+ end
174
+
175
+ private
176
+
177
+ def store
178
+ ActiveSupport::IsolatedExecutionState[KEY]
179
+ end
180
+
181
+ def registry_check(keys)
182
+ return unless config.strict
183
+ registry = config.registry
184
+ return if registry.nil?
185
+ keys.each { |key| registry.track(key.to_s) }
186
+ end
187
+ end
188
+ end
@@ -0,0 +1,3 @@
1
+ require "wide_event"
2
+
3
+ require "wide_event/railtie" if defined?(Rails::Railtie)
@@ -0,0 +1,73 @@
1
+ ---
2
+ name: debugging-with-wide-events
3
+ description: Use when investigating production behavior by querying wide events (a slow route, an error spike, a suspicious deploy, "why is this happening for account X"). Covers the data model, the symptom-to-query workflow, and standing queries worth running.
4
+ ---
5
+
6
+ # Debugging with wide events
7
+
8
+ Every request and job execution is one row: the OTel root span with
9
+ `main = true`, carrying route, user/account ids, build SHA, status, error
10
+ state, per-dependency query counts, cache hits, feature flags, phase
11
+ timings, and whatever domain attributes the app set. Cross-request analysis
12
+ never needs child spans: filter `main = true` and aggregate.
13
+
14
+ ## Where the rows live
15
+
16
+ - **ClickHouse (HyperDX/ClickStack default schema):** the `otel_traces`
17
+ table; wide-event attributes are in the `SpanAttributes` map, resource
18
+ attributes (build, host, environment) in `ResourceAttributes`. Adapt table
19
+ and column names to the backend at hand.
20
+ - **Log sink deployments:** one JSON object per line; query with your log
21
+ store's JSON operators.
22
+
23
+ Filter by environment when staging and production share storage.
24
+
25
+ ## Symptom to query
26
+
27
+ Start from the unit of work, not from logs. One GROUP BY usually replaces a
28
+ cross-tool investigation.
29
+
30
+ "The reports route is slow":
31
+
32
+ ```sql
33
+ SELECT SpanAttributes['user.account.id'] AS account,
34
+ ResourceAttributes['service.version'] AS build,
35
+ count() AS requests,
36
+ quantile(0.5)(Duration/1e6) AS p50_ms,
37
+ avg(toFloat64OrZero(SpanAttributes['stats.postgres_query_count'])) AS avg_queries
38
+ FROM otel_traces
39
+ WHERE SpanAttributes['main'] = 'true'
40
+ AND SpanAttributes['http.route.controller'] = 'ReportsController#index'
41
+ AND Timestamp > now() - INTERVAL 1 DAY
42
+ GROUP BY account, build
43
+ ORDER BY p50_ms DESC
44
+ LIMIT 20
45
+ ```
46
+
47
+ "Did the deploy change anything": group the affected route by
48
+ `ResourceAttributes['service.version']` and compare p50/p95 and
49
+ `stats.postgres_query_count` across builds.
50
+
51
+ "Why did this specific request fail": filter by
52
+ `SpanAttributes['http.request.id']` and read every attribute of the one row
53
+ before reaching for logs or the trace waterfall.
54
+
55
+ ## Standing queries
56
+
57
+ - **Uninstrumented failures**: `error = 'true' AND SpanAttributes['exception.slug'] = ''`:
58
+ failures nobody wrote a rescue for. Each row is a candidate `error!` call;
59
+ fixing them is normal instrumentation work (see the
60
+ instrumenting-wide-events skill).
61
+ - **Expected-error drift**: count by `exception.slug` week over week; a
62
+ slug trending up is a quiet regression.
63
+ - **Query-count outliers**: max `stats.postgres_query_count` per route; the
64
+ top entries are N+1s with names and account ids attached.
65
+ - **Cold starts**: correlate latency with `uptime_sec_log10` to separate
66
+ deploy warm-up from real regressions.
67
+
68
+ ## Verifying your own fix
69
+
70
+ After a deploy, don't ask a human to check a dashboard. Query the affected
71
+ route/job filtered to the new `service.version` and compare against the
72
+ prior build: error rate, p50, query count, and the specific attribute your
73
+ change was supposed to move. State the numbers in your summary.
@@ -0,0 +1,93 @@
1
+ ---
2
+ name: instrumenting-wide-events
3
+ description: Use when adding or changing app code that should be observable in production: new features, slow paths, rescue blocks, background jobs. Covers the WideEvent API (set/phase/error!), attribute naming conventions, the registry workflow, and test assertions.
4
+ ---
5
+
6
+ # Instrumenting wide events
7
+
8
+ Every HTTP request and job execution in this app emits one flat telemetry
9
+ event: the OTel root span, marked `main = true` (or one JSON log line with
10
+ the `:log` sink). The gem records the generic attributes (route, status,
11
+ timings, query counts, cache hits). Your job is the attributes only the app
12
+ knows: the domain nouns.
13
+
14
+ ## The API
15
+
16
+ All calls are safe no-ops outside a request/job and never raise into app
17
+ code: instrument freely.
18
+
19
+ ```ruby
20
+ # Flat attributes, merged into the current event
21
+ WideEvent.set("report.id" => report.id, "report.format" => "pdf")
22
+
23
+ # Time a block into <name>.duration_ms (returns the block's value)
24
+ WideEvent.phase("pdf_render") { render_pdf }
25
+
26
+ # Mark a handled failure in a rescue block
27
+ rescue Api::ClientError => e
28
+ WideEvent.error!(slug: "err-vendor-user-not-found", exception: e, expected: true)
29
+ ```
30
+
31
+ `error!` slugs are static kebab-case string literals, unique across the app,
32
+ prefixed `err-`. Never interpolate into a slug. Unhandled exceptions get
33
+ `error: true` with NO slug automatically: that absence is the standing
34
+ "instrument this rescue" query, so don't fake slugs for paths you didn't
35
+ handle.
36
+
37
+ ## Naming conventions
38
+
39
+ - Flat keys, dot namespaces, snake_case leaves: `report.page_count`, not `reportPageCount` or nested hashes.
40
+ - Durations end in `_duration_ms` (float milliseconds). Counts end in `_count`.
41
+ - Booleans read as assertions: `report.cached`, not `report.cache_status`.
42
+ - Timestamps are RFC 3339 strings (pass a Time; the gem serializes it).
43
+ - Sizes and counts over raw content: `note_chars: 2140`, never the note text.
44
+
45
+ ## PII policy
46
+
47
+ Opaque ids (user id, account id) are fine. Names, emails, free-text user
48
+ input, and request params are not. If an attribute could quote user input,
49
+ mark it `pii: review` in the registry and expect it to be scrubbed or
50
+ excluded from forwarding.
51
+
52
+ ## The registry is the contract
53
+
54
+ Every attribute you set must be declared in `config/wide_event/registry.yml`
55
+ in the same change:
56
+
57
+ ```yaml
58
+ report.format:
59
+ type: string
60
+ set_by: ReportsController#create
61
+ pii: none
62
+ notes: pdf or csv
63
+ ```
64
+
65
+ Keys containing `*` are globs for dynamic families (`feature_flag.*`). With
66
+ `config.strict = true` in the test environment, the suite records undeclared
67
+ attributes; `assert_registered_wide_event_attributes` fails on them. Run
68
+ `rake wide_events:registry:docs` if this app generates the human-readable
69
+ registry doc, and never rename a shipped attribute without a reason -
70
+ renames orphan historical data.
71
+
72
+ ## Prove it in tests
73
+
74
+ ```ruby
75
+ require "wide_event/test_helper" # include WideEvent::TestHelper
76
+
77
+ test "report rendering is instrumented" do
78
+ assert_wide_event("pdf_render.duration_ms", "report.format" => "pdf") do
79
+ Report.new(format: "pdf").render
80
+ end
81
+ end
82
+ ```
83
+
84
+ For code that runs its own unit of work (jobs, full request dispatch), use
85
+ `capture_wide_events { ... }` and assert on the returned events.
86
+
87
+ ## Checklist for any instrumentation change
88
+
89
+ 1. Attributes follow the naming conventions above.
90
+ 2. New rescue blocks that swallow errors call `error!` with a unique static slug.
91
+ 3. Every new attribute has a registry entry in the same change.
92
+ 4. A test asserts the attribute is set (`assert_wide_event`).
93
+ 5. No PII beyond opaque ids; anything questionable is flagged `pii: review`.