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.
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require_relative "error"
5
+ require_relative "dsn"
6
+
7
+ module Logtide
8
+ # Holds the SDK configuration and the exact spec defaults (004 section 2).
9
+ #
10
+ # Durations are expressed in seconds (Ruby has no duration type; seconds with
11
+ # an optional fractional part is the idiomatic choice and matches sleep,
12
+ # Net::HTTP timeouts, etc.).
13
+ class Configuration
14
+ INGEST_SUFFIX = "/api/v1/ingest"
15
+ OTLP_TRACES_SUFFIX = "/v1/otlp/traces"
16
+
17
+ attr_reader :api_url, :api_key, :service, :environment, :release, :server_name,
18
+ :batch_size, :flush_interval, :max_buffer_size, :max_retries,
19
+ :retry_delay, :max_backoff, :circuit_breaker_threshold,
20
+ :circuit_breaker_reset, :flush_timeout, :max_breadcrumbs,
21
+ :sample_rate, :traces_sample_rate, :global_metadata,
22
+ :attach_stacktrace, :send_default_pii, :debug,
23
+ :before_send, :before_breadcrumb
24
+
25
+ def initialize(dsn: nil, api_url: nil, api_key: nil, service: nil,
26
+ environment: "production", release: nil, server_name: nil,
27
+ batch_size: 100, flush_interval: 5, max_buffer_size: 10_000,
28
+ max_retries: 3, retry_delay: 1, max_backoff: 60,
29
+ circuit_breaker_threshold: 5, circuit_breaker_reset: 30,
30
+ flush_timeout: 10, max_breadcrumbs: 100,
31
+ sample_rate: 1.0, traces_sample_rate: 1.0,
32
+ global_metadata: nil, attach_stacktrace: true,
33
+ send_default_pii: false, debug: false,
34
+ before_send: nil, before_breadcrumb: nil)
35
+ resolve_endpoint(dsn, api_url, api_key)
36
+ @service = require_service(service)
37
+ @environment = environment
38
+ @release = release
39
+ @server_name = server_name || Socket.gethostname
40
+
41
+ @batch_size = batch_size
42
+ @flush_interval = flush_interval
43
+ @max_buffer_size = max_buffer_size
44
+ @max_retries = max_retries
45
+ @retry_delay = retry_delay
46
+ @max_backoff = max_backoff
47
+ @circuit_breaker_threshold = circuit_breaker_threshold
48
+ @circuit_breaker_reset = circuit_breaker_reset
49
+ @flush_timeout = flush_timeout
50
+ @max_breadcrumbs = max_breadcrumbs
51
+ @sample_rate = sample_rate
52
+ @traces_sample_rate = traces_sample_rate
53
+ @global_metadata = global_metadata || {}
54
+ @attach_stacktrace = attach_stacktrace
55
+ @send_default_pii = send_default_pii
56
+ @debug = debug
57
+ @before_send = before_send
58
+ @before_breadcrumb = before_breadcrumb
59
+ end
60
+
61
+ def ingest_url
62
+ "#{@api_url}#{INGEST_SUFFIX}"
63
+ end
64
+
65
+ def otlp_traces_url
66
+ "#{@api_url}#{OTLP_TRACES_SUFFIX}"
67
+ end
68
+
69
+ private
70
+
71
+ def resolve_endpoint(dsn, api_url, api_key)
72
+ if dsn
73
+ parsed = DSN.parse(dsn)
74
+ @api_url = parsed.base_url
75
+ @api_key = parsed.api_key
76
+ elsif api_url && api_key
77
+ @api_url = normalize_base_url(api_url)
78
+ @api_key = api_key
79
+ else
80
+ raise ConfigurationError, "either dsn or both api_url and api_key are required"
81
+ end
82
+ end
83
+
84
+ def normalize_base_url(url)
85
+ url.to_s.delete_suffix("/").delete_suffix(INGEST_SUFFIX)
86
+ end
87
+
88
+ def require_service(service)
89
+ raise ConfigurationError, "service is required" if service.nil? || service.to_s.empty?
90
+
91
+ service.to_s
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require_relative "error"
5
+
6
+ module Logtide
7
+ # A parsed DSN: scheme://apiKey@host[:port][/base-path] (spec 002 section 3).
8
+ # The base path prefix is preserved; the ingest/OTLP suffixes are appended by
9
+ # the configuration, never carried here.
10
+ class DSN
11
+ INGEST_SUFFIX = "/api/v1/ingest"
12
+
13
+ attr_reader :api_key, :base_url
14
+
15
+ def self.parse(dsn)
16
+ new(dsn)
17
+ end
18
+
19
+ def initialize(dsn)
20
+ uri = parse_uri(dsn)
21
+ validate!(uri)
22
+ @api_key = uri.userinfo
23
+ @base_url = build_base_url(uri)
24
+ end
25
+
26
+ private
27
+
28
+ def parse_uri(dsn)
29
+ URI.parse(dsn.to_s)
30
+ rescue URI::InvalidURIError => e
31
+ raise ConfigurationError, "invalid DSN: #{e.message}"
32
+ end
33
+
34
+ def validate!(uri)
35
+ raise ConfigurationError, "invalid DSN: scheme must be http or https" unless %w[http https].include?(uri.scheme)
36
+ raise ConfigurationError, "invalid DSN: missing host" if uri.host.nil? || uri.host.empty?
37
+ raise ConfigurationError, "invalid DSN: missing api key" if uri.userinfo.nil? || uri.userinfo.empty?
38
+ end
39
+
40
+ def build_base_url(uri)
41
+ authority = uri.host
42
+ authority = "#{authority}:#{uri.port}" if uri.port && !default_port?(uri)
43
+ path = normalize_path(uri.path)
44
+ "#{uri.scheme}://#{authority}#{path}"
45
+ end
46
+
47
+ def default_port?(uri)
48
+ (uri.scheme == "https" && uri.port == 443) || (uri.scheme == "http" && uri.port == 80)
49
+ end
50
+
51
+ def normalize_path(path)
52
+ path = path.to_s.delete_suffix("/")
53
+ path.delete_suffix(INGEST_SUFFIX)
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Logtide
4
+ # Base class for errors raised by the SDK itself.
5
+ class Error < StandardError; end
6
+
7
+ # Raised at init time for invalid configuration. This is the one place where
8
+ # the SDK fails loudly rather than swallowing the problem (spec 002 section 3).
9
+ class ConfigurationError < Error; end
10
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Logtide
6
+ # A single log entry in the LogTide wire format (spec 003).
7
+ #
8
+ # The wire format is strict: the backend keeps only the top-level fields
9
+ # time|service|level|message|metadata|trace_id|span_id|session_id and silently
10
+ # strips everything else, so all extra data is nested under metadata.
11
+ class Event
12
+ SDK_NAME = "logtide-ruby"
13
+
14
+ # Reserved metadata keys the SDK populates from structured inputs (003 section 3).
15
+ # `sdk` is handled separately: a caller-provided value wins, so it is never
16
+ # namespaced.
17
+ RESERVED_KEYS = %w[exception breadcrumbs tags user event_id release environment server_name].freeze
18
+
19
+ attr_reader :service, :level, :message, :time, :metadata,
20
+ :trace_id, :span_id, :session_id
21
+
22
+ def initialize(service:, level:, message:, metadata: nil, time: nil,
23
+ tags: nil, user: nil, breadcrumbs: nil, exception: nil,
24
+ event_id: nil, release: nil, environment: nil, server_name: nil,
25
+ trace_id: nil, span_id: nil, session_id: nil)
26
+ @service = service
27
+ @level = level.to_s
28
+ @message = message
29
+ @time = time || Time.now.utc
30
+ @metadata = metadata || {}
31
+ @reserved = {
32
+ "tags" => tags, "user" => user, "breadcrumbs" => breadcrumbs,
33
+ "exception" => exception, "event_id" => event_id, "release" => release,
34
+ "environment" => environment, "server_name" => server_name
35
+ }.compact
36
+ @trace_id = trace_id
37
+ @span_id = span_id
38
+ @session_id = session_id
39
+ end
40
+
41
+ # Build the JSON-ready Hash sent on the wire.
42
+ def to_wire
43
+ wire = {
44
+ "time" => format_time(@time),
45
+ "service" => @service,
46
+ "level" => @level,
47
+ "message" => @message,
48
+ "metadata" => build_metadata
49
+ }
50
+ wire["trace_id"] = @trace_id if @trace_id
51
+ wire["span_id"] = @span_id if @span_id
52
+ wire["session_id"] = @session_id if @session_id
53
+ wire
54
+ end
55
+
56
+ private
57
+
58
+ def build_metadata
59
+ metadata = sanitize(@metadata)
60
+ reserved = sanitize(@reserved)
61
+
62
+ # The SDK owns the reserved keys; a colliding user value is namespaced
63
+ # rather than dropped or allowed to overwrite the SDK value.
64
+ reserved.each_key do |key|
65
+ metadata["user_data_#{key}"] = metadata.delete(key) if metadata.key?(key)
66
+ end
67
+ metadata.merge!(reserved)
68
+
69
+ # `sdk`: caller value wins, default stamp otherwise.
70
+ metadata["sdk"] ||= { "name" => SDK_NAME, "version" => Logtide::VERSION }
71
+ metadata
72
+ end
73
+
74
+ def format_time(time)
75
+ time.utc.strftime("%Y-%m-%dT%H:%M:%S.%LZ")
76
+ end
77
+
78
+ # Produce a JSON-safe deep copy: stringify keys, break cycles, replace
79
+ # binary/unserialisable values rather than raising (003 section 8, C17).
80
+ def sanitize(value, seen = nil)
81
+ case value
82
+ when Hash
83
+ seen = guard_cycle(value, seen) or return "[Circular]"
84
+ value.each_with_object({}) { |(k, v), out| out[k.to_s] = sanitize(v, seen) }
85
+ when Array
86
+ seen = guard_cycle(value, seen) or return "[Circular]"
87
+ value.map { |v| sanitize(v, seen) }
88
+ when String
89
+ printable_string(value)
90
+ when Integer, Float, true, false, nil
91
+ value
92
+ when Symbol
93
+ value.to_s
94
+ when Time
95
+ format_time(value)
96
+ else
97
+ safe_to_s(value)
98
+ end
99
+ end
100
+
101
+ def guard_cycle(value, seen)
102
+ seen ||= {}.compare_by_identity
103
+ return nil if seen.key?(value)
104
+
105
+ seen.dup.tap { |copy| copy[value] = true }
106
+ end
107
+
108
+ def printable_string(value)
109
+ if value.encoding == Encoding::BINARY || !value.valid_encoding?
110
+ "[binary #{value.bytesize} bytes]"
111
+ else
112
+ value
113
+ end
114
+ end
115
+
116
+ def safe_to_s(obj)
117
+ obj.to_s
118
+ rescue StandardError
119
+ obj.class.name
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "scope"
4
+ require_relative "tracing"
5
+
6
+ module Logtide
7
+ # Binds a Client to a stack of Scopes (spec 001 terminology, 004 section 4).
8
+ # Captures merge the current scope; with_scope/clone provide isolation.
9
+ class Hub
10
+ def initialize(client, scope = Scope.new)
11
+ @client = client
12
+ @scope_stack = [scope]
13
+ end
14
+
15
+ attr_reader :client
16
+
17
+ def current_scope
18
+ @scope_stack.last
19
+ end
20
+
21
+ def push_scope
22
+ @scope_stack.push(current_scope.clone)
23
+ current_scope
24
+ end
25
+
26
+ def pop_scope
27
+ @scope_stack.pop if @scope_stack.size > 1
28
+ end
29
+
30
+ def with_scope
31
+ push_scope
32
+ yield current_scope
33
+ ensure
34
+ pop_scope
35
+ end
36
+
37
+ def configure_scope
38
+ yield current_scope
39
+ self
40
+ end
41
+
42
+ def clone
43
+ Hub.new(@client, current_scope.clone)
44
+ end
45
+
46
+ def capture_log(level, message, metadata = nil, **opts)
47
+ return unless @client
48
+
49
+ @client.capture_log(level, message, metadata, scope: current_scope, **opts)
50
+ end
51
+
52
+ def capture_exception(exception, **opts)
53
+ return unless @client
54
+
55
+ @client.capture_exception(exception, scope: current_scope, **opts)
56
+ end
57
+
58
+ def add_breadcrumb(breadcrumb)
59
+ current_scope.add_breadcrumb(breadcrumb)
60
+ end
61
+
62
+ # Start a span and make it the active span. With a block, the span is
63
+ # finished and the previous active span restored automatically.
64
+ def start_span(name, **opts)
65
+ return block_given? ? yield(nil) : nil unless @client
66
+
67
+ span = @client.start_span(name, scope: current_scope, **opts)
68
+ previous = current_scope.span
69
+ current_scope.set_span(span)
70
+ return span unless block_given?
71
+
72
+ begin
73
+ yield span
74
+ ensure
75
+ span.finish
76
+ current_scope.set_span(previous)
77
+ end
78
+ end
79
+
80
+ # Headers to inject into an outbound HTTP request so the trace continues
81
+ # across services (spec 005 section 2). Empty when there is no trace context.
82
+ # Never emits X-Trace-ID.
83
+ def trace_propagation_headers
84
+ scope = current_scope
85
+ trace_id = scope.span&.trace_id || scope.trace_id
86
+ span_id = scope.span&.span_id || scope.span_id
87
+ return {} unless trace_id && span_id
88
+
89
+ sampled = scope.span ? scope.span.sampled? : true
90
+ { "traceparent" => Tracing::Propagation.format_traceparent(trace_id: trace_id, span_id: span_id, sampled: sampled) }
91
+ end
92
+
93
+ def flush(timeout = nil)
94
+ @client&.flush(*[timeout].compact)
95
+ end
96
+
97
+ def close(timeout = nil)
98
+ @client&.close(*[timeout].compact)
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+
5
+ module Logtide
6
+ # A drop-in stdlib Logger that routes records through LogTide (spec 004
7
+ # section 7). Severities are mapped to the five LogTide levels and entries are
8
+ # enriched with the current scope.
9
+ #
10
+ # logger = Logtide::LoggerBridge.new
11
+ # logger.warn("disk almost full")
12
+ class LoggerBridge < ::Logger
13
+ SEVERITY_MAP = {
14
+ ::Logger::DEBUG => "debug",
15
+ ::Logger::INFO => "info",
16
+ ::Logger::WARN => "warn",
17
+ ::Logger::ERROR => "error",
18
+ ::Logger::FATAL => "critical",
19
+ ::Logger::UNKNOWN => "error"
20
+ }.freeze
21
+
22
+ def initialize(hub: nil, level: ::Logger::DEBUG)
23
+ super(nil)
24
+ self.level = level
25
+ @hub = hub
26
+ end
27
+
28
+ def add(severity, message = nil, progname = nil) # rubocop:disable Naming/PredicateMethod
29
+ severity ||= ::Logger::UNKNOWN
30
+ return true if severity < level
31
+
32
+ if message.nil?
33
+ if block_given?
34
+ message = yield
35
+ else
36
+ message = progname
37
+ progname = @progname
38
+ end
39
+ end
40
+
41
+ metadata = progname ? { "logger" => progname.to_s } : nil
42
+ hub.capture_log(SEVERITY_MAP.fetch(severity, "info"), message.to_s, metadata)
43
+ true
44
+ end
45
+
46
+ private
47
+
48
+ def hub
49
+ @hub || Logtide.current_hub
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Logtide
4
+ # Thread-safe self-metrics counters (spec 002 section 9).
5
+ class Metrics
6
+ COUNTERS = %i[logs_sent logs_dropped errors retries circuit_breaker_trips].freeze
7
+
8
+ def initialize
9
+ @mutex = Mutex.new
10
+ @counters = COUNTERS.to_h { |name| [name, 0] }
11
+ end
12
+
13
+ def increment(name, amount = 1)
14
+ @mutex.synchronize { @counters[name] += amount }
15
+ end
16
+
17
+ def snapshot
18
+ @mutex.synchronize { @counters.dup }
19
+ end
20
+
21
+ def reset
22
+ @mutex.synchronize { COUNTERS.each { |name| @counters[name] = 0 } }
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Logtide
4
+ module Rack
5
+ # Rack middleware (spec 004 section 6). Per request it clones the hub for
6
+ # isolation, reads the inbound traceparent, tags the scope, records request
7
+ # and response breadcrumbs, emits a request log, and captures unhandled
8
+ # exceptions before re-raising them (it never swallows errors).
9
+ class Middleware
10
+ DEFAULT_SKIP_PATHS = %w[/health /healthz].freeze
11
+
12
+ # Redacted by default so secrets never reach captured data (spec 004 section 6).
13
+ SENSITIVE_HEADERS = %w[
14
+ authorization cookie set-cookie x-api-key x-auth-token proxy-authorization
15
+ ].freeze
16
+
17
+ def initialize(app, skip_paths: DEFAULT_SKIP_PATHS, capture_request_logs: true)
18
+ @app = app
19
+ @skip_paths = skip_paths
20
+ @capture_request_logs = capture_request_logs
21
+ end
22
+
23
+ def call(env)
24
+ path = env["PATH_INFO"].to_s
25
+ return @app.call(env) if @skip_paths.include?(path)
26
+
27
+ Logtide.with_request_hub do |hub|
28
+ start = monotonic
29
+ setup_scope(hub, env)
30
+ handle(hub, env, start)
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def handle(hub, env, start)
37
+ status, headers, body = @app.call(env)
38
+ finish(hub, env, status, duration_ms(start))
39
+ [status, headers, body]
40
+ rescue Exception => e # rubocop:disable Lint/RescueException
41
+ on_error(hub, e, duration_ms(start))
42
+ raise
43
+ end
44
+
45
+ def setup_scope(hub, env)
46
+ method = request_method(env)
47
+ path = env["PATH_INFO"].to_s
48
+ hub.configure_scope do |scope|
49
+ apply_trace_context(scope, env)
50
+ scope.set_tag("http.method", method)
51
+ scope.set_tag("http.route", route(env))
52
+ end
53
+ hub.add_breadcrumb(breadcrumb("request", "#{method} #{path}", { "method" => method }))
54
+ end
55
+
56
+ def apply_trace_context(scope, env)
57
+ parsed = Tracing::Propagation.parse_traceparent(env["HTTP_TRACEPARENT"])
58
+ if parsed
59
+ scope.set_trace_context(parsed.trace_id, Tracing.generate_span_id)
60
+ elsif (legacy = env["HTTP_X_TRACE_ID"])
61
+ scope.set_trace_context(legacy, Tracing.generate_span_id)
62
+ else
63
+ scope.set_trace_context(Tracing.generate_trace_id, Tracing.generate_span_id)
64
+ end
65
+ end
66
+
67
+ def finish(hub, env, status, duration)
68
+ hub.configure_scope do |scope|
69
+ scope.set_tag("http.status_code", status.to_s)
70
+ scope.set_tag("duration_ms", duration.to_s)
71
+ end
72
+ hub.add_breadcrumb(breadcrumb("response", "#{status} #{env["PATH_INFO"]}",
73
+ { "status_code" => status, "duration_ms" => duration }))
74
+ return unless @capture_request_logs
75
+
76
+ hub.capture_log("info", "#{request_method(env)} #{route(env)}",
77
+ { "http.status_code" => status, "duration_ms" => duration })
78
+ end
79
+
80
+ def on_error(hub, error, duration)
81
+ hub.configure_scope do |scope|
82
+ scope.set_tag("http.status_code", "500")
83
+ scope.set_tag("duration_ms", duration.to_s)
84
+ end
85
+ hub.capture_exception(error)
86
+ end
87
+
88
+ def breadcrumb(category, message, data)
89
+ Breadcrumb.new(type: "http", category: category, message: message, data: data)
90
+ end
91
+
92
+ def request_method(env)
93
+ env["REQUEST_METHOD"].to_s
94
+ end
95
+
96
+ # Plain Rack only knows the raw path; framework integrations supply the
97
+ # route pattern via env (e.g. Rails sets the matched route).
98
+ def route(env)
99
+ env["logtide.route"] || env["sinatra.route"] || env["PATH_INFO"].to_s
100
+ end
101
+
102
+ def duration_ms(start)
103
+ ((monotonic - start) * 1000).round
104
+ end
105
+
106
+ def monotonic
107
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+ require_relative "../rack/middleware"
5
+
6
+ module Logtide
7
+ module Rails
8
+ # Wires the Rack middleware into the Rails stack. Placed after
9
+ # ActionDispatch::ShowExceptions so unhandled exceptions are captured and
10
+ # then re-raised to Rails' own error handling (spec 004 section 6).
11
+ #
12
+ # Call Logtide.init(...) from an initializer; this railtie only installs the
13
+ # middleware.
14
+ class Railtie < ::Rails::Railtie
15
+ initializer "logtide.middleware" do |app|
16
+ app.middleware.insert_after(
17
+ ActionDispatch::ShowExceptions,
18
+ Logtide::Rack::Middleware
19
+ )
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Logtide
4
+ # Decides whether a delivery failure is retryable and how long to wait
5
+ # (spec 002 section 6). Retryable: network errors and 408/429/5xx; never any
6
+ # other 4xx. Backoff is exponential with full jitter, never below the base.
7
+ class RetryPolicy
8
+ RETRYABLE_STATUSES = [408, 429, 500, 502, 503, 504].freeze
9
+
10
+ attr_reader :max_retries
11
+
12
+ def initialize(base:, max_backoff:, max_retries:, rng: -> { rand })
13
+ @base = base
14
+ @max_backoff = max_backoff
15
+ @max_retries = max_retries
16
+ @rng = rng
17
+ end
18
+
19
+ def retryable_status?(status)
20
+ RETRYABLE_STATUSES.include?(status)
21
+ end
22
+
23
+ # Seconds to wait before the next attempt. A server-provided Retry-After wins.
24
+ def delay_for(attempt, retry_after: nil)
25
+ return retry_after if retry_after
26
+
27
+ capped = [@base * (2**attempt), @max_backoff].min
28
+ low = [@base, capped].min
29
+ low + (@rng.call * (capped - low))
30
+ end
31
+ end
32
+ end