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