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.
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EvolveLogs
4
+ module Flags
5
+ # Minimal text/event-stream parser: `event` and `data` fields, a blank
6
+ # line dispatches one event, `data` lines join with "\n". Port of
7
+ # logs-js/src/flags/sse.ts (and S2's Python).
8
+ class SseParser
9
+ def initialize(&on_event)
10
+ @on_event = on_event
11
+ @buffer = +""
12
+ @event = "message"
13
+ @data = []
14
+ end
15
+
16
+ def push(text)
17
+ @buffer << text
18
+ while (idx = @buffer.index("\n"))
19
+ line = @buffer[0...idx]
20
+ @buffer = @buffer[(idx + 1)..-1] || +""
21
+ line = line.chomp("\r")
22
+ if line.empty?
23
+ @on_event.call(@event, @data.join("\n")) unless @data.empty?
24
+ @event = "message"
25
+ @data = []
26
+ next
27
+ end
28
+ next if line.start_with?(":")
29
+
30
+ field, colon, value = line.partition(":")
31
+ value = value[1..-1] if colon == ":" && value.start_with?(" ")
32
+ if field == "event"
33
+ @event = value
34
+ elsif field == "data"
35
+ @data << value
36
+ end
37
+ end
38
+ nil
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ # e-volv Launch flags for the Ruby Observer SDK (docs/LAUNCH-SDK.md).
4
+ #
5
+ # One init and one key serve both logs and flags: the flags client binds to
6
+ # the Observer client's key and runs flag delivery on its own background
7
+ # thread, because a held-open stream would stall log flushing.
8
+ #
9
+ # Evaluation is synchronous, never raises and never performs I/O — the
10
+ # delivery state machine (PORTING.md "Delivery behaviour") owns the network
11
+ # on its own thread.
12
+
13
+ require "digest/sha1"
14
+ require "json"
15
+ require "net/http"
16
+ require "securerandom"
17
+ require "tempfile"
18
+ require "tmpdir"
19
+ require "uri"
20
+
21
+ module EvolveLogs
22
+ module Flags
23
+ # The production control plane (contract §2).
24
+ DEFAULT_FLAGS_BASE = "https://api.e-volv.io/api/public/v1/flags"
25
+
26
+ module_function
27
+
28
+ # base_url resolves the flags endpoint: an explicit flags url wins; else
29
+ # the origin of the Observer ingest url; else production. Never throws.
30
+ def base_url(observer_url, flags_url)
31
+ return flags_url.sub(%r{/+\z}, "") if flags_url.is_a?(String) && !flags_url.empty?
32
+
33
+ if observer_url.is_a?(String) && !observer_url.empty?
34
+ begin
35
+ uri = URI.parse(observer_url)
36
+ unless uri.scheme.nil? || uri.host.nil?
37
+ netloc = uri.host
38
+ netloc = "#{netloc}:#{uri.port}" if uri.port && uri.default_port != uri.port
39
+ return "#{uri.scheme}://#{netloc}/api/public/v1/flags"
40
+ end
41
+ rescue StandardError
42
+ # Fall through to production, like the JS URL() catch.
43
+ end
44
+ end
45
+ DEFAULT_FLAGS_BASE
46
+ end
47
+
48
+ # full_jitter_seconds is the reconnect/retry sleep (contract §4):
49
+ # random(0, min(60, 2^attempt)) seconds.
50
+ def full_jitter_seconds(attempt, rng = Random)
51
+ rng.rand * [60, 2**[0, attempt].max].min
52
+ end
53
+ end
54
+ end
55
+
56
+ require "evolve_logs/flags/js_values"
57
+ require "evolve_logs/flags/context"
58
+ require "evolve_logs/flags/regex_cache"
59
+ require "evolve_logs/flags/kernel"
60
+ require "evolve_logs/flags/options"
61
+ require "evolve_logs/flags/cache"
62
+ require "evolve_logs/flags/exposures"
63
+ require "evolve_logs/flags/sse"
64
+ require "evolve_logs/flags/delivery"
65
+ require "evolve_logs/flags/client"
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+
5
+ module EvolveLogs
6
+ # Outbound traceparent injection, monkeypatch-free — helper modules you opt
7
+ # into per call site instead of patching Net::HTTP or Faraday globally.
8
+ module HTTP
9
+ # inject_traceparent sets the W3C traceparent header on a Net::HTTP
10
+ # request (or anything with Net::HTTPHeader-style #[]/#[]=) when called
11
+ # inside a trace. An existing traceparent header is never overwritten.
12
+ #
13
+ # request = Net::HTTP::Post.new(uri)
14
+ # EvolveLogs::HTTP.inject_traceparent(request)
15
+ # http.request(request)
16
+ def self.inject_traceparent(request)
17
+ tp = EvolveLogs.traceparent
18
+ if tp && (request["traceparent"].nil? || request["traceparent"].empty?)
19
+ request["traceparent"] = tp
20
+ end
21
+ request
22
+ end
23
+ end
24
+
25
+ # FaradayMiddleware is a duck-typed Faraday middleware (no faraday require;
26
+ # any object with #call(env) works). Add it where the connection is built:
27
+ #
28
+ # conn = Faraday.new(url: "https://internal") do |builder|
29
+ # builder.use EvolveLogs::FaradayMiddleware
30
+ # builder.adapter Faraday.default_adapter
31
+ # end
32
+ #
33
+ # Outbound requests made inside a trace carry the traceparent header; an
34
+ # existing header is never overwritten.
35
+ class FaradayMiddleware
36
+ def initialize(app, _options = {})
37
+ @app = app
38
+ end
39
+
40
+ def call(env)
41
+ headers = env.request_headers
42
+ tp = EvolveLogs.traceparent
43
+ existing = headers["traceparent"]
44
+ headers["traceparent"] = tp if tp && (existing.nil? || existing.empty?)
45
+ @app.call(env)
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+
5
+ module EvolveLogs
6
+ # Logger is a stdlib ::Logger subclass that ships every line to Observer in
7
+ # addition to the underlying log device, so
8
+ #
9
+ # Rails.logger = EvolveLogs::Logger.new($stdout)
10
+ #
11
+ # keeps the familiar Logger interface while every event reaches the ingest
12
+ # with its severity mapped to the OTel number. Logging an Exception as the
13
+ # message (`Rails.logger.error(err)`) becomes an exception.* error event.
14
+ class Logger < ::Logger
15
+ SEVERITY_TO_OTEL = {
16
+ ::Logger::DEBUG => EvolveLogs::OTEL_DEBUG,
17
+ ::Logger::INFO => EvolveLogs::OTEL_INFO,
18
+ ::Logger::WARN => EvolveLogs::OTEL_WARN,
19
+ ::Logger::ERROR => EvolveLogs::OTEL_ERROR,
20
+ ::Logger::FATAL => EvolveLogs::OTEL_FATAL,
21
+ ::Logger::UNKNOWN => EvolveLogs::OTEL_FATAL,
22
+ }.freeze
23
+
24
+ def initialize(logdev, client: nil)
25
+ @evolve_client = client
26
+ super(logdev)
27
+ end
28
+
29
+ def add(severity, message = nil, progname = nil, &block)
30
+ severity ||= ::Logger::UNKNOWN
31
+ raw = message.nil? ? (block ? block.call : progname) : message
32
+ emit_to_evolve(severity, raw)
33
+ super(severity, message, progname, &block)
34
+ end
35
+
36
+ private
37
+
38
+ def emit_to_evolve(severity, raw)
39
+ client = @evolve_client || EvolveLogs.get_client
40
+ return if client.nil? || !client.enabled?
41
+ if raw.is_a?(Exception)
42
+ client.exception(raw)
43
+ else
44
+ otel = SEVERITY_TO_OTEL[severity] || EvolveLogs::OTEL_INFO
45
+ client.log(otel, raw.nil? ? "" : raw.to_s)
46
+ end
47
+ rescue StandardError
48
+ nil
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EvolveLogs
4
+ # RackMiddleware is plain Rack middleware: one root span per request,
5
+ # continuing an inbound traceparent header (or starting a fresh trace when
6
+ # there is none). Rack is a soft dependency — the middleware is duck-typed
7
+ # on #call and nothing requires "rack".
8
+ #
9
+ # use EvolveLogs::RackMiddleware
10
+ #
11
+ # An app that raises ends the "http.request" span as failed and is
12
+ # re-raised, so the server sees the crash exactly as it would without the
13
+ # SDK.
14
+ class RackMiddleware
15
+ def initialize(app, client: nil, span_name: "http.request")
16
+ @app = app
17
+ @client = client
18
+ @span_name = span_name
19
+ end
20
+
21
+ def call(env)
22
+ client = @client || EvolveLogs.get_client
23
+ inbound = env["HTTP_TRACEPARENT"]
24
+ EvolveLogs.run_with_traceparent(inbound) do
25
+ attrs = {
26
+ "http.method" => env["REQUEST_METHOD"].to_s,
27
+ "http.path" => env["PATH_INFO"].to_s,
28
+ }
29
+ client.span(@span_name, attrs) { @app.call(env) }
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EvolveLogs
4
+ # The backstop list: attribute keys matching this pattern are redacted
5
+ # before anything leaves the process. Pinned by the shared contract;
6
+ # redact_keys extends it (docs/OBSERVER-SDK.md).
7
+ BACKSTOP_PATTERN = /password|secret|token|authorization|cookie|set-cookie|api[-_]?key/i.freeze
8
+ REDACTED = "[redacted]"
9
+
10
+ # Redactor deep-walks hashes and arrays; values whose key matches the
11
+ # pattern (case-insensitive substring) are replaced with "[redacted]".
12
+ class Redactor
13
+ def initialize(extra_keys = nil)
14
+ sources = [BACKSTOP_PATTERN.source]
15
+ Array(extra_keys).each { |k| sources << Regexp.escape(k.to_s) }
16
+ @pattern = Regexp.new(sources.join("|"), Regexp::IGNORECASE)
17
+ end
18
+
19
+ def call(attrs)
20
+ walk(attrs || {})
21
+ end
22
+
23
+ private
24
+
25
+ def walk(value)
26
+ case value
27
+ when Hash
28
+ out = {}
29
+ value.each do |key, val|
30
+ out[key] = @pattern.match?(key.to_s) ? REDACTED : walk(val)
31
+ end
32
+ out
33
+ when Array
34
+ value.map { |val| walk(val) }
35
+ else
36
+ value
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EvolveLogs
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ # e-volv Observer SDK for Ruby (gem: e-volv-logs).
4
+ #
5
+ # Speaks the one wire contract documented in docs/OBSERVER-SDK.md: batches
6
+ # events (200 events / 2 s / 512 KB), gzips the payload, retries 429 with
7
+ # backoff and 413 by halving, redacts sensitive attribute keys before
8
+ # enqueue, and carries W3C traceparent in Thread/Fiber-local context.
9
+ # Everything is fail-silent: a logging SDK never raises into user code.
10
+
11
+ require "json"
12
+ require "zlib"
13
+ require "securerandom"
14
+ require "net/http"
15
+ require "uri"
16
+
17
+ module EvolveLogs
18
+ # Shared batching and retry numbers — identical in every SDK
19
+ # (docs/OBSERVER-SDK.md §"The contract every SDK speaks").
20
+ MAX_BATCH = 200
21
+ FLUSH_INTERVAL = 2.0
22
+ MAX_PAYLOAD_BYTES = 512 * 1024
23
+ MAX_BUFFER = MAX_BATCH * 2 # then the oldest events are dropped
24
+ MAX_ATTEMPTS = 3
25
+ BACKOFF_BASE = 0.5 # seconds, doubling
26
+ BACKOFF_MAX = 10.0
27
+ DELIVERY_TIMEOUT = 10 # seconds per request; matches the ingest's budget
28
+
29
+ # OTel severity numbers (trace 1 … fatal 21).
30
+ OTEL_TRACE = 1
31
+ OTEL_DEBUG = 5
32
+ OTEL_INFO = 9
33
+ OTEL_WARN = 13
34
+ OTEL_ERROR = 17
35
+ OTEL_FATAL = 21
36
+ end
37
+
38
+ require "evolve_logs/version"
39
+ require "evolve_logs/context"
40
+ require "evolve_logs/redact"
41
+ require "evolve_logs/flags"
42
+ require "evolve_logs/client"
43
+ require "evolve_logs/logger"
44
+ require "evolve_logs/rack_middleware"
45
+ require "evolve_logs/carriers"
46
+ require "evolve_logs/http"
47
+
48
+ module EvolveLogs
49
+ # Registry of live clients, flushed from the at_exit hook at the bottom of
50
+ # this file. A process that never calls init has nothing to flush.
51
+ module Registry
52
+ @clients = []
53
+ @mutex = Mutex.new
54
+
55
+ def self.register(client)
56
+ @mutex.synchronize { @clients << client }
57
+ end
58
+
59
+ def self.flush_all
60
+ clients = @mutex.synchronize { @clients.dup }
61
+ clients.each(&:flush)
62
+ end
63
+ end
64
+
65
+ @default_mutex = Mutex.new
66
+ @default_client = nil
67
+
68
+ class << self
69
+ # init installs the module-level default client built from the keyword
70
+ # options and returns it. Module-level helpers (EvolveLogs.info,
71
+ # EvolveLogs.span, …) delegate to it; a later init replaces the previous
72
+ # default. With an empty key or url the client is a no-op and warns once
73
+ # on stderr. `flags:` carries the Launch flags options (a
74
+ # EvolveLogs::Flags::Options or an options Hash); the flags handle is
75
+ # available as client.flags / EvolveLogs.flags.
76
+ def init(key: nil, url: nil, service: nil, environment: nil, release: nil,
77
+ redact_keys: nil, sample_rate: 1.0, flags: nil, **opts)
78
+ install(Client.new(key: key, url: url, service: service,
79
+ environment: environment, release: release,
80
+ redact_keys: redact_keys, sample_rate: sample_rate,
81
+ flags: flags, **opts))
82
+ end
83
+
84
+ # install replaces the module-level default client (used by tests and
85
+ # custom wiring) and returns it.
86
+ def install(client)
87
+ @default_mutex.synchronize { @default_client = client }
88
+ client
89
+ end
90
+
91
+ # get_client returns the current default client, lazily a silent no-op —
92
+ # the one-warning contract belongs to init, not to the pre-init default.
93
+ def get_client
94
+ @default_mutex.synchronize { @default_client } ||
95
+ install(Client.new(key: nil, url: nil, silent: true))
96
+ end
97
+
98
+ # log enqueues an event at the given OTel severity number.
99
+ def log(severity, message, attrs = nil)
100
+ get_client.log(severity, message, attrs)
101
+ end
102
+
103
+ def trace(message, attrs = nil)
104
+ log(OTEL_TRACE, message, attrs)
105
+ end
106
+
107
+ def debug(message, attrs = nil)
108
+ log(OTEL_DEBUG, message, attrs)
109
+ end
110
+
111
+ def info(message, attrs = nil)
112
+ log(OTEL_INFO, message, attrs)
113
+ end
114
+
115
+ def warn(message, attrs = nil)
116
+ log(OTEL_WARN, message, attrs)
117
+ end
118
+
119
+ def error(message, attrs = nil)
120
+ log(OTEL_ERROR, message, attrs)
121
+ end
122
+
123
+ def fatal(message, attrs = nil)
124
+ log(OTEL_FATAL, message, attrs)
125
+ end
126
+
127
+ # exception logs err as an error event: severity 17, err.message as the
128
+ # message, and exception.type / exception.message / exception.stack
129
+ # attributes, which make the event an error occurrence on the group page.
130
+ def exception(err, attrs = nil)
131
+ get_client.exception(err, attrs)
132
+ end
133
+
134
+ # span opens a child span of the current trace (or a new root trace) and
135
+ # runs the block inside it. A raised exception ends the span failed —
136
+ # exception.* attrs, severity 17 — and is re-raised, so the caller sees
137
+ # the failure exactly as before.
138
+ def span(name, attrs = nil, &block)
139
+ get_client.span(name, attrs, &block)
140
+ end
141
+
142
+ # traceparent returns the W3C traceparent of the current trace,
143
+ # "00-<traceId>-<spanId>-01", or nil outside a trace.
144
+ def traceparent
145
+ Context.traceparent
146
+ end
147
+
148
+ # run_with_traceparent runs the block with the next hop of the trace
149
+ # named by a W3C traceparent header — the consumer side of a queue, the
150
+ # callee side of an inbound request. An absent or malformed header starts
151
+ # a fresh root trace.
152
+ def run_with_traceparent(header)
153
+ Context.with(Context.hop(header)) { yield }
154
+ end
155
+
156
+ def current_context
157
+ Context.current
158
+ end
159
+
160
+ # flush sends pending events on the default client; safe to call
161
+ # repeatedly (also from an at_exit hook).
162
+ def flush
163
+ get_client.flush
164
+ end
165
+
166
+ # flags returns the default client's Launch flags handle
167
+ # (docs/LAUNCH-SDK.md §3).
168
+ def flags
169
+ get_client.flags
170
+ end
171
+
172
+ # dropped returns the default client's dropped-event counter.
173
+ def dropped
174
+ get_client.dropped
175
+ end
176
+ end
177
+ end
178
+
179
+ at_exit { EvolveLogs::Registry.flush_all }
metadata ADDED
@@ -0,0 +1,95 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: e-volv-logs
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - e-volv
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-09-14 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: minitest
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '5.0'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '5.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '12.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '12.0'
41
+ description: Batched log shipping, trace context and error capture for the e-volv
42
+ Observer ingest. Pure Ruby + stdlib; Rack middleware, Rails.logger bridge, ActiveJob
43
+ and Sidekiq carriers, Net::HTTP and Faraday injection.
44
+ email:
45
+ - support@e-volv.io
46
+ executables: []
47
+ extensions: []
48
+ extra_rdoc_files: []
49
+ files:
50
+ - LICENSE
51
+ - README.md
52
+ - lib/evolve_logs.rb
53
+ - lib/evolve_logs/carriers.rb
54
+ - lib/evolve_logs/client.rb
55
+ - lib/evolve_logs/context.rb
56
+ - lib/evolve_logs/flags.rb
57
+ - lib/evolve_logs/flags/cache.rb
58
+ - lib/evolve_logs/flags/client.rb
59
+ - lib/evolve_logs/flags/context.rb
60
+ - lib/evolve_logs/flags/delivery.rb
61
+ - lib/evolve_logs/flags/exposures.rb
62
+ - lib/evolve_logs/flags/js_values.rb
63
+ - lib/evolve_logs/flags/kernel.rb
64
+ - lib/evolve_logs/flags/options.rb
65
+ - lib/evolve_logs/flags/regex_cache.rb
66
+ - lib/evolve_logs/flags/sse.rb
67
+ - lib/evolve_logs/http.rb
68
+ - lib/evolve_logs/logger.rb
69
+ - lib/evolve_logs/rack_middleware.rb
70
+ - lib/evolve_logs/redact.rb
71
+ - lib/evolve_logs/version.rb
72
+ homepage: https://e-volv.io
73
+ licenses:
74
+ - MIT
75
+ metadata: {}
76
+ post_install_message:
77
+ rdoc_options: []
78
+ require_paths:
79
+ - lib
80
+ required_ruby_version: !ruby/object:Gem::Requirement
81
+ requirements:
82
+ - - ">="
83
+ - !ruby/object:Gem::Version
84
+ version: '2.6'
85
+ required_rubygems_version: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ requirements: []
91
+ rubygems_version: 3.5.22
92
+ signing_key:
93
+ specification_version: 4
94
+ summary: e-volv Observer SDK for Ruby
95
+ test_files: []