tracelit 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/README.md +361 -0
- data/lib/tracelit/configuration.rb +54 -0
- data/lib/tracelit/error_always_on_sampler.rb +47 -0
- data/lib/tracelit/error_span_processor.rb +48 -0
- data/lib/tracelit/instrumentation.rb +130 -0
- data/lib/tracelit/metrics.rb +284 -0
- data/lib/tracelit/rails_logger_bridge.rb +85 -0
- data/lib/tracelit/railtie.rb +14 -0
- data/lib/tracelit/version.rb +5 -0
- data/lib/tracelit.rb +65 -0
- data/tracelit.gemspec +35 -0
- metadata +153 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 58eb691b0dfacebc7072a0ee10ebc4fb88ddc5074c659211a689532bf2e86f3b
|
|
4
|
+
data.tar.gz: 9a73dad4b506173c24f6e64a17c227812a0ab90651ef3a08e493ab9a68c602e1
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 7ca34cc0206da44b31ceb865848ec08ef39f015764ae7f1f20dcf6d2b9af78820de537f6994ea48a224415350878ea9c206a54f21dd434cb37146451a04a06ba
|
|
7
|
+
data.tar.gz: 999c495a4c363af34e4bdd1671d66611c42124040f2e20172040590dbb5e6a715f9123e4550a29c2378c77d5275cede91fdc8dd46ca1830c1eabfa2b6b92eac3
|
data/README.md
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
# Tracelit Ruby SDK
|
|
2
|
+
|
|
3
|
+
Official Ruby SDK for [Tracelit](https://tracelit.io) — drop-in OpenTelemetry instrumentation for Rails, Sinatra, and Rack apps. Sends traces, metrics, and logs to the Tracelit ingest API via OTLP/HTTP.
|
|
4
|
+
|
|
5
|
+
**Requirements:** Ruby >= 3.0
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Set up with AI
|
|
10
|
+
|
|
11
|
+
Copy the prompt below and paste it into Cursor, Claude, ChatGPT, or any AI assistant. It contains everything the AI needs to fully integrate Tracelit into your app — gem, initializer, manual spans, custom metrics, and test guard.
|
|
12
|
+
|
|
13
|
+
````
|
|
14
|
+
You are integrating the Tracelit Ruby SDK into this codebase.
|
|
15
|
+
|
|
16
|
+
## What Tracelit does
|
|
17
|
+
Tracelit is a drop-in OpenTelemetry SDK that sends traces, metrics, and logs
|
|
18
|
+
to https://tracelit.io via OTLP/HTTP. It auto-instruments Rails, Sidekiq,
|
|
19
|
+
ActiveRecord, Redis, Faraday, Net::HTTP, and more with zero code changes.
|
|
20
|
+
|
|
21
|
+
## Steps
|
|
22
|
+
|
|
23
|
+
### 1. Add the gem
|
|
24
|
+
In Gemfile add:
|
|
25
|
+
gem "tracelit"
|
|
26
|
+
Then run: bundle install
|
|
27
|
+
|
|
28
|
+
### 2. Create the initializer
|
|
29
|
+
Create config/initializers/tracelit.rb with this content
|
|
30
|
+
(replace MY_SERVICE_NAME with the actual name of this service):
|
|
31
|
+
|
|
32
|
+
Tracelit.configure do |config|
|
|
33
|
+
config.api_key = ENV["TRACELIT_API_KEY"]
|
|
34
|
+
config.service_name = "MY_SERVICE_NAME"
|
|
35
|
+
config.environment = ENV.fetch("RAILS_ENV", "production")
|
|
36
|
+
config.sample_rate = 1.0
|
|
37
|
+
config.enabled = ENV["TRACELIT_ENABLED"] != "false"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
For Sinatra/Rack apps also add Tracelit.start! after the configure block.
|
|
41
|
+
For Rails, the Railtie calls start! automatically — no extra line needed.
|
|
42
|
+
|
|
43
|
+
### 3. Disable in tests
|
|
44
|
+
In config/environments/test.rb (or .env.test) add:
|
|
45
|
+
TRACELIT_ENABLED=false
|
|
46
|
+
|
|
47
|
+
### 4. Add manual spans (optional)
|
|
48
|
+
Wrap any important operation with a custom span:
|
|
49
|
+
|
|
50
|
+
Tracelit.tracer.in_span("describe_the_operation") do |span|
|
|
51
|
+
span.set_attribute("relevant.attribute", value.to_s)
|
|
52
|
+
do_the_work
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
### 5. Add custom metrics (optional)
|
|
56
|
+
Use counters, histograms, or gauges for business-level signals:
|
|
57
|
+
|
|
58
|
+
# Counter — increment by 1 each time an event occurs
|
|
59
|
+
counter = Tracelit.metrics.counter("my.event.count",
|
|
60
|
+
description: "...", unit: "{events}")
|
|
61
|
+
counter.add(1, attributes: { "key" => "value" })
|
|
62
|
+
|
|
63
|
+
# Histogram — record a measured value (latency, size, etc.)
|
|
64
|
+
histogram = Tracelit.metrics.histogram("my.operation.duration",
|
|
65
|
+
description: "...", unit: "ms")
|
|
66
|
+
histogram.record(elapsed_ms, attributes: { "key" => "value" })
|
|
67
|
+
|
|
68
|
+
# Gauge — record a current level (queue depth, pool size, etc.)
|
|
69
|
+
gauge = Tracelit.metrics.gauge("my.queue.depth",
|
|
70
|
+
description: "...", unit: "{items}")
|
|
71
|
+
gauge.record(current_depth, attributes: { "queue" => "default" })
|
|
72
|
+
|
|
73
|
+
## What you get automatically (no code required)
|
|
74
|
+
- Traces for every HTTP request, SQL query, Redis call, Sidekiq job, etc.
|
|
75
|
+
- Metrics: http.server.request.count/duration, http.server.error.count,
|
|
76
|
+
db.query.duration, sidekiq.job.count/duration/error.count,
|
|
77
|
+
db.connection_pool.size/busy/idle/waiting, process.memory.rss
|
|
78
|
+
- Rails.logger lines forwarded to Tracelit logs, correlated to the active trace
|
|
79
|
+
- Error spans always exported even when the trace is outside the sample ratio
|
|
80
|
+
|
|
81
|
+
## Configuration reference
|
|
82
|
+
| Option | Env var | Default |
|
|
83
|
+
|--------------------|--------------------------|--------------------------------|
|
|
84
|
+
| api_key | TRACELIT_API_KEY | nil (required) |
|
|
85
|
+
| service_name | TRACELIT_SERVICE_NAME | Rails app name (required) |
|
|
86
|
+
| environment | TRACELIT_ENVIRONMENT | "production" |
|
|
87
|
+
| endpoint | TRACELIT_ENDPOINT | https://ingest.tracelit.app |
|
|
88
|
+
| sample_rate | TRACELIT_SAMPLE_RATE | 1.0 |
|
|
89
|
+
| enabled | TRACELIT_ENABLED | true |
|
|
90
|
+
| resource_attributes| — | {} (extra span/metric tags) |
|
|
91
|
+
````
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## Installation
|
|
96
|
+
|
|
97
|
+
Add to your `Gemfile` and run `bundle install`:
|
|
98
|
+
|
|
99
|
+
```ruby
|
|
100
|
+
gem "tracelit"
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## Setup
|
|
106
|
+
|
|
107
|
+
### Rails
|
|
108
|
+
|
|
109
|
+
Create `config/initializers/tracelit.rb`:
|
|
110
|
+
|
|
111
|
+
```ruby
|
|
112
|
+
Tracelit.configure do |config|
|
|
113
|
+
config.api_key = ENV["TRACELIT_API_KEY"] # required
|
|
114
|
+
config.service_name = "payments-api" # required
|
|
115
|
+
config.environment = ENV["RAILS_ENV"]
|
|
116
|
+
config.sample_rate = 1.0
|
|
117
|
+
end
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
That is all. The Railtie picks up the configuration automatically and calls `Tracelit.start!` at boot — no further changes needed.
|
|
121
|
+
|
|
122
|
+
### Sinatra / Rack
|
|
123
|
+
|
|
124
|
+
```ruby
|
|
125
|
+
require "tracelit"
|
|
126
|
+
|
|
127
|
+
Tracelit.configure do |config|
|
|
128
|
+
config.api_key = ENV["TRACELIT_API_KEY"]
|
|
129
|
+
config.service_name = "my-sinatra-app"
|
|
130
|
+
config.environment = ENV["RACK_ENV"]
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
Tracelit.start! # must be called explicitly outside Rails
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
## Configuration reference
|
|
139
|
+
|
|
140
|
+
All options can be set in the `configure` block or via environment variables.
|
|
141
|
+
|
|
142
|
+
| Option | Env variable | Default | Description |
|
|
143
|
+
|---|---|---|---|
|
|
144
|
+
| `api_key` | `TRACELIT_API_KEY` | `nil` | **Required.** Your Tracelit ingest API key. |
|
|
145
|
+
| `service_name` | `TRACELIT_SERVICE_NAME` | Rails app name | **Required.** Name of this service as it appears in Tracelit. Falls back to the Rails application module name when inside Rails, or `"unknown-service"` otherwise. |
|
|
146
|
+
| `environment` | `TRACELIT_ENVIRONMENT` | `"production"` | Deployment environment tag — e.g. `production`, `staging`, `development`. |
|
|
147
|
+
| `endpoint` | `TRACELIT_ENDPOINT` | `https://ingest.tracelit.app` | Base URL of the Tracelit ingest API. Override only when self-hosting. |
|
|
148
|
+
| `sample_rate` | `TRACELIT_SAMPLE_RATE` | `1.0` | Head-based trace sampling ratio between `0.0` and `1.0`. `1.0` keeps every trace; `0.1` keeps 10%. **Errors are always exported regardless of this setting.** |
|
|
149
|
+
| `enabled` | `TRACELIT_ENABLED` | `true` | Set to `false` (or `TRACELIT_ENABLED=false`) to disable all telemetry without removing the gem — useful in test environments. |
|
|
150
|
+
| `resource_attributes` | — | `{}` | Extra key/value pairs appended to every span, metric, and log record as resource attributes. |
|
|
151
|
+
|
|
152
|
+
### Adding custom resource attributes
|
|
153
|
+
|
|
154
|
+
```ruby
|
|
155
|
+
Tracelit.configure do |config|
|
|
156
|
+
config.api_key = ENV["TRACELIT_API_KEY"]
|
|
157
|
+
config.service_name = "orders-api"
|
|
158
|
+
config.resource_attributes = {
|
|
159
|
+
"deployment.region" => "us-east-1",
|
|
160
|
+
"team" => "platform",
|
|
161
|
+
}
|
|
162
|
+
end
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
---
|
|
166
|
+
|
|
167
|
+
## Manual trace instrumentation
|
|
168
|
+
|
|
169
|
+
Use `Tracelit.tracer` to create custom spans around any block of work:
|
|
170
|
+
|
|
171
|
+
```ruby
|
|
172
|
+
Tracelit.tracer.in_span("process_payment") do |span|
|
|
173
|
+
span.set_attribute("payment.id", payment.id.to_s)
|
|
174
|
+
span.set_attribute("payment.amount", amount)
|
|
175
|
+
span.set_attribute("payment.currency", currency)
|
|
176
|
+
|
|
177
|
+
result = process(payment)
|
|
178
|
+
|
|
179
|
+
span.set_attribute("payment.status", result.status)
|
|
180
|
+
result
|
|
181
|
+
end
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
The tracer is an `OpenTelemetry::Trace::Tracer` and supports the full [OpenTelemetry Ruby API](https://opentelemetry.io/docs/languages/ruby/api/).
|
|
185
|
+
|
|
186
|
+
---
|
|
187
|
+
|
|
188
|
+
## Manual metrics instrumentation
|
|
189
|
+
|
|
190
|
+
Access the metrics interface via `Tracelit.metrics` (an alias for `Tracelit::Metrics`):
|
|
191
|
+
|
|
192
|
+
### Counter
|
|
193
|
+
|
|
194
|
+
Counts discrete events. Use for request counts, job completions, errors, etc.
|
|
195
|
+
|
|
196
|
+
```ruby
|
|
197
|
+
counter = Tracelit.metrics.counter(
|
|
198
|
+
"orders.placed",
|
|
199
|
+
description: "Total orders placed",
|
|
200
|
+
unit: "{orders}"
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
counter.add(1, attributes: { "currency" => "USD", "channel" => "web" })
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
### Histogram
|
|
207
|
+
|
|
208
|
+
Records distributions of values. Use for durations, payload sizes, queue depths, etc.
|
|
209
|
+
|
|
210
|
+
```ruby
|
|
211
|
+
histogram = Tracelit.metrics.histogram(
|
|
212
|
+
"external.api.duration",
|
|
213
|
+
description: "External API call duration",
|
|
214
|
+
unit: "ms"
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
218
|
+
call_external_api
|
|
219
|
+
elapsed_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000.0
|
|
220
|
+
|
|
221
|
+
histogram.record(elapsed_ms, attributes: { "service" => "stripe" })
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
### Gauge
|
|
225
|
+
|
|
226
|
+
Records a point-in-time value. Use for pool sizes, queue lengths, cache hit rates, etc.
|
|
227
|
+
|
|
228
|
+
```ruby
|
|
229
|
+
gauge = Tracelit.metrics.gauge(
|
|
230
|
+
"job_queue.depth",
|
|
231
|
+
description: "Number of pending background jobs",
|
|
232
|
+
unit: "{jobs}"
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
gauge.record(JobQueue.pending_count, attributes: { "queue" => "default" })
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
---
|
|
239
|
+
|
|
240
|
+
## Automatic instrumentation
|
|
241
|
+
|
|
242
|
+
The SDK enables every instrumentation gem bundled in `opentelemetry-instrumentation-all`, including:
|
|
243
|
+
|
|
244
|
+
| Library | What is captured |
|
|
245
|
+
|---|---|
|
|
246
|
+
| Rails / Action Pack | HTTP request traces, controller and action attributes |
|
|
247
|
+
| Active Record | SQL query traces with sanitised statement text |
|
|
248
|
+
| Action View | Template render times |
|
|
249
|
+
| Rack | Low-level HTTP middleware spans |
|
|
250
|
+
| Net::HTTP | Outbound HTTP call traces |
|
|
251
|
+
| Faraday | Outbound HTTP call traces |
|
|
252
|
+
| Redis | Cache command traces |
|
|
253
|
+
| Sidekiq | Job enqueue and execute traces |
|
|
254
|
+
| Bunny | AMQP publish/subscribe traces |
|
|
255
|
+
| gRPC | Client and server RPC traces |
|
|
256
|
+
|
|
257
|
+
Additional libraries (Mongo, pg, mysql2, Kafka, etc.) are also instrumented when their gems are present.
|
|
258
|
+
|
|
259
|
+
---
|
|
260
|
+
|
|
261
|
+
## Automatic metrics collection
|
|
262
|
+
|
|
263
|
+
Once `Tracelit.start!` has been called, the following metrics are collected with no additional configuration:
|
|
264
|
+
|
|
265
|
+
### HTTP server metrics (Rails)
|
|
266
|
+
|
|
267
|
+
Emitted per request via `ActiveSupport::Notifications`:
|
|
268
|
+
|
|
269
|
+
| Metric | Type | Description |
|
|
270
|
+
|---|---|---|
|
|
271
|
+
| `http.server.request.count` | Counter | Total HTTP requests processed |
|
|
272
|
+
| `http.server.request.duration` | Histogram | Request duration in milliseconds |
|
|
273
|
+
| `http.server.error.count` | Counter | Total 5xx responses |
|
|
274
|
+
| `db.query.duration` | Histogram | ActiveRecord time per request in milliseconds |
|
|
275
|
+
|
|
276
|
+
Attributes on all HTTP metrics: `http.method`, `http.route`, `http.status_code`, `controller`, `action`.
|
|
277
|
+
|
|
278
|
+
### Sidekiq job metrics
|
|
279
|
+
|
|
280
|
+
Emitted per job execution via server middleware:
|
|
281
|
+
|
|
282
|
+
| Metric | Type | Description |
|
|
283
|
+
|---|---|---|
|
|
284
|
+
| `sidekiq.job.count` | Counter | Total jobs processed |
|
|
285
|
+
| `sidekiq.job.duration` | Histogram | Job execution duration in milliseconds |
|
|
286
|
+
| `sidekiq.job.error.count` | Counter | Total jobs that raised an error |
|
|
287
|
+
|
|
288
|
+
Attributes: `sidekiq.job.class`, `sidekiq.queue`, `sidekiq.status` (`success` or `error`).
|
|
289
|
+
|
|
290
|
+
### Database connection pool metrics (ActiveRecord)
|
|
291
|
+
|
|
292
|
+
Polled every 30 seconds on a background thread:
|
|
293
|
+
|
|
294
|
+
| Metric | Type | Description |
|
|
295
|
+
|---|---|---|
|
|
296
|
+
| `db.connection_pool.size` | Gauge | Maximum connections in the pool |
|
|
297
|
+
| `db.connection_pool.busy` | Gauge | Connections currently checked out |
|
|
298
|
+
| `db.connection_pool.idle` | Gauge | Connections available for checkout |
|
|
299
|
+
| `db.connection_pool.waiting` | Gauge | Threads waiting for a connection |
|
|
300
|
+
|
|
301
|
+
### Process memory
|
|
302
|
+
|
|
303
|
+
Polled every 60 seconds:
|
|
304
|
+
|
|
305
|
+
| Metric | Type | Description |
|
|
306
|
+
|---|---|---|
|
|
307
|
+
| `process.memory.rss` | Gauge | Process RSS memory in megabytes |
|
|
308
|
+
|
|
309
|
+
---
|
|
310
|
+
|
|
311
|
+
## Log forwarding (Rails)
|
|
312
|
+
|
|
313
|
+
When Rails is present, `Tracelit.start!` installs a broadcast target on `Rails.logger`. Every `Rails.logger` call is forwarded to the OTel LoggerProvider and exported to the Tracelit logs table via OTLP. The original logger output is preserved — nothing changes for your existing log pipeline.
|
|
314
|
+
|
|
315
|
+
Log records are automatically correlated with the active trace via `trace_id` and `span_id`.
|
|
316
|
+
|
|
317
|
+
---
|
|
318
|
+
|
|
319
|
+
## Sampling and error guarantee
|
|
320
|
+
|
|
321
|
+
Set `config.sample_rate` below `1.0` to reduce trace volume in high-traffic environments:
|
|
322
|
+
|
|
323
|
+
```ruby
|
|
324
|
+
config.sample_rate = 0.1 # keep 10% of traces
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
**Error spans are always exported**, even when the parent trace is outside the sample ratio. The SDK uses a custom `ErrorAlwaysOnSampler` + `ErrorSpanProcessor` pair to guarantee this — no configuration required.
|
|
328
|
+
|
|
329
|
+
---
|
|
330
|
+
|
|
331
|
+
## Disabling in tests
|
|
332
|
+
|
|
333
|
+
```ruby
|
|
334
|
+
# config/initializers/tracelit.rb
|
|
335
|
+
Tracelit.configure do |config|
|
|
336
|
+
config.api_key = ENV["TRACELIT_API_KEY"]
|
|
337
|
+
config.service_name = "my-app"
|
|
338
|
+
config.enabled = ENV["TRACELIT_ENABLED"] != "false"
|
|
339
|
+
end
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
Then in your test environment:
|
|
343
|
+
|
|
344
|
+
```bash
|
|
345
|
+
TRACELIT_ENABLED=false bundle exec rspec
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
Or set it permanently in `config/environments/test.rb` / `.env.test`:
|
|
349
|
+
|
|
350
|
+
```
|
|
351
|
+
TRACELIT_ENABLED=false
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
---
|
|
355
|
+
|
|
356
|
+
## Running the SDK's own tests
|
|
357
|
+
|
|
358
|
+
```bash
|
|
359
|
+
bundle install
|
|
360
|
+
bundle exec rspec
|
|
361
|
+
```
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tracelit
|
|
4
|
+
class Configuration
|
|
5
|
+
# Required
|
|
6
|
+
attr_accessor :api_key
|
|
7
|
+
|
|
8
|
+
# The name of this service as it will appear in Tracelit.
|
|
9
|
+
# Defaults to the Rails app name if Rails is present.
|
|
10
|
+
attr_accessor :service_name
|
|
11
|
+
|
|
12
|
+
# Environment tag — production, staging, development, etc.
|
|
13
|
+
attr_accessor :environment
|
|
14
|
+
|
|
15
|
+
# Full URL of the Tracelit ingest endpoint.
|
|
16
|
+
# Override only if self-hosting.
|
|
17
|
+
attr_accessor :endpoint
|
|
18
|
+
|
|
19
|
+
# Head-based sampling rate (0.0–1.0). Default: 1.0 (keep all traces).
|
|
20
|
+
# Set to 0.1 to keep 10% of traces. Errors are always kept regardless.
|
|
21
|
+
attr_accessor :sample_rate
|
|
22
|
+
|
|
23
|
+
# Set false to disable all telemetry without removing the gem.
|
|
24
|
+
# Useful for test environments.
|
|
25
|
+
attr_accessor :enabled
|
|
26
|
+
|
|
27
|
+
# Additional resource attributes appended to every span and log.
|
|
28
|
+
# Hash of string keys and string values.
|
|
29
|
+
attr_accessor :resource_attributes
|
|
30
|
+
|
|
31
|
+
def initialize
|
|
32
|
+
@api_key = ENV["TRACELIT_API_KEY"]
|
|
33
|
+
@service_name = ENV["TRACELIT_SERVICE_NAME"]
|
|
34
|
+
@environment = ENV["TRACELIT_ENVIRONMENT"] || "production"
|
|
35
|
+
@endpoint = ENV["TRACELIT_ENDPOINT"] || "https://ingest.tracelit.app"
|
|
36
|
+
@sample_rate = (ENV["TRACELIT_SAMPLE_RATE"] || "1.0").to_f
|
|
37
|
+
@enabled = ENV["TRACELIT_ENABLED"] != "false"
|
|
38
|
+
@resource_attributes = {}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def validate!
|
|
42
|
+
raise ArgumentError, "Tracelit.config.api_key is required" if api_key.nil? || api_key.empty?
|
|
43
|
+
raise ArgumentError, "Tracelit.config.service_name is required" if service_name.nil? || service_name.empty?
|
|
44
|
+
raise ArgumentError, "sample_rate must be between 0.0 and 1.0" unless sample_rate.between?(0.0, 1.0)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Infer service name from Rails application if not explicitly set.
|
|
48
|
+
def resolved_service_name
|
|
49
|
+
return service_name if service_name && !service_name.empty?
|
|
50
|
+
return ::Rails.application.class.module_parent_name.underscore if defined?(::Rails)
|
|
51
|
+
"unknown-service"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tracelit
|
|
4
|
+
# ErrorAlwaysOnSampler wraps a ratio-based sampler but upgrades DROP
|
|
5
|
+
# decisions to RECORD_ONLY. This ensures span processors (including
|
|
6
|
+
# ErrorSpanProcessor) fire on_end for ALL spans, even those outside
|
|
7
|
+
# the sampling ratio.
|
|
8
|
+
#
|
|
9
|
+
# Without this, TraceIdRatioBased(0.0) returns DROP, which causes the
|
|
10
|
+
# SDK to create NonRecordingSpans that bypass the processor pipeline
|
|
11
|
+
# entirely — so ErrorSpanProcessor.on_end is never called.
|
|
12
|
+
#
|
|
13
|
+
# With RECORD_ONLY:
|
|
14
|
+
# - Real spans are created and all processors fire
|
|
15
|
+
# - BatchSpanProcessor ignores them (checks trace_flags.sampled? == false)
|
|
16
|
+
# - ErrorSpanProcessor sees them and exports any that end in ERROR
|
|
17
|
+
class ErrorAlwaysOnSampler
|
|
18
|
+
Decision = OpenTelemetry::SDK::Trace::Samplers::Decision
|
|
19
|
+
Result = OpenTelemetry::SDK::Trace::Samplers::Result
|
|
20
|
+
|
|
21
|
+
def initialize(rate)
|
|
22
|
+
@inner = OpenTelemetry::SDK::Trace::Samplers.trace_id_ratio_based(rate)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def should_sample?(trace_id:, parent_context:, links:, name:, kind:, attributes:)
|
|
26
|
+
result = @inner.should_sample?(
|
|
27
|
+
trace_id: trace_id,
|
|
28
|
+
parent_context: parent_context,
|
|
29
|
+
links: links,
|
|
30
|
+
name: name,
|
|
31
|
+
kind: kind,
|
|
32
|
+
attributes: attributes
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
if result.recording?
|
|
36
|
+
result
|
|
37
|
+
else
|
|
38
|
+
# Upgrade DROP → RECORD_ONLY so processor pipeline fires
|
|
39
|
+
Result.new(decision: Decision::RECORD_ONLY, tracestate: result.tracestate)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def description
|
|
44
|
+
"ErrorAlwaysOnSampler{#{@inner.description}}"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tracelit
|
|
4
|
+
# ErrorSpanProcessor ensures error spans are always exported
|
|
5
|
+
# regardless of the sampling decision made at span creation time.
|
|
6
|
+
#
|
|
7
|
+
# How it works:
|
|
8
|
+
# - ErrorAlwaysOnSampler returns RECORD_ONLY (not DROP) for unsampled spans,
|
|
9
|
+
# which ensures this processor's on_finish is called for every span
|
|
10
|
+
# - On span finish, if the span has status ERROR, this processor forces it
|
|
11
|
+
# through the exporter directly, bypassing the BatchSpanProcessor
|
|
12
|
+
# - BatchSpanProcessor ignores RECORD_ONLY spans (trace_flags.sampled? false)
|
|
13
|
+
# so there is no double-export for sampled error spans
|
|
14
|
+
#
|
|
15
|
+
# NOTE: opentelemetry-sdk 1.x uses on_finish (not on_end) as the hook name.
|
|
16
|
+
class ErrorSpanProcessor
|
|
17
|
+
def initialize(exporter)
|
|
18
|
+
@exporter = exporter
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def on_start(span, parent_context)
|
|
22
|
+
# nothing to do at start
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def on_finish(span)
|
|
26
|
+
# Skip spans that are not in error — only intervene for errors
|
|
27
|
+
return if span.status.ok?
|
|
28
|
+
|
|
29
|
+
# Skip spans that were fully sampled — BatchSpanProcessor handles those.
|
|
30
|
+
# This prevents double-export of error spans on traces that were sampled.
|
|
31
|
+
return if span.context.trace_flags.sampled?
|
|
32
|
+
|
|
33
|
+
# Force-export this error span regardless of sampling decision
|
|
34
|
+
@exporter.export([span.to_span_data])
|
|
35
|
+
rescue StandardError
|
|
36
|
+
# Never let processor errors propagate to the application
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def force_flush(timeout: nil)
|
|
40
|
+
@exporter.force_flush(timeout: timeout)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def shutdown(timeout: nil)
|
|
44
|
+
# Do not shut down the shared exporter here —
|
|
45
|
+
# the BatchSpanProcessor owns its lifecycle
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "opentelemetry/sdk"
|
|
4
|
+
require "opentelemetry/exporter/otlp"
|
|
5
|
+
require "opentelemetry/instrumentation/all"
|
|
6
|
+
require "opentelemetry-logs-sdk"
|
|
7
|
+
require "opentelemetry/exporter/otlp_logs"
|
|
8
|
+
require_relative "error_span_processor"
|
|
9
|
+
require_relative "error_always_on_sampler"
|
|
10
|
+
require_relative "rails_logger_bridge"
|
|
11
|
+
require_relative "metrics"
|
|
12
|
+
|
|
13
|
+
module Tracelit
|
|
14
|
+
module Instrumentation
|
|
15
|
+
# Sets up the OpenTelemetry SDK with the Tracelit OTLP exporter.
|
|
16
|
+
# Called once at application boot. Idempotent — safe to call multiple times.
|
|
17
|
+
def self.setup(config)
|
|
18
|
+
return if @configured
|
|
19
|
+
return unless config.enabled
|
|
20
|
+
|
|
21
|
+
config.validate!
|
|
22
|
+
|
|
23
|
+
OpenTelemetry::SDK.configure do |otel|
|
|
24
|
+
# Resource attributes identify this service in Tracelit.
|
|
25
|
+
# These populate the `resource` Map column on every telemetry row.
|
|
26
|
+
otel.resource = OpenTelemetry::SDK::Resources::Resource.create(
|
|
27
|
+
{
|
|
28
|
+
OpenTelemetry::SemanticConventions::Resource::SERVICE_NAME => config.resolved_service_name,
|
|
29
|
+
OpenTelemetry::SemanticConventions::Resource::DEPLOYMENT_ENVIRONMENT => config.environment,
|
|
30
|
+
"telemetry.sdk.language" => "ruby",
|
|
31
|
+
"telemetry.sdk.name" => detect_framework,
|
|
32
|
+
"telemetry.sdk.version" => Tracelit::VERSION,
|
|
33
|
+
}.merge(config.resource_attributes)
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# Build the OTLP exporter once — shared by both processors
|
|
37
|
+
exporter = OpenTelemetry::Exporter::OTLP::Exporter.new(
|
|
38
|
+
endpoint: "#{config.endpoint}/v1/traces",
|
|
39
|
+
headers: {
|
|
40
|
+
"Authorization" => "Bearer #{config.api_key}",
|
|
41
|
+
"X-Service-Name" => config.resolved_service_name,
|
|
42
|
+
"X-Environment" => config.environment,
|
|
43
|
+
}
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# Primary processor: batches and exports sampled spans
|
|
47
|
+
otel.add_span_processor(
|
|
48
|
+
OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(exporter)
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# Error processor: always exports error spans regardless of
|
|
52
|
+
# sampling decision — fires on_finish after status is known
|
|
53
|
+
otel.add_span_processor(
|
|
54
|
+
Tracelit::ErrorSpanProcessor.new(exporter)
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# Auto-instrumentation: instruments Rails, Rack, ActiveRecord,
|
|
58
|
+
# Action View, Net::HTTP, Faraday, Redis, Sidekiq, and more.
|
|
59
|
+
# use_all() enables every installed instrumentation gem.
|
|
60
|
+
otel.use_all
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Set sampler after configure — Configurator does not expose
|
|
64
|
+
# sampler= in OTel SDK 1.x, must be set on the provider directly.
|
|
65
|
+
# Skip at 1.0: the default AlwaysOn sampler is correct and we do not touch it.
|
|
66
|
+
if config.sample_rate < 1.0
|
|
67
|
+
OpenTelemetry.tracer_provider.sampler = error_always_on_sampler(config.sample_rate)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
@configured = true
|
|
71
|
+
setup_logs(config)
|
|
72
|
+
Tracelit::Metrics.setup(config)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def self.reset!
|
|
76
|
+
@configured = false
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
# Detects the web framework in use for the telemetry.sdk.name attribute.
|
|
82
|
+
# This value appears as the `framework` column in the services table.
|
|
83
|
+
def self.detect_framework
|
|
84
|
+
return "rails" if defined?(::Rails)
|
|
85
|
+
return "sinatra" if defined?(::Sinatra)
|
|
86
|
+
return "rack" if defined?(::Rack)
|
|
87
|
+
"ruby"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Returns an ErrorAlwaysOnSampler wrapped in ParentBased so child spans
|
|
91
|
+
# honour the parent's sampling decision. ErrorAlwaysOnSampler upgrades
|
|
92
|
+
# DROP → RECORD_ONLY so that ErrorSpanProcessor.on_finish fires for all spans,
|
|
93
|
+
# allowing error spans to be exported even outside the sampling ratio.
|
|
94
|
+
def self.error_always_on_sampler(rate)
|
|
95
|
+
OpenTelemetry::SDK::Trace::Samplers.parent_based(
|
|
96
|
+
root: Tracelit::ErrorAlwaysOnSampler.new(rate)
|
|
97
|
+
)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Sets up the OTel Logs SDK: creates a LoggerProvider, attaches a
|
|
101
|
+
# BatchLogRecordProcessor with an OTLP/HTTP exporter, registers it
|
|
102
|
+
# globally, and installs the Rails.logger bridge.
|
|
103
|
+
def self.setup_logs(config)
|
|
104
|
+
logs_exporter = OpenTelemetry::Exporter::OTLP::Logs::LogsExporter.new(
|
|
105
|
+
endpoint: "#{config.endpoint}/v1/logs",
|
|
106
|
+
headers: {
|
|
107
|
+
"Authorization" => "Bearer #{config.api_key}",
|
|
108
|
+
"X-Service-Name" => config.resolved_service_name,
|
|
109
|
+
"X-Environment" => config.environment,
|
|
110
|
+
}
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
logger_provider = OpenTelemetry::SDK::Logs::LoggerProvider.new(
|
|
114
|
+
resource: OpenTelemetry.tracer_provider.resource
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
logger_provider.add_log_record_processor(
|
|
118
|
+
OpenTelemetry::SDK::Logs::Export::BatchLogRecordProcessor.new(logs_exporter)
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
OpenTelemetry.logger_provider = logger_provider
|
|
122
|
+
|
|
123
|
+
# Install the Rails.logger → OTel bridge after the provider is ready.
|
|
124
|
+
# Called here (after Rails boot) so Rails.logger is already initialised.
|
|
125
|
+
RailsLoggerBridge.install(logger_provider)
|
|
126
|
+
rescue StandardError => e
|
|
127
|
+
OpenTelemetry.logger.warn("Tracelit: failed to set up logs: #{e.message}")
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "opentelemetry/metrics"
|
|
4
|
+
require "opentelemetry-metrics-sdk"
|
|
5
|
+
require "opentelemetry/exporter/otlp_metrics"
|
|
6
|
+
|
|
7
|
+
module Tracelit
|
|
8
|
+
module Metrics
|
|
9
|
+
# Sets up the OpenTelemetry MeterProvider with OTLP exporter.
|
|
10
|
+
# Called once from Instrumentation.setup after trace setup.
|
|
11
|
+
def self.setup(config)
|
|
12
|
+
# Force delta temporality for all instruments. The SDK aggregation classes
|
|
13
|
+
# (Sum, ExplicitBucketHistogram) read this env var at construction time;
|
|
14
|
+
# there is no constructor keyword on MetricsExporter for this in v0.8.0.
|
|
15
|
+
ENV["OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE"] = "delta"
|
|
16
|
+
|
|
17
|
+
exporter = OpenTelemetry::Exporter::OTLP::Metrics::MetricsExporter.new(
|
|
18
|
+
endpoint: "#{config.endpoint}/v1/metrics",
|
|
19
|
+
headers: {
|
|
20
|
+
"Authorization" => "Bearer #{config.api_key}",
|
|
21
|
+
"X-Service-Name" => config.resolved_service_name,
|
|
22
|
+
"X-Environment" => config.environment,
|
|
23
|
+
}
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
reader = OpenTelemetry::SDK::Metrics::Export::PeriodicMetricReader.new(
|
|
27
|
+
exporter: exporter,
|
|
28
|
+
export_interval_millis: 60_000,
|
|
29
|
+
export_timeout_millis: 10_000
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
provider = OpenTelemetry::SDK::Metrics::MeterProvider.new(
|
|
33
|
+
resource: OpenTelemetry.tracer_provider.resource
|
|
34
|
+
)
|
|
35
|
+
provider.add_metric_reader(reader)
|
|
36
|
+
|
|
37
|
+
OpenTelemetry.meter_provider = provider
|
|
38
|
+
|
|
39
|
+
@meter = provider.meter(
|
|
40
|
+
config.resolved_service_name,
|
|
41
|
+
version: Tracelit::VERSION
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
install_rails_subscriber if defined?(::Rails)
|
|
45
|
+
install_sidekiq_middleware if defined?(::Sidekiq)
|
|
46
|
+
install_connection_pool_poller if defined?(::ActiveRecord)
|
|
47
|
+
install_memory_poller
|
|
48
|
+
rescue StandardError => e
|
|
49
|
+
OpenTelemetry.logger.warn("Tracelit: failed to set up metrics: #{e.message}")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.meter
|
|
53
|
+
@meter
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Exposes a counter for manual instrumentation in user code:
|
|
57
|
+
# Tracelit::Metrics.counter("orders.placed").add(1)
|
|
58
|
+
def self.counter(name, description: "", unit: "")
|
|
59
|
+
@meter&.create_counter(name,
|
|
60
|
+
description: description,
|
|
61
|
+
unit: unit
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def self.histogram(name, description: "", unit: "")
|
|
66
|
+
@meter&.create_histogram(name,
|
|
67
|
+
description: description,
|
|
68
|
+
unit: unit
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def self.gauge(name, description: "", unit: "")
|
|
73
|
+
@meter&.create_gauge(name,
|
|
74
|
+
description: description,
|
|
75
|
+
unit: unit
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Subscribes to Rails process_action.action_controller to emit:
|
|
80
|
+
# http.server.request.count — counter per request
|
|
81
|
+
# http.server.request.duration — histogram in milliseconds
|
|
82
|
+
# http.server.error.count — counter for 5xx responses
|
|
83
|
+
# db.query.duration — histogram for ActiveRecord time per request
|
|
84
|
+
def self.install_rails_subscriber
|
|
85
|
+
request_counter = @meter.create_counter(
|
|
86
|
+
"http.server.request.count",
|
|
87
|
+
description: "Total HTTP requests processed",
|
|
88
|
+
unit: "{requests}"
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
duration_histogram = @meter.create_histogram(
|
|
92
|
+
"http.server.request.duration",
|
|
93
|
+
description: "HTTP request duration",
|
|
94
|
+
unit: "ms"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
error_counter = @meter.create_counter(
|
|
98
|
+
"http.server.error.count",
|
|
99
|
+
description: "Total HTTP 5xx responses",
|
|
100
|
+
unit: "{errors}"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
db_duration_histogram = @meter.create_histogram(
|
|
104
|
+
"db.query.duration",
|
|
105
|
+
description: "Database query duration",
|
|
106
|
+
unit: "ms"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
ActiveSupport::Notifications.subscribe("process_action.action_controller") do |*args|
|
|
110
|
+
event = ActiveSupport::Notifications::Event.new(*args)
|
|
111
|
+
payload = event.payload
|
|
112
|
+
|
|
113
|
+
attrs = {
|
|
114
|
+
"http.method" => payload[:method].to_s,
|
|
115
|
+
"http.route" => payload[:path].to_s,
|
|
116
|
+
"http.status_code" => payload[:status].to_s,
|
|
117
|
+
"controller" => payload[:controller].to_s,
|
|
118
|
+
"action" => payload[:action].to_s,
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
request_counter.add(1, attributes: attrs)
|
|
122
|
+
duration_histogram.record(event.duration, attributes: attrs)
|
|
123
|
+
|
|
124
|
+
error_counter.add(1, attributes: attrs) if payload[:status].to_i >= 500
|
|
125
|
+
|
|
126
|
+
if payload[:db_runtime]
|
|
127
|
+
db_duration_histogram.record(
|
|
128
|
+
payload[:db_runtime].to_f,
|
|
129
|
+
attributes: { "controller" => payload[:controller].to_s }
|
|
130
|
+
)
|
|
131
|
+
end
|
|
132
|
+
rescue StandardError
|
|
133
|
+
# Never let metric errors surface to the application
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Installs a Sidekiq server middleware that emits per-job metrics.
|
|
138
|
+
# Uses a dynamically defined class so the instrument references are
|
|
139
|
+
# captured in the closure without global state.
|
|
140
|
+
def self.install_sidekiq_middleware
|
|
141
|
+
job_counter = @meter.create_counter(
|
|
142
|
+
"sidekiq.job.count",
|
|
143
|
+
description: "Total Sidekiq jobs processed",
|
|
144
|
+
unit: "{jobs}"
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
job_duration = @meter.create_histogram(
|
|
148
|
+
"sidekiq.job.duration",
|
|
149
|
+
description: "Sidekiq job execution duration",
|
|
150
|
+
unit: "ms"
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
job_error_counter = @meter.create_counter(
|
|
154
|
+
"sidekiq.job.error.count",
|
|
155
|
+
description: "Total Sidekiq jobs that raised an error",
|
|
156
|
+
unit: "{jobs}"
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
_job_counter = job_counter
|
|
160
|
+
_job_duration = job_duration
|
|
161
|
+
_job_error_counter = job_error_counter
|
|
162
|
+
|
|
163
|
+
middleware_class = Class.new do
|
|
164
|
+
define_method(:call) do |_worker, msg, queue, &block|
|
|
165
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
166
|
+
error_raised = false
|
|
167
|
+
|
|
168
|
+
begin
|
|
169
|
+
block.call
|
|
170
|
+
rescue StandardError
|
|
171
|
+
error_raised = true
|
|
172
|
+
raise
|
|
173
|
+
ensure
|
|
174
|
+
elapsed_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000.0
|
|
175
|
+
|
|
176
|
+
attrs = {
|
|
177
|
+
"sidekiq.job.class" => msg["class"].to_s,
|
|
178
|
+
"sidekiq.queue" => queue.to_s,
|
|
179
|
+
"sidekiq.status" => error_raised ? "error" : "success",
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
_job_counter.add(1, attributes: attrs)
|
|
183
|
+
_job_duration.record(elapsed_ms, attributes: attrs)
|
|
184
|
+
_job_error_counter.add(1, attributes: attrs) if error_raised
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
Sidekiq.configure_server do |config|
|
|
190
|
+
config.server_middleware do |chain|
|
|
191
|
+
chain.add middleware_class
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
rescue StandardError => e
|
|
195
|
+
warn "Tracelit: failed to install Sidekiq middleware: #{e.message}"
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Polls ActiveRecord connection pool stats every 30 seconds on a daemon
|
|
199
|
+
# thread and records them as gauges. Does not require a live connection
|
|
200
|
+
# at install time — errors during polling are silently retried next cycle.
|
|
201
|
+
def self.install_connection_pool_poller
|
|
202
|
+
pool_size = @meter.create_gauge(
|
|
203
|
+
"db.connection_pool.size",
|
|
204
|
+
description: "Maximum connections in the pool",
|
|
205
|
+
unit: "{connections}"
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
pool_busy = @meter.create_gauge(
|
|
209
|
+
"db.connection_pool.busy",
|
|
210
|
+
description: "Connections currently checked out",
|
|
211
|
+
unit: "{connections}"
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
pool_idle = @meter.create_gauge(
|
|
215
|
+
"db.connection_pool.idle",
|
|
216
|
+
description: "Connections available for checkout",
|
|
217
|
+
unit: "{connections}"
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
pool_waiting = @meter.create_gauge(
|
|
221
|
+
"db.connection_pool.waiting",
|
|
222
|
+
description: "Threads waiting for a connection",
|
|
223
|
+
unit: "{threads}"
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
thread = Thread.new do
|
|
227
|
+
Thread.current[:tracelit_pool_poller] = true
|
|
228
|
+
loop do
|
|
229
|
+
sleep 30
|
|
230
|
+
begin
|
|
231
|
+
pool = ActiveRecord::Base.connection_pool
|
|
232
|
+
stat = pool.stat
|
|
233
|
+
attrs = { "db.system" => pool.pool_config.db_config.adapter.to_s }
|
|
234
|
+
pool_size.record(stat[:size], attributes: attrs)
|
|
235
|
+
pool_busy.record(stat[:busy], attributes: attrs)
|
|
236
|
+
pool_idle.record(stat[:idle], attributes: attrs)
|
|
237
|
+
pool_waiting.record(stat[:waiting], attributes: attrs)
|
|
238
|
+
rescue StandardError
|
|
239
|
+
# Pool may not be connected yet — retry next cycle
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
thread.abort_on_exception = false
|
|
244
|
+
thread
|
|
245
|
+
rescue StandardError => e
|
|
246
|
+
warn "Tracelit: failed to install connection pool poller: #{e.message}"
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Polls process RSS memory every 60 seconds on a daemon thread using ps,
|
|
250
|
+
# which works on both macOS (arm64-darwin) and Linux without /proc.
|
|
251
|
+
def self.install_memory_poller
|
|
252
|
+
memory_gauge = @meter.create_gauge(
|
|
253
|
+
"process.memory.rss",
|
|
254
|
+
description: "Process resident set size (RSS)",
|
|
255
|
+
unit: "MB"
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
pid = Process.pid
|
|
259
|
+
|
|
260
|
+
thread = Thread.new do
|
|
261
|
+
Thread.current[:tracelit_memory_poller] = true
|
|
262
|
+
loop do
|
|
263
|
+
sleep 60
|
|
264
|
+
begin
|
|
265
|
+
rss_kb = `ps -o rss= -p #{pid} 2>/dev/null`.strip.to_i
|
|
266
|
+
next if rss_kb == 0
|
|
267
|
+
|
|
268
|
+
rss_mb = rss_kb / 1024.0
|
|
269
|
+
memory_gauge.record(rss_mb, attributes: {
|
|
270
|
+
"process.pid" => pid.to_s,
|
|
271
|
+
"process.runtime" => "ruby",
|
|
272
|
+
})
|
|
273
|
+
rescue StandardError
|
|
274
|
+
# Ignore — ps may not be available in all environments
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
thread.abort_on_exception = false
|
|
279
|
+
thread
|
|
280
|
+
rescue StandardError => e
|
|
281
|
+
warn "Tracelit: failed to install memory poller: #{e.message}"
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tracelit
|
|
4
|
+
# RailsLoggerBridge adds an OpenTelemetry log emitter to Rails.logger
|
|
5
|
+
# so that every Rails.logger call is also forwarded to the OTel
|
|
6
|
+
# LoggerProvider and exported via OTLP to the Tracelit logs table.
|
|
7
|
+
#
|
|
8
|
+
# It works by broadcasting a lightweight Logger subclass (OTelLogger)
|
|
9
|
+
# alongside the existing logger. In Rails 7.1+, Rails.logger is already
|
|
10
|
+
# an ActiveSupport::BroadcastLogger, so we call broadcast_to directly.
|
|
11
|
+
# In older setups we wrap it in a new BroadcastLogger.
|
|
12
|
+
#
|
|
13
|
+
# Severity mapping (OTel SeverityNumber spec):
|
|
14
|
+
# Rails DEBUG (0) → OTel 5 (SEVERITY_NUMBER_DEBUG)
|
|
15
|
+
# Rails INFO (1) → OTel 9 (SEVERITY_NUMBER_INFO)
|
|
16
|
+
# Rails WARN (2) → OTel 13 (SEVERITY_NUMBER_WARN)
|
|
17
|
+
# Rails ERROR (3) → OTel 17 (SEVERITY_NUMBER_ERROR)
|
|
18
|
+
# Rails FATAL (4) → OTel 21 (SEVERITY_NUMBER_FATAL)
|
|
19
|
+
# Rails UNKNOWN → OTel 1 (SEVERITY_NUMBER_TRACE)
|
|
20
|
+
module RailsLoggerBridge
|
|
21
|
+
SEVERITY_MAP = [5, 9, 13, 17, 21, 1].freeze
|
|
22
|
+
|
|
23
|
+
def self.install(logger_provider)
|
|
24
|
+
return unless defined?(::Rails) && ::Rails.logger
|
|
25
|
+
|
|
26
|
+
otel_logger = logger_provider.logger(
|
|
27
|
+
name: "rails",
|
|
28
|
+
version: Tracelit::VERSION
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
otel_sink = OTelLogger.new(otel_logger)
|
|
32
|
+
|
|
33
|
+
if ::Rails.logger.is_a?(ActiveSupport::BroadcastLogger)
|
|
34
|
+
::Rails.logger.broadcast_to(otel_sink)
|
|
35
|
+
else
|
|
36
|
+
::Rails.logger = ActiveSupport::BroadcastLogger.new(
|
|
37
|
+
::Rails.logger,
|
|
38
|
+
otel_sink
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
rescue StandardError => e
|
|
42
|
+
warn "Tracelit: failed to install Rails logger bridge: #{e.message}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# OTelLogger is a Logger subclass whose add method emits an OTel LogRecord
|
|
46
|
+
# instead of writing to an IO device. It is added as a broadcast target so
|
|
47
|
+
# the original Rails logger output is preserved.
|
|
48
|
+
#
|
|
49
|
+
# The SDK Logger#on_emit defaults context: to OpenTelemetry::Context.current,
|
|
50
|
+
# which automatically correlates the log record to the current active span
|
|
51
|
+
# (trace_id + span_id) without any extra work here.
|
|
52
|
+
class OTelLogger < ::Logger
|
|
53
|
+
def initialize(otel_logger)
|
|
54
|
+
# Discard output — this logger only emits OTel records
|
|
55
|
+
super(File::NULL)
|
|
56
|
+
@otel_logger = otel_logger
|
|
57
|
+
# Accept all severities so we don't filter below the original logger
|
|
58
|
+
self.level = ::Logger::DEBUG
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def add(severity, message = nil, progname = nil)
|
|
62
|
+
severity_number = SEVERITY_MAP[severity.to_i] || 9
|
|
63
|
+
severity_text = ::Logger::SEV_LABEL[severity.to_i] || "ANY"
|
|
64
|
+
|
|
65
|
+
body = if message.nil?
|
|
66
|
+
block_given? ? yield : progname
|
|
67
|
+
else
|
|
68
|
+
message
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
@otel_logger.on_emit(
|
|
72
|
+
timestamp: Time.now,
|
|
73
|
+
severity_number: severity_number,
|
|
74
|
+
severity_text: severity_text,
|
|
75
|
+
body: body.to_s
|
|
76
|
+
)
|
|
77
|
+
rescue StandardError
|
|
78
|
+
# Never let OTel errors surface to the application
|
|
79
|
+
end
|
|
80
|
+
alias_method :log, :add
|
|
81
|
+
|
|
82
|
+
def close; end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tracelit
|
|
4
|
+
# Railtie hooks Tracelit into the Rails boot sequence automatically.
|
|
5
|
+
# No explicit initializer call needed — adding the gem to the Gemfile
|
|
6
|
+
# is sufficient for Rails apps.
|
|
7
|
+
class Railtie < ::Rails::Railtie
|
|
8
|
+
# Run after the app is initialized so config/initializers/ have
|
|
9
|
+
# already been evaluated and Tracelit.configure { } blocks applied.
|
|
10
|
+
initializer "tracelit.configure", after: :load_config_initializers do
|
|
11
|
+
Tracelit::Instrumentation.setup(Tracelit.config)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
data/lib/tracelit.rb
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "tracelit/version"
|
|
4
|
+
require_relative "tracelit/configuration"
|
|
5
|
+
require_relative "tracelit/instrumentation"
|
|
6
|
+
|
|
7
|
+
module Tracelit
|
|
8
|
+
class << self
|
|
9
|
+
# Global configuration instance. Thread-safe after boot — configure
|
|
10
|
+
# once in an initializer, read-only thereafter.
|
|
11
|
+
def config
|
|
12
|
+
@config ||= Configuration.new
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Yields the configuration object for block-style setup:
|
|
16
|
+
#
|
|
17
|
+
# Tracelit.configure do |config|
|
|
18
|
+
# config.api_key = "tl_live_abc123"
|
|
19
|
+
# config.service_name = "payments-api"
|
|
20
|
+
# config.environment = "production"
|
|
21
|
+
# config.sample_rate = 0.2
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
def configure
|
|
25
|
+
yield config
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Manually trigger SDK setup. Not needed for Rails — the Railtie
|
|
29
|
+
# handles this automatically. Call explicitly for Sinatra/Rack:
|
|
30
|
+
#
|
|
31
|
+
# Tracelit.start!
|
|
32
|
+
#
|
|
33
|
+
def start!
|
|
34
|
+
Instrumentation.setup(config)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Returns the OpenTelemetry tracer for this service.
|
|
38
|
+
# Use for manual instrumentation of custom operations:
|
|
39
|
+
#
|
|
40
|
+
# Tracelit.tracer.in_span("my_operation") do |span|
|
|
41
|
+
# span.set_attribute("order.id", order.id)
|
|
42
|
+
# do_work
|
|
43
|
+
# end
|
|
44
|
+
#
|
|
45
|
+
def tracer
|
|
46
|
+
OpenTelemetry.tracer_provider.tracer(
|
|
47
|
+
config.resolved_service_name,
|
|
48
|
+
VERSION
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Returns the Tracelit metrics interface for manual instrumentation:
|
|
53
|
+
#
|
|
54
|
+
# Tracelit.metrics.counter("payments.processed").add(1,
|
|
55
|
+
# attributes: { "currency" => "USD" }
|
|
56
|
+
# )
|
|
57
|
+
#
|
|
58
|
+
def metrics
|
|
59
|
+
Tracelit::Metrics
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Auto-require Railtie when Rails is present.
|
|
65
|
+
require_relative "tracelit/railtie" if defined?(::Rails::Railtie)
|
data/tracelit.gemspec
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lib/tracelit/version"
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = "tracelit"
|
|
7
|
+
spec.version = Tracelit::VERSION
|
|
8
|
+
spec.authors = ["Tracelit"]
|
|
9
|
+
spec.email = ["hey@tracelit.io"]
|
|
10
|
+
spec.summary = "Official Ruby SDK for Tracelit backend observability"
|
|
11
|
+
spec.description = "Drop-in OpenTelemetry instrumentation for Rails, Sinatra, " \
|
|
12
|
+
"and Rack apps. Sends traces, metrics, and logs to the " \
|
|
13
|
+
"Tracelit ingest API via OTLP/HTTP."
|
|
14
|
+
spec.homepage = "https://tracelit.io"
|
|
15
|
+
spec.license = "MIT"
|
|
16
|
+
|
|
17
|
+
spec.required_ruby_version = ">= 3.0"
|
|
18
|
+
|
|
19
|
+
spec.files = Dir[
|
|
20
|
+
"lib/**/*.rb",
|
|
21
|
+
"tracelit.gemspec",
|
|
22
|
+
"README.md"
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
spec.require_paths = ["lib"]
|
|
26
|
+
|
|
27
|
+
# Core OTel runtime dependencies
|
|
28
|
+
spec.add_dependency "opentelemetry-sdk", "~> 1.4"
|
|
29
|
+
spec.add_dependency "opentelemetry-exporter-otlp", "~> 0.26"
|
|
30
|
+
spec.add_dependency "opentelemetry-instrumentation-all", "~> 0.62"
|
|
31
|
+
spec.add_dependency "opentelemetry-logs-sdk", "~> 0.5"
|
|
32
|
+
spec.add_dependency "opentelemetry-exporter-otlp-logs", "~> 0.4"
|
|
33
|
+
spec.add_dependency "opentelemetry-metrics-sdk", "~> 0.13"
|
|
34
|
+
spec.add_dependency "opentelemetry-exporter-otlp-metrics", "~> 0.8"
|
|
35
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: tracelit
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Tracelit
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-04-27 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: opentelemetry-sdk
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '1.4'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '1.4'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: opentelemetry-exporter-otlp
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '0.26'
|
|
34
|
+
type: :runtime
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '0.26'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: opentelemetry-instrumentation-all
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - "~>"
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '0.62'
|
|
48
|
+
type: :runtime
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - "~>"
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '0.62'
|
|
55
|
+
- !ruby/object:Gem::Dependency
|
|
56
|
+
name: opentelemetry-logs-sdk
|
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - "~>"
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '0.5'
|
|
62
|
+
type: :runtime
|
|
63
|
+
prerelease: false
|
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - "~>"
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '0.5'
|
|
69
|
+
- !ruby/object:Gem::Dependency
|
|
70
|
+
name: opentelemetry-exporter-otlp-logs
|
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
|
72
|
+
requirements:
|
|
73
|
+
- - "~>"
|
|
74
|
+
- !ruby/object:Gem::Version
|
|
75
|
+
version: '0.4'
|
|
76
|
+
type: :runtime
|
|
77
|
+
prerelease: false
|
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
79
|
+
requirements:
|
|
80
|
+
- - "~>"
|
|
81
|
+
- !ruby/object:Gem::Version
|
|
82
|
+
version: '0.4'
|
|
83
|
+
- !ruby/object:Gem::Dependency
|
|
84
|
+
name: opentelemetry-metrics-sdk
|
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
|
86
|
+
requirements:
|
|
87
|
+
- - "~>"
|
|
88
|
+
- !ruby/object:Gem::Version
|
|
89
|
+
version: '0.13'
|
|
90
|
+
type: :runtime
|
|
91
|
+
prerelease: false
|
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
93
|
+
requirements:
|
|
94
|
+
- - "~>"
|
|
95
|
+
- !ruby/object:Gem::Version
|
|
96
|
+
version: '0.13'
|
|
97
|
+
- !ruby/object:Gem::Dependency
|
|
98
|
+
name: opentelemetry-exporter-otlp-metrics
|
|
99
|
+
requirement: !ruby/object:Gem::Requirement
|
|
100
|
+
requirements:
|
|
101
|
+
- - "~>"
|
|
102
|
+
- !ruby/object:Gem::Version
|
|
103
|
+
version: '0.8'
|
|
104
|
+
type: :runtime
|
|
105
|
+
prerelease: false
|
|
106
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
107
|
+
requirements:
|
|
108
|
+
- - "~>"
|
|
109
|
+
- !ruby/object:Gem::Version
|
|
110
|
+
version: '0.8'
|
|
111
|
+
description: Drop-in OpenTelemetry instrumentation for Rails, Sinatra, and Rack apps.
|
|
112
|
+
Sends traces, metrics, and logs to the Tracelit ingest API via OTLP/HTTP.
|
|
113
|
+
email:
|
|
114
|
+
- hey@tracelit.io
|
|
115
|
+
executables: []
|
|
116
|
+
extensions: []
|
|
117
|
+
extra_rdoc_files: []
|
|
118
|
+
files:
|
|
119
|
+
- README.md
|
|
120
|
+
- lib/tracelit.rb
|
|
121
|
+
- lib/tracelit/configuration.rb
|
|
122
|
+
- lib/tracelit/error_always_on_sampler.rb
|
|
123
|
+
- lib/tracelit/error_span_processor.rb
|
|
124
|
+
- lib/tracelit/instrumentation.rb
|
|
125
|
+
- lib/tracelit/metrics.rb
|
|
126
|
+
- lib/tracelit/rails_logger_bridge.rb
|
|
127
|
+
- lib/tracelit/railtie.rb
|
|
128
|
+
- lib/tracelit/version.rb
|
|
129
|
+
- tracelit.gemspec
|
|
130
|
+
homepage: https://tracelit.io
|
|
131
|
+
licenses:
|
|
132
|
+
- MIT
|
|
133
|
+
metadata: {}
|
|
134
|
+
post_install_message:
|
|
135
|
+
rdoc_options: []
|
|
136
|
+
require_paths:
|
|
137
|
+
- lib
|
|
138
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
139
|
+
requirements:
|
|
140
|
+
- - ">="
|
|
141
|
+
- !ruby/object:Gem::Version
|
|
142
|
+
version: '3.0'
|
|
143
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
144
|
+
requirements:
|
|
145
|
+
- - ">="
|
|
146
|
+
- !ruby/object:Gem::Version
|
|
147
|
+
version: '0'
|
|
148
|
+
requirements: []
|
|
149
|
+
rubygems_version: 3.0.3.1
|
|
150
|
+
signing_key:
|
|
151
|
+
specification_version: 4
|
|
152
|
+
summary: Official Ruby SDK for Tracelit backend observability
|
|
153
|
+
test_files: []
|