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.
data/README.md ADDED
@@ -0,0 +1,1020 @@
1
+ # rails-otel-context
2
+
3
+ [![CI](https://github.com/last9/rails-otel-context/actions/workflows/ci.yml/badge.svg)](https://github.com/last9/rails-otel-context/actions/workflows/ci.yml)
4
+ [![Gem Version](https://badge.fury.io/rb/rails-otel-context.svg)](https://badge.fury.io/rb/rails-otel-context)
5
+ [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.1.0-ruby.svg)](https://www.ruby-lang.org/)
6
+ [![Rails](https://img.shields.io/badge/rails-%3E%3D%207.0-red.svg)](https://rubyonrails.org/)
7
+ [![License](https://img.shields.io/badge/License-MIT-yellow.svg)](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