logbrew-sdk 0.1.1 → 0.1.3

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,622 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../logbrew" unless defined?(LogBrew::Client)
4
+ require "uri"
5
+
6
+ module LogBrew
7
+ module Rails
8
+ # Immutable, environment-derived settings for the automatic Rails adapter.
9
+ class Configuration
10
+ DEFAULT_ENDPOINT = LogBrew::HttpTransport::DEFAULT_ENDPOINT
11
+ DEFAULT_REQUEST_TIMEOUT_MS = 10_000
12
+ DEFAULT_FLUSH_INTERVAL_MS = 5_000
13
+ DEFAULT_FLUSH_THRESHOLD = 100
14
+ MAX_LABEL_BYTES = 255
15
+ MAX_ENDPOINT_BYTES = 2_048
16
+ LOOPBACK_HOSTS = %w[localhost 127.0.0.1 ::1 [::1]].freeze
17
+
18
+ attr_reader(
19
+ :api_key,
20
+ :service_name,
21
+ :app_environment,
22
+ :rails_version,
23
+ :release,
24
+ :endpoint,
25
+ :request_timeout,
26
+ :flush_interval,
27
+ :flush_threshold
28
+ )
29
+
30
+ def self.from_environment(environment, application_name:, rails_environment:, rails_version:)
31
+ unless environment.respond_to?(:[]) && environment.respond_to?(:key?)
32
+ raise SdkError.new("configuration_error", "Rails environment must provide key lookup")
33
+ end
34
+
35
+ enabled_setting = boolean_value(environment, "LOGBREW_ENABLED", nil)
36
+ return disabled(application_name, rails_environment, rails_version) if enabled_setting == false
37
+
38
+ canonical_key = server_key_value(environment["LOGBREW_SERVER_API_KEY"])
39
+ if canonical_key.nil?
40
+ legacy_present = %w[LOGBREW_API_KEY LOGBREW_INGEST_KEY].any? do |name|
41
+ !optional_text(environment[name]).nil?
42
+ end
43
+ if enabled_setting == true || legacy_present || environment.key?("LOGBREW_SERVER_API_KEY")
44
+ raise SdkError.new(
45
+ "configuration_error",
46
+ "set LOGBREW_SERVER_API_KEY to a non-empty server API key, or set LOGBREW_ENABLED=false"
47
+ )
48
+ end
49
+ return disabled(application_name, rails_environment, rails_version)
50
+ end
51
+
52
+ new(
53
+ enabled: true,
54
+ api_key: canonical_key,
55
+ service_name: bounded_label(
56
+ optional_text(environment["LOGBREW_SERVICE_NAME"]) || application_name,
57
+ "LOGBREW_SERVICE_NAME"
58
+ ),
59
+ app_environment: bounded_label(
60
+ optional_text(environment["LOGBREW_ENVIRONMENT"]) || rails_environment,
61
+ "LOGBREW_ENVIRONMENT"
62
+ ),
63
+ rails_version: bounded_label(rails_version, "Rails version"),
64
+ release: optional_bounded_label(environment["LOGBREW_RELEASE"], "LOGBREW_RELEASE"),
65
+ endpoint: endpoint_value(environment["LOGBREW_ENDPOINT"]),
66
+ request_timeout: integer_value(
67
+ environment,
68
+ "LOGBREW_REQUEST_TIMEOUT_MS",
69
+ DEFAULT_REQUEST_TIMEOUT_MS,
70
+ 1,
71
+ 600_000
72
+ ) / 1_000.0,
73
+ flush_interval: integer_value(
74
+ environment,
75
+ "LOGBREW_FLUSH_INTERVAL_MS",
76
+ DEFAULT_FLUSH_INTERVAL_MS,
77
+ 10,
78
+ 3_600_000
79
+ ) / 1_000.0,
80
+ flush_threshold: integer_value(
81
+ environment,
82
+ "LOGBREW_FLUSH_THRESHOLD",
83
+ DEFAULT_FLUSH_THRESHOLD,
84
+ 1,
85
+ 1_000
86
+ ),
87
+ capture_exception_messages: boolean_value(
88
+ environment,
89
+ "LOGBREW_CAPTURE_EXCEPTION_MESSAGES",
90
+ false
91
+ ),
92
+ include_exception_backtrace: boolean_value(
93
+ environment,
94
+ "LOGBREW_INCLUDE_EXCEPTION_BACKTRACE",
95
+ false
96
+ )
97
+ )
98
+ end
99
+
100
+ def self.disabled(application_name, rails_environment, rails_version)
101
+ new(
102
+ enabled: false,
103
+ api_key: nil,
104
+ service_name: bounded_label(application_name, "Rails application name"),
105
+ app_environment: bounded_label(rails_environment, "Rails environment"),
106
+ rails_version: bounded_label(rails_version, "Rails version"),
107
+ release: nil,
108
+ endpoint: DEFAULT_ENDPOINT,
109
+ request_timeout: DEFAULT_REQUEST_TIMEOUT_MS / 1_000.0,
110
+ flush_interval: DEFAULT_FLUSH_INTERVAL_MS / 1_000.0,
111
+ flush_threshold: DEFAULT_FLUSH_THRESHOLD,
112
+ capture_exception_messages: false,
113
+ include_exception_backtrace: false
114
+ )
115
+ end
116
+
117
+ def self.optional_text(value)
118
+ return nil if value.nil?
119
+
120
+ text = value.to_s.strip
121
+ text.empty? ? nil : text
122
+ end
123
+ private_class_method :optional_text
124
+
125
+ def self.server_key_value(value)
126
+ text = optional_text(value)
127
+ return nil if text.nil?
128
+ if text.bytesize > 4_096 || !text.valid_encoding?
129
+ raise SdkError.new("configuration_error", "LOGBREW_SERVER_API_KEY is invalid")
130
+ end
131
+
132
+ text
133
+ end
134
+ private_class_method :server_key_value
135
+
136
+ def self.bounded_label(value, label)
137
+ text = optional_text(value)
138
+ raise SdkError.new("configuration_error", "#{label} must be non-empty") if text.nil?
139
+ if text.bytesize > MAX_LABEL_BYTES || !text.valid_encoding? || text.match?(/[[:cntrl:]]/)
140
+ raise SdkError.new("configuration_error", "#{label} must be at most #{MAX_LABEL_BYTES} bytes")
141
+ end
142
+
143
+ text.freeze
144
+ end
145
+ private_class_method :bounded_label
146
+
147
+ def self.optional_bounded_label(value, label)
148
+ text = optional_text(value)
149
+ return nil if text.nil?
150
+
151
+ bounded_label(text, label)
152
+ end
153
+ private_class_method :optional_bounded_label
154
+
155
+ def self.boolean_value(environment, name, default)
156
+ value = optional_text(environment[name])
157
+ return default if value.nil? && !environment.key?(name)
158
+
159
+ case value&.downcase
160
+ when "true", "1", "yes", "on" then true
161
+ when "false", "0", "no", "off" then false
162
+ else
163
+ raise SdkError.new("configuration_error", "#{name} must be true or false")
164
+ end
165
+ end
166
+ private_class_method :boolean_value
167
+
168
+ def self.integer_value(environment, name, default, minimum, maximum)
169
+ text = optional_text(environment[name])
170
+ return default if text.nil? && !environment.key?(name)
171
+
172
+ value = Integer(text, 10)
173
+ return value if value >= minimum && value <= maximum
174
+
175
+ raise ArgumentError
176
+ rescue ArgumentError, TypeError
177
+ raise SdkError.new(
178
+ "configuration_error",
179
+ "#{name} must be an integer between #{minimum} and #{maximum}"
180
+ )
181
+ end
182
+ private_class_method :integer_value
183
+
184
+ def self.endpoint_value(value)
185
+ text = optional_text(value) || DEFAULT_ENDPOINT
186
+ if text.bytesize > MAX_ENDPOINT_BYTES
187
+ raise SdkError.new("configuration_error", "LOGBREW_ENDPOINT is too long")
188
+ end
189
+
190
+ uri = URI.parse(text)
191
+ valid_http = uri.is_a?(URI::HTTP) && !uri.host.to_s.empty?
192
+ safe_scheme = uri.scheme == "https" || (
193
+ uri.scheme == "http" && LOOPBACK_HOSTS.include?(uri.host.to_s.downcase)
194
+ )
195
+ if !valid_http || !safe_scheme || !uri.userinfo.nil? || !uri.fragment.nil?
196
+ raise SdkError.new(
197
+ "configuration_error",
198
+ "LOGBREW_ENDPOINT must use https, or http on localhost, without embedded user info or a fragment"
199
+ )
200
+ end
201
+
202
+ uri.to_s.freeze
203
+ rescue URI::InvalidURIError
204
+ raise SdkError.new("configuration_error", "LOGBREW_ENDPOINT must be a valid HTTP URL")
205
+ end
206
+ private_class_method :endpoint_value
207
+
208
+ def initialize(
209
+ enabled:,
210
+ api_key:,
211
+ service_name:,
212
+ app_environment:,
213
+ rails_version:,
214
+ release:,
215
+ endpoint:,
216
+ request_timeout:,
217
+ flush_interval:,
218
+ flush_threshold:,
219
+ capture_exception_messages:,
220
+ include_exception_backtrace:
221
+ )
222
+ @enabled = enabled
223
+ @api_key = api_key&.dup&.freeze
224
+ @service_name = service_name
225
+ @app_environment = app_environment
226
+ @rails_version = rails_version
227
+ @release = release
228
+ @endpoint = endpoint
229
+ @request_timeout = request_timeout
230
+ @flush_interval = flush_interval
231
+ @flush_threshold = flush_threshold
232
+ @capture_exception_messages = capture_exception_messages
233
+ @include_exception_backtrace = include_exception_backtrace
234
+ freeze
235
+ end
236
+
237
+ def enabled?
238
+ @enabled
239
+ end
240
+
241
+ def capture_exception_messages?
242
+ @capture_exception_messages
243
+ end
244
+
245
+ def include_exception_backtrace?
246
+ @include_exception_backtrace
247
+ end
248
+ end
249
+
250
+ # Owns one lazy automatic-delivery client per operating-system process.
251
+ class Runtime
252
+ attr_reader :configuration
253
+
254
+ def initialize(
255
+ configuration,
256
+ transport_factory: nil,
257
+ client_factory: nil,
258
+ timestamp_provider: nil,
259
+ process_id_provider: nil,
260
+ on_error: nil
261
+ )
262
+ unless configuration.is_a?(Configuration)
263
+ raise SdkError.new("configuration_error", "Rails runtime requires a Rails configuration")
264
+ end
265
+
266
+ @configuration = configuration
267
+ @transport_factory = transport_factory || method(:build_transport)
268
+ @client_factory = client_factory || method(:build_client)
269
+ @timestamp_provider = timestamp_provider || -> { Time.now.utc }
270
+ @process_id_provider = process_id_provider || -> { Process.pid }
271
+ @on_error = on_error
272
+ @mutex = Mutex.new
273
+ @state_process_id = @process_id_provider.call
274
+ @client = nil
275
+ @shutdown_response = nil
276
+ end
277
+
278
+ def client
279
+ return nil unless @configuration.enabled?
280
+
281
+ prepare_process_state
282
+ @mutex.synchronize do
283
+ return nil unless @shutdown_response.nil?
284
+
285
+ @client ||= create_client
286
+ end
287
+ rescue StandardError => error
288
+ report_error("client_initialization", error)
289
+ nil
290
+ end
291
+
292
+ def delivery_health
293
+ active_client = client
294
+ active_client&.delivery_health
295
+ rescue StandardError => error
296
+ report_error("delivery_health", error)
297
+ nil
298
+ end
299
+
300
+ def shutdown
301
+ return nil unless @configuration.enabled?
302
+
303
+ prepare_process_state
304
+ @mutex.synchronize do
305
+ return @shutdown_response unless @shutdown_response.nil?
306
+ return nil if @client.nil?
307
+
308
+ @shutdown_response = @client.shutdown
309
+ end
310
+ rescue StandardError => error
311
+ report_error("shutdown", error)
312
+ nil
313
+ end
314
+
315
+ def report_error(stage, error)
316
+ return unless @on_error.respond_to?(:call)
317
+
318
+ @on_error.call(stage.to_s, error)
319
+ rescue StandardError
320
+ nil
321
+ end
322
+
323
+ private
324
+
325
+ def prepare_process_state
326
+ process_id = @process_id_provider.call
327
+ return if @state_process_id == process_id
328
+
329
+ @mutex = Mutex.new
330
+ @client = nil
331
+ @shutdown_response = nil
332
+ @state_process_id = process_id
333
+ end
334
+
335
+ def create_client
336
+ transport = @transport_factory.call(@configuration)
337
+ created = @client_factory.call(@configuration, transport)
338
+ record_process_context(created)
339
+ created
340
+ end
341
+
342
+ def build_transport(configuration)
343
+ LogBrew::HttpTransport.new(
344
+ endpoint: configuration.endpoint,
345
+ timeout: configuration.request_timeout
346
+ )
347
+ end
348
+
349
+ def build_client(configuration, transport)
350
+ LogBrew::Client.create_automatic(
351
+ api_key: configuration.api_key,
352
+ sdk_name: "logbrew-ruby-rails",
353
+ sdk_version: LogBrew::VERSION,
354
+ transport: transport,
355
+ flush_interval: configuration.flush_interval,
356
+ flush_threshold: configuration.flush_threshold
357
+ )
358
+ end
359
+
360
+ def record_process_context(created)
361
+ timestamp = logbrew_timestamp
362
+ metadata = base_metadata
363
+ created.environment(
364
+ "ruby_rails_environment_#{SecureRandom.hex(8)}",
365
+ timestamp,
366
+ name: @configuration.app_environment,
367
+ metadata: metadata
368
+ )
369
+ return if @configuration.release.nil?
370
+
371
+ created.release(
372
+ "ruby_rails_release_#{SecureRandom.hex(8)}",
373
+ timestamp,
374
+ version: @configuration.release,
375
+ metadata: metadata
376
+ )
377
+ end
378
+
379
+ def base_metadata
380
+ {
381
+ "service" => @configuration.service_name,
382
+ "environment" => @configuration.app_environment,
383
+ "framework" => "rails",
384
+ "framework.version" => @configuration.rails_version
385
+ }
386
+ end
387
+
388
+ def logbrew_timestamp
389
+ timestamp = @timestamp_provider.call
390
+ return timestamp.iso8601 if timestamp.respond_to?(:iso8601)
391
+
392
+ timestamp.to_s
393
+ end
394
+ end
395
+
396
+ # Internal Rack adapter that replaces concrete request paths with Rails
397
+ # route templates while reusing the core request/error lifecycle.
398
+ class RailsRackMiddleware < LogBrew::RackMiddleware
399
+ private
400
+
401
+ def request_name(env)
402
+ "#{request_method(env)} #{route_template(env)}"
403
+ end
404
+
405
+ def request_method(env)
406
+ value = env_value(env, "REQUEST_METHOD").to_s.upcase
407
+ value.match?(/\A[A-Z]{1,16}\z/) ? value : "GET"
408
+ end
409
+
410
+ def request_path(env)
411
+ route_template(env)
412
+ end
413
+
414
+ def request_metadata(env, status_code)
415
+ metadata = super
416
+ metadata.delete("http.path")
417
+ metadata.delete("action_dispatch.request_id")
418
+ metadata.delete("HTTP_X_REQUEST_ID")
419
+ metadata["source"] = "rails"
420
+ metadata["http.method"] = request_method(env)
421
+ metadata["http.route"] = route_template(env)
422
+ metadata["http.status_code"] = status_code
423
+ metadata["http.status_class"] = "#{status_code.to_i / 100}xx"
424
+ controller, action = controller_and_action(env)
425
+ metadata["rails.controller"] = controller unless controller.nil?
426
+ metadata["rails.action"] = action unless action.nil?
427
+ metadata
428
+ end
429
+
430
+ def route_template(env)
431
+ route = bounded_route(env_value(env, "action_dispatch.route_uri_pattern"))
432
+ return route unless route.nil?
433
+
434
+ route = bounded_route(matched_route_pattern(env))
435
+ return route unless route.nil?
436
+
437
+ controller, action = controller_and_action(env)
438
+ return "/#{controller}##{action}" unless controller.nil? || action.nil?
439
+
440
+ "<unmatched>"
441
+ end
442
+
443
+ def matched_route_pattern(env)
444
+ return nil unless env.respond_to?(:[])
445
+
446
+ route = env["action_dispatch.route"]
447
+ return nil unless route.respond_to?(:path)
448
+
449
+ path = route.path
450
+ return nil unless path.respond_to?(:spec)
451
+
452
+ path.spec.to_s
453
+ rescue StandardError
454
+ nil
455
+ end
456
+
457
+ def controller_and_action(env)
458
+ return [nil, nil] unless env.respond_to?(:[])
459
+
460
+ parameters = env["action_dispatch.request.path_parameters"]
461
+ return [nil, nil] unless parameters.is_a?(Hash)
462
+
463
+ [bounded_identifier(parameters[:controller] || parameters["controller"]),
464
+ bounded_identifier(parameters[:action] || parameters["action"])]
465
+ end
466
+
467
+ def bounded_route(value)
468
+ return nil if value.nil?
469
+
470
+ route = value.to_s.split(/[?#]/, 2).first.to_s.strip
471
+ return nil if route.empty? || route.bytesize > 255 || !route.valid_encoding?
472
+
473
+ route.start_with?("/") ? route : "/#{route}"
474
+ end
475
+
476
+ def bounded_identifier(value)
477
+ return nil if value.nil?
478
+
479
+ identifier = value.to_s
480
+ return nil unless identifier.match?(/\A[a-zA-Z0-9_\/.-]{1,128}\z/)
481
+
482
+ identifier
483
+ end
484
+ end
485
+ private_constant :RailsRackMiddleware
486
+
487
+ # Rails middleware entry point. Capture failures never call the app twice.
488
+ class RequestMiddleware
489
+ def initialize(app, runtime: LogBrew::Rails.runtime)
490
+ raise SdkError.new("validation_error", "Rails app must respond to call") unless app.respond_to?(:call)
491
+ raise SdkError.new("configuration_error", "LogBrew Rails runtime is not installed") if runtime.nil?
492
+
493
+ @app = app
494
+ @runtime = runtime
495
+ @mutex = Mutex.new
496
+ @adapter_client = nil
497
+ @adapter = nil
498
+ end
499
+
500
+ def call(environment)
501
+ active_client = @runtime.client
502
+ return @app.call(environment) if active_client.nil?
503
+
504
+ adapter = adapter_for(active_client)
505
+ return @app.call(environment) if adapter.nil?
506
+
507
+ adapter.call(environment)
508
+ end
509
+
510
+ private
511
+
512
+ def adapter_for(active_client)
513
+ @mutex.synchronize do
514
+ return @adapter if @adapter_client.equal?(active_client) && !@adapter.nil?
515
+
516
+ @adapter = RailsRackMiddleware.new(
517
+ @app,
518
+ client: active_client,
519
+ flush_on_response: false,
520
+ metadata: base_metadata,
521
+ include_exception_message: @runtime.configuration.capture_exception_messages?,
522
+ include_exception_backtrace: @runtime.configuration.include_exception_backtrace?,
523
+ on_error: ->(error) { @runtime.report_error("request_capture", error) }
524
+ )
525
+ @adapter_client = active_client
526
+ @adapter
527
+ end
528
+ rescue StandardError => error
529
+ @runtime.report_error("request_adapter", error)
530
+ nil
531
+ end
532
+
533
+ def base_metadata
534
+ configuration = @runtime.configuration
535
+ {
536
+ "service" => configuration.service_name,
537
+ "environment" => configuration.app_environment,
538
+ "framework" => "rails",
539
+ "framework.version" => configuration.rails_version
540
+ }.tap do |metadata|
541
+ metadata["release"] = configuration.release unless configuration.release.nil?
542
+ end
543
+ end
544
+ end
545
+
546
+ # Rails.error subscriber for handled reports. Unhandled errors stay owned
547
+ # by the request middleware so one exception cannot create two issues.
548
+ class ErrorReporter
549
+ CONTEXT_KEYS = %w[controller action].freeze
550
+
551
+ def initialize(runtime)
552
+ @runtime = runtime
553
+ @mutex = Mutex.new
554
+ @subscriber_client = nil
555
+ @subscriber = nil
556
+ end
557
+
558
+ def report(error, handled: true, severity: :error, context: nil, source: nil, **options)
559
+ return nil unless handled
560
+
561
+ active_client = @runtime.client
562
+ return nil if active_client.nil?
563
+
564
+ subscriber_for(active_client).report(
565
+ error,
566
+ handled: true,
567
+ severity: severity,
568
+ context: safe_context(context),
569
+ source: bounded_source(source),
570
+ **options
571
+ )
572
+ rescue StandardError => capture_error
573
+ @runtime.report_error("handled_error_capture", capture_error)
574
+ nil
575
+ end
576
+
577
+ private
578
+
579
+ def subscriber_for(active_client)
580
+ @mutex.synchronize do
581
+ return @subscriber if @subscriber_client.equal?(active_client) && !@subscriber.nil?
582
+
583
+ configuration = @runtime.configuration
584
+ @subscriber = LogBrew::RailsErrorSubscriber.new(
585
+ client: active_client,
586
+ flush_on_report: false,
587
+ metadata: {
588
+ "service" => configuration.service_name,
589
+ "environment" => configuration.app_environment,
590
+ "framework" => "rails",
591
+ "framework.version" => configuration.rails_version
592
+ },
593
+ include_exception_message: configuration.capture_exception_messages?,
594
+ include_exception_backtrace: configuration.include_exception_backtrace?,
595
+ on_error: ->(error) { @runtime.report_error("handled_error_capture", error) }
596
+ )
597
+ @subscriber_client = active_client
598
+ @subscriber
599
+ end
600
+ end
601
+
602
+ def safe_context(context)
603
+ return nil unless context.is_a?(Hash)
604
+
605
+ CONTEXT_KEYS.each_with_object({}) do |key, safe|
606
+ value = context[key] || context[key.to_sym]
607
+ next if value.nil?
608
+
609
+ text = value.to_s
610
+ safe[key] = text if text.match?(/\A[a-zA-Z0-9_\/.-]{1,128}\z/)
611
+ end
612
+ end
613
+
614
+ def bounded_source(source)
615
+ return nil if source.nil?
616
+
617
+ value = source.to_s
618
+ value.match?(/\A[a-zA-Z0-9_.:-]{1,64}\z/) ? value : "rails"
619
+ end
620
+ end
621
+ end
622
+ end