logtide 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +23 -0
- data/LICENSE +21 -0
- data/README.md +168 -0
- data/lib/logtide/breadcrumb.rb +65 -0
- data/lib/logtide/circuit_breaker.rb +93 -0
- data/lib/logtide/client.rb +255 -0
- data/lib/logtide/configuration.rb +94 -0
- data/lib/logtide/dsn.rb +56 -0
- data/lib/logtide/error.rb +10 -0
- data/lib/logtide/event.rb +122 -0
- data/lib/logtide/hub.rb +101 -0
- data/lib/logtide/logger_bridge.rb +52 -0
- data/lib/logtide/metrics.rb +25 -0
- data/lib/logtide/rack/middleware.rb +111 -0
- data/lib/logtide/rails/railtie.rb +23 -0
- data/lib/logtide/retry_policy.rb +32 -0
- data/lib/logtide/scope.rb +94 -0
- data/lib/logtide/structured_exception.rb +94 -0
- data/lib/logtide/tracing/span.rb +89 -0
- data/lib/logtide/tracing.rb +61 -0
- data/lib/logtide/transport/batcher.rb +209 -0
- data/lib/logtide/transport/buffer.rb +37 -0
- data/lib/logtide/transport/http.rb +94 -0
- data/lib/logtide/transport/otlp.rb +78 -0
- data/lib/logtide/version.rb +8 -0
- data/lib/logtide.rb +152 -0
- metadata +73 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: a68971846b51c0b0413c804adac77efb0a47c2ac04431d2158e93b9124015581
|
|
4
|
+
data.tar.gz: 5ae7b9131a3d57b163756965bd036eb6b13c0fa1fa95346f7e3194771ff9f0ba
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: d96d2a0f31c1bd3a53bb4d4fa1a3e655de72ca7b41c9f83427b97a404d058005b1b08fb16c043a72c53e52b117c9527401ea7cd956047f1aca79ae7d58d1109c
|
|
7
|
+
data.tar.gz: 4f605cd5487d398297ae4a172b371322d645ed01d83f2cdbffc62804a0a7ca9f00b3e41600c024ab93ad05cdaa7d1e11c34b659d44d04a94c353246e6717f7ee
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format is based on
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/) and this project follows SemVer.
|
|
5
|
+
|
|
6
|
+
## [1.0.0] - 2026-06-17
|
|
7
|
+
|
|
8
|
+
First release. Implements the LogTide SDK spec v1.0.
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- Log capture at five levels and structured exception capture with cause chains
|
|
12
|
+
- Strict wire format with reserved metadata keys and robust serialisation
|
|
13
|
+
- HTTP transport with batching, exponential-backoff retry (retryable statuses
|
|
14
|
+
only, Retry-After honoured) and a circuit breaker
|
|
15
|
+
- Bounded buffer with drop accounting, `flush`/`close` and an at_exit hook
|
|
16
|
+
- DSN parsing and explicit `api_url`/`api_key` configuration
|
|
17
|
+
- Hub/Scope model with tags, user, breadcrumbs and per-request isolation
|
|
18
|
+
- `before_send` hook, log sampling and trace sampling
|
|
19
|
+
- W3C `traceparent` inbound/outbound propagation, spans and OTLP export
|
|
20
|
+
- Rack middleware and Rails Railtie
|
|
21
|
+
- stdlib `Logger` bridge
|
|
22
|
+
- Self-metrics (`logs_sent`, `logs_dropped`, `errors`, `retries`,
|
|
23
|
+
`circuit_breaker_trips`)
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 LogTide
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/logtide-dev/logtide/main/docs/images/logo.png" alt="LogTide Logo" width="400">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<h1 align="center">LogTide Ruby SDK</h1>
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
<a href="https://rubygems.org/gems/logtide"><img src="https://img.shields.io/gem/v/logtide?color=blue" alt="Gem"></a>
|
|
9
|
+
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="License"></a>
|
|
10
|
+
<a href="https://www.ruby-lang.org/"><img src="https://img.shields.io/badge/Ruby-3.1+-red.svg" alt="Ruby"></a>
|
|
11
|
+
<a href="https://github.com/logtide-dev/logtide-ruby/actions"><img src="https://img.shields.io/github/actions/workflow/status/logtide-dev/logtide-ruby/ci.yml?branch=main" alt="CI"></a>
|
|
12
|
+
<a href="https://github.com/logtide-dev/logtide-ruby/releases"><img src="https://img.shields.io/github/v/release/logtide-dev/logtide-ruby" alt="Release"></a>
|
|
13
|
+
</p>
|
|
14
|
+
|
|
15
|
+
<p align="center">
|
|
16
|
+
Official Ruby SDK for <a href="https://logtide.dev">LogTide</a> — self-hosted log management with distributed tracing, error capture, breadcrumbs, and Rack/Rails middleware.
|
|
17
|
+
</p>
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Features
|
|
22
|
+
|
|
23
|
+
- **Leveled logging** — `debug`, `info`, `warn`, `error`, `critical`, plus `capture_exception`
|
|
24
|
+
- **Structured exception capture** with cause chains and stack frames
|
|
25
|
+
- **Hub / Scope model** — per-request isolation with tags, user, breadcrumbs, and trace context
|
|
26
|
+
- **Automatic batching** with configurable size and interval
|
|
27
|
+
- **Retry with backoff** — exponential backoff with jitter; honours `Retry-After`
|
|
28
|
+
- **Circuit breaker** to prevent cascading failures
|
|
29
|
+
- **Bounded buffer** with a drop policy to cap memory; `flush`/`close` and a best-effort exit hook
|
|
30
|
+
- **W3C `traceparent`** inbound and outbound propagation
|
|
31
|
+
- **Spans + OTLP export** with log/trace correlation and sampling
|
|
32
|
+
- **`before_send` hook** and log/trace sampling
|
|
33
|
+
- **Rack middleware + Rails Railtie**
|
|
34
|
+
- **stdlib `Logger` bridge**
|
|
35
|
+
- **Self-metrics** (`logs_sent`, `logs_dropped`, `errors`, `retries`, `circuit_breaker_trips`)
|
|
36
|
+
- **Standard library only** — no runtime dependencies
|
|
37
|
+
- **Thread-safe**
|
|
38
|
+
|
|
39
|
+
Implements the **LogTide SDK spec v1.0**.
|
|
40
|
+
|
|
41
|
+
## Requirements
|
|
42
|
+
|
|
43
|
+
- Ruby 3.1 or later
|
|
44
|
+
- A LogTide instance and DSN
|
|
45
|
+
|
|
46
|
+
## Installation
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
# Gemfile
|
|
50
|
+
gem "logtide"
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
bundle install
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Quick start
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
require "logtide"
|
|
61
|
+
|
|
62
|
+
Logtide.init(
|
|
63
|
+
dsn: "https://lp_your_key@logs.example.com",
|
|
64
|
+
service: "checkout",
|
|
65
|
+
environment: "production"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
Logtide.info("order placed", { order_id: 42 })
|
|
69
|
+
|
|
70
|
+
begin
|
|
71
|
+
charge_card!
|
|
72
|
+
rescue => e
|
|
73
|
+
Logtide.capture_exception(e)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
Logtide.flush
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Configuration also accepts explicit endpoint options:
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
Logtide.init(api_url: "https://logs.example.com", api_key: "lp_your_key", service: "checkout")
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Scope, tags and breadcrumbs
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
Logtide.configure_scope do |scope|
|
|
89
|
+
scope.set_user(id: "u_123", email: "alice@example.com")
|
|
90
|
+
scope.set_tag("region", "eu")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
Logtide.add_breadcrumb(Logtide::Breadcrumb.new(type: "query", message: "SELECT * FROM carts"))
|
|
94
|
+
|
|
95
|
+
Logtide.with_scope do |scope|
|
|
96
|
+
scope.set_tag("step", "payment")
|
|
97
|
+
Logtide.error("payment failed")
|
|
98
|
+
end
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Rails
|
|
102
|
+
|
|
103
|
+
The Railtie installs the Rack middleware automatically. Initialise the SDK in an
|
|
104
|
+
initializer:
|
|
105
|
+
|
|
106
|
+
```ruby
|
|
107
|
+
# config/initializers/logtide.rb
|
|
108
|
+
Logtide.init(dsn: ENV["LOGTIDE_DSN"], service: "my-app", release: ENV["GIT_SHA"])
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Plain Rack
|
|
112
|
+
|
|
113
|
+
```ruby
|
|
114
|
+
use Logtide::Rack::Middleware
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## stdlib Logger bridge
|
|
118
|
+
|
|
119
|
+
```ruby
|
|
120
|
+
logger = Logtide::LoggerBridge.new
|
|
121
|
+
logger.warn("disk almost full") # delivered as a LogTide entry with scope context
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Tracing
|
|
125
|
+
|
|
126
|
+
```ruby
|
|
127
|
+
Logtide.start_span("checkout", kind: :server) do |span|
|
|
128
|
+
span.set_attribute("cart.size", 3)
|
|
129
|
+
Logtide.info("processing") # carries the span's trace_id/span_id
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Propagate the trace to a downstream service:
|
|
133
|
+
headers = Logtide.trace_propagation_headers # { "traceparent" => "00-..." }
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## Configuration options
|
|
137
|
+
|
|
138
|
+
Defaults follow the spec exactly (durations in seconds):
|
|
139
|
+
|
|
140
|
+
| Option | Default |
|
|
141
|
+
|--------|---------|
|
|
142
|
+
| `environment` | `"production"` |
|
|
143
|
+
| `batch_size` | `100` |
|
|
144
|
+
| `flush_interval` | `5` |
|
|
145
|
+
| `max_buffer_size` | `10_000` |
|
|
146
|
+
| `max_retries` | `3` |
|
|
147
|
+
| `retry_delay` | `1` |
|
|
148
|
+
| `circuit_breaker_threshold` | `5` |
|
|
149
|
+
| `circuit_breaker_reset` | `30` |
|
|
150
|
+
| `flush_timeout` | `10` |
|
|
151
|
+
| `max_breadcrumbs` | `100` |
|
|
152
|
+
| `sample_rate` | `1.0` |
|
|
153
|
+
| `traces_sample_rate` | `1.0` |
|
|
154
|
+
| `attach_stacktrace` | `true` |
|
|
155
|
+
| `send_default_pii` | `false` |
|
|
156
|
+
| `debug` | `false` |
|
|
157
|
+
|
|
158
|
+
## Development
|
|
159
|
+
|
|
160
|
+
```sh
|
|
161
|
+
bundle install
|
|
162
|
+
bundle exec rspec
|
|
163
|
+
bundle exec rubocop
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## License
|
|
167
|
+
|
|
168
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Logtide
|
|
6
|
+
# A breadcrumb: a small structured event in the trail leading up to an entry
|
|
7
|
+
# (spec 003 section 5).
|
|
8
|
+
class Breadcrumb
|
|
9
|
+
TYPES = %w[http navigation ui console query error custom].freeze
|
|
10
|
+
|
|
11
|
+
def initialize(type: "custom", category: nil, message: nil, data: nil,
|
|
12
|
+
level: "info", timestamp: nil)
|
|
13
|
+
@type = type
|
|
14
|
+
@category = category
|
|
15
|
+
@message = message
|
|
16
|
+
@data = data
|
|
17
|
+
@level = level.to_s
|
|
18
|
+
@timestamp = timestamp || Time.now.utc
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def to_h
|
|
22
|
+
result = {
|
|
23
|
+
"type" => @type,
|
|
24
|
+
"level" => @level,
|
|
25
|
+
"timestamp" => @timestamp.utc.strftime("%Y-%m-%dT%H:%M:%S.%LZ")
|
|
26
|
+
}
|
|
27
|
+
result["category"] = @category if @category
|
|
28
|
+
result["message"] = @message if @message
|
|
29
|
+
result["data"] = @data if @data
|
|
30
|
+
result
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# A ring buffer of breadcrumbs, oldest first, capped at max_breadcrumbs
|
|
35
|
+
# (spec 004 section 4).
|
|
36
|
+
class BreadcrumbBuffer
|
|
37
|
+
def initialize(max_breadcrumbs)
|
|
38
|
+
@max = max_breadcrumbs
|
|
39
|
+
@crumbs = []
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def add(crumb)
|
|
43
|
+
@crumbs << crumb
|
|
44
|
+
@crumbs.shift while @crumbs.size > @max
|
|
45
|
+
crumb
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def to_a
|
|
49
|
+
@crumbs.dup
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def clear
|
|
53
|
+
@crumbs.clear
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def empty?
|
|
57
|
+
@crumbs.empty?
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def initialize_copy(other)
|
|
61
|
+
super
|
|
62
|
+
@crumbs = other.to_a
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Logtide
|
|
4
|
+
# Circuit breaker around batch delivery (spec 002 section 7).
|
|
5
|
+
#
|
|
6
|
+
# closed -> count consecutive failures; at threshold -> open
|
|
7
|
+
# open -> fail fast until reset_timeout has elapsed -> half-open
|
|
8
|
+
# half-open -> allow exactly one probe; success -> closed, failure -> open
|
|
9
|
+
#
|
|
10
|
+
# A threshold of 0 disables the breaker entirely.
|
|
11
|
+
class CircuitBreaker
|
|
12
|
+
def initialize(threshold:, reset_timeout:, clock: -> { Time.now })
|
|
13
|
+
@threshold = threshold
|
|
14
|
+
@reset_timeout = reset_timeout
|
|
15
|
+
@clock = clock
|
|
16
|
+
@mutex = Mutex.new
|
|
17
|
+
@state = :closed
|
|
18
|
+
@failures = 0
|
|
19
|
+
@opened_at = nil
|
|
20
|
+
@probe_in_flight = false
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def state
|
|
24
|
+
@mutex.synchronize do
|
|
25
|
+
refresh
|
|
26
|
+
@state
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def allow?
|
|
31
|
+
return true if disabled?
|
|
32
|
+
|
|
33
|
+
@mutex.synchronize do
|
|
34
|
+
refresh
|
|
35
|
+
case @state
|
|
36
|
+
when :closed then true
|
|
37
|
+
when :half_open
|
|
38
|
+
if @probe_in_flight
|
|
39
|
+
false
|
|
40
|
+
else
|
|
41
|
+
@probe_in_flight = true
|
|
42
|
+
true
|
|
43
|
+
end
|
|
44
|
+
else
|
|
45
|
+
false
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def record_success
|
|
51
|
+
return if disabled?
|
|
52
|
+
|
|
53
|
+
@mutex.synchronize do
|
|
54
|
+
@state = :closed
|
|
55
|
+
@failures = 0
|
|
56
|
+
@opened_at = nil
|
|
57
|
+
@probe_in_flight = false
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def record_failure
|
|
62
|
+
return if disabled?
|
|
63
|
+
|
|
64
|
+
@mutex.synchronize do
|
|
65
|
+
if @state == :half_open
|
|
66
|
+
open!
|
|
67
|
+
else
|
|
68
|
+
@failures += 1
|
|
69
|
+
open! if @failures >= @threshold
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def disabled?
|
|
77
|
+
@threshold.zero?
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Must be called holding the mutex.
|
|
81
|
+
def refresh
|
|
82
|
+
return unless @state == :open && @opened_at
|
|
83
|
+
|
|
84
|
+
@state = :half_open if @clock.call - @opened_at >= @reset_timeout
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def open!
|
|
88
|
+
@state = :open
|
|
89
|
+
@opened_at = @clock.call
|
|
90
|
+
@probe_in_flight = false
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require_relative "event"
|
|
5
|
+
require_relative "scope"
|
|
6
|
+
require_relative "metrics"
|
|
7
|
+
require_relative "circuit_breaker"
|
|
8
|
+
require_relative "retry_policy"
|
|
9
|
+
require_relative "structured_exception"
|
|
10
|
+
require_relative "tracing"
|
|
11
|
+
require_relative "tracing/span"
|
|
12
|
+
require_relative "transport/http"
|
|
13
|
+
require_relative "transport/otlp"
|
|
14
|
+
require_relative "transport/batcher"
|
|
15
|
+
|
|
16
|
+
module Logtide
|
|
17
|
+
# Owns configuration and the transport, and runs the capture pipeline:
|
|
18
|
+
# merge scope + global metadata, stamp the sdk, run event processors and
|
|
19
|
+
# before_send, sample, then enqueue. Public capture methods are
|
|
20
|
+
# fire-and-forget and never raise for transport problems (spec 001).
|
|
21
|
+
class Client
|
|
22
|
+
LEVELS = %w[debug info warn error critical].freeze
|
|
23
|
+
|
|
24
|
+
attr_reader :configuration
|
|
25
|
+
|
|
26
|
+
def initialize(configuration, batcher: nil, span_batcher: nil, sampler: -> { rand })
|
|
27
|
+
@configuration = configuration
|
|
28
|
+
@metrics = Metrics.new
|
|
29
|
+
@sampler = sampler
|
|
30
|
+
@batcher = batcher || build_batcher
|
|
31
|
+
@span_batcher = span_batcher
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def capture_log(level, message, metadata = nil, scope: nil, hint: nil,
|
|
35
|
+
trace_id: nil, span_id: nil, time: nil)
|
|
36
|
+
dispatch(
|
|
37
|
+
level: normalize_level(level), message: message, metadata: metadata,
|
|
38
|
+
scope: scope, hint: hint || {}, trace_id: trace_id, span_id: span_id, time: time
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def capture_exception(exception, message: nil, level: "error", metadata: nil,
|
|
43
|
+
scope: nil, hint: nil, trace_id: nil, span_id: nil, time: nil)
|
|
44
|
+
structured = StructuredException.serialize(exception, include_stacktrace: @configuration.attach_stacktrace)
|
|
45
|
+
hint = (hint || {}).merge(exception: exception)
|
|
46
|
+
dispatch(
|
|
47
|
+
level: normalize_level(level), message: message || exception.message.to_s,
|
|
48
|
+
metadata: metadata, scope: scope, hint: hint, exception: structured,
|
|
49
|
+
trace_id: trace_id, span_id: span_id, time: time
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Start a span. The returned span becomes the active span on the given scope
|
|
54
|
+
# (the caller is responsible for finishing it). Sampling is decided once per
|
|
55
|
+
# trace at the root span (spec 005 section 5).
|
|
56
|
+
def start_span(name, scope:, kind: :internal)
|
|
57
|
+
parent = scope&.span
|
|
58
|
+
trace_id = parent&.trace_id || scope&.trace_id || Tracing.generate_trace_id
|
|
59
|
+
sampled = parent ? parent.sampled? : sample_trace?
|
|
60
|
+
|
|
61
|
+
Tracing::Span.new(
|
|
62
|
+
name: name, trace_id: trace_id, span_id: Tracing.generate_span_id,
|
|
63
|
+
parent_span_id: parent&.span_id || scope&.span_id, kind: kind,
|
|
64
|
+
sampled: sampled, reporter: ->(span) { export_span(span) }
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def flush(timeout = @configuration.flush_timeout)
|
|
69
|
+
@batcher.flush(timeout)
|
|
70
|
+
@span_batcher&.flush(timeout)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def close(timeout = @configuration.flush_timeout)
|
|
74
|
+
@batcher.close(timeout)
|
|
75
|
+
@span_batcher&.close(timeout)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def metrics
|
|
79
|
+
@metrics.snapshot
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def reset_metrics
|
|
83
|
+
@metrics.reset
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def dispatch(level:, message:, metadata:, scope:, hint:, trace_id:, span_id:,
|
|
89
|
+
time:, exception: nil)
|
|
90
|
+
event = build_event(
|
|
91
|
+
level: level, message: message, metadata: metadata, scope: scope,
|
|
92
|
+
exception: exception, trace_id: trace_id, span_id: span_id, time: time
|
|
93
|
+
)
|
|
94
|
+
wire = event.to_wire
|
|
95
|
+
|
|
96
|
+
wire = run_processors(scope, wire, hint)
|
|
97
|
+
return nil if wire.nil?
|
|
98
|
+
|
|
99
|
+
wire = run_before_send(wire, hint)
|
|
100
|
+
return nil if wire.nil?
|
|
101
|
+
|
|
102
|
+
return nil unless sampled?
|
|
103
|
+
|
|
104
|
+
@batcher.enqueue(wire)
|
|
105
|
+
wire.dig("metadata", "event_id")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def build_event(level:, message:, metadata:, scope:, exception:, trace_id:, span_id:, time:)
|
|
109
|
+
Event.new(
|
|
110
|
+
service: @configuration.service,
|
|
111
|
+
level: level,
|
|
112
|
+
message: message,
|
|
113
|
+
time: time,
|
|
114
|
+
metadata: merged_metadata(scope, metadata),
|
|
115
|
+
tags: scope_value(scope) { |s| s.tags unless s.tags.empty? },
|
|
116
|
+
user: scope_value(scope, &:user),
|
|
117
|
+
breadcrumbs: breadcrumbs_for(scope),
|
|
118
|
+
exception: exception,
|
|
119
|
+
event_id: SecureRandom.hex(16),
|
|
120
|
+
release: @configuration.release,
|
|
121
|
+
environment: @configuration.environment,
|
|
122
|
+
server_name: @configuration.server_name,
|
|
123
|
+
session_id: scope_value(scope, &:session_id),
|
|
124
|
+
trace_id: trace_id || resolve_trace_id(scope),
|
|
125
|
+
span_id: span_id || resolve_span_id(scope)
|
|
126
|
+
)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Resolution order: explicit per-entry > active span > scope trace context
|
|
130
|
+
# (spec 005 section 4).
|
|
131
|
+
def resolve_trace_id(scope)
|
|
132
|
+
scope_value(scope) { |s| s.span&.trace_id || s.trace_id }
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def resolve_span_id(scope)
|
|
136
|
+
scope_value(scope) { |s| s.span&.span_id || s.span_id }
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# entry wins over scope, scope wins over global (spec 004 section 4).
|
|
140
|
+
def merged_metadata(scope, metadata)
|
|
141
|
+
merged = @configuration.global_metadata.dup
|
|
142
|
+
merged.merge!(scope.extra) if scope
|
|
143
|
+
merged.merge!(metadata) if metadata
|
|
144
|
+
merged
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def breadcrumbs_for(scope)
|
|
148
|
+
return nil unless scope
|
|
149
|
+
|
|
150
|
+
crumbs = scope.breadcrumbs.to_a
|
|
151
|
+
crumbs.empty? ? nil : crumbs.map(&:to_h)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def scope_value(scope)
|
|
155
|
+
return nil unless scope
|
|
156
|
+
|
|
157
|
+
yield scope
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def run_processors(scope, wire, hint)
|
|
161
|
+
return wire unless scope
|
|
162
|
+
|
|
163
|
+
scope.event_processors.reduce(wire) do |event, processor|
|
|
164
|
+
return nil if event.nil?
|
|
165
|
+
|
|
166
|
+
processor.call(event, hint)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def run_before_send(wire, hint)
|
|
171
|
+
hook = @configuration.before_send
|
|
172
|
+
return wire unless hook
|
|
173
|
+
|
|
174
|
+
hook.call(wire, hint)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def sampled?
|
|
178
|
+
sample_at?(@configuration.sample_rate)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def sample_trace?
|
|
182
|
+
sample_at?(@configuration.traces_sample_rate)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def sample_at?(rate)
|
|
186
|
+
return true if rate >= 1.0
|
|
187
|
+
return false if rate <= 0
|
|
188
|
+
|
|
189
|
+
@sampler.call < rate
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def export_span(span)
|
|
193
|
+
span_batcher.enqueue(span)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def span_batcher
|
|
197
|
+
@span_batcher ||= build_span_batcher
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def normalize_level(level)
|
|
201
|
+
level = level.to_s
|
|
202
|
+
LEVELS.include?(level) ? level : "info"
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def build_batcher
|
|
206
|
+
sender = Transport::HTTP.new(
|
|
207
|
+
url: @configuration.ingest_url,
|
|
208
|
+
api_key: @configuration.api_key,
|
|
209
|
+
timeout: @configuration.flush_timeout
|
|
210
|
+
)
|
|
211
|
+
build_transport_batcher(sender: sender, metrics: @metrics)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def build_span_batcher
|
|
215
|
+
sender = Transport::Otlp.new(
|
|
216
|
+
url: @configuration.otlp_traces_url,
|
|
217
|
+
api_key: @configuration.api_key,
|
|
218
|
+
timeout: @configuration.flush_timeout,
|
|
219
|
+
resource: {
|
|
220
|
+
service_name: @configuration.service,
|
|
221
|
+
environment: @configuration.environment,
|
|
222
|
+
service_version: @configuration.release
|
|
223
|
+
}
|
|
224
|
+
)
|
|
225
|
+
build_transport_batcher(sender: sender, metrics: Metrics.new)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def build_transport_batcher(sender:, metrics:)
|
|
229
|
+
Transport::Batcher.new(
|
|
230
|
+
sender: sender,
|
|
231
|
+
metrics: metrics,
|
|
232
|
+
circuit_breaker: CircuitBreaker.new(
|
|
233
|
+
threshold: @configuration.circuit_breaker_threshold,
|
|
234
|
+
reset_timeout: @configuration.circuit_breaker_reset
|
|
235
|
+
),
|
|
236
|
+
retry_policy: RetryPolicy.new(
|
|
237
|
+
base: @configuration.retry_delay,
|
|
238
|
+
max_backoff: @configuration.max_backoff,
|
|
239
|
+
max_retries: @configuration.max_retries
|
|
240
|
+
),
|
|
241
|
+
batch_size: @configuration.batch_size,
|
|
242
|
+
max_buffer_size: @configuration.max_buffer_size,
|
|
243
|
+
flush_interval: @configuration.flush_interval,
|
|
244
|
+
flush_timeout: @configuration.flush_timeout,
|
|
245
|
+
logger: debug_logger
|
|
246
|
+
)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def debug_logger
|
|
250
|
+
return nil unless @configuration.debug
|
|
251
|
+
|
|
252
|
+
->(message) { warn(message) }
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|