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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 60fd90a80072a4f375595529b63b5c739751de76d92d7e29ff1d8581ae6b5a07
|
|
4
|
+
data.tar.gz: 6656514b71043fa80493946446f19582d1a32fe63799f4f7a04e9f67b2cd595f
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 43721569390e303660bde87aae8317fe320cd3c9722f33ec0a49e6bebc180258c5536e1596181653bc3302b4010fba56153b70c0129c3c5f458e89fbb055d6e2
|
|
7
|
+
data.tar.gz: 587ec94b148be767437f9d0d126c0ebd321ae5f91db31ef4236ff75cad1d39c5572f8e44d3791a410420b48c7304143260d51049e43607afb2d0573d220ab82b
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## v0.1.0 (2026-08-03)
|
|
4
|
+
|
|
5
|
+
First release, extracted from a production Rails app.
|
|
6
|
+
|
|
7
|
+
- Wide-event accumulator (`set`, `phase`, `error!`, `count`) with a
|
|
8
|
+
never-raise contract and per-request/per-job isolation.
|
|
9
|
+
- Rack middleware and ActiveJob instrumentation, wired by a railtie.
|
|
10
|
+
- Sinks: OTel root span (default), JSON log lines, in-memory (tests).
|
|
11
|
+
- Span counter: child-span rollups into `stats.*` (configurable scope map).
|
|
12
|
+
- Notification subscribers: `db.duration_ms`, `view.duration_ms`, capped
|
|
13
|
+
`cache.*` hit/miss booleans.
|
|
14
|
+
- Attribute registry (`config/wide_event/registry.yml`): gem defaults,
|
|
15
|
+
glob entries, strict-mode tracking, `rake wide_events:registry:check`
|
|
16
|
+
and `rake wide_events:registry:docs`.
|
|
17
|
+
- Test helpers: `assert_wide_event`, `capture_wide_events`,
|
|
18
|
+
`assert_registered_wide_event_attributes`.
|
|
19
|
+
- Generators: `wide_events:install` (initializer, registry, AGENTS.md
|
|
20
|
+
section) and `wide_events:skills` (agent skills for writing and
|
|
21
|
+
querying instrumentation).
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Adam Miribyan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# Wide Events
|
|
2
|
+
|
|
3
|
+
One wide telemetry event per Rails request or job execution, in a database you own.
|
|
4
|
+
|
|
5
|
+
Wide Events collects everything your app knows about each unit of work (route, user, account, build SHA, query counts, cache hits, feature flags, phase timings, errors) into one flat, high-cardinality event on the OpenTelemetry root span you already export. Every request becomes one row. Every question becomes one query against storage you run: ClickHouse with HyperDX on top is a proven pairing (both open source), any OTLP backend works, and a log-line sink emits one JSON event per request if you'd rather not run tracing at all.
|
|
6
|
+
|
|
7
|
+
That format matters more now that agents build with you. Telemetry stops being something humans glance at and becomes something software queries in a loop: an agent that instruments a feature, deploys it, and verifies it in production will hit your observability stack fifty times before lunch. One row per request is the densest way to feed production behavior back into a context window, and owning the storage means the loop has no price per iteration.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
# Gemfile
|
|
13
|
+
gem "wide_events"
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
bin/rails generate wide_events:install # initializer + attribute registry + AGENTS.md section
|
|
18
|
+
bin/rails generate wide_events:skills # agent skills into .claude/skills/
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Wide events turn on wherever `OTEL_EXPORTER_OTLP_ENDPOINT` is set. Dev and test pay nothing.
|
|
22
|
+
|
|
23
|
+
## What you get per event
|
|
24
|
+
|
|
25
|
+
The gem records the generic attributes on every request and job:
|
|
26
|
+
|
|
27
|
+
- `http.request.id`, `http.response.status_code`, `http.route.controller`, request body size, parsed `user_agent.*`
|
|
28
|
+
- `db.duration_ms` and `view.duration_ms` (Rails' own measurements)
|
|
29
|
+
- `stats.postgres_query_count` / `_duration_ms` and `stats.http_call_count` / `_duration_ms`, rolled up from OTel child spans (scope map is configurable)
|
|
30
|
+
- `cache.<prefix>` hit/miss booleans, capped per event
|
|
31
|
+
- `job.class`, `job.queue`, `job.queue_latency_ms`, `job.executions`, `job.scheduled` (Solid Queue recurring detection built in, detector pluggable)
|
|
32
|
+
- `error`, `exception.type`, `exception.message`, `uptime_sec`, `main: true`
|
|
33
|
+
|
|
34
|
+
Your code adds the parts only it knows:
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
WideEvent.set("report.id" => report.id, "report.format" => "pdf")
|
|
38
|
+
WideEvent.phase("pdf_render") { render_pdf } # -> pdf_render.duration_ms
|
|
39
|
+
WideEvent.error!(slug: "err-export-source-missing", exception: e, expected: true)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Every call is a safe no-op outside a unit of work and never raises into app code. A telemetry bug cannot fail a request or a job.
|
|
43
|
+
|
|
44
|
+
"Something is slow" turns into "the reports route is slow for account 4218 on build f3a91c, and it's running 742 queries": one query in HyperDX for you, one SQL call for your agent.
|
|
45
|
+
|
|
46
|
+
## The registry is a schema, not a wiki page
|
|
47
|
+
|
|
48
|
+
Every attribute is declared in `config/wide_event/registry.yml`:
|
|
49
|
+
|
|
50
|
+
```yaml
|
|
51
|
+
report.format:
|
|
52
|
+
type: string
|
|
53
|
+
set_by: ReportsController#create
|
|
54
|
+
pii: none
|
|
55
|
+
notes: pdf or csv
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The gem registers its own attributes by default; globs (`feature_flag.*`) cover dynamic families. With `config.strict = true` in the test environment, the suite records every undeclared attribute it sees, and `assert_registered_wide_event_attributes` fails on them. `rake wide_events:registry:check` validates the file; `rake wide_events:registry:docs` generates the human-readable registry doc from it, so documentation can't drift from reality.
|
|
59
|
+
|
|
60
|
+
## Testing
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
# test_helper.rb
|
|
64
|
+
require "wide_event/test_helper"
|
|
65
|
+
class ActiveSupport::TestCase
|
|
66
|
+
include WideEvent::TestHelper
|
|
67
|
+
end
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
test "report rendering is instrumented" do
|
|
72
|
+
assert_wide_event("pdf_render.duration_ms", "report.format" => "pdf") do
|
|
73
|
+
Report.new(format: "pdf").render
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
test "job emits one wide event" do
|
|
78
|
+
events = capture_wide_events { ExportJob.perform_now }
|
|
79
|
+
assert_equal 1, events.length
|
|
80
|
+
end
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Built for the agent loop
|
|
84
|
+
|
|
85
|
+
`bin/rails generate wide_events:skills` installs two agent skills:
|
|
86
|
+
|
|
87
|
+
- **instrumenting-wide-events**: the write path. Naming conventions, when to use `set` vs `phase` vs `error!`, the registry workflow, PII rules, test assertions.
|
|
88
|
+
- **debugging-with-wide-events**: the read path. Symptom-to-query workflow against ClickHouse/HyperDX or JSON logs, plus the standing queries worth running: `error = true AND exception.slug IS NULL` is a permanent, queryable to-do list of rescues nobody instrumented.
|
|
89
|
+
|
|
90
|
+
The install generator also appends a wide-events section to `AGENTS.md`, so every future session knows the instrumentation exists and how to extend it.
|
|
91
|
+
|
|
92
|
+
## Configuration
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
WideEvent.configure do |config|
|
|
96
|
+
config.enabled = ENV["OTEL_EXPORTER_OTLP_ENDPOINT"].present? # default
|
|
97
|
+
config.sink = :otel # :otel, :log, or any object responding to flush(attrs)
|
|
98
|
+
config.strict = Rails.env.test? # track attributes against the registry
|
|
99
|
+
config.max_cache_attrs = 10
|
|
100
|
+
config.span_scopes = { # OTel instrumentation scope -> stats.* name
|
|
101
|
+
"OpenTelemetry::Instrumentation::PG" => "postgres_query",
|
|
102
|
+
"OpenTelemetry::Instrumentation::Net::HTTP" => "http_call"
|
|
103
|
+
}
|
|
104
|
+
config.scheduled_job_detector = ->(job) { ... } # default detects Solid Queue recurring executions
|
|
105
|
+
config.error_handler = ->(exception, message) { ... } # default reports via OpenTelemetry.handle_error
|
|
106
|
+
end
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
The railtie inserts the middleware directly below `ActionDispatch::Executor` (the executor clears the per-request store, so placement matters), instruments `ActiveJob::Base`, and wires the span counter and notification subscribers after your initializers run. Without Rails, call `WideEvent.install!` yourself after configuring the OpenTelemetry SDK.
|
|
110
|
+
|
|
111
|
+
## Conventions
|
|
112
|
+
|
|
113
|
+
Flat keys, dot namespaces, snake_case leaves. Durations end in `_duration_ms`, counts in `_count`, booleans read as assertions, timestamps serialize to RFC 3339. Opaque ids are fine; names, emails, and request params are not, and anything that could quote user input is flagged `pii: review` in the registry.
|
|
114
|
+
|
|
115
|
+
Unhandled exceptions get `error: true` with no slug, deliberately: the missing slug marks the rescue you haven't instrumented yet.
|
|
116
|
+
|
|
117
|
+
## Development
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
bin/setup # bundle install + appraisal gemfiles
|
|
121
|
+
bundle exec rake test # run the suite
|
|
122
|
+
bin/rubocop # lint (rubocop-rails-omakase)
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
The CI matrix runs the suite across Ruby 3.2 to 4.0 and Rails 7.1 to main via [Appraisal](https://github.com/thoughtbot/appraisal); run a specific combination locally with `BUNDLE_GEMFILE=gemfiles/rails_7_1.gemfile bundle exec rake test`. Releases go out with `bin/release <version>`.
|
|
126
|
+
|
|
127
|
+
## Requirements
|
|
128
|
+
|
|
129
|
+
Ruby >= 3.2, Rails >= 7.1 (activesupport and rack are the only hard dependencies; opentelemetry-sdk and useragent are optional and detected at runtime).
|
|
130
|
+
|
|
131
|
+
## License
|
|
132
|
+
|
|
133
|
+
MIT.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
require "rails/generators/base"
|
|
2
|
+
|
|
3
|
+
module WideEvents
|
|
4
|
+
module Generators
|
|
5
|
+
class InstallGenerator < Rails::Generators::Base
|
|
6
|
+
source_root File.expand_path("templates", __dir__)
|
|
7
|
+
|
|
8
|
+
def create_initializer
|
|
9
|
+
copy_file "initializer.rb", "config/initializers/wide_events.rb"
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def create_registry
|
|
13
|
+
copy_file "registry.yml", "config/wide_event/registry.yml"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def add_agents_section
|
|
17
|
+
section = File.read(File.expand_path("templates/agents_md_section.md", __dir__))
|
|
18
|
+
if File.exist?(File.join(destination_root, "AGENTS.md"))
|
|
19
|
+
append_to_file "AGENTS.md", "\n#{section}"
|
|
20
|
+
else
|
|
21
|
+
create_file "AGENTS.md", section
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
## Wide events
|
|
2
|
+
|
|
3
|
+
Every HTTP request and job execution emits one flat telemetry event (the OTel
|
|
4
|
+
root span, marked `main = true`). When you add or change a feature,
|
|
5
|
+
instrument it:
|
|
6
|
+
|
|
7
|
+
- `WideEvent.set("report.id" => report.id)` for domain attributes
|
|
8
|
+
- `WideEvent.phase("pdf_render") { ... }` to time a block into `pdf_render.duration_ms`
|
|
9
|
+
- `WideEvent.error!(slug: "err-report-source-missing", exception: e, expected: true)` in rescue blocks
|
|
10
|
+
|
|
11
|
+
Naming: dot namespaces with snake_case leaves, durations end in
|
|
12
|
+
`_duration_ms`, counts in `_count`, booleans read as assertions. Every new
|
|
13
|
+
attribute must be declared in `config/wide_event/registry.yml` in the same
|
|
14
|
+
change; the test suite fails on undeclared attributes. Assert instrumentation
|
|
15
|
+
with `assert_wide_event` from `wide_event/test_helper`. See the
|
|
16
|
+
`instrumenting-wide-events` skill for full conventions and
|
|
17
|
+
`debugging-with-wide-events` for querying production.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
WideEvent.configure do |config|
|
|
2
|
+
# Enabled wherever OTEL_EXPORTER_OTLP_ENDPOINT is set, flushing onto the
|
|
3
|
+
# OTel root span. Uncomment to emit JSON log lines instead (no tracing
|
|
4
|
+
# required):
|
|
5
|
+
# config.enabled = true
|
|
6
|
+
# config.sink = :log
|
|
7
|
+
|
|
8
|
+
# Track attributes against config/wide_event/registry.yml in tests; pair
|
|
9
|
+
# with assert_registered_wide_event_attributes to enforce the schema.
|
|
10
|
+
config.strict = Rails.env.test?
|
|
11
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Attributes your app sets on wide events, one entry per attribute. The gem's
|
|
2
|
+
# own attributes (main, error, http.*, user_agent.*, job.*, stats.*, cache.*,
|
|
3
|
+
# db/view durations, exception.*) are registered by default: declare only
|
|
4
|
+
# what your code adds. Keys containing `*` are globs for dynamic attributes.
|
|
5
|
+
#
|
|
6
|
+
# Fields: type (string/integer/float/boolean, or a list), set_by, pii
|
|
7
|
+
# (none/opaque_id/review), notes, forward (optional list of sinks that may
|
|
8
|
+
# receive the attribute).
|
|
9
|
+
#
|
|
10
|
+
# Example:
|
|
11
|
+
#
|
|
12
|
+
# report.format:
|
|
13
|
+
# type: string
|
|
14
|
+
# set_by: ReportsController#create
|
|
15
|
+
# pii: none
|
|
16
|
+
# notes: pdf or csv
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
require "rails/generators/base"
|
|
2
|
+
|
|
3
|
+
module WideEvents
|
|
4
|
+
module Generators
|
|
5
|
+
# Copies the agent skills shipped with the gem into .claude/skills/ so
|
|
6
|
+
# coding agents pick up the instrumentation and debugging conventions.
|
|
7
|
+
class SkillsGenerator < Rails::Generators::Base
|
|
8
|
+
SKILLS_ROOT = File.expand_path("../../../../skills", __dir__)
|
|
9
|
+
|
|
10
|
+
source_root SKILLS_ROOT
|
|
11
|
+
|
|
12
|
+
def copy_skills
|
|
13
|
+
Dir[File.join(SKILLS_ROOT, "*", "SKILL.md")].sort.each do |path|
|
|
14
|
+
name = File.basename(File.dirname(path))
|
|
15
|
+
copy_file File.join(name, "SKILL.md"), ".claude/skills/#{name}/SKILL.md"
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
module WideEvent
|
|
2
|
+
class Configuration
|
|
3
|
+
# Reports through OTel's error handler when it's loaded; silent otherwise.
|
|
4
|
+
DEFAULT_ERROR_HANDLER = lambda do |exception, message|
|
|
5
|
+
if defined?(OpenTelemetry) && OpenTelemetry.respond_to?(:handle_error)
|
|
6
|
+
OpenTelemetry.handle_error(exception: exception, message: "wide_event #{message}")
|
|
7
|
+
end
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# Recurring (scheduled) Solid Queue executions have a RecurringExecution
|
|
11
|
+
# row pointing at the underlying job. One indexed query per execution.
|
|
12
|
+
SOLID_QUEUE_DETECTOR = lambda do |job|
|
|
13
|
+
return false unless defined?(SolidQueue::RecurringExecution)
|
|
14
|
+
job.provider_job_id.present? && SolidQueue::RecurringExecution.exists?(job_id: job.provider_job_id)
|
|
15
|
+
rescue StandardError
|
|
16
|
+
false
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
attr_accessor :enabled, :max_cache_attrs, :span_scopes, :strict,
|
|
20
|
+
:logger, :error_handler, :scheduled_job_detector
|
|
21
|
+
attr_reader :sink, :registry_path
|
|
22
|
+
|
|
23
|
+
def initialize
|
|
24
|
+
@enabled = !ENV["OTEL_EXPORTER_OTLP_ENDPOINT"].to_s.empty?
|
|
25
|
+
@sink = :otel
|
|
26
|
+
@resolved_sink = nil
|
|
27
|
+
@max_cache_attrs = 10
|
|
28
|
+
@span_scopes = {
|
|
29
|
+
"OpenTelemetry::Instrumentation::PG" => "postgres_query",
|
|
30
|
+
"OpenTelemetry::Instrumentation::Net::HTTP" => "http_call"
|
|
31
|
+
}
|
|
32
|
+
@strict = false
|
|
33
|
+
@registry_path = nil
|
|
34
|
+
@registry = nil
|
|
35
|
+
@logger = nil
|
|
36
|
+
@error_handler = DEFAULT_ERROR_HANDLER
|
|
37
|
+
@scheduled_job_detector = SOLID_QUEUE_DETECTOR
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def sink=(value)
|
|
41
|
+
@sink = value
|
|
42
|
+
@resolved_sink = nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def resolved_sink
|
|
46
|
+
@resolved_sink ||= case @sink
|
|
47
|
+
when :otel then Sinks::OtelSpan.new
|
|
48
|
+
when :log then Sinks::LogLine.new(logger)
|
|
49
|
+
else @sink
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def registry_path=(path)
|
|
54
|
+
@registry_path = path
|
|
55
|
+
@registry = nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def registry
|
|
59
|
+
return nil if @registry_path.nil?
|
|
60
|
+
@registry ||= Registry.load(@registry_path)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
require "active_support/concern"
|
|
2
|
+
|
|
3
|
+
module WideEvent
|
|
4
|
+
# One wide event per job execution: the job-shaped unit of work. Included
|
|
5
|
+
# into ActiveJob::Base by the railtie; include it manually in ApplicationJob
|
|
6
|
+
# when not using the railtie.
|
|
7
|
+
#
|
|
8
|
+
# Known semantics: exceptions later swallowed by retry_on/discard_on still
|
|
9
|
+
# mark this execution error=true: the wide event records that THIS attempt
|
|
10
|
+
# failed, which is accurate per-execution; roll up by job.id to judge the
|
|
11
|
+
# job.
|
|
12
|
+
module JobInstrumentation
|
|
13
|
+
extend ActiveSupport::Concern
|
|
14
|
+
|
|
15
|
+
included do
|
|
16
|
+
around_perform :wide_event
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def wide_event(&block)
|
|
22
|
+
return block.call unless WideEvent.enabled?
|
|
23
|
+
|
|
24
|
+
WideEvent.with do |attrs|
|
|
25
|
+
enqueued = enqueued_at.is_a?(String) ? Time.iso8601(enqueued_at) : enqueued_at
|
|
26
|
+
WideEvent.set({
|
|
27
|
+
"main" => true,
|
|
28
|
+
"job.class" => self.class.name,
|
|
29
|
+
"job.queue" => queue_name,
|
|
30
|
+
"job.id" => job_id,
|
|
31
|
+
"job.executions" => executions,
|
|
32
|
+
"job.enqueued_at" => enqueued,
|
|
33
|
+
"job.queue_latency_ms" => enqueued ? ((Time.now - enqueued) * 1000).round : nil,
|
|
34
|
+
"job.scheduled" => wide_event_scheduled?
|
|
35
|
+
}.merge(WideEvent.uptime_attributes))
|
|
36
|
+
begin
|
|
37
|
+
block.call
|
|
38
|
+
rescue Exception => e
|
|
39
|
+
WideEvent.set("error" => true, "exception.type" => e.class.name,
|
|
40
|
+
"exception.message" => e.message.to_s[0, 500])
|
|
41
|
+
raise
|
|
42
|
+
ensure
|
|
43
|
+
WideEvent.flush(attrs)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def wide_event_scheduled?
|
|
49
|
+
detector = WideEvent.config.scheduled_job_detector
|
|
50
|
+
detector ? detector.call(self) == true : false
|
|
51
|
+
rescue StandardError
|
|
52
|
+
false
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
begin
|
|
2
|
+
require "useragent"
|
|
3
|
+
rescue LoadError
|
|
4
|
+
# Optional: without the useragent gem only user_agent.original is set.
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
module WideEvent
|
|
8
|
+
# Opens the wide-event accumulator for each request and flushes it to the
|
|
9
|
+
# configured sink as the response unwinds. With the OTel sink, the flush
|
|
10
|
+
# target is the root span opened by OTel's Rack instrumentation at the top
|
|
11
|
+
# of the stack. Must be inserted BELOW ActionDispatch::Executor: the
|
|
12
|
+
# executor clears IsolatedExecutionState on completion, so above it the
|
|
13
|
+
# store would be gone before the flush. The railtie handles placement.
|
|
14
|
+
class Middleware
|
|
15
|
+
def initialize(app)
|
|
16
|
+
@app = app
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call(env)
|
|
20
|
+
return @app.call(env) unless WideEvent.enabled?
|
|
21
|
+
|
|
22
|
+
WideEvent.with do |attrs|
|
|
23
|
+
seed(env)
|
|
24
|
+
begin
|
|
25
|
+
status, headers, body = @app.call(env)
|
|
26
|
+
ex = env["action_dispatch.exception"]
|
|
27
|
+
WideEvent.set(
|
|
28
|
+
"http.response.status_code" => status.to_i,
|
|
29
|
+
"error" => status.to_i >= 500 || attrs["error"] == true || !ex.nil?
|
|
30
|
+
)
|
|
31
|
+
if ex
|
|
32
|
+
# Rendered by ActionDispatch::ShowExceptions before this
|
|
33
|
+
# middleware's rescue ever fires: error attrs WITHOUT a slug,
|
|
34
|
+
# same convention as the unhandled-exception path below.
|
|
35
|
+
WideEvent.set("error" => true, "exception.type" => ex.class.name,
|
|
36
|
+
"exception.message" => ex.message.to_s[0, 500])
|
|
37
|
+
end
|
|
38
|
+
[ status, headers, body ]
|
|
39
|
+
rescue Exception => e
|
|
40
|
+
# Unhandled exception: error attrs WITHOUT a slug: the standing
|
|
41
|
+
# "instrument this rescue" query keys off the missing slug.
|
|
42
|
+
WideEvent.set("error" => true, "exception.type" => e.class.name,
|
|
43
|
+
"exception.message" => e.message.to_s[0, 500])
|
|
44
|
+
raise
|
|
45
|
+
ensure
|
|
46
|
+
# Read at flush time, on every path (success AND unhandled
|
|
47
|
+
# exception): ActionDispatch::RequestId runs deeper in the stack
|
|
48
|
+
# and mutates env in place, so the id doesn't exist at seed time
|
|
49
|
+
# but does by the time any outcome unwinds back to here.
|
|
50
|
+
WideEvent.set("http.request.id" => env["action_dispatch.request_id"] || env["HTTP_X_REQUEST_ID"])
|
|
51
|
+
WideEvent.flush(attrs)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def seed(env)
|
|
59
|
+
WideEvent.set({
|
|
60
|
+
"main" => true,
|
|
61
|
+
"http.request.body_size" => env["CONTENT_LENGTH"]&.to_i
|
|
62
|
+
}.merge(user_agent_attributes(env["HTTP_USER_AGENT"])).merge(WideEvent.uptime_attributes))
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def user_agent_attributes(ua)
|
|
66
|
+
return {} if ua.nil? || ua.empty?
|
|
67
|
+
return { "user_agent.original" => ua[0, 300] } unless defined?(UserAgent)
|
|
68
|
+
|
|
69
|
+
parsed = UserAgent.parse(ua)
|
|
70
|
+
{
|
|
71
|
+
"user_agent.original" => ua[0, 300],
|
|
72
|
+
"user_agent.browser" => parsed.browser,
|
|
73
|
+
"user_agent.browser_version" => parsed.version.to_s,
|
|
74
|
+
"user_agent.os" => parsed.os,
|
|
75
|
+
"user_agent.platform" => parsed.platform
|
|
76
|
+
}
|
|
77
|
+
rescue StandardError
|
|
78
|
+
{ "user_agent.original" => ua[0, 300] }
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
require "rails/railtie"
|
|
2
|
+
|
|
3
|
+
module WideEvent
|
|
4
|
+
class Railtie < Rails::Railtie
|
|
5
|
+
# Below ActionDispatch::Executor on purpose: the executor clears
|
|
6
|
+
# IsolatedExecutionState on completion; above it the store would be gone
|
|
7
|
+
# before the flush. With OTel Rack instrumentation at the top of the
|
|
8
|
+
# stack, the flush still lands inside the root span.
|
|
9
|
+
initializer "wide_event.middleware" do |app|
|
|
10
|
+
app.middleware.insert_after ActionDispatch::Executor, WideEvent::Middleware
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
initializer "wide_event.active_job" do
|
|
14
|
+
ActiveSupport.on_load(:active_job) { include WideEvent::JobInstrumentation }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
initializer "wide_event.registry" do |app|
|
|
18
|
+
path = app.root.join("config", "wide_event", "registry.yml")
|
|
19
|
+
WideEvent.config.registry_path ||= path.to_s if path.exist?
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# After the app's own initializers, so the host's OpenTelemetry::SDK
|
|
23
|
+
# configuration (and any WideEvent.configure block) has already run.
|
|
24
|
+
config.after_initialize do
|
|
25
|
+
WideEvent.install! if WideEvent.enabled?
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
rake_tasks do
|
|
29
|
+
load File.expand_path("tasks/registry.rake", __dir__)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# Attributes the gem sets on every wide event. App attributes belong in the
|
|
2
|
+
# app's own registry file (config/wide_event/registry.yml), which merges over
|
|
3
|
+
# these entries.
|
|
4
|
+
main:
|
|
5
|
+
type: boolean
|
|
6
|
+
set_by: wide_events
|
|
7
|
+
pii: none
|
|
8
|
+
notes: Marks the root span as a wide event. Query surface is `main = true`.
|
|
9
|
+
error:
|
|
10
|
+
type: boolean
|
|
11
|
+
set_by: wide_events
|
|
12
|
+
pii: none
|
|
13
|
+
notes: Status >= 500, an unhandled exception, or an error! call.
|
|
14
|
+
uptime_sec:
|
|
15
|
+
type: integer
|
|
16
|
+
set_by: wide_events
|
|
17
|
+
pii: none
|
|
18
|
+
notes: Process age at the time of the event.
|
|
19
|
+
uptime_sec_log10:
|
|
20
|
+
type: float
|
|
21
|
+
set_by: wide_events
|
|
22
|
+
pii: none
|
|
23
|
+
notes: log10 of uptime_sec, for cold-start histograms.
|
|
24
|
+
http.request.id:
|
|
25
|
+
type: string
|
|
26
|
+
set_by: wide_events
|
|
27
|
+
pii: none
|
|
28
|
+
notes: X-Request-Id / ActionDispatch request id.
|
|
29
|
+
http.request.body_size:
|
|
30
|
+
type: integer
|
|
31
|
+
set_by: wide_events
|
|
32
|
+
pii: none
|
|
33
|
+
http.response.status_code:
|
|
34
|
+
type: integer
|
|
35
|
+
set_by: wide_events
|
|
36
|
+
pii: none
|
|
37
|
+
http.route.controller:
|
|
38
|
+
type: string
|
|
39
|
+
set_by: wide_events
|
|
40
|
+
pii: none
|
|
41
|
+
notes: Controller#action, from process_action.action_controller.
|
|
42
|
+
user_agent.original:
|
|
43
|
+
type: string
|
|
44
|
+
set_by: wide_events
|
|
45
|
+
pii: none
|
|
46
|
+
notes: Capped at 300 chars.
|
|
47
|
+
user_agent.browser:
|
|
48
|
+
type: string
|
|
49
|
+
set_by: wide_events
|
|
50
|
+
pii: none
|
|
51
|
+
user_agent.browser_version:
|
|
52
|
+
type: string
|
|
53
|
+
set_by: wide_events
|
|
54
|
+
pii: none
|
|
55
|
+
user_agent.os:
|
|
56
|
+
type: string
|
|
57
|
+
set_by: wide_events
|
|
58
|
+
pii: none
|
|
59
|
+
user_agent.platform:
|
|
60
|
+
type: string
|
|
61
|
+
set_by: wide_events
|
|
62
|
+
pii: none
|
|
63
|
+
db.duration_ms:
|
|
64
|
+
type: float
|
|
65
|
+
set_by: wide_events
|
|
66
|
+
pii: none
|
|
67
|
+
notes: Rails' own db_runtime measurement.
|
|
68
|
+
view.duration_ms:
|
|
69
|
+
type: float
|
|
70
|
+
set_by: wide_events
|
|
71
|
+
pii: none
|
|
72
|
+
notes: Rails' own view_runtime measurement.
|
|
73
|
+
"cache.*":
|
|
74
|
+
type: boolean
|
|
75
|
+
set_by: wide_events
|
|
76
|
+
pii: none
|
|
77
|
+
notes: Hit/miss per normalized cache key prefix, capped per event.
|
|
78
|
+
"stats.*":
|
|
79
|
+
type: [integer, float]
|
|
80
|
+
set_by: wide_events
|
|
81
|
+
pii: none
|
|
82
|
+
notes: Per-dependency child span counts and total durations.
|
|
83
|
+
job.class:
|
|
84
|
+
type: string
|
|
85
|
+
set_by: wide_events
|
|
86
|
+
pii: none
|
|
87
|
+
job.queue:
|
|
88
|
+
type: string
|
|
89
|
+
set_by: wide_events
|
|
90
|
+
pii: none
|
|
91
|
+
job.id:
|
|
92
|
+
type: string
|
|
93
|
+
set_by: wide_events
|
|
94
|
+
pii: none
|
|
95
|
+
job.executions:
|
|
96
|
+
type: integer
|
|
97
|
+
set_by: wide_events
|
|
98
|
+
pii: none
|
|
99
|
+
notes: Retry count, 1 on the first attempt.
|
|
100
|
+
job.enqueued_at:
|
|
101
|
+
type: string
|
|
102
|
+
set_by: wide_events
|
|
103
|
+
pii: none
|
|
104
|
+
notes: RFC 3339.
|
|
105
|
+
job.queue_latency_ms:
|
|
106
|
+
type: integer
|
|
107
|
+
set_by: wide_events
|
|
108
|
+
pii: none
|
|
109
|
+
job.scheduled:
|
|
110
|
+
type: boolean
|
|
111
|
+
set_by: wide_events
|
|
112
|
+
pii: none
|
|
113
|
+
notes: True for recurring/scheduled executions (Solid Queue detector by default).
|
|
114
|
+
exception.slug:
|
|
115
|
+
type: string
|
|
116
|
+
set_by: app rescue sites via error!
|
|
117
|
+
pii: none
|
|
118
|
+
notes: Static kebab-case identifier. Absent on unhandled exceptions, by design.
|
|
119
|
+
exception.type:
|
|
120
|
+
type: string
|
|
121
|
+
set_by: wide_events
|
|
122
|
+
pii: none
|
|
123
|
+
exception.message:
|
|
124
|
+
type: string
|
|
125
|
+
set_by: wide_events
|
|
126
|
+
pii: review
|
|
127
|
+
notes: Capped at 500 chars. May quote user input (review before forwarding).
|
|
128
|
+
exception.expected:
|
|
129
|
+
type: boolean
|
|
130
|
+
set_by: app rescue sites via error!
|
|
131
|
+
pii: none
|