rails-otel-context 0.5.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/LICENSE +21 -0
- data/README.md +1020 -0
- data/lib/rails_otel_context/activerecord_context.rb +124 -0
- data/lib/rails_otel_context/adapters/clickhouse.rb +133 -0
- data/lib/rails_otel_context/adapters/mysql2.rb +84 -0
- data/lib/rails_otel_context/adapters/pg.rb +76 -0
- data/lib/rails_otel_context/adapters/redis.rb +78 -0
- data/lib/rails_otel_context/adapters/trilogy.rb +65 -0
- data/lib/rails_otel_context/adapters.rb +51 -0
- data/lib/rails_otel_context/call_context_processor.rb +153 -0
- data/lib/rails_otel_context/configuration.rb +54 -0
- data/lib/rails_otel_context/railtie.rb +51 -0
- data/lib/rails_otel_context/request_context.rb +44 -0
- data/lib/rails_otel_context/source_location.rb +21 -0
- data/lib/rails_otel_context/version.rb +5 -0
- data/lib/rails_otel_context.rb +87 -0
- metadata +100 -0
data/README.md
ADDED
|
@@ -0,0 +1,1020 @@
|
|
|
1
|
+
# rails-otel-context
|
|
2
|
+
|
|
3
|
+
[](https://github.com/last9/rails-otel-context/actions/workflows/ci.yml)
|
|
4
|
+
[](https://badge.fury.io/rb/rails-otel-context)
|
|
5
|
+
[](https://www.ruby-lang.org/)
|
|
6
|
+
[](https://rubyonrails.org/)
|
|
7
|
+
[](https://opensource.org/licenses/MIT)
|
|
8
|
+
|
|
9
|
+
Production-ready OpenTelemetry enhancements for Ruby on Rails applications, maintained by Last9.
|
|
10
|
+
|
|
11
|
+
## Overview
|
|
12
|
+
|
|
13
|
+
`rails-otel-context` extends the default Ruby OpenTelemetry SDK with production-grade observability features specifically designed for Rails applications. While the standard OpenTelemetry instrumentations provide basic database and cache operation tracing, they lack critical debugging context that Rails developers need when investigating slow queries and performance issues in production.
|
|
14
|
+
|
|
15
|
+
This gem adds intelligent span enrichment that captures **exactly where in your Rails application code** slow operations are called—down to the specific controller, model, or service method and line number—making it trivial to jump from a trace in your observability platform directly to the problematic code.
|
|
16
|
+
|
|
17
|
+
**⚠️ Rails-Only:** This gem is designed specifically for Rails applications (>= 7.0) and requires Rails to function.
|
|
18
|
+
|
|
19
|
+
## Key Improvements Over Default Ruby OpenTelemetry SDK
|
|
20
|
+
|
|
21
|
+
### 1. **Source Code Location Tracking**
|
|
22
|
+
The default OpenTelemetry instrumentations tell you *what* query was slow, but not *where* in your application it was called from. This gem adds:
|
|
23
|
+
- `code.filepath` - The relative path to your application file
|
|
24
|
+
- `code.lineno` - The exact line number where the call originated
|
|
25
|
+
- `code.activerecord.model` - The ActiveRecord model name (e.g., "User", "Product")
|
|
26
|
+
- `code.activerecord.method` - The method that triggered the query (e.g., "find", "create")
|
|
27
|
+
|
|
28
|
+
**Why this matters:** When investigating a slow query in production, you can immediately identify not just the file and line, but also the specific ActiveRecord model and method responsible—no grepping through your codebase required.
|
|
29
|
+
|
|
30
|
+
### 2. **Selective Slow Query Enrichment**
|
|
31
|
+
Instead of adding attributes to every single database span (which can increase trace size and costs), this gem intelligently adds context *only* for operations exceeding your configured thresholds:
|
|
32
|
+
- `db.query.duration_ms` - Precise query duration
|
|
33
|
+
- `db.query.slow_threshold_ms` - The threshold that triggered enrichment
|
|
34
|
+
|
|
35
|
+
**Why this matters:** Keeps your traces lean while ensuring slow queries have full debugging context. Fast queries (which are working fine) don't carry unnecessary metadata.
|
|
36
|
+
|
|
37
|
+
### 3. **ClickHouse Instrumentation**
|
|
38
|
+
The official OpenTelemetry Ruby ecosystem lacks native ClickHouse instrumentation. This gem provides:
|
|
39
|
+
- Automatic client span creation for query operations
|
|
40
|
+
- Full support for popular ClickHouse client gems (`click_house`, `clickhouse` variants)
|
|
41
|
+
- Semantic conventions following OpenTelemetry database patterns
|
|
42
|
+
- Optional slow query detection with source tracking
|
|
43
|
+
|
|
44
|
+
**Why this matters:** ClickHouse is increasingly popular for analytical workloads, but without instrumentation, these operations are invisible in your traces—creating blind spots in your observability.
|
|
45
|
+
|
|
46
|
+
### 4. **Smart Application Code Filtering**
|
|
47
|
+
All adapters intelligently filter stack traces to show only your application code:
|
|
48
|
+
- Automatically excludes gem/library internal calls
|
|
49
|
+
- Strips app root prefix for cleaner paths
|
|
50
|
+
- Uses `Thread.each_caller_location` for accurate, low-overhead location tracking
|
|
51
|
+
|
|
52
|
+
**Why this matters:** You see `app/models/checkout.rb:88` instead of deeply nested gem internals that don't help with debugging.
|
|
53
|
+
|
|
54
|
+
### 5. **Caller Context on All Spans**
|
|
55
|
+
A SpanProcessor automatically enriches **every** span in your application—not just database spans—with the calling Ruby class, method, and line number:
|
|
56
|
+
- `code.namespace` - The calling class name (e.g., "OrderService", "InvoiceJob", "ProductsController")
|
|
57
|
+
- `code.function` - The calling method name (e.g., "create", "perform", "index")
|
|
58
|
+
- `code.lineno` - The exact line number where the span was created
|
|
59
|
+
|
|
60
|
+
**Why this matters:** HTTP spans, background job spans, and custom spans previously had no caller context. Now you can see that a slow external HTTP call originated from `InvoiceJob#perform` at line 42—without any manual instrumentation.
|
|
61
|
+
|
|
62
|
+
**Note:** Controller spans already have `code.namespace`/`code.function` set by the standard `opentelemetry-instrumentation-action_pack` gem. The processor adds this context to everything that doesn't already have it.
|
|
63
|
+
|
|
64
|
+
### 6. **Zero-Config Rails Integration**
|
|
65
|
+
Automatic setup via Railtie—just add the gem:
|
|
66
|
+
- Adapters install automatically when ActiveRecord loads
|
|
67
|
+
- Caller context processor registers after the OTel SDK is configured
|
|
68
|
+
- Environment variable configuration support
|
|
69
|
+
- No manual initialization code required
|
|
70
|
+
- Integrates seamlessly with Rails boot process
|
|
71
|
+
|
|
72
|
+
## Included Adapters
|
|
73
|
+
|
|
74
|
+
### PostgreSQL (`pg`)
|
|
75
|
+
**Status:** ✅ Implemented
|
|
76
|
+
|
|
77
|
+
Enhances the official `opentelemetry-instrumentation-pg` gem:
|
|
78
|
+
- Patches all `exec`-family methods (`exec`, `exec_params`, `exec_prepared`, etc.)
|
|
79
|
+
- Adds source location attributes only for queries exceeding the threshold
|
|
80
|
+
- Works with streaming queries and async execution
|
|
81
|
+
- Captures both query duration and threshold for context
|
|
82
|
+
|
|
83
|
+
**Added Attributes:**
|
|
84
|
+
- `code.filepath` - Application file path (relative to Rails.root)
|
|
85
|
+
- `code.lineno` - Line number where query originated
|
|
86
|
+
- `code.activerecord.model` - ActiveRecord model name (e.g., "User")
|
|
87
|
+
- `code.activerecord.method` - Method that triggered the query (e.g., "find")
|
|
88
|
+
- `db.query.duration_ms` - Query execution time in milliseconds
|
|
89
|
+
- `db.query.slow_threshold_ms` - Configured threshold value
|
|
90
|
+
|
|
91
|
+
### MySQL (`mysql2`)
|
|
92
|
+
**Status:** ✅ Implemented
|
|
93
|
+
|
|
94
|
+
Enhances MySQL2 client instrumentation:
|
|
95
|
+
- Patches `query` and `prepare` methods
|
|
96
|
+
- Validates span context before setting attributes (defensive coding)
|
|
97
|
+
- Same intelligent slow query detection as PG adapter
|
|
98
|
+
- Full async query support
|
|
99
|
+
|
|
100
|
+
**Added Attributes:**
|
|
101
|
+
- `code.filepath` - Application file path
|
|
102
|
+
- `code.lineno` - Line number where query originated
|
|
103
|
+
- `code.activerecord.model` - ActiveRecord model name (e.g., "Product")
|
|
104
|
+
- `code.activerecord.method` - Method that triggered the query (e.g., "where")
|
|
105
|
+
- `db.query.duration_ms` - Query execution time
|
|
106
|
+
- `db.query.slow_threshold_ms` - Configured threshold value
|
|
107
|
+
|
|
108
|
+
### Redis (`redis`)
|
|
109
|
+
**Status:** ✅ Implemented (opt-in)
|
|
110
|
+
|
|
111
|
+
Enhances the official `opentelemetry-instrumentation-redis` gem:
|
|
112
|
+
- Patches `RedisClient::Middlewares` for both single and pipelined commands
|
|
113
|
+
- Uses official instrumentation's `with_attributes` API for proper attribute injection
|
|
114
|
+
- Disabled by default (can be noisy for high-throughput Redis usage)
|
|
115
|
+
- Works with both standalone commands and pipelined operations
|
|
116
|
+
|
|
117
|
+
**Added Attributes:**
|
|
118
|
+
- `code.filepath` - Application file path
|
|
119
|
+
- `code.lineno` - Line number where Redis call originated
|
|
120
|
+
|
|
121
|
+
**Note:** Unlike database adapters, Redis tracking doesn't use slow query thresholds—it's all-or-nothing to avoid complexity with pipelined operations.
|
|
122
|
+
|
|
123
|
+
### Caller Context Processor
|
|
124
|
+
**Status:** ✅ Implemented
|
|
125
|
+
|
|
126
|
+
Enriches every span in the application with the calling Ruby class and method via an OpenTelemetry SpanProcessor:
|
|
127
|
+
- Hooks into `on_start` for all spans application-wide
|
|
128
|
+
- Walks the call stack to find the first frame inside your application code (skips gems and stdlib)
|
|
129
|
+
- Infers class names from frame labels (`"User.find"` → `User`) or from file-path basenames (`order_service.rb` → `OrderService`)
|
|
130
|
+
- Strips `block in` / `rescue in` / `ensure in` label prefixes automatically
|
|
131
|
+
|
|
132
|
+
**Added Attributes:**
|
|
133
|
+
- `code.namespace` - Calling class name (e.g., "InvoiceJob", "OrderService")
|
|
134
|
+
- `code.function` - Calling method name (e.g., "perform", "create")
|
|
135
|
+
- `code.lineno` - Line number where the span was initiated
|
|
136
|
+
|
|
137
|
+
**Configuration:**
|
|
138
|
+
- Enabled by default (opt-out)
|
|
139
|
+
- Set `call_context_enabled = false` or `RAILS_OTEL_CONTEXT_CALL_CONTEXT_ENABLED=false` to disable
|
|
140
|
+
|
|
141
|
+
### ClickHouse (`clickhouse`)
|
|
142
|
+
**Status:** ✅ Implemented
|
|
143
|
+
|
|
144
|
+
Creates full OpenTelemetry instrumentation for ClickHouse clients (no official instrumentation exists):
|
|
145
|
+
- Supports multiple client gem variants (`ClickHouse::Client`, `Clickhouse::Client`, `ClickHouse::Connection`)
|
|
146
|
+
- Creates client spans with semantic conventions (`db.system`, `db.operation`, `db.statement`)
|
|
147
|
+
- Patches common methods: `query`, `select`, `insert`, `execute`, `command`
|
|
148
|
+
- Includes slow query detection with source tracking
|
|
149
|
+
- Thread-local reentrancy guard to prevent double-instrumentation
|
|
150
|
+
|
|
151
|
+
**Span Attributes:**
|
|
152
|
+
- `db.system` - Always set to `"clickhouse"`
|
|
153
|
+
- `db.operation` - Operation type (QUERY, SELECT, INSERT, etc.)
|
|
154
|
+
- `db.statement` - The SQL statement (when available)
|
|
155
|
+
- `code.filepath` - Source location (for slow queries)
|
|
156
|
+
- `code.lineno` - Line number (for slow queries)
|
|
157
|
+
- `db.query.duration_ms` - Duration (for slow queries)
|
|
158
|
+
- `db.query.slow_threshold_ms` - Threshold (for slow queries)
|
|
159
|
+
|
|
160
|
+
## Installation
|
|
161
|
+
|
|
162
|
+
Add to your Rails Gemfile:
|
|
163
|
+
|
|
164
|
+
```ruby
|
|
165
|
+
gem 'rails-otel-context', '~> 0.5'
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Or from GitHub:
|
|
169
|
+
|
|
170
|
+
```ruby
|
|
171
|
+
gem 'rails-otel-context', github: 'last9/rails-otel-context', branch: 'main'
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Then run:
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
bundle install
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
### Automatic Rails Integration
|
|
181
|
+
|
|
182
|
+
The gem automatically integrates via a Railtie—**no manual initialization needed!**
|
|
183
|
+
|
|
184
|
+
- Adapters install automatically when ActiveRecord loads
|
|
185
|
+
- Environment variables are read and applied during Rails initialization
|
|
186
|
+
- Works with standard Rails boot process (no special setup required)
|
|
187
|
+
|
|
188
|
+
Just add the gem and configure OpenTelemetry as usual. `rails-otel-context` will enhance your traces automatically.
|
|
189
|
+
|
|
190
|
+
## Usage
|
|
191
|
+
|
|
192
|
+
### Basic Configuration
|
|
193
|
+
|
|
194
|
+
The gem works out-of-the-box with sensible defaults (200ms slow query threshold). To customize:
|
|
195
|
+
|
|
196
|
+
```ruby
|
|
197
|
+
# config/initializers/rails_otel_context.rb (Rails)
|
|
198
|
+
RailsOtelContext.configure do |c|
|
|
199
|
+
# PostgreSQL slow query tracking
|
|
200
|
+
c.pg_slow_query_enabled = true
|
|
201
|
+
c.pg_slow_query_threshold_ms = 200.0
|
|
202
|
+
|
|
203
|
+
# MySQL slow query tracking
|
|
204
|
+
c.mysql2_slow_query_enabled = true
|
|
205
|
+
c.mysql2_slow_query_threshold_ms = 200.0
|
|
206
|
+
|
|
207
|
+
# Trilogy (MySQL) slow query tracking
|
|
208
|
+
c.trilogy_slow_query_enabled = true
|
|
209
|
+
c.trilogy_slow_query_threshold_ms = 200.0
|
|
210
|
+
|
|
211
|
+
# Redis source tracking (opt-in, can be noisy)
|
|
212
|
+
c.redis_source_enabled = false
|
|
213
|
+
|
|
214
|
+
# ClickHouse instrumentation and slow query tracking
|
|
215
|
+
c.clickhouse_enabled = true
|
|
216
|
+
c.clickhouse_slow_query_threshold_ms = 200.0
|
|
217
|
+
|
|
218
|
+
# Caller context on all spans (code.namespace, code.function, code.lineno)
|
|
219
|
+
c.call_context_enabled = true
|
|
220
|
+
end
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
### Custom Span Names (Recommended)
|
|
224
|
+
|
|
225
|
+
By default, database spans have generic names like `select` or `insert`. The span name formatter renames them using ActiveRecord context — including scope names and custom model methods:
|
|
226
|
+
|
|
227
|
+
```ruby
|
|
228
|
+
# config/initializers/rails_otel_context.rb
|
|
229
|
+
RailsOtelContext.configure do |c|
|
|
230
|
+
c.span_name_formatter = lambda do |original_name, ar_context|
|
|
231
|
+
model = ar_context[:model_name]
|
|
232
|
+
return original_name unless model
|
|
233
|
+
|
|
234
|
+
scope = ar_context[:scope_name] # "recent_completed" (lazy scopes)
|
|
235
|
+
code_fn = ar_context[:code_function] # "total_revenue" (custom model methods)
|
|
236
|
+
code_ns = ar_context[:code_namespace] # "Transaction" (calling class)
|
|
237
|
+
ar_op = ar_context[:method_name] # "Load", "Count", "Create"
|
|
238
|
+
|
|
239
|
+
# Priority: scope name > custom model method > AR operation type
|
|
240
|
+
method = if scope
|
|
241
|
+
scope
|
|
242
|
+
elsif code_fn && code_ns == model && !code_fn.start_with?('<')
|
|
243
|
+
code_fn
|
|
244
|
+
else
|
|
245
|
+
ar_op
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
"#{model}.#{method}"
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
**Result:**
|
|
254
|
+
|
|
255
|
+
| ActiveRecord Call | Span Name |
|
|
256
|
+
|---|---|
|
|
257
|
+
| `Transaction.recent_completed.to_a` | `Transaction.recent_completed` |
|
|
258
|
+
| `Transaction.total_revenue` | `Transaction.total_revenue` |
|
|
259
|
+
| `Transaction.where(...).first` | `Transaction.Load` |
|
|
260
|
+
| `Transaction.create(...)` | `Transaction.Create` |
|
|
261
|
+
| `record.update(...)` | `Transaction.Update` |
|
|
262
|
+
|
|
263
|
+
The original span name is preserved in the `l9.orig.name` attribute.
|
|
264
|
+
|
|
265
|
+
### ActiveRecord Model Attributes
|
|
266
|
+
|
|
267
|
+
The gem automatically sets these attributes on every database span:
|
|
268
|
+
|
|
269
|
+
- `code.activerecord.model` — the model name (e.g., `Transaction`, `User`)
|
|
270
|
+
- `code.activerecord.method` — the AR operation type (e.g., `Load`, `Count`, `Create`)
|
|
271
|
+
- `code.activerecord.scope` — the scope name, if a scope triggered the query (e.g., `recent_completed`)
|
|
272
|
+
|
|
273
|
+
All standard operations are supported: `where`, `find_by`, `count`, `create`, `update`, `destroy`, `pluck`, `exists?`, `sum`, etc.
|
|
274
|
+
|
|
275
|
+
### Custom Span Attributes
|
|
276
|
+
|
|
277
|
+
Propagate request-scoped attributes (like team, domain, tenant) to **every span** in a trace — including database queries, HTTP calls, Redis operations, and background jobs.
|
|
278
|
+
|
|
279
|
+
This is useful for splitting a monolith into virtual services, adding tenant context, or any scenario where you need an attribute on all spans, not just the root controller span.
|
|
280
|
+
|
|
281
|
+
```ruby
|
|
282
|
+
# config/initializers/rails_otel_context.rb
|
|
283
|
+
RailsOtelContext.configure do |c|
|
|
284
|
+
c.custom_span_attributes = lambda {
|
|
285
|
+
team = Current.team
|
|
286
|
+
team ? { 'team' => team } : nil
|
|
287
|
+
}
|
|
288
|
+
end
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
The lambda is called on every span start. It should be fast — reading a thread-local (`Current` attribute) is ~10ns. Return `nil` or an empty hash to skip setting attributes. Nil values in the returned hash are automatically skipped.
|
|
292
|
+
|
|
293
|
+
**Setting the value in your controller:**
|
|
294
|
+
|
|
295
|
+
```ruby
|
|
296
|
+
# app/models/current.rb
|
|
297
|
+
class Current < ActiveSupport::CurrentAttributes
|
|
298
|
+
attribute :team
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# app/controllers/application_controller.rb
|
|
302
|
+
TEAM_MAP = {
|
|
303
|
+
'payments' => 'billing',
|
|
304
|
+
'chat_rooms' => 'messaging',
|
|
305
|
+
'spaces' => 'content'
|
|
306
|
+
}.freeze
|
|
307
|
+
|
|
308
|
+
before_action :set_team_context
|
|
309
|
+
|
|
310
|
+
def set_team_context
|
|
311
|
+
Current.team = TEAM_MAP[controller_name]
|
|
312
|
+
end
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
Every span created during the request (DB queries, HTTP calls, Redis, Sidekiq jobs) will have `team` set automatically.
|
|
316
|
+
|
|
317
|
+
**Safety guarantees:**
|
|
318
|
+
- Exceptions in the callback are silently rescued — your application is never affected
|
|
319
|
+
- Setting the callback to `nil` disables it: `c.custom_span_attributes = nil`
|
|
320
|
+
- Non-callable values raise `ArgumentError` at configuration time (fail fast)
|
|
321
|
+
|
|
322
|
+
**Instant rollback** without code changes or redeployment:
|
|
323
|
+
|
|
324
|
+
```bash
|
|
325
|
+
export RAILS_OTEL_CONTEXT_CUSTOM_SPAN_ATTRIBUTES_ENABLED=false
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
### Request Context Propagation
|
|
329
|
+
|
|
330
|
+
Automatically propagate `request.controller` and `request.action` to all child spans within a request. This lets you answer "which controller triggered this slow DB query?" without needing to look at the parent span.
|
|
331
|
+
|
|
332
|
+
```ruby
|
|
333
|
+
# config/initializers/rails_otel_context.rb
|
|
334
|
+
RailsOtelContext.configure do |c|
|
|
335
|
+
c.request_context_enabled = true
|
|
336
|
+
end
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
When enabled, the gem installs an `around_action` on `ActionController::Base` that:
|
|
340
|
+
1. Sets `request.controller` (e.g., `Api::PaymentsController`) and `request.action` (e.g., `create`) at request start
|
|
341
|
+
2. Propagates them to every child span via the SpanProcessor
|
|
342
|
+
3. Clears them in an `ensure` block — no leakage between requests, even on exceptions
|
|
343
|
+
|
|
344
|
+
**Instant rollback:**
|
|
345
|
+
|
|
346
|
+
```bash
|
|
347
|
+
export RAILS_OTEL_CONTEXT_REQUEST_CONTEXT_ENABLED=false
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
### Environment Variable Configuration
|
|
351
|
+
|
|
352
|
+
All settings can be controlled via environment variables—ideal for container deployments:
|
|
353
|
+
|
|
354
|
+
```bash
|
|
355
|
+
# PostgreSQL
|
|
356
|
+
export RAILS_OTEL_CONTEXT_PG_SLOW_QUERY_ENABLED=true
|
|
357
|
+
export RAILS_OTEL_CONTEXT_PG_SLOW_QUERY_MS=200.0
|
|
358
|
+
|
|
359
|
+
# Legacy fallback (still supported)
|
|
360
|
+
export OTEL_SLOW_QUERY_MS=200.0
|
|
361
|
+
|
|
362
|
+
# MySQL
|
|
363
|
+
export RAILS_OTEL_CONTEXT_MYSQL2_SLOW_QUERY_ENABLED=true
|
|
364
|
+
export RAILS_OTEL_CONTEXT_MYSQL2_SLOW_QUERY_MS=200.0
|
|
365
|
+
|
|
366
|
+
# Redis
|
|
367
|
+
export RAILS_OTEL_CONTEXT_REDIS_SOURCE_ENABLED=false
|
|
368
|
+
|
|
369
|
+
# ClickHouse
|
|
370
|
+
export RAILS_OTEL_CONTEXT_CLICKHOUSE_ENABLED=true
|
|
371
|
+
export RAILS_OTEL_CONTEXT_CLICKHOUSE_SLOW_QUERY_MS=200.0
|
|
372
|
+
|
|
373
|
+
# Caller context processor (all spans)
|
|
374
|
+
export RAILS_OTEL_CONTEXT_CALL_CONTEXT_ENABLED=true
|
|
375
|
+
|
|
376
|
+
# Custom span attributes
|
|
377
|
+
export RAILS_OTEL_CONTEXT_CUSTOM_SPAN_ATTRIBUTES_ENABLED=true
|
|
378
|
+
|
|
379
|
+
# Request context propagation
|
|
380
|
+
export RAILS_OTEL_CONTEXT_REQUEST_CONTEXT_ENABLED=false
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
**Supported boolean values:** `1`, `true`, `yes`, `on` (case-insensitive) → `true` | `0`, `false`, `no`, `off` → `false`
|
|
384
|
+
|
|
385
|
+
### Environment Variables Reference
|
|
386
|
+
|
|
387
|
+
| Variable | Default | Description |
|
|
388
|
+
|----------|---------|-------------|
|
|
389
|
+
| `RAILS_OTEL_CONTEXT_PG_SLOW_QUERY_ENABLED` | `true` | Enable/disable PG slow query enrichment |
|
|
390
|
+
| `RAILS_OTEL_CONTEXT_PG_SLOW_QUERY_MS` | `200.0` | PG slow query threshold in milliseconds |
|
|
391
|
+
| `OTEL_SLOW_QUERY_MS` | `200.0` | Legacy fallback for PG threshold |
|
|
392
|
+
| `RAILS_OTEL_CONTEXT_MYSQL2_SLOW_QUERY_ENABLED` | `true` | Enable/disable MySQL2 slow query enrichment |
|
|
393
|
+
| `RAILS_OTEL_CONTEXT_MYSQL2_SLOW_QUERY_MS` | `200.0` | MySQL2 slow query threshold in milliseconds |
|
|
394
|
+
| `RAILS_OTEL_CONTEXT_REDIS_SOURCE_ENABLED` | `false` | Enable/disable Redis source location tracking |
|
|
395
|
+
| `RAILS_OTEL_CONTEXT_CLICKHOUSE_ENABLED` | `true` | Enable/disable ClickHouse instrumentation |
|
|
396
|
+
| `RAILS_OTEL_CONTEXT_CLICKHOUSE_SLOW_QUERY_MS` | `200.0` | ClickHouse slow query threshold in milliseconds |
|
|
397
|
+
| `RAILS_OTEL_CONTEXT_CALL_CONTEXT_ENABLED` | `true` | Enable/disable caller context on all spans |
|
|
398
|
+
| `RAILS_OTEL_CONTEXT_CUSTOM_SPAN_ATTRIBUTES_ENABLED` | `true` | Enable/disable custom span attributes callback |
|
|
399
|
+
| `RAILS_OTEL_CONTEXT_REQUEST_CONTEXT_ENABLED` | `false` | Enable/disable request controller/action propagation |
|
|
400
|
+
|
|
401
|
+
## Configuration Best Practices
|
|
402
|
+
|
|
403
|
+
### Choosing Slow Query Thresholds
|
|
404
|
+
|
|
405
|
+
The default 200ms threshold works well for most applications, but consider your specific SLOs:
|
|
406
|
+
|
|
407
|
+
- **API services:** 100-200ms (user-facing requests need tight budgets)
|
|
408
|
+
- **Background jobs:** 500-1000ms (more tolerance for slower operations)
|
|
409
|
+
- **Analytical workloads:** 1000-5000ms (complex queries are expected)
|
|
410
|
+
|
|
411
|
+
**Pro tip:** Set thresholds at **50-75% of your SLO target**. If your p99 target is 400ms, use a 200-300ms threshold to catch queries that are consuming most of your budget.
|
|
412
|
+
|
|
413
|
+
### Redis Source Tracking
|
|
414
|
+
|
|
415
|
+
Redis source tracking is **disabled by default** because:
|
|
416
|
+
- High-throughput applications make thousands of Redis calls per second
|
|
417
|
+
- Most Redis operations are fast (<1ms), so tracking isn't as valuable
|
|
418
|
+
- The overhead and span size increase may not justify the benefit
|
|
419
|
+
|
|
420
|
+
**Enable it when:**
|
|
421
|
+
- Debugging cache key patterns or cache penetration issues
|
|
422
|
+
- Your application has moderate Redis usage (<1000 ops/sec)
|
|
423
|
+
- You're investigating Redis hot spots and need to identify calling code
|
|
424
|
+
|
|
425
|
+
### Per-Environment Configuration
|
|
426
|
+
|
|
427
|
+
Different environments have different performance characteristics:
|
|
428
|
+
|
|
429
|
+
```ruby
|
|
430
|
+
# config/initializers/rails_otel_context.rb
|
|
431
|
+
RailsOtelContext.configure do |c|
|
|
432
|
+
if Rails.env.production?
|
|
433
|
+
# Tighter thresholds in production
|
|
434
|
+
c.pg_slow_query_threshold_ms = 150.0
|
|
435
|
+
c.mysql2_slow_query_threshold_ms = 150.0
|
|
436
|
+
c.redis_source_enabled = false # Too noisy in prod
|
|
437
|
+
elsif Rails.env.development?
|
|
438
|
+
# Lower thresholds to catch issues early
|
|
439
|
+
c.pg_slow_query_threshold_ms = 50.0
|
|
440
|
+
c.mysql2_slow_query_threshold_ms = 50.0
|
|
441
|
+
c.redis_source_enabled = true # Helpful for debugging
|
|
442
|
+
end
|
|
443
|
+
end
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
## Rails Usage Examples
|
|
447
|
+
|
|
448
|
+
### Example 1: Complete Rails Setup
|
|
449
|
+
|
|
450
|
+
```ruby
|
|
451
|
+
# Gemfile
|
|
452
|
+
gem 'opentelemetry-sdk'
|
|
453
|
+
gem 'opentelemetry-exporter-otlp'
|
|
454
|
+
gem 'opentelemetry-instrumentation-rails'
|
|
455
|
+
gem 'opentelemetry-instrumentation-pg'
|
|
456
|
+
gem 'opentelemetry-instrumentation-redis'
|
|
457
|
+
gem 'rails-otel-context' # 👈 Add this for enhanced tracing
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
```ruby
|
|
461
|
+
# config/initializers/opentelemetry.rb
|
|
462
|
+
require 'opentelemetry/sdk'
|
|
463
|
+
require 'opentelemetry/instrumentation/all'
|
|
464
|
+
|
|
465
|
+
OpenTelemetry::SDK.configure do |c|
|
|
466
|
+
c.service_name = 'my-rails-app'
|
|
467
|
+
c.service_version = ENV['APP_VERSION']
|
|
468
|
+
c.use_all # Install all available instrumentations
|
|
469
|
+
end
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
```ruby
|
|
473
|
+
# config/initializers/rails_otel_context.rb (optional - for custom config)
|
|
474
|
+
RailsOtelContext.configure do |c|
|
|
475
|
+
c.pg_slow_query_threshold_ms = 150.0
|
|
476
|
+
c.redis_source_enabled = Rails.env.development?
|
|
477
|
+
end
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
That's it! No manual adapter installation needed—Rails handles everything via the Railtie.
|
|
481
|
+
|
|
482
|
+
### Example 2: Environment-Based Configuration
|
|
483
|
+
|
|
484
|
+
```ruby
|
|
485
|
+
# config/initializers/rails_otel_context.rb
|
|
486
|
+
RailsOtelContext.configure do |c|
|
|
487
|
+
if Rails.env.production?
|
|
488
|
+
# Strict thresholds in production
|
|
489
|
+
c.pg_slow_query_threshold_ms = 150.0
|
|
490
|
+
c.mysql2_slow_query_threshold_ms = 150.0
|
|
491
|
+
c.redis_source_enabled = false # Too noisy
|
|
492
|
+
elsif Rails.env.development?
|
|
493
|
+
# Catch issues early in dev
|
|
494
|
+
c.pg_slow_query_threshold_ms = 50.0
|
|
495
|
+
c.mysql2_slow_query_threshold_ms = 50.0
|
|
496
|
+
c.redis_source_enabled = true
|
|
497
|
+
elsif Rails.env.test?
|
|
498
|
+
# Disable in test environment
|
|
499
|
+
c.pg_slow_query_enabled = false
|
|
500
|
+
c.mysql2_slow_query_enabled = false
|
|
501
|
+
end
|
|
502
|
+
end
|
|
503
|
+
```
|
|
504
|
+
|
|
505
|
+
### Example 3: Docker/Kubernetes Configuration
|
|
506
|
+
|
|
507
|
+
Instead of Ruby configuration, use environment variables in your container:
|
|
508
|
+
|
|
509
|
+
```yaml
|
|
510
|
+
# docker-compose.yml or Kubernetes manifest
|
|
511
|
+
environment:
|
|
512
|
+
# OpenTelemetry base config
|
|
513
|
+
OTEL_SERVICE_NAME: my-rails-app
|
|
514
|
+
OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4318
|
|
515
|
+
|
|
516
|
+
# rails-otel-context config
|
|
517
|
+
RAILS_OTEL_CONTEXT_PG_SLOW_QUERY_MS: "150"
|
|
518
|
+
RAILS_OTEL_CONTEXT_MYSQL2_SLOW_QUERY_MS: "150"
|
|
519
|
+
RAILS_OTEL_CONTEXT_REDIS_SOURCE_ENABLED: "false"
|
|
520
|
+
RAILS_OTEL_CONTEXT_CLICKHOUSE_ENABLED: "true"
|
|
521
|
+
RAILS_OTEL_CONTEXT_CLICKHOUSE_SLOW_QUERY_MS: "200"
|
|
522
|
+
RAILS_OTEL_CONTEXT_CALL_CONTEXT_ENABLED: "true"
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
No code changes needed—the gem reads these automatically!
|
|
526
|
+
|
|
527
|
+
### Example 4: What You'll See in Production
|
|
528
|
+
|
|
529
|
+
**Scenario:** A user reports slow page loads on `/products` page.
|
|
530
|
+
|
|
531
|
+
**Without rails-otel-context:**
|
|
532
|
+
```json
|
|
533
|
+
{
|
|
534
|
+
"span": "SELECT products",
|
|
535
|
+
"db.statement": "SELECT * FROM products WHERE active = true",
|
|
536
|
+
"duration_ms": 450
|
|
537
|
+
}
|
|
538
|
+
```
|
|
539
|
+
You know there's a slow query, but where is it called from? Time to grep the codebase... 😞
|
|
540
|
+
|
|
541
|
+
**With rails-otel-context:**
|
|
542
|
+
```json
|
|
543
|
+
{
|
|
544
|
+
"span": "SELECT products",
|
|
545
|
+
"db.statement": "SELECT * FROM products WHERE active = true",
|
|
546
|
+
"duration_ms": 450,
|
|
547
|
+
"code.filepath": "app/controllers/products_controller.rb",
|
|
548
|
+
"code.lineno": 23,
|
|
549
|
+
"code.activerecord.model": "Product",
|
|
550
|
+
"code.activerecord.method": "where",
|
|
551
|
+
"db.query.duration_ms": 447.3,
|
|
552
|
+
"db.query.slow_threshold_ms": 200.0
|
|
553
|
+
}
|
|
554
|
+
```
|
|
555
|
+
Navigate directly to `app/controllers/products_controller.rb:23` and see it's `Product.where`—found the issue! 🎉
|
|
556
|
+
|
|
557
|
+
## How It Works
|
|
558
|
+
|
|
559
|
+
### Source Location Tracking
|
|
560
|
+
|
|
561
|
+
The gem uses Ruby's `Thread.each_caller_location` API (introduced in Ruby 3.1) to walk the call stack efficiently:
|
|
562
|
+
|
|
563
|
+
```ruby
|
|
564
|
+
Thread.each_caller_location do |location|
|
|
565
|
+
path = location.absolute_path || location.path
|
|
566
|
+
# Skip if not in Rails app code
|
|
567
|
+
next unless path&.start_with?(Rails.root.to_s)
|
|
568
|
+
# Skip gem internals
|
|
569
|
+
next if path.include?('/gems/')
|
|
570
|
+
|
|
571
|
+
return [path.delete_prefix("#{Rails.root}/"), location.lineno]
|
|
572
|
+
end
|
|
573
|
+
```
|
|
574
|
+
|
|
575
|
+
This approach:
|
|
576
|
+
- ✅ Is more performant than `caller_locations` (no array allocation)
|
|
577
|
+
- ✅ Stops early when application code is found
|
|
578
|
+
- ✅ Filters out gem internals automatically
|
|
579
|
+
- ✅ Returns relative paths for cleaner output
|
|
580
|
+
|
|
581
|
+
### Monkey-Patching Strategy
|
|
582
|
+
|
|
583
|
+
The gem uses Ruby's `prepend` mechanism to intercept method calls without breaking the original implementation:
|
|
584
|
+
|
|
585
|
+
```ruby
|
|
586
|
+
module MyPatch
|
|
587
|
+
def query(*args)
|
|
588
|
+
# Capture source location and timing
|
|
589
|
+
result = super(*args) # Call original method
|
|
590
|
+
# Add span attributes if slow
|
|
591
|
+
result
|
|
592
|
+
end
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
Mysql2::Client.prepend(MyPatch)
|
|
596
|
+
```
|
|
597
|
+
|
|
598
|
+
**Why prepend?**
|
|
599
|
+
- Cleaner than `alias_method` chains
|
|
600
|
+
- Plays nicely with other gems that may also patch the same methods
|
|
601
|
+
- Easy to detect and skip if already applied
|
|
602
|
+
- Supports method signature changes across gem versions
|
|
603
|
+
|
|
604
|
+
### Span Attribute Timing
|
|
605
|
+
|
|
606
|
+
For PostgreSQL and MySQL, attributes are added **after** the query completes:
|
|
607
|
+
1. Query starts → capture stack trace and start time
|
|
608
|
+
2. Query executes → (original instrumentation creates span)
|
|
609
|
+
3. Query completes → calculate duration
|
|
610
|
+
4. If slow → add attributes to the current span
|
|
611
|
+
|
|
612
|
+
For ClickHouse, we create the span ourselves:
|
|
613
|
+
1. Start span with operation name
|
|
614
|
+
2. Execute query
|
|
615
|
+
3. Add attributes based on duration threshold
|
|
616
|
+
|
|
617
|
+
## Example Output
|
|
618
|
+
|
|
619
|
+
### Slow PostgreSQL Query in Your Observability Platform
|
|
620
|
+
|
|
621
|
+
Without `rails-otel-context`:
|
|
622
|
+
```json
|
|
623
|
+
{
|
|
624
|
+
"name": "SELECT products",
|
|
625
|
+
"db.system": "postgresql",
|
|
626
|
+
"db.statement": "SELECT * FROM products WHERE category = $1",
|
|
627
|
+
"duration_ms": 450
|
|
628
|
+
}
|
|
629
|
+
```
|
|
630
|
+
|
|
631
|
+
With `rails-otel-context`:
|
|
632
|
+
```json
|
|
633
|
+
{
|
|
634
|
+
"name": "SELECT products",
|
|
635
|
+
"db.system": "postgresql",
|
|
636
|
+
"db.statement": "SELECT * FROM products WHERE category = $1",
|
|
637
|
+
"duration_ms": 450,
|
|
638
|
+
"code.filepath": "app/controllers/products_controller.rb",
|
|
639
|
+
"code.lineno": 23,
|
|
640
|
+
"db.query.duration_ms": 447.3,
|
|
641
|
+
"db.query.slow_threshold_ms": 200.0
|
|
642
|
+
}
|
|
643
|
+
```
|
|
644
|
+
|
|
645
|
+
**The difference:** You can immediately navigate to `app/controllers/products_controller.rb:23` and see exactly which code path triggered the slow query—no guessing, no grepping.
|
|
646
|
+
|
|
647
|
+
### ClickHouse Query Trace
|
|
648
|
+
|
|
649
|
+
```json
|
|
650
|
+
{
|
|
651
|
+
"name": "SELECT clickhouse",
|
|
652
|
+
"db.system": "clickhouse",
|
|
653
|
+
"db.operation": "SELECT",
|
|
654
|
+
"db.statement": "SELECT user_id, count(*) FROM events WHERE timestamp > ?",
|
|
655
|
+
"duration_ms": 1250,
|
|
656
|
+
"code.filepath": "app/services/analytics_service.rb",
|
|
657
|
+
"code.lineno": 67,
|
|
658
|
+
"db.query.duration_ms": 1247.8,
|
|
659
|
+
"db.query.slow_threshold_ms": 200.0
|
|
660
|
+
}
|
|
661
|
+
```
|
|
662
|
+
|
|
663
|
+
### Caller Context on All Spans
|
|
664
|
+
|
|
665
|
+
Every span—including HTTP requests, background jobs, and custom spans—now carries the calling Ruby class and method:
|
|
666
|
+
|
|
667
|
+
**Background job span (Sidekiq/ActiveJob):**
|
|
668
|
+
```json
|
|
669
|
+
{
|
|
670
|
+
"name": "InvoiceJob",
|
|
671
|
+
"code.namespace": "InvoiceJob",
|
|
672
|
+
"code.function": "perform",
|
|
673
|
+
"code.lineno": 12
|
|
674
|
+
}
|
|
675
|
+
```
|
|
676
|
+
|
|
677
|
+
**External HTTP call from a service class:**
|
|
678
|
+
```json
|
|
679
|
+
{
|
|
680
|
+
"name": "HTTP POST",
|
|
681
|
+
"code.namespace": "PaymentGatewayService",
|
|
682
|
+
"code.function": "charge",
|
|
683
|
+
"code.lineno": 58
|
|
684
|
+
}
|
|
685
|
+
```
|
|
686
|
+
|
|
687
|
+
**Custom span from application code:**
|
|
688
|
+
```json
|
|
689
|
+
{
|
|
690
|
+
"name": "pdf.generate",
|
|
691
|
+
"code.namespace": "ReportExporter",
|
|
692
|
+
"code.function": "export_monthly",
|
|
693
|
+
"code.lineno": 34
|
|
694
|
+
}
|
|
695
|
+
```
|
|
696
|
+
|
|
697
|
+
**The difference:** Previously these spans had no caller context—you could see a span existed but not which class or method created it. Now you can filter, group, and drill into spans by calling class without adding any manual instrumentation.
|
|
698
|
+
|
|
699
|
+
## Troubleshooting
|
|
700
|
+
|
|
701
|
+
### Attributes Not Appearing
|
|
702
|
+
|
|
703
|
+
**Problem:** Slow queries aren't getting enriched with source location attributes.
|
|
704
|
+
|
|
705
|
+
**Solutions:**
|
|
706
|
+
|
|
707
|
+
1. **Check your Ruby version:**
|
|
708
|
+
```bash
|
|
709
|
+
ruby -v # Must be >= 3.1.0
|
|
710
|
+
```
|
|
711
|
+
The gem requires Ruby 3.1+ for `Thread.each_caller_location` support.
|
|
712
|
+
|
|
713
|
+
2. **Verify adapters are installed:**
|
|
714
|
+
```ruby
|
|
715
|
+
# In Rails console
|
|
716
|
+
PG::Connection.ancestors.any? { |m| m.to_s.include?('RailsOtelContext') }
|
|
717
|
+
# => Should return true
|
|
718
|
+
```
|
|
719
|
+
|
|
720
|
+
3. **Check threshold configuration:**
|
|
721
|
+
```ruby
|
|
722
|
+
RailsOtelContext.configuration.pg_slow_query_threshold_ms
|
|
723
|
+
# Make sure it's lower than your query duration
|
|
724
|
+
```
|
|
725
|
+
|
|
726
|
+
4. **Ensure queries originate from Rails app code:**
|
|
727
|
+
- Only calls from files under `Rails.root` are tracked
|
|
728
|
+
- Gem internal calls are intentionally excluded
|
|
729
|
+
|
|
730
|
+
### ClickHouse Spans Not Created
|
|
731
|
+
|
|
732
|
+
**Problem:** ClickHouse operations aren't creating spans.
|
|
733
|
+
|
|
734
|
+
**Solutions:**
|
|
735
|
+
|
|
736
|
+
1. **Verify ClickHouse client is loaded:**
|
|
737
|
+
```ruby
|
|
738
|
+
defined?(ClickHouse::Client) # or Clickhouse::Client
|
|
739
|
+
# => Should return "constant"
|
|
740
|
+
```
|
|
741
|
+
|
|
742
|
+
2. **Check if enabled:**
|
|
743
|
+
```ruby
|
|
744
|
+
RailsOtelContext.configuration.clickhouse_enabled
|
|
745
|
+
# => Should be true
|
|
746
|
+
```
|
|
747
|
+
|
|
748
|
+
3. **Ensure tracer provider is configured:**
|
|
749
|
+
```ruby
|
|
750
|
+
OpenTelemetry.tracer_provider.tracer('test').in_span('test') { |span| puts span.class }
|
|
751
|
+
# Should output a span class, not a no-op span
|
|
752
|
+
```
|
|
753
|
+
|
|
754
|
+
### High Cardinality Concerns
|
|
755
|
+
|
|
756
|
+
**Problem:** Worried about attribute cardinality with `code.filepath` and `code.lineno`.
|
|
757
|
+
|
|
758
|
+
**Answer:** This is generally safe because:
|
|
759
|
+
- Attributes are added to individual spans, not as metrics dimensions
|
|
760
|
+
- Source locations have bounded cardinality (limited by your codebase size)
|
|
761
|
+
- Only slow queries get enriched (a small fraction of total queries)
|
|
762
|
+
- File paths are relative to app root (no customer/tenant-specific data)
|
|
763
|
+
|
|
764
|
+
**If storage is a concern:**
|
|
765
|
+
- Increase slow query thresholds to reduce the number of enriched spans
|
|
766
|
+
- Use tail-based sampling to keep only traces with slow queries
|
|
767
|
+
- Disable specific adapters (e.g., Redis) that may be high-volume
|
|
768
|
+
|
|
769
|
+
### Performance Impact
|
|
770
|
+
|
|
771
|
+
**Q: What's the overhead?**
|
|
772
|
+
|
|
773
|
+
**A:** Minimal in production:
|
|
774
|
+
- `Thread.each_caller_location` is optimized for early termination
|
|
775
|
+
- Stack walking only happens during database calls (already I/O-bound)
|
|
776
|
+
- Attributes are only added for slow queries (fast queries have zero overhead)
|
|
777
|
+
- Module prepending has negligible cost (single method dispatch indirection)
|
|
778
|
+
|
|
779
|
+
**Benchmarks:** In typical Rails applications, the overhead is <0.1ms per database call—imperceptible compared to actual query execution time.
|
|
780
|
+
|
|
781
|
+
## Integrating Log-Trace Correlation
|
|
782
|
+
|
|
783
|
+
While `rails-otel-context` focuses on enriching database spans with source code locations, you can easily add **log-trace correlation** to link your logs to traces in your observability platform.
|
|
784
|
+
|
|
785
|
+
**Why this isn't in the gem:** Log correlation is standard OpenTelemetry practice that's well-documented and simple to implement (2-3 lines of code). Every Rails application has different logging needs (Logger, Lograge, Semantic Logger, JSON formatters, etc.), so we provide integration examples rather than implementing it in the gem.
|
|
786
|
+
|
|
787
|
+
### How It Works
|
|
788
|
+
|
|
789
|
+
OpenTelemetry provides trace and span IDs via the current span context:
|
|
790
|
+
|
|
791
|
+
```ruby
|
|
792
|
+
span = OpenTelemetry::Trace.current_span
|
|
793
|
+
trace_id = span.context.hex_trace_id # "7f8a9b2c1d3e4f5a6b7c8d9e0f1a2b3c"
|
|
794
|
+
span_id = span.context.hex_span_id # "1a2b3c4d5e6f7a8b"
|
|
795
|
+
```
|
|
796
|
+
|
|
797
|
+
### Integration Examples
|
|
798
|
+
|
|
799
|
+
#### Standard Rails Logger
|
|
800
|
+
|
|
801
|
+
Add trace context to every log entry:
|
|
802
|
+
|
|
803
|
+
```ruby
|
|
804
|
+
# config/initializers/logging.rb
|
|
805
|
+
Rails.application.config.after_initialize do
|
|
806
|
+
original_formatter = Rails.logger.formatter || Logger::Formatter.new
|
|
807
|
+
|
|
808
|
+
Rails.logger.formatter = proc do |severity, timestamp, progname, msg|
|
|
809
|
+
formatted_msg = original_formatter.call(severity, timestamp, progname, msg)
|
|
810
|
+
|
|
811
|
+
# Extract trace context
|
|
812
|
+
span = OpenTelemetry::Trace.current_span
|
|
813
|
+
if span&.context&.valid?
|
|
814
|
+
trace_id = span.context.hex_trace_id
|
|
815
|
+
span_id = span.context.hex_span_id
|
|
816
|
+
formatted_msg = formatted_msg.sub(/\n$/, '')
|
|
817
|
+
"#{formatted_msg} [trace_id=#{trace_id} span_id=#{span_id}]\n"
|
|
818
|
+
else
|
|
819
|
+
formatted_msg
|
|
820
|
+
end
|
|
821
|
+
end
|
|
822
|
+
end
|
|
823
|
+
```
|
|
824
|
+
|
|
825
|
+
**Result:**
|
|
826
|
+
```
|
|
827
|
+
[2026-02-19 10:23:45] INFO User login successful [trace_id=abc123... span_id=def456...]
|
|
828
|
+
```
|
|
829
|
+
|
|
830
|
+
#### Lograge (Popular for Rails APIs)
|
|
831
|
+
|
|
832
|
+
Add trace IDs to structured logs:
|
|
833
|
+
|
|
834
|
+
```ruby
|
|
835
|
+
# config/initializers/lograge.rb
|
|
836
|
+
Rails.application.configure do
|
|
837
|
+
config.lograge.enabled = true
|
|
838
|
+
config.lograge.formatter = Lograge::Formatters::Json.new
|
|
839
|
+
|
|
840
|
+
config.lograge.custom_options = lambda do |event|
|
|
841
|
+
# Extract trace context following OpenTelemetry best practices
|
|
842
|
+
# https://github.com/open-telemetry/opentelemetry-ruby/discussions/1289
|
|
843
|
+
span = OpenTelemetry::Trace.current_span
|
|
844
|
+
|
|
845
|
+
if span&.context&.valid?
|
|
846
|
+
{
|
|
847
|
+
trace_id: span.context.hex_trace_id,
|
|
848
|
+
span_id: span.context.hex_span_id
|
|
849
|
+
}
|
|
850
|
+
else
|
|
851
|
+
{}
|
|
852
|
+
end
|
|
853
|
+
end
|
|
854
|
+
end
|
|
855
|
+
```
|
|
856
|
+
|
|
857
|
+
**Result:**
|
|
858
|
+
```json
|
|
859
|
+
{
|
|
860
|
+
"method": "GET",
|
|
861
|
+
"path": "/api/users/123",
|
|
862
|
+
"status": 200,
|
|
863
|
+
"duration": 45.2,
|
|
864
|
+
"trace_id": "7f8a9b2c1d3e4f5a6b7c8d9e0f1a2b3c",
|
|
865
|
+
"span_id": "1a2b3c4d5e6f7a8b"
|
|
866
|
+
}
|
|
867
|
+
```
|
|
868
|
+
|
|
869
|
+
#### Semantic Logger (Production-Grade Logging)
|
|
870
|
+
|
|
871
|
+
Semantic Logger has built-in support for OpenTelemetry:
|
|
872
|
+
|
|
873
|
+
```ruby
|
|
874
|
+
# config/initializers/semantic_logger.rb
|
|
875
|
+
require 'semantic_logger'
|
|
876
|
+
|
|
877
|
+
SemanticLogger.add_appender(io: $stdout, formatter: :json)
|
|
878
|
+
|
|
879
|
+
Rails.application.config.after_initialize do
|
|
880
|
+
Rails.logger = SemanticLogger[Rails.application.class.name]
|
|
881
|
+
|
|
882
|
+
# Add OpenTelemetry trace context to all logs
|
|
883
|
+
SemanticLogger.on_log do |log|
|
|
884
|
+
span = OpenTelemetry::Trace.current_span
|
|
885
|
+
if span&.context&.valid?
|
|
886
|
+
log.named_tags[:trace_id] = span.context.hex_trace_id
|
|
887
|
+
log.named_tags[:span_id] = span.context.hex_span_id
|
|
888
|
+
end
|
|
889
|
+
end
|
|
890
|
+
end
|
|
891
|
+
```
|
|
892
|
+
|
|
893
|
+
#### JSON Formatter (For Cloud Logging)
|
|
894
|
+
|
|
895
|
+
Custom JSON formatter with trace context:
|
|
896
|
+
|
|
897
|
+
```ruby
|
|
898
|
+
# lib/json_logger.rb
|
|
899
|
+
class JsonLogger < Logger::Formatter
|
|
900
|
+
def call(severity, timestamp, progname, msg)
|
|
901
|
+
data = {
|
|
902
|
+
timestamp: timestamp.utc.iso8601(3),
|
|
903
|
+
severity: severity,
|
|
904
|
+
message: msg
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
# Add OpenTelemetry trace context
|
|
908
|
+
span = OpenTelemetry::Trace.current_span
|
|
909
|
+
if span&.context&.valid?
|
|
910
|
+
data[:trace_id] = span.context.hex_trace_id
|
|
911
|
+
data[:span_id] = span.context.hex_span_id
|
|
912
|
+
end
|
|
913
|
+
|
|
914
|
+
"#{data.to_json}\n"
|
|
915
|
+
end
|
|
916
|
+
end
|
|
917
|
+
|
|
918
|
+
# config/initializers/logging.rb
|
|
919
|
+
Rails.logger.formatter = JsonLogger.new
|
|
920
|
+
```
|
|
921
|
+
|
|
922
|
+
**Result:**
|
|
923
|
+
```json
|
|
924
|
+
{"timestamp":"2026-02-19T10:23:45.123Z","severity":"INFO","message":"User login","trace_id":"abc123","span_id":"def456"}
|
|
925
|
+
```
|
|
926
|
+
|
|
927
|
+
### Using Log Correlation in Observability Platforms
|
|
928
|
+
|
|
929
|
+
Once you've added `trace_id` and `span_id` to your logs, your observability platform can link them:
|
|
930
|
+
|
|
931
|
+
**Datadog:**
|
|
932
|
+
- Automatically correlates logs and traces when both have `trace_id` and `span_id`
|
|
933
|
+
- Click "View Trace" button in log viewer to jump to the trace
|
|
934
|
+
|
|
935
|
+
**Grafana/Loki:**
|
|
936
|
+
- Use TraceQL to query: `{trace_id="abc123"}`
|
|
937
|
+
- Tempo automatically links to Loki logs with matching trace IDs
|
|
938
|
+
|
|
939
|
+
**Honeycomb:**
|
|
940
|
+
- Traces and logs with matching `trace_id` appear together in the timeline
|
|
941
|
+
- Use "Show Logs" to see correlated log events
|
|
942
|
+
|
|
943
|
+
**Elastic APM:**
|
|
944
|
+
- Searches logs by `trace.id` and `span.id` fields
|
|
945
|
+
- Displays correlated logs in the trace waterfall view
|
|
946
|
+
|
|
947
|
+
### Best Practices
|
|
948
|
+
|
|
949
|
+
1. **Use the OpenTelemetry approach:** Always extract trace context via `OpenTelemetry::Trace.current_span` (don't use propagation headers)
|
|
950
|
+
|
|
951
|
+
2. **Handle missing spans gracefully:** Check `span&.context&.valid?` before accessing trace IDs
|
|
952
|
+
|
|
953
|
+
3. **Use hex format:** `hex_trace_id` and `hex_span_id` return W3C trace context format (what observability platforms expect)
|
|
954
|
+
|
|
955
|
+
4. **Be consistent:** Use `trace_id` and `span_id` field names (lowercase, snake_case) across your logging
|
|
956
|
+
|
|
957
|
+
5. **Consider performance:** Extracting trace context is fast (<0.01ms), but formatting large log messages can be expensive
|
|
958
|
+
|
|
959
|
+
### References
|
|
960
|
+
|
|
961
|
+
- [OpenTelemetry Ruby Log Correlation Discussion](https://github.com/open-telemetry/opentelemetry-ruby/discussions/1289)
|
|
962
|
+
- [OpenTelemetry Logging Specification](https://opentelemetry.io/docs/specs/otel/logs/)
|
|
963
|
+
- [Datadog Ruby Log-Trace Correlation](https://docs.datadoghq.com/tracing/other_telemetry/connect_logs_and_traces/ruby/)
|
|
964
|
+
|
|
965
|
+
## Compatibility
|
|
966
|
+
|
|
967
|
+
### Ruby Versions
|
|
968
|
+
- **Required:** Ruby >= 3.1.0 (for `Thread.each_caller_location`)
|
|
969
|
+
|
|
970
|
+
### Rails Versions
|
|
971
|
+
- **Required:** Rails >= 7.0
|
|
972
|
+
- **Recommended:** Rails 7.1+ for best compatibility
|
|
973
|
+
- **Note:** This gem is Rails-only and will not work in standalone Ruby applications
|
|
974
|
+
|
|
975
|
+
### Database Gems
|
|
976
|
+
- `pg` - All versions with `PG::Constants::EXEC_ISH_METHODS`
|
|
977
|
+
- `mysql2` - All versions with `query` and `prepare` methods
|
|
978
|
+
- `redis-client` with `opentelemetry-instrumentation-redis`
|
|
979
|
+
- `click_house`, `clickhouse` - Multiple client variants supported
|
|
980
|
+
|
|
981
|
+
### OpenTelemetry
|
|
982
|
+
- `opentelemetry-api` >= 1.0
|
|
983
|
+
- `opentelemetry-sdk` (required for tracing)
|
|
984
|
+
- `opentelemetry-instrumentation-pg` (optional, but recommended for PG)
|
|
985
|
+
- `opentelemetry-instrumentation-mysql2` (optional, but recommended for MySQL)
|
|
986
|
+
- `opentelemetry-instrumentation-redis` (required for Redis adapter)
|
|
987
|
+
|
|
988
|
+
## Roadmap
|
|
989
|
+
|
|
990
|
+
Potential future enhancements:
|
|
991
|
+
|
|
992
|
+
- [ ] **More database adapters:** SQLite, Oracle, SQL Server
|
|
993
|
+
- [ ] **HTTP client enrichment:** Add source locations to HTTP spans
|
|
994
|
+
- [ ] **Sampling controls:** Per-adapter sampling rates
|
|
995
|
+
- [ ] **Query parameter capture:** Optionally capture bind parameters
|
|
996
|
+
- [ ] **Async query support:** Better handling for async database operations
|
|
997
|
+
- [ ] **Custom attribute callbacks:** User-defined attributes based on query patterns
|
|
998
|
+
|
|
999
|
+
## Contributing
|
|
1000
|
+
|
|
1001
|
+
We welcome contributions! Areas of interest:
|
|
1002
|
+
|
|
1003
|
+
1. **New adapters** for popular Ruby database/cache clients
|
|
1004
|
+
2. **Test coverage** improvements
|
|
1005
|
+
3. **Performance optimizations**
|
|
1006
|
+
4. **Documentation** enhancements
|
|
1007
|
+
|
|
1008
|
+
## License
|
|
1009
|
+
|
|
1010
|
+
MIT License - See [LICENSE](LICENSE) for details.
|
|
1011
|
+
|
|
1012
|
+
## Maintainers
|
|
1013
|
+
|
|
1014
|
+
Maintained with ❤️ by the observability team at [Last9](https://last9.io).
|
|
1015
|
+
|
|
1016
|
+
## Support
|
|
1017
|
+
|
|
1018
|
+
- 🐛 **Issues:** [GitHub Issues](https://github.com/last9/rails-otel-context/issues)
|
|
1019
|
+
- 💬 **Discussions:** [GitHub Discussions](https://github.com/last9/rails-otel-context/discussions)
|
|
1020
|
+
- 📧 **Email:** engineering@last9.io
|