logbrew-sdk 0.1.0 → 0.1.2

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,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LogBrew
4
+ class EventBatcher
5
+ DEFAULT_MAX_SIZE = 100
6
+ DEFAULT_MAX_BYTES = 262_144
7
+ BATCH_SUFFIX = "]}"
8
+
9
+ Batch = Struct.new(:body, :event_count, :event_bytes)
10
+ private_constant :Batch
11
+
12
+ attr_reader :max_event_bytes
13
+
14
+ def initialize(sdk:, max_size:, max_bytes:)
15
+ validate_positive_integer("max_batch_size", max_size)
16
+ validate_positive_integer("max_batch_bytes", max_bytes)
17
+
18
+ @max_size = max_size
19
+ @max_bytes = max_bytes
20
+ @batch_prefix = "{\"sdk\":#{JSON.generate(sdk)},\"events\":["
21
+ @base_bytes = @batch_prefix.bytesize + BATCH_SUFFIX.bytesize
22
+ if @base_bytes >= @max_bytes
23
+ raise SdkError.new("validation_error", "max_batch_bytes must fit the SDK envelope")
24
+ end
25
+
26
+ @max_event_bytes = @max_bytes - @base_bytes
27
+ rescue JSON::GeneratorError, EncodingError
28
+ raise SdkError.new("validation_error", "sdk identity must be JSON serializable")
29
+ end
30
+
31
+ def next_batch(serialized_events, limit:)
32
+ event_json = []
33
+ event_bytes = 0
34
+ body_bytes = @base_bytes
35
+ maximum = [serialized_events.length, limit, @max_size].min
36
+
37
+ maximum.times do |index|
38
+ serialized = serialized_events.fetch(index)
39
+ next_body_bytes = body_bytes + (event_json.empty? ? 0 : 1) + serialized.bytesize
40
+ break if next_body_bytes > @max_bytes
41
+
42
+ event_json << serialized
43
+ event_bytes += serialized.bytesize
44
+ body_bytes = next_body_bytes
45
+ end
46
+
47
+ if event_json.empty?
48
+ raise SdkError.new("transport_error", "queued event cannot fit the configured batch byte limit")
49
+ end
50
+
51
+ Batch.new(
52
+ (@batch_prefix + event_json.join(",") + BATCH_SUFFIX).freeze,
53
+ event_json.length,
54
+ event_bytes
55
+ ).freeze
56
+ end
57
+
58
+ private
59
+
60
+ def validate_positive_integer(label, value)
61
+ return if value.is_a?(Integer) && value.positive?
62
+
63
+ raise SdkError.new("validation_error", "#{label} must be a positive integer")
64
+ end
65
+ end
66
+ private_constant :EventBatcher
67
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require_relative "../logbrew"
5
+
6
+ module LogBrew
7
+ class FaradayTracingMiddleware < ::Faraday::Middleware
8
+ def initialize(app, client:, on_capture_error: nil)
9
+ super(app)
10
+ @client = client
11
+ @on_capture_error = on_capture_error
12
+ end
13
+
14
+ def call(env)
15
+ prepared = HttpClientTracing.prepare(
16
+ client: @client,
17
+ source: "faraday",
18
+ on_capture_error: @on_capture_error
19
+ ) do
20
+ url = env.url
21
+ [env.method, url && url.host, HttpClientTracing::FaradayHeaderSnapshot.new(env.request_headers)]
22
+ end
23
+ return @app.call(env) unless prepared
24
+
25
+ operation, header = prepared
26
+ begin
27
+ header.inject(operation.traceparent)
28
+ rescue StandardError => error
29
+ operation.capture_error(error)
30
+ HttpClientTracing.reset_header(header, operation)
31
+ return @app.call(env)
32
+ end
33
+
34
+ response = nil
35
+ begin
36
+ response = operation.around { @app.call(env) }
37
+ rescue StandardError => error
38
+ operation.finish(error: error)
39
+ raise
40
+ ensure
41
+ HttpClientTracing.reset_header(header, operation)
42
+ end
43
+
44
+ begin
45
+ response.on_complete do |completed|
46
+ operation.finish(status_code: HttpClientTracing.read_status(completed, :status, operation))
47
+ end
48
+ rescue StandardError => error
49
+ operation.capture_error(error)
50
+ operation.finish(status_code: HttpClientTracing.read_status(response, :status, operation))
51
+ end
52
+ response
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,282 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ipaddr"
4
+
5
+ module LogBrew
6
+ class HttpClientTraceOperation
7
+ attr_reader :context, :traceparent
8
+
9
+ def initialize(client:, parent:, source:, method:, host:, on_capture_error:)
10
+ @client = client
11
+ @context = Trace.create(
12
+ trace_id: parent.trace_id,
13
+ span_id: Trace.generate_span_id,
14
+ parent_span_id: parent.span_id,
15
+ trace_flags: parent.trace_flags
16
+ )
17
+ @traceparent = Trace.create_headers(@context).fetch("traceparent")
18
+ @source = source
19
+ @method = HttpClientTracing.normalize_method(method)
20
+ @host = HttpClientTracing.normalize_host(host)
21
+ @on_capture_error = on_capture_error
22
+ @started_at = HttpClientTracing.monotonic_time
23
+ @mutex = Mutex.new
24
+ @finished = false
25
+ end
26
+
27
+ def around
28
+ HttpClientTracing.with_operation(self) { yield }
29
+ end
30
+
31
+ def finish(status_code: nil, error: nil)
32
+ should_capture = @mutex.synchronize do
33
+ next false if @finished
34
+
35
+ @finished = true
36
+ true
37
+ end
38
+ return unless should_capture
39
+
40
+ capture(status_code, error)
41
+ end
42
+
43
+ def capture_error(error)
44
+ HttpClientTracing.report_capture_error(@on_capture_error, error)
45
+ end
46
+
47
+ private
48
+
49
+ def capture(status_code, error)
50
+ normalized_status = HttpClientTracing.normalize_status_code(status_code)
51
+ metadata = {
52
+ method: @method,
53
+ source: @source,
54
+ sampled: @context.sampled
55
+ }
56
+ metadata[:host] = @host if @host
57
+ metadata[:statusCode] = normalized_status if normalized_status
58
+ metadata[:exceptionType] = error.class.name if error
59
+ status = error || (normalized_status && normalized_status >= 400) ? "error" : "ok"
60
+
61
+ @client.span(
62
+ "ruby_http_span_#{@context.span_id}",
63
+ Time.now.utc.iso8601,
64
+ {
65
+ name: "http.client:#{@method}",
66
+ traceId: @context.trace_id,
67
+ spanId: @context.span_id,
68
+ parentSpanId: @context.parent_span_id,
69
+ status: status,
70
+ durationMs: ((HttpClientTracing.monotonic_time - @started_at) * 1000.0).round(3),
71
+ metadata: metadata
72
+ }
73
+ )
74
+ rescue StandardError => capture_error
75
+ HttpClientTracing.report_capture_error(@on_capture_error, capture_error)
76
+ end
77
+ end
78
+
79
+ class NetHttpTracingClient
80
+ attr_reader :http
81
+
82
+ def initialize(http, client:, on_capture_error: nil)
83
+ @http = http
84
+ @client = client
85
+ @on_capture_error = on_capture_error
86
+ end
87
+
88
+ def request(request, body = nil, &block)
89
+ prepared = HttpClientTracing.prepare(
90
+ client: @client,
91
+ source: "net_http",
92
+ on_capture_error: @on_capture_error
93
+ ) do
94
+ [request.method, address, HttpClientTracing::NetHttpHeaderSnapshot.new(request)]
95
+ end
96
+ return @http.request(request, body, &block) unless prepared
97
+
98
+ operation, header = prepared
99
+ begin
100
+ header.inject(operation.traceparent)
101
+ rescue StandardError => error
102
+ operation.capture_error(error)
103
+ HttpClientTracing.reset_header(header, operation)
104
+ return @http.request(request, body, &block)
105
+ end
106
+
107
+ response = nil
108
+ begin
109
+ response = operation.around { @http.request(request, body, &block) }
110
+ rescue StandardError => error
111
+ operation.finish(error: error)
112
+ raise
113
+ ensure
114
+ HttpClientTracing.reset_header(header, operation)
115
+ end
116
+ operation.finish(status_code: HttpClientTracing.read_status(response, :code, operation))
117
+ response
118
+ end
119
+
120
+ def start(*arguments)
121
+ unless block_given?
122
+ started = @http.start(*arguments)
123
+ return started.equal?(@http) ? self : started
124
+ end
125
+
126
+ @http.start(*arguments) { yield self }
127
+ end
128
+
129
+ def method_missing(name, *arguments, &block)
130
+ return super unless @http.respond_to?(name)
131
+
132
+ @http.public_send(name, *arguments, &block)
133
+ end
134
+
135
+ def respond_to_missing?(name, include_private = false)
136
+ @http.respond_to?(name, include_private) || super
137
+ end
138
+ end
139
+
140
+ module HttpClientTracing
141
+ ACTIVE_OPERATION_KEY = :logbrew_http_client_operation
142
+ SUPPRESSION_KEY = :logbrew_http_client_suppression
143
+ SOURCES = %w[net_http faraday].freeze
144
+ private_constant :ACTIVE_OPERATION_KEY, :SUPPRESSION_KEY, :SOURCES
145
+
146
+ class NetHttpHeaderSnapshot
147
+ def initialize(request)
148
+ @request = request
149
+ @values = request.get_fields("traceparent")
150
+ end
151
+
152
+ def inject(traceparent)
153
+ @request["traceparent"] = traceparent
154
+ end
155
+
156
+ def reset
157
+ @request.delete("traceparent")
158
+ Array(@values).each { |value| @request.add_field("traceparent", value) }
159
+ end
160
+ end
161
+
162
+ class FaradayHeaderSnapshot
163
+ def initialize(headers)
164
+ @headers = headers
165
+ @present = headers.key?("traceparent")
166
+ @value = headers["traceparent"]
167
+ end
168
+
169
+ def inject(traceparent)
170
+ @headers["traceparent"] = traceparent
171
+ end
172
+
173
+ def reset
174
+ if @present
175
+ @headers["traceparent"] = @value
176
+ else
177
+ @headers.delete("traceparent")
178
+ end
179
+ end
180
+ end
181
+
182
+ module_function
183
+
184
+ def wrap_net_http(http, client:, on_capture_error: nil)
185
+ return http if http.is_a?(NetHttpTracingClient)
186
+
187
+ NetHttpTracingClient.new(http, client: client, on_capture_error: on_capture_error)
188
+ end
189
+
190
+ def prepare(client:, source:, on_capture_error: nil)
191
+ parent = Trace.current
192
+ return nil unless parent
193
+ return nil if suppressed? || active_operation?
194
+ return nil unless SOURCES.include?(source)
195
+
196
+ method, host, header = yield
197
+ operation = HttpClientTraceOperation.new(
198
+ client: client,
199
+ parent: parent,
200
+ source: source,
201
+ method: method,
202
+ host: host,
203
+ on_capture_error: on_capture_error
204
+ )
205
+ [operation, header]
206
+ rescue StandardError => error
207
+ report_capture_error(on_capture_error, error)
208
+ nil
209
+ end
210
+
211
+ def with_operation(operation)
212
+ previous = Thread.current[ACTIVE_OPERATION_KEY]
213
+ Thread.current[ACTIVE_OPERATION_KEY] = operation
214
+ Trace.with_context(operation.context) { yield }
215
+ ensure
216
+ Thread.current[ACTIVE_OPERATION_KEY] = previous
217
+ end
218
+
219
+ def suppress
220
+ previous = Thread.current[SUPPRESSION_KEY]
221
+ Thread.current[SUPPRESSION_KEY] = true
222
+ yield
223
+ ensure
224
+ Thread.current[SUPPRESSION_KEY] = previous
225
+ end
226
+
227
+ def active_operation?
228
+ !Thread.current[ACTIVE_OPERATION_KEY].nil?
229
+ end
230
+
231
+ def suppressed?
232
+ Thread.current[SUPPRESSION_KEY] == true
233
+ end
234
+
235
+ def normalize_method(method)
236
+ value = method.to_s.strip.upcase
237
+ value.empty? ? "HTTP" : value[0, 32]
238
+ end
239
+
240
+ def normalize_host(host)
241
+ value = host.to_s.strip.downcase.sub(/\.+\z/, "")
242
+ return nil if value.empty? || value.bytesize > 253
243
+ return nil if value.match?(/\A[0-9.]+\z/)
244
+ return nil unless value.match?(/\A[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?\z/)
245
+
246
+ begin
247
+ IPAddr.new(value)
248
+ nil
249
+ rescue IPAddr::InvalidAddressError
250
+ value
251
+ end
252
+ end
253
+
254
+ def normalize_status_code(status_code)
255
+ value = status_code.to_i
256
+ value.positive? && value <= 999 ? value : nil
257
+ end
258
+
259
+ def read_status(response, method, operation)
260
+ response.public_send(method)
261
+ rescue StandardError => error
262
+ operation.capture_error(error)
263
+ nil
264
+ end
265
+
266
+ def reset_header(header, operation)
267
+ header.reset
268
+ rescue StandardError => error
269
+ operation.capture_error(error)
270
+ end
271
+
272
+ def monotonic_time
273
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
274
+ end
275
+
276
+ def report_capture_error(callback, error)
277
+ callback.call(error) if callback.respond_to?(:call)
278
+ rescue StandardError
279
+ nil
280
+ end
281
+ end
282
+ end
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LogBrew
4
+ # Explicit dependency spans for app-owned database, cache, and queue work.
5
+ module OperationTracing
6
+ UNSAFE_METADATA_PATTERNS = [
7
+ /authorization/i,
8
+ /body/i,
9
+ /broker/i,
10
+ /cache.?key/i,
11
+ /command/i,
12
+ /connection/i,
13
+ /cookie/i,
14
+ /dsn/i,
15
+ /header/i,
16
+ /host/i,
17
+ /\bjid\z/i,
18
+ /job.?id/i,
19
+ /key/i,
20
+ /message/i,
21
+ /param/i,
22
+ /pass#{'word'}/i,
23
+ /payload/i,
24
+ /query/i,
25
+ /sec#{'ret'}/i,
26
+ /sql/i,
27
+ /statement/i,
28
+ /to#{'ken'}/i,
29
+ /url/i,
30
+ /username/i,
31
+ /value/i
32
+ ].freeze
33
+ private_constant :UNSAFE_METADATA_PATTERNS
34
+
35
+ module_function
36
+
37
+ def database_operation(client, name, **options, &block)
38
+ capture_operation(client, "database", name, options, &block)
39
+ end
40
+
41
+ def cache_operation(client, name, **options, &block)
42
+ capture_operation(client, "cache", name, options, &block)
43
+ end
44
+
45
+ def queue_operation(client, name, **options, &block)
46
+ capture_operation(client, "queue", name, options, &block)
47
+ end
48
+
49
+ def capture_operation(client, kind, name, options)
50
+ raise SdkError.new("validation_error", "#{kind} operation block is required") unless block_given?
51
+
52
+ Validation.require_non_empty("#{kind} operation name", name)
53
+ started_at = monotonic_time
54
+ context = child_context
55
+ error = nil
56
+ result = nil
57
+
58
+ Trace.with_context(context) do
59
+ begin
60
+ result = yield context
61
+ rescue StandardError => captured
62
+ error = captured
63
+ end
64
+ end
65
+
66
+ capture_span(client, kind, name, context, started_at, options, error)
67
+ raise error if error
68
+
69
+ result
70
+ end
71
+
72
+ def capture_span(client, kind, name, context, started_at, options, error)
73
+ client.span(
74
+ event_id(kind, context, options),
75
+ timestamp(options),
76
+ {
77
+ name: "#{kind}.operation:#{name}",
78
+ traceId: context.trace_id,
79
+ spanId: context.span_id,
80
+ parentSpanId: context.parent_span_id,
81
+ status: error ? "error" : "ok",
82
+ durationMs: duration_ms(started_at, options),
83
+ metadata: span_metadata(kind, options, error),
84
+ events: span_events(error)
85
+ }
86
+ )
87
+ rescue StandardError => capture_error
88
+ on_error = read_option(options, :on_error)
89
+ begin
90
+ on_error.call(capture_error) if on_error.respond_to?(:call)
91
+ rescue StandardError
92
+ nil
93
+ end
94
+ end
95
+
96
+ def child_context
97
+ parent = Trace.current
98
+ return Trace.create_root unless parent
99
+
100
+ Trace.create(
101
+ trace_id: parent.trace_id,
102
+ span_id: generate_span_id,
103
+ parent_span_id: parent.span_id,
104
+ trace_flags: parent.trace_flags
105
+ )
106
+ end
107
+
108
+ def span_metadata(kind, options, error)
109
+ sanitized_metadata(read_option(options, :metadata)).tap do |metadata|
110
+ metadata["source"] = "#{kind}.operation"
111
+ add_option(metadata, "#{kind}.system", read_option(options, :system))
112
+ add_option(metadata, "#{kind}.operation", read_option(options, :operation))
113
+ add_option(metadata, "#{kind}.target", read_option(options, :target))
114
+ metadata["exceptionType"] = error.class.name if error
115
+ end
116
+ end
117
+
118
+ def span_events(error)
119
+ return nil unless error
120
+
121
+ [
122
+ {
123
+ name: "exception",
124
+ metadata: {
125
+ exceptionType: error.class.name,
126
+ exceptionEscaped: true
127
+ }
128
+ }
129
+ ]
130
+ end
131
+
132
+ def sanitized_metadata(metadata)
133
+ return {} if metadata.nil?
134
+ raise SdkError.new("validation_error", "operation metadata must be an object") unless metadata.is_a?(Hash)
135
+
136
+ metadata.each_with_object({}) do |(key, value), copied|
137
+ normalized_key = key.to_s
138
+ next if normalized_key.strip.empty?
139
+ next if unsafe_metadata_key?(normalized_key)
140
+ next unless primitive_metadata_value?(value)
141
+
142
+ copied[normalized_key] = value
143
+ end
144
+ end
145
+
146
+ def unsafe_metadata_key?(key)
147
+ UNSAFE_METADATA_PATTERNS.any? { |pattern| key.match?(pattern) }
148
+ end
149
+
150
+ def primitive_metadata_value?(value)
151
+ return true if value.nil? || value == true || value == false
152
+ return true if value.is_a?(String) || value.is_a?(Integer)
153
+
154
+ value.is_a?(Float) && value.finite?
155
+ end
156
+
157
+ def add_option(metadata, key, value)
158
+ metadata[key] = value if primitive_metadata_value?(value) && !(value.is_a?(String) && value.strip.empty?)
159
+ end
160
+
161
+ def read_option(options, key)
162
+ options[key] || options[key.to_s]
163
+ end
164
+
165
+ def event_id(kind, context, options)
166
+ read_option(options, :event_id) || "ruby_#{kind}_span_#{context.span_id}"
167
+ end
168
+
169
+ def timestamp(options)
170
+ value = read_option(options, :timestamp)
171
+ return value unless value.nil?
172
+
173
+ Time.now.utc.iso8601
174
+ end
175
+
176
+ def duration_ms(started_at, options)
177
+ configured = read_option(options, :duration_ms)
178
+ return configured unless configured.nil?
179
+
180
+ ((monotonic_time - started_at) * 1000.0).round(3)
181
+ end
182
+
183
+ def monotonic_time
184
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
185
+ end
186
+
187
+ def generate_span_id
188
+ loop do
189
+ value = SecureRandom.hex(8)
190
+ return value unless value.delete("0").empty?
191
+ end
192
+ end
193
+ end
194
+ end