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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +21 -0
- data/LICENSE.txt +21 -0
- data/README.md +133 -0
- data/lib/generators/wide_events/install/install_generator.rb +26 -0
- data/lib/generators/wide_events/install/templates/agents_md_section.md +17 -0
- data/lib/generators/wide_events/install/templates/initializer.rb +11 -0
- data/lib/generators/wide_events/install/templates/registry.yml +16 -0
- data/lib/generators/wide_events/skills/skills_generator.rb +20 -0
- data/lib/wide_event/configuration.rb +63 -0
- data/lib/wide_event/job_instrumentation.rb +55 -0
- data/lib/wide_event/middleware.rb +81 -0
- data/lib/wide_event/railtie.rb +32 -0
- data/lib/wide_event/registry/defaults.yml +131 -0
- data/lib/wide_event/registry.rb +69 -0
- data/lib/wide_event/sinks/log_line.rb +27 -0
- data/lib/wide_event/sinks/memory.rb +20 -0
- data/lib/wide_event/sinks/otel_span.rb +12 -0
- data/lib/wide_event/span_counter_processor.rb +29 -0
- data/lib/wide_event/subscribers.rb +40 -0
- data/lib/wide_event/tasks/registry.rake +26 -0
- data/lib/wide_event/test_helper.rb +55 -0
- data/lib/wide_event/version.rb +3 -0
- data/lib/wide_event.rb +188 -0
- data/lib/wide_events.rb +3 -0
- data/skills/debugging-with-wide-events/SKILL.md +73 -0
- data/skills/instrumenting-wide-events/SKILL.md +93 -0
- metadata +103 -0
|
@@ -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
|
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
|
data/lib/wide_events.rb
ADDED
|
@@ -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`.
|