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 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