e-volv-logs 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/LICENSE +21 -0
- data/README.md +230 -0
- data/lib/evolve_logs/carriers.rb +60 -0
- data/lib/evolve_logs/client.rb +383 -0
- data/lib/evolve_logs/context.rb +69 -0
- data/lib/evolve_logs/flags/cache.rb +87 -0
- data/lib/evolve_logs/flags/client.rb +316 -0
- data/lib/evolve_logs/flags/context.rb +128 -0
- data/lib/evolve_logs/flags/delivery.rb +391 -0
- data/lib/evolve_logs/flags/exposures.rb +128 -0
- data/lib/evolve_logs/flags/js_values.rb +228 -0
- data/lib/evolve_logs/flags/kernel.rb +334 -0
- data/lib/evolve_logs/flags/options.rb +64 -0
- data/lib/evolve_logs/flags/regex_cache.rb +72 -0
- data/lib/evolve_logs/flags/sse.rb +42 -0
- data/lib/evolve_logs/flags.rb +65 -0
- data/lib/evolve_logs/http.rb +48 -0
- data/lib/evolve_logs/logger.rb +51 -0
- data/lib/evolve_logs/rack_middleware.rb +33 -0
- data/lib/evolve_logs/redact.rb +40 -0
- data/lib/evolve_logs/version.rb +5 -0
- data/lib/evolve_logs.rb +179 -0
- metadata +95 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: a038bda7b8de408b1355408bddc11c2a15d131bffb1079c79ae8234507495af8
|
|
4
|
+
data.tar.gz: bae9886d0cf9622f5cd7dcbdc85aa9465411ebd2a6569eeae0911e5ae0e88fe0
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 829a85718b9ca629f29df44bf4e6bfe362a17f674419322b75f6aaf1e643f03cff93bc59936a2353c91bb8badb95ca008614acab1e0010bea644f00a5bf3554a
|
|
7
|
+
data.tar.gz: 9858b58c19c56ec8013c8e633532932d084b735b49165a661ba7796804d5ae39c746a25ef3c082d53744b1e2b03e967a8af5e97b6f4537288a6b51bd50b113a6
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 e-volv
|
|
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,230 @@
|
|
|
1
|
+
# e-volv-logs (Ruby)
|
|
2
|
+
|
|
3
|
+
e-volv Observer SDK for Ruby — batched log shipping, trace context and error
|
|
4
|
+
capture against the e-volv ingest endpoint (`POST /api/public/v1/logs`).
|
|
5
|
+
Companions: the Node.js SDK `@e-volv/logs`, the Python SDK `e-volv-logs` and
|
|
6
|
+
the Go SDK `e-volv-logs-go`. All four speak one wire contract
|
|
7
|
+
(`docs/OBSERVER-SDK.md`).
|
|
8
|
+
|
|
9
|
+
Ruby 2.6+. Standard library only — no runtime dependencies. `rack` and
|
|
10
|
+
`faraday` are soft, duck-typed integrations; nothing is monkeypatched.
|
|
11
|
+
|
|
12
|
+
## Quick start
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
gem install e-volv-logs
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
```ruby
|
|
19
|
+
require "evolve_logs"
|
|
20
|
+
|
|
21
|
+
EvolveLogs.init(
|
|
22
|
+
key: ENV["EVOLVE_LOGS_KEY"], # project ingest key
|
|
23
|
+
url: "https://api.e-volv.io/api/public/v1/logs",
|
|
24
|
+
service: "orders-api",
|
|
25
|
+
environment: ENV["RACK_ENV"],
|
|
26
|
+
release: ENV["GIT_SHA"]
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
EvolveLogs.info("order created", { "orderId" => "o_1", "total" => 42.5 })
|
|
30
|
+
EvolveLogs.error("payment failed", { "orderId" => "o_1" })
|
|
31
|
+
|
|
32
|
+
begin
|
|
33
|
+
charge
|
|
34
|
+
rescue StandardError => err
|
|
35
|
+
EvolveLogs.exception(err, { "orderId" => "o_1" }) # exception.type/message/stack
|
|
36
|
+
end
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
If `key` or `url` is empty, `init` returns a no-op client and warns once on
|
|
40
|
+
stderr. The SDK never raises into your code.
|
|
41
|
+
|
|
42
|
+
Use `EvolveLogs::Client.new(...)` for a non-default client; module-level
|
|
43
|
+
helpers (`EvolveLogs.info`, `EvolveLogs.span`, `EvolveLogs.flush`, …)
|
|
44
|
+
delegate to the client installed by `EvolveLogs.init`.
|
|
45
|
+
|
|
46
|
+
## Traces and spans
|
|
47
|
+
|
|
48
|
+
Trace context lives in `Thread.current` (fiber-local): it flows into fibers
|
|
49
|
+
under the current thread and never leaks across threads.
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
EvolveLogs.span("db.query", { "table" => "orders" }) do
|
|
53
|
+
db.query("SELECT …")
|
|
54
|
+
end # an exception inside ends the span as failed and is re-raised
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The span end is recorded as an event with `span.name` and `durationMs`
|
|
58
|
+
attributes; the span's trace and span ids ride on the event.
|
|
59
|
+
`EvolveLogs.traceparent` returns the W3C `00-<traceId>-<spanId>-01` header,
|
|
60
|
+
or `nil` outside a trace.
|
|
61
|
+
|
|
62
|
+
### Rack / Rails
|
|
63
|
+
|
|
64
|
+
```ruby
|
|
65
|
+
# config.ru — one root span per request; an inbound traceparent is continued
|
|
66
|
+
use EvolveLogs::RackMiddleware
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
```ruby
|
|
70
|
+
# config/application.rb — every Rails.logger line also ships to Observer;
|
|
71
|
+
# Rails.logger.error(err) carries exception.*
|
|
72
|
+
config.logger = EvolveLogs::Logger.new($stdout)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
An app that raises ends the `http.request` span as failed and is re-raised,
|
|
76
|
+
so the server crashes exactly as it would without the SDK.
|
|
77
|
+
|
|
78
|
+
### Queue hops (ActiveJob, Sidekiq)
|
|
79
|
+
|
|
80
|
+
Send `EvolveLogs.traceparent` with the job; in the worker, wrap the body:
|
|
81
|
+
|
|
82
|
+
```ruby
|
|
83
|
+
# producer
|
|
84
|
+
MyJob.perform_later(args, traceparent: EvolveLogs.traceparent)
|
|
85
|
+
|
|
86
|
+
# consumer
|
|
87
|
+
EvolveLogs::ActiveJob.perform(
|
|
88
|
+
EvolveLogs::ActiveJob.traceparent_in(arguments),
|
|
89
|
+
self.class.name
|
|
90
|
+
) do
|
|
91
|
+
# one trace with the producer
|
|
92
|
+
end
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
`EvolveLogs::Sidekiq.perform(traceparent, worker, attrs) { … }` is the
|
|
96
|
+
Sidekiq shape; `EvolveLogs::Carriers.wrap(header, span_name, attrs) { … }`
|
|
97
|
+
covers anything else. For raw headers,
|
|
98
|
+
`EvolveLogs.run_with_traceparent(header) { … }` runs the block with the next
|
|
99
|
+
hop of that trace — an absent or malformed header starts a fresh trace.
|
|
100
|
+
|
|
101
|
+
### Outbound HTTP (Net::HTTP, Faraday)
|
|
102
|
+
|
|
103
|
+
Monkeypatch-free — opt in per call site:
|
|
104
|
+
|
|
105
|
+
```ruby
|
|
106
|
+
request = Net::HTTP::Post.new(uri)
|
|
107
|
+
EvolveLogs::HTTP.inject_traceparent(request) # never overwrites an existing header
|
|
108
|
+
http.request(request)
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
```ruby
|
|
112
|
+
conn = Faraday.new(url: "https://internal") do |builder|
|
|
113
|
+
builder.use EvolveLogs::FaradayMiddleware
|
|
114
|
+
builder.adapter Faraday.default_adapter
|
|
115
|
+
end
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Errors
|
|
119
|
+
|
|
120
|
+
`EvolveLogs.exception(err, attrs)` sends severity 17 with `err.message` as
|
|
121
|
+
the message and `exception.type` (the class name), `exception.message` and
|
|
122
|
+
`exception.stack` (`err.backtrace`) as attributes — an error occurrence with
|
|
123
|
+
a stack on the group page. A failed `span` records `exception.type` and
|
|
124
|
+
`exception.message` without a stack.
|
|
125
|
+
|
|
126
|
+
## Batching, retries, drops
|
|
127
|
+
|
|
128
|
+
- Flushes at **200 events**, every **2 s**, or a **512 KB** payload —
|
|
129
|
+
whichever comes first. Bodies are always gzipped (`Content-Encoding: gzip`).
|
|
130
|
+
- **429** honours `Retry-After`, otherwise exponential backoff (500 ms
|
|
131
|
+
doubling, capped at 10 s), up to 3 attempts. **413** halves the batch; the
|
|
132
|
+
excess half is dropped and counted.
|
|
133
|
+
- When the pending buffer exceeds **2× batch size**, the **oldest** events
|
|
134
|
+
are dropped; losses are visible on `client.dropped`.
|
|
135
|
+
- `EvolveLogs.flush` sends what is pending; it is safe to call repeatedly
|
|
136
|
+
(also from an `at_exit` hook, which the SDK installs itself).
|
|
137
|
+
|
|
138
|
+
## Redaction and sampling
|
|
139
|
+
|
|
140
|
+
Attribute keys matching `password|secret|token|authorization|cookie|set-cookie|api[-_]?key`
|
|
141
|
+
(case-insensitive substring, recursing into nested hashes and arrays) are
|
|
142
|
+
replaced with `[redacted]` before enqueue; `redact_keys:` extends the list.
|
|
143
|
+
`sample_rate:` (0–1) randomly drops events below 1.
|
|
144
|
+
|
|
145
|
+
Every event carries `service.name`, `deployment.environment` and
|
|
146
|
+
`service.release` from `init`.
|
|
147
|
+
|
|
148
|
+
## Feature flags (e-volv Launch)
|
|
149
|
+
|
|
150
|
+
Evaluate e-volv Launch flags locally, in-process, on the same key as
|
|
151
|
+
telemetry:
|
|
152
|
+
|
|
153
|
+
```ruby
|
|
154
|
+
EvolveLogs.init(
|
|
155
|
+
key: ENV["EVOLVE_LOGS_KEY"],
|
|
156
|
+
url: "https://api.e-volv.io/api/public/v1/logs",
|
|
157
|
+
flags: { mode: "stream" } # optional; see the options table below
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
EvolveLogs.flags.bool("checkout.new", false, { "targetingKey" => "u_1", "plan" => "pro" })
|
|
161
|
+
EvolveLogs.flags.string("banner.copy", "Hello")
|
|
162
|
+
EvolveLogs.flags.number("limits.maxItems", 10)
|
|
163
|
+
EvolveLogs.flags.json("theme.config", {})
|
|
164
|
+
EvolveLogs.flags.detail("checkout.new", false, ctx) # Evaluation(value, variant, reason)
|
|
165
|
+
|
|
166
|
+
EvolveLogs.flags.ready(timeout: 5) # the only call that waits
|
|
167
|
+
EvolveLogs.flags.on_change { |keys| } # returns an unsubscribe proc
|
|
168
|
+
EvolveLogs.flags.last_updated_at # epoch seconds, nil before the first payload
|
|
169
|
+
EvolveLogs.flags.verify # install check against GET /ping
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Flag options (Ruby spellings of the Launch contract, §4) — an
|
|
173
|
+
`EvolveLogs::Flags::Options` or a Hash with the same keys:
|
|
174
|
+
|
|
175
|
+
| Option | Default | Meaning |
|
|
176
|
+
| ----------------------- | ---------- | ----------------------------------------------- |
|
|
177
|
+
| `enabled` | `true` | start the flags client |
|
|
178
|
+
| `mode` | `"stream"` | `stream` \| `poll` \| `offline` |
|
|
179
|
+
| `poll_interval_seconds` | `30` | poll period; minimum 15 |
|
|
180
|
+
| `cache` | temp dir | last-known-payload dir; `false` disables |
|
|
181
|
+
| `bootstrap` | none | a bundled ruleset served before the first fetch |
|
|
182
|
+
| `exposures` | see below | a Hash of exposure options (§7) |
|
|
183
|
+
| `private_attributes` | `[]` | attribute names never sent anywhere |
|
|
184
|
+
|
|
185
|
+
`exposures` carries `enabled` (default `true`), `sample_rate` (`1.0`),
|
|
186
|
+
`dedupe_window_seconds` (`60`) and `send_attributes` (`false`).
|
|
187
|
+
|
|
188
|
+
Guarantees (the e-volv Launch SDK contract):
|
|
189
|
+
|
|
190
|
+
- Evaluation is **synchronous, never raises, never performs I/O** — every
|
|
191
|
+
call answers from the last-held ruleset or your default.
|
|
192
|
+
- Wrong type → your default with reason `TYPE_MISMATCH`.
|
|
193
|
+
- Absent flag → your default with reason `FLAG_NOT_FOUND`.
|
|
194
|
+
- The control plane unreachable → last held value; a cold start serves a
|
|
195
|
+
valid cache file instantly while fetching in the background.
|
|
196
|
+
- Flag delivery runs on its own `e-volv-flags` thread (a held-open stream
|
|
197
|
+
would stall log flushing) and survives `fork` — the threads relaunch on
|
|
198
|
+
the first evaluation in a forked child (Puma, Unicorn, Resque, Sidekiq).
|
|
199
|
+
- Server keys never send `Origin` or `x-evolve-app-id`; requests carry
|
|
200
|
+
`User-Agent: e-volv-logs-ruby/<version>`.
|
|
201
|
+
- Flag evaluation is byte-for-byte identical to every other e-volv SDK: the
|
|
202
|
+
Ruby kernel is a port held to `packages/flags-kernel/fixture.json`.
|
|
203
|
+
|
|
204
|
+
## Conformance
|
|
205
|
+
|
|
206
|
+
```bash
|
|
207
|
+
npx tsx packages/logs-conformance/run.ts packages/logs-conformance/fixture.json -- \
|
|
208
|
+
ruby packages/logs-ruby/conformance_runner.rb
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Replays the shared fixture (`packages/logs-conformance/fixture.json`) through
|
|
212
|
+
this runner against a stub ingest — the same gate every SDK runs, pinning the
|
|
213
|
+
wire contract. See `packages/logs-conformance/runner-protocol.md`.
|
|
214
|
+
|
|
215
|
+
The Launch flags gates:
|
|
216
|
+
|
|
217
|
+
```bash
|
|
218
|
+
ruby packages/logs-ruby/flags_kernel_runner.rb packages/flags-kernel/fixture.json
|
|
219
|
+
npx tsx packages/flags-conformance/run.ts server -- ruby packages/logs-ruby/flags_delivery_runner.rb
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
## Development
|
|
223
|
+
|
|
224
|
+
```bash
|
|
225
|
+
cd packages/logs-ruby
|
|
226
|
+
ruby -Ilib -Itests tests/all.rb
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
No bundler install is required — the tests use minitest, which ships with
|
|
230
|
+
Ruby.
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EvolveLogs
|
|
4
|
+
# Queue carriers. Every queue speaks strings, so the producer captures
|
|
5
|
+
# EvolveLogs.traceparent when enqueuing and the consumer wraps the job body
|
|
6
|
+
# in the matching helper:
|
|
7
|
+
#
|
|
8
|
+
# # producer
|
|
9
|
+
# MyJob.perform_later(args, traceparent: EvolveLogs.traceparent)
|
|
10
|
+
#
|
|
11
|
+
# # consumer (ActiveJob)
|
|
12
|
+
# EvolveLogs::ActiveJob.perform(traceparent, self.class.name) do
|
|
13
|
+
# # job body — one trace with the producer
|
|
14
|
+
# end
|
|
15
|
+
module Carriers
|
|
16
|
+
# wrap continues `traceparent` (a W3C header captured at enqueue time)
|
|
17
|
+
# and opens a span around the block. An absent or malformed header
|
|
18
|
+
# starts a fresh trace, so a producer that sends nothing still yields a
|
|
19
|
+
# trace of its own.
|
|
20
|
+
def self.wrap(traceparent, span_name = "queue.job", attrs = nil, &block)
|
|
21
|
+
EvolveLogs.run_with_traceparent(traceparent) do
|
|
22
|
+
EvolveLogs.span(span_name, attrs, &block)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# ActiveJob carrier: wrap the perform body with the traceparent stored in
|
|
28
|
+
# the job's serialized arguments.
|
|
29
|
+
module ActiveJob
|
|
30
|
+
def self.perform(traceparent, job_class, attrs = nil, &block)
|
|
31
|
+
merged = { "job.class" => job_class.to_s }
|
|
32
|
+
(attrs || {}).each { |k, v| merged[k] = v }
|
|
33
|
+
Carriers.wrap(traceparent, "queue.active_job", merged, &block)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# traceparent_in reads the traceparent a producer stored as the last
|
|
37
|
+
# serialized job argument, e.g. MyJob.perform_later(args,
|
|
38
|
+
# traceparent: EvolveLogs.traceparent).
|
|
39
|
+
def self.traceparent_in(arguments)
|
|
40
|
+
last = Array(arguments).last
|
|
41
|
+
last["traceparent"] if last.is_a?(Hash)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Sidekiq carrier: wrap the perform body with the traceparent carried in
|
|
46
|
+
# the job payload.
|
|
47
|
+
module Sidekiq
|
|
48
|
+
def self.perform(traceparent, worker, attrs = nil, &block)
|
|
49
|
+
merged = { "worker" => worker.to_s }
|
|
50
|
+
(attrs || {}).each { |k, v| merged[k] = v }
|
|
51
|
+
Carriers.wrap(traceparent, "queue.sidekiq", merged, &block)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# traceparent_in reads the traceparent from a Sidekiq job hash
|
|
55
|
+
# ({ "traceparent" => ... } in the payload).
|
|
56
|
+
def self.traceparent_in(job)
|
|
57
|
+
job["traceparent"] if job.is_a?(Hash)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EvolveLogs
|
|
4
|
+
# Client batches and delivers events. Construct one directly for a
|
|
5
|
+
# non-default instance, or through EvolveLogs.init for the module-level
|
|
6
|
+
# default. With an empty key or url the client is a no-op and warns once on
|
|
7
|
+
# stderr (unless silent: true). All public methods are safe for concurrent
|
|
8
|
+
# use and never raise.
|
|
9
|
+
class Client
|
|
10
|
+
attr_reader :key, :url, :service, :environment, :release, :sample_rate, :flags
|
|
11
|
+
|
|
12
|
+
def initialize(key: nil, url: nil, service: nil, environment: nil,
|
|
13
|
+
release: nil, redact_keys: nil, sample_rate: 1.0,
|
|
14
|
+
transport: nil, background: true, silent: false, flags: nil)
|
|
15
|
+
@key = key
|
|
16
|
+
@url = url
|
|
17
|
+
@service = service
|
|
18
|
+
@environment = environment
|
|
19
|
+
@release = release
|
|
20
|
+
@sample_rate = clamp_sample_rate(sample_rate)
|
|
21
|
+
@redact = Redactor.new(redact_keys)
|
|
22
|
+
@transport = transport || method(:net_http_deliver)
|
|
23
|
+
@enabled = present?(@key) && present?(@url)
|
|
24
|
+
@mutex = Mutex.new
|
|
25
|
+
@flush_mutex = Mutex.new
|
|
26
|
+
@buffer = []
|
|
27
|
+
@buffered_bytes = 0
|
|
28
|
+
@dropped = 0
|
|
29
|
+
@stop = false
|
|
30
|
+
@flusher = nil
|
|
31
|
+
# Flags bind to the same key and run delivery on their own thread, so
|
|
32
|
+
# a held-open stream never stalls log flushing. Built before the
|
|
33
|
+
# `enabled` early path: flags work without an ingest url.
|
|
34
|
+
flags_options = Flags::Options.build(flags)
|
|
35
|
+
@flags = key.to_s.empty? || flags_options.enabled == false ?
|
|
36
|
+
Flags::Disabled.new :
|
|
37
|
+
Flags::Client.new(key, flags_options, observer_url: url, post_json: method(:post_json))
|
|
38
|
+
@flags.start
|
|
39
|
+
if @enabled
|
|
40
|
+
Registry.register(self)
|
|
41
|
+
@flusher = Thread.new { flusher_loop } if background
|
|
42
|
+
elsif !silent
|
|
43
|
+
# Kernel#warn, not Client#warn — this is the one-time stderr notice.
|
|
44
|
+
Kernel.warn "e-volv-logs: init without key and url — the client is a no-op."
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def enabled?
|
|
49
|
+
@enabled
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# dropped returns the number of events dropped: evicted past the pending
|
|
53
|
+
# buffer cap (2× the batch size, oldest first) or abandoned after
|
|
54
|
+
# retries. Visible so buffer losses are never silent.
|
|
55
|
+
def dropped
|
|
56
|
+
@mutex.synchronize { @dropped }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# ------------------------------------------------------------------ logging
|
|
60
|
+
|
|
61
|
+
# log enqueues an event at the given OTel severity number.
|
|
62
|
+
def log(severity, message, attrs = nil)
|
|
63
|
+
enqueue(severity, message, attrs, nil)
|
|
64
|
+
rescue StandardError
|
|
65
|
+
nil
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def trace(message, attrs = nil)
|
|
69
|
+
log(OTEL_TRACE, message, attrs)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def debug(message, attrs = nil)
|
|
73
|
+
log(OTEL_DEBUG, message, attrs)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def info(message, attrs = nil)
|
|
77
|
+
log(OTEL_INFO, message, attrs)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def warn(message, attrs = nil)
|
|
81
|
+
log(OTEL_WARN, message, attrs)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def error(message, attrs = nil)
|
|
85
|
+
log(OTEL_ERROR, message, attrs)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def fatal(message, attrs = nil)
|
|
89
|
+
log(OTEL_FATAL, message, attrs)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# exception logs err as an error event: severity 17, err.message as the
|
|
93
|
+
# message, and exception.type (the class name), exception.message and
|
|
94
|
+
# exception.stack attributes — an error occurrence with a stack on the
|
|
95
|
+
# group page.
|
|
96
|
+
def exception(err, attrs = nil)
|
|
97
|
+
return if err.nil?
|
|
98
|
+
merged = {}
|
|
99
|
+
(attrs || {}).each { |k, v| merged[k] = v }
|
|
100
|
+
merged["exception.type"] = err.class.name
|
|
101
|
+
merged["exception.message"] = err.message.to_s
|
|
102
|
+
merged["exception.stack"] = Array(err.backtrace).join("\n")
|
|
103
|
+
log(OTEL_ERROR, err.message.to_s, merged)
|
|
104
|
+
rescue StandardError
|
|
105
|
+
nil
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# ------------------------------------------------------------------ spans
|
|
109
|
+
|
|
110
|
+
# span opens a child span of the current trace (or a new root trace when
|
|
111
|
+
# there is none) and runs the block inside it. The span end is recorded
|
|
112
|
+
# as an event with span.name and durationMs attributes; the span's trace
|
|
113
|
+
# and span ids ride on the event. A raised exception ends the span as
|
|
114
|
+
# failed (severity 17, "span <name> failed", exception.type and
|
|
115
|
+
# exception.message) and is re-raised.
|
|
116
|
+
def span(name, attrs = nil)
|
|
117
|
+
raise ArgumentError, "EvolveLogs.span requires a block" unless block_given?
|
|
118
|
+
tc = Context.child(Context.current)
|
|
119
|
+
start_ms = monotonic_ms
|
|
120
|
+
Context.with(tc) do
|
|
121
|
+
begin
|
|
122
|
+
result = yield
|
|
123
|
+
end_span(name, attrs, tc, start_ms, nil)
|
|
124
|
+
result
|
|
125
|
+
rescue Exception => err # rubocop:disable Lint/RescueException — re-raised below
|
|
126
|
+
end_span(name, attrs, tc, start_ms, err)
|
|
127
|
+
raise
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# ----------------------------------------------------------------- batching
|
|
133
|
+
|
|
134
|
+
# flush sends pending events, then pending flag exposures. Safe to call
|
|
135
|
+
# repeatedly; never raises. 429 honours Retry-After (otherwise
|
|
136
|
+
# exponential backoff, 500 ms doubling to 10 s, 3 attempts); 413 halves
|
|
137
|
+
# the batch, dropping the excess half. Exposures flush even when
|
|
138
|
+
# telemetry is disabled (contract §7).
|
|
139
|
+
def flush
|
|
140
|
+
flush_events
|
|
141
|
+
@flags&.flush_exposures
|
|
142
|
+
nil
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# close stops the background flusher and flushes what is pending.
|
|
146
|
+
# Delivery is best-effort; events still in flight are dropped.
|
|
147
|
+
def close
|
|
148
|
+
@flags&.close
|
|
149
|
+
@stop = true
|
|
150
|
+
flush
|
|
151
|
+
@flusher&.join(1)
|
|
152
|
+
nil
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
private
|
|
156
|
+
|
|
157
|
+
# flush_events drains the pending event buffer with the retry policy.
|
|
158
|
+
def flush_events
|
|
159
|
+
return unless enabled?
|
|
160
|
+
@flush_mutex.synchronize do
|
|
161
|
+
batch = nil
|
|
162
|
+
@mutex.synchronize do
|
|
163
|
+
unless @buffer.empty?
|
|
164
|
+
batch = @buffer.map(&:first)
|
|
165
|
+
@buffer = []
|
|
166
|
+
@buffered_bytes = 0
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
return if batch.nil? || batch.empty?
|
|
170
|
+
|
|
171
|
+
attempt = 0
|
|
172
|
+
loop do
|
|
173
|
+
status, resp_headers = begin
|
|
174
|
+
deliver(batch)
|
|
175
|
+
rescue StandardError
|
|
176
|
+
# A raising transport counts as a transport error: [0, {}].
|
|
177
|
+
[0, {}]
|
|
178
|
+
end
|
|
179
|
+
return if status >= 200 && status < 300
|
|
180
|
+
|
|
181
|
+
if status == 429 && attempt < MAX_ATTEMPTS - 1
|
|
182
|
+
attempt += 1
|
|
183
|
+
interruptible_sleep(backoff(attempt, resp_headers["retry-after"]))
|
|
184
|
+
next
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
if status == 413 && batch.length > 1
|
|
188
|
+
# Halve the batch; the excess half is dropped — visible on dropped.
|
|
189
|
+
half = (batch.length / 2.0).ceil
|
|
190
|
+
@mutex.synchronize { @dropped += batch.length - half }
|
|
191
|
+
batch = batch[0, half]
|
|
192
|
+
next
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
if status.zero? && attempt < MAX_ATTEMPTS - 1
|
|
196
|
+
# Transport error: retry with plain exponential backoff.
|
|
197
|
+
attempt += 1
|
|
198
|
+
interruptible_sleep(backoff(attempt, nil))
|
|
199
|
+
next
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Other statuses and exhausted retries drop the batch.
|
|
203
|
+
@mutex.synchronize { @dropped += batch.length }
|
|
204
|
+
return
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
rescue StandardError
|
|
208
|
+
nil
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
private
|
|
212
|
+
|
|
213
|
+
def enqueue(severity, message, attrs, explicit_context)
|
|
214
|
+
return unless enabled?
|
|
215
|
+
return if @sample_rate < 1 && rand >= @sample_rate
|
|
216
|
+
|
|
217
|
+
tc = explicit_context || Context.current
|
|
218
|
+
event_attrs = @redact.call(attrs)
|
|
219
|
+
event_attrs["service.name"] = @service if present?(@service)
|
|
220
|
+
event_attrs["deployment.environment"] = @environment if present?(@environment)
|
|
221
|
+
event_attrs["service.release"] = @release if present?(@release)
|
|
222
|
+
|
|
223
|
+
event = {
|
|
224
|
+
"ts" => now_ts,
|
|
225
|
+
"severity" => severity,
|
|
226
|
+
"message" => message.to_s,
|
|
227
|
+
"attrs" => event_attrs,
|
|
228
|
+
}
|
|
229
|
+
unless tc.nil?
|
|
230
|
+
event["traceId"] = tc.trace_id
|
|
231
|
+
event["spanId"] = tc.span_id
|
|
232
|
+
event["parentSpanId"] = tc.parent_span_id unless tc.root?
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
size = JSON.generate(event).bytesize
|
|
236
|
+
should_flush = false
|
|
237
|
+
@mutex.synchronize do
|
|
238
|
+
@buffer << [event, size]
|
|
239
|
+
@buffered_bytes += size
|
|
240
|
+
while @buffer.length > MAX_BUFFER
|
|
241
|
+
@dropped += 1
|
|
242
|
+
@buffered_bytes -= @buffer.first[1]
|
|
243
|
+
@buffer.shift
|
|
244
|
+
end
|
|
245
|
+
should_flush = @buffer.length >= MAX_BATCH || @buffered_bytes >= MAX_PAYLOAD_BYTES
|
|
246
|
+
end
|
|
247
|
+
# Threshold flush on a background thread so enqueue never blocks on the
|
|
248
|
+
# network; the periodic flusher covers the background: false case.
|
|
249
|
+
Thread.new { flush } if should_flush && @flusher && !@stop
|
|
250
|
+
nil
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def end_span(name, attrs, tc, start_ms, err)
|
|
254
|
+
span_attrs = {}
|
|
255
|
+
(attrs || {}).each { |k, v| span_attrs[k] = v }
|
|
256
|
+
span_attrs["span.name"] = name
|
|
257
|
+
span_attrs["durationMs"] = (monotonic_ms - start_ms).round
|
|
258
|
+
if err
|
|
259
|
+
span_attrs["exception.type"] = err.class.name
|
|
260
|
+
span_attrs["exception.message"] = err.message.to_s
|
|
261
|
+
enqueue(OTEL_ERROR, "span #{name} failed", span_attrs, tc)
|
|
262
|
+
else
|
|
263
|
+
enqueue(OTEL_INFO, "span #{name} completed", span_attrs, tc)
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def deliver(batch)
|
|
268
|
+
payload = JSON.generate("events" => batch)
|
|
269
|
+
@transport.call(@url, request_headers, Zlib.gzip(payload))
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def request_headers
|
|
273
|
+
{
|
|
274
|
+
"authorization" => "Bearer #{@key}",
|
|
275
|
+
"content-type" => "application/json",
|
|
276
|
+
"content-encoding" => "gzip",
|
|
277
|
+
}
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# post_json sends one JSON payload (flag exposures) through the same
|
|
281
|
+
# transport injection point as events and returns the last status: 2xx
|
|
282
|
+
# sent, an unrecoverable 4xx dropped, 429 and transport errors retried
|
|
283
|
+
# with the existing backoff up to MAX_ATTEMPTS.
|
|
284
|
+
def post_json(url, payload)
|
|
285
|
+
status = 0
|
|
286
|
+
MAX_ATTEMPTS.times do |attempt|
|
|
287
|
+
status, resp_headers = begin
|
|
288
|
+
@transport.call(url, json_headers, JSON.generate(payload))
|
|
289
|
+
rescue StandardError
|
|
290
|
+
[0, {}]
|
|
291
|
+
end
|
|
292
|
+
return status if status >= 200 && status < 300
|
|
293
|
+
return status if status != 429 && status >= 400 && status < 500
|
|
294
|
+
|
|
295
|
+
break if attempt >= MAX_ATTEMPTS - 1
|
|
296
|
+
|
|
297
|
+
interruptible_sleep(backoff(attempt + 1, resp_headers["retry-after"]))
|
|
298
|
+
end
|
|
299
|
+
status
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def json_headers
|
|
303
|
+
{
|
|
304
|
+
"authorization" => "Bearer #{@key}",
|
|
305
|
+
"content-type" => "application/json",
|
|
306
|
+
"user-agent" => "e-volv-logs-ruby/#{VERSION}",
|
|
307
|
+
}
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# net_http_deliver is the default transport: POST the gzipped body.
|
|
311
|
+
# Returns [status, headers], or [0, {}] on any transport error — the
|
|
312
|
+
# retry loop turns that into backoff instead of an exception.
|
|
313
|
+
def net_http_deliver(url, headers, body)
|
|
314
|
+
uri = URI.parse(url)
|
|
315
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
316
|
+
http.use_ssl = uri.scheme == "https"
|
|
317
|
+
http.open_timeout = DELIVERY_TIMEOUT
|
|
318
|
+
http.read_timeout = DELIVERY_TIMEOUT
|
|
319
|
+
path = uri.request_uri
|
|
320
|
+
path = "/" if path.nil? || path.empty?
|
|
321
|
+
request = Net::HTTP::Post.new(path)
|
|
322
|
+
headers.each { |k, v| request[k] = v }
|
|
323
|
+
request.body = body
|
|
324
|
+
response = http.start { |conn| conn.request(request) }
|
|
325
|
+
resp_headers = {}
|
|
326
|
+
response.each_header { |k, v| resp_headers[k] = v }
|
|
327
|
+
[response.code.to_i, resp_headers]
|
|
328
|
+
rescue StandardError
|
|
329
|
+
[0, {}]
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# backoff computes the retry delay: Retry-After (seconds) when the ingest
|
|
333
|
+
# sent one, otherwise exponential backoff from 500 ms doubling to 10 s.
|
|
334
|
+
def backoff(attempt, retry_after)
|
|
335
|
+
text = retry_after.to_s.strip
|
|
336
|
+
unless text.empty?
|
|
337
|
+
begin
|
|
338
|
+
seconds = Float(text)
|
|
339
|
+
return seconds if seconds >= 0
|
|
340
|
+
rescue ArgumentError, TypeError
|
|
341
|
+
# Fall through to exponential backoff.
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
delay = BACKOFF_BASE
|
|
345
|
+
(attempt - 1).times { delay *= 2 }
|
|
346
|
+
[delay, BACKOFF_MAX].min
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def interruptible_sleep(seconds)
|
|
350
|
+
sleep(seconds) unless @stop
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def flusher_loop
|
|
354
|
+
loop do
|
|
355
|
+
sleep(FLUSH_INTERVAL)
|
|
356
|
+
break if @stop
|
|
357
|
+
flush
|
|
358
|
+
end
|
|
359
|
+
rescue StandardError
|
|
360
|
+
nil
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# now_ts formats Time as ISO 8601 UTC with millisecond precision, e.g.
|
|
364
|
+
# 2026-09-06T14:00:00.123Z.
|
|
365
|
+
def now_ts
|
|
366
|
+
Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%LZ")
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def monotonic_ms
|
|
370
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond)
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def clamp_sample_rate(rate)
|
|
374
|
+
rate = rate.to_f
|
|
375
|
+
return 1.0 if rate.nan?
|
|
376
|
+
[[rate, 0.0].max, 1.0].min
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def present?(value)
|
|
380
|
+
!value.nil? && !value.to_s.empty?
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
end
|