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 +7 -0
- data/CHANGELOG.md +23 -0
- data/LICENSE +21 -0
- data/README.md +168 -0
- data/lib/logtide/breadcrumb.rb +65 -0
- data/lib/logtide/circuit_breaker.rb +93 -0
- data/lib/logtide/client.rb +255 -0
- data/lib/logtide/configuration.rb +94 -0
- data/lib/logtide/dsn.rb +56 -0
- data/lib/logtide/error.rb +10 -0
- data/lib/logtide/event.rb +122 -0
- data/lib/logtide/hub.rb +101 -0
- data/lib/logtide/logger_bridge.rb +52 -0
- data/lib/logtide/metrics.rb +25 -0
- data/lib/logtide/rack/middleware.rb +111 -0
- data/lib/logtide/rails/railtie.rb +23 -0
- data/lib/logtide/retry_policy.rb +32 -0
- data/lib/logtide/scope.rb +94 -0
- data/lib/logtide/structured_exception.rb +94 -0
- data/lib/logtide/tracing/span.rb +89 -0
- data/lib/logtide/tracing.rb +61 -0
- data/lib/logtide/transport/batcher.rb +209 -0
- data/lib/logtide/transport/buffer.rb +37 -0
- data/lib/logtide/transport/http.rb +94 -0
- data/lib/logtide/transport/otlp.rb +78 -0
- data/lib/logtide/version.rb +8 -0
- data/lib/logtide.rb +152 -0
- metadata +73 -0
|
@@ -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
|
data/lib/logtide/dsn.rb
ADDED
|
@@ -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
|
data/lib/logtide/hub.rb
ADDED
|
@@ -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
|