logbrew-sdk 0.1.2 → 0.1.4
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 +4 -4
- data/README.md +334 -63
- data/examples/Makefile +5 -1
- data/examples/automatic_delivery.rb +1 -1
- data/examples/first_useful_telemetry.rb +90 -72
- data/examples/http_trace_correlation.rb +1 -0
- data/examples/issue_diagnostics.rb +55 -0
- data/examples/readme_example.rb +2 -1
- data/examples/real_user_smoke.rb +4 -2
- data/examples/sidekiq_tracing.rb +1 -1
- data/lib/logbrew/issue_diagnostics.rb +576 -0
- data/lib/logbrew/product_timeline.rb +24 -3
- data/lib/logbrew/rails.rb +105 -0
- data/lib/logbrew/rails_integration.rb +643 -0
- data/lib/logbrew/telemetry.rb +60 -0
- data/lib/logbrew/telemetry_context.rb +306 -0
- data/lib/logbrew/telemetry_context_value.rb +152 -0
- data/lib/logbrew/telemetry_resource.rb +161 -0
- data/lib/logbrew/version.rb +5 -0
- data/lib/logbrew-sdk.rb +4 -0
- data/lib/logbrew.rb +180 -48
- metadata +14 -4
|
@@ -0,0 +1,643 @@
|
|
|
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
|
+
context: client_context(configuration)
|
|
358
|
+
)
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def client_context(configuration)
|
|
362
|
+
resource = LogBrew::TelemetryResource.create
|
|
363
|
+
.with_service(name: configuration.service_name)
|
|
364
|
+
.with_deployment(
|
|
365
|
+
environment: configuration.app_environment,
|
|
366
|
+
release: configuration.release
|
|
367
|
+
)
|
|
368
|
+
.with_framework(name: "rails", version: configuration.rails_version)
|
|
369
|
+
.build
|
|
370
|
+
LogBrew::TelemetryContext.create.with_resource(resource).build
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def record_process_context(created)
|
|
374
|
+
timestamp = logbrew_timestamp
|
|
375
|
+
metadata = base_metadata
|
|
376
|
+
created.environment(
|
|
377
|
+
"ruby_rails_environment_#{SecureRandom.hex(8)}",
|
|
378
|
+
timestamp,
|
|
379
|
+
name: @configuration.app_environment,
|
|
380
|
+
metadata: metadata
|
|
381
|
+
)
|
|
382
|
+
return if @configuration.release.nil?
|
|
383
|
+
|
|
384
|
+
created.release(
|
|
385
|
+
"ruby_rails_release_#{SecureRandom.hex(8)}",
|
|
386
|
+
timestamp,
|
|
387
|
+
version: @configuration.release,
|
|
388
|
+
metadata: metadata
|
|
389
|
+
)
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def base_metadata
|
|
393
|
+
{
|
|
394
|
+
"service" => @configuration.service_name,
|
|
395
|
+
"environment" => @configuration.app_environment,
|
|
396
|
+
"framework" => "rails",
|
|
397
|
+
"framework.version" => @configuration.rails_version
|
|
398
|
+
}
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def logbrew_timestamp
|
|
402
|
+
timestamp = @timestamp_provider.call
|
|
403
|
+
return timestamp.iso8601 if timestamp.respond_to?(:iso8601)
|
|
404
|
+
|
|
405
|
+
timestamp.to_s
|
|
406
|
+
end
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
# Internal Rack adapter that replaces concrete request paths with Rails
|
|
410
|
+
# route templates while reusing the core request/error lifecycle.
|
|
411
|
+
class RailsRackMiddleware < LogBrew::RackMiddleware
|
|
412
|
+
private
|
|
413
|
+
|
|
414
|
+
def exception_mechanism_type
|
|
415
|
+
"rails.middleware"
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
def exception_grouping_prefix
|
|
419
|
+
"rails-exception"
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
def request_name(env)
|
|
423
|
+
"#{request_method(env)} #{route_template(env)}"
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
def request_method(env)
|
|
427
|
+
value = env_value(env, "REQUEST_METHOD").to_s.upcase
|
|
428
|
+
value.match?(/\A[A-Z]{1,16}\z/) ? value : "GET"
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
def request_path(env)
|
|
432
|
+
route_template(env)
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
def request_metadata(env, status_code)
|
|
436
|
+
metadata = super
|
|
437
|
+
metadata.delete("http.path")
|
|
438
|
+
metadata.delete("action_dispatch.request_id")
|
|
439
|
+
metadata.delete("HTTP_X_REQUEST_ID")
|
|
440
|
+
metadata["source"] = "rails"
|
|
441
|
+
metadata["http.method"] = request_method(env)
|
|
442
|
+
metadata["http.route"] = route_template(env)
|
|
443
|
+
metadata["http.status_code"] = status_code
|
|
444
|
+
metadata["http.status_class"] = "#{status_code.to_i / 100}xx"
|
|
445
|
+
controller, action = controller_and_action(env)
|
|
446
|
+
metadata["rails.controller"] = controller unless controller.nil?
|
|
447
|
+
metadata["rails.action"] = action unless action.nil?
|
|
448
|
+
metadata
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
def route_template(env)
|
|
452
|
+
route = bounded_route(env_value(env, "action_dispatch.route_uri_pattern"))
|
|
453
|
+
return route unless route.nil?
|
|
454
|
+
|
|
455
|
+
route = bounded_route(matched_route_pattern(env))
|
|
456
|
+
return route unless route.nil?
|
|
457
|
+
|
|
458
|
+
controller, action = controller_and_action(env)
|
|
459
|
+
return "/#{controller}##{action}" unless controller.nil? || action.nil?
|
|
460
|
+
|
|
461
|
+
"<unmatched>"
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
def matched_route_pattern(env)
|
|
465
|
+
return nil unless env.respond_to?(:[])
|
|
466
|
+
|
|
467
|
+
route = env["action_dispatch.route"]
|
|
468
|
+
return nil unless route.respond_to?(:path)
|
|
469
|
+
|
|
470
|
+
path = route.path
|
|
471
|
+
return nil unless path.respond_to?(:spec)
|
|
472
|
+
|
|
473
|
+
path.spec.to_s
|
|
474
|
+
rescue StandardError
|
|
475
|
+
nil
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
def controller_and_action(env)
|
|
479
|
+
return [nil, nil] unless env.respond_to?(:[])
|
|
480
|
+
|
|
481
|
+
parameters = env["action_dispatch.request.path_parameters"]
|
|
482
|
+
return [nil, nil] unless parameters.is_a?(Hash)
|
|
483
|
+
|
|
484
|
+
[bounded_identifier(parameters[:controller] || parameters["controller"]),
|
|
485
|
+
bounded_identifier(parameters[:action] || parameters["action"])]
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
def bounded_route(value)
|
|
489
|
+
return nil if value.nil?
|
|
490
|
+
|
|
491
|
+
route = value.to_s.split(/[?#]/, 2).first.to_s.strip
|
|
492
|
+
return nil if route.empty? || route.bytesize > 255 || !route.valid_encoding?
|
|
493
|
+
|
|
494
|
+
route.start_with?("/") ? route : "/#{route}"
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
def bounded_identifier(value)
|
|
498
|
+
return nil if value.nil?
|
|
499
|
+
|
|
500
|
+
identifier = value.to_s
|
|
501
|
+
return nil unless identifier.match?(/\A[a-zA-Z0-9_\/.-]{1,128}\z/)
|
|
502
|
+
|
|
503
|
+
identifier
|
|
504
|
+
end
|
|
505
|
+
end
|
|
506
|
+
private_constant :RailsRackMiddleware
|
|
507
|
+
|
|
508
|
+
# Rails middleware entry point. Capture failures never call the app twice.
|
|
509
|
+
class RequestMiddleware
|
|
510
|
+
def initialize(app, runtime: LogBrew::Rails.runtime)
|
|
511
|
+
raise SdkError.new("validation_error", "Rails app must respond to call") unless app.respond_to?(:call)
|
|
512
|
+
raise SdkError.new("configuration_error", "LogBrew Rails runtime is not installed") if runtime.nil?
|
|
513
|
+
|
|
514
|
+
@app = app
|
|
515
|
+
@runtime = runtime
|
|
516
|
+
@mutex = Mutex.new
|
|
517
|
+
@adapter_client = nil
|
|
518
|
+
@adapter = nil
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
def call(environment)
|
|
522
|
+
active_client = @runtime.client
|
|
523
|
+
return @app.call(environment) if active_client.nil?
|
|
524
|
+
|
|
525
|
+
adapter = adapter_for(active_client)
|
|
526
|
+
return @app.call(environment) if adapter.nil?
|
|
527
|
+
|
|
528
|
+
adapter.call(environment)
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
private
|
|
532
|
+
|
|
533
|
+
def adapter_for(active_client)
|
|
534
|
+
@mutex.synchronize do
|
|
535
|
+
return @adapter if @adapter_client.equal?(active_client) && !@adapter.nil?
|
|
536
|
+
|
|
537
|
+
@adapter = RailsRackMiddleware.new(
|
|
538
|
+
@app,
|
|
539
|
+
client: active_client,
|
|
540
|
+
flush_on_response: false,
|
|
541
|
+
metadata: base_metadata,
|
|
542
|
+
include_exception_message: @runtime.configuration.capture_exception_messages?,
|
|
543
|
+
include_exception_backtrace: @runtime.configuration.include_exception_backtrace?,
|
|
544
|
+
on_error: ->(error) { @runtime.report_error("request_capture", error) }
|
|
545
|
+
)
|
|
546
|
+
@adapter_client = active_client
|
|
547
|
+
@adapter
|
|
548
|
+
end
|
|
549
|
+
rescue StandardError => error
|
|
550
|
+
@runtime.report_error("request_adapter", error)
|
|
551
|
+
nil
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
def base_metadata
|
|
555
|
+
configuration = @runtime.configuration
|
|
556
|
+
{
|
|
557
|
+
"service" => configuration.service_name,
|
|
558
|
+
"environment" => configuration.app_environment,
|
|
559
|
+
"framework" => "rails",
|
|
560
|
+
"framework.version" => configuration.rails_version
|
|
561
|
+
}.tap do |metadata|
|
|
562
|
+
metadata["release"] = configuration.release unless configuration.release.nil?
|
|
563
|
+
end
|
|
564
|
+
end
|
|
565
|
+
end
|
|
566
|
+
|
|
567
|
+
# Rails.error subscriber for handled reports. Unhandled errors stay owned
|
|
568
|
+
# by the request middleware so one exception cannot create two issues.
|
|
569
|
+
class ErrorReporter
|
|
570
|
+
CONTEXT_KEYS = %w[controller action].freeze
|
|
571
|
+
|
|
572
|
+
def initialize(runtime)
|
|
573
|
+
@runtime = runtime
|
|
574
|
+
@mutex = Mutex.new
|
|
575
|
+
@subscriber_client = nil
|
|
576
|
+
@subscriber = nil
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
def report(error, handled: true, severity: :error, context: nil, source: nil, **options)
|
|
580
|
+
return nil unless handled
|
|
581
|
+
|
|
582
|
+
active_client = @runtime.client
|
|
583
|
+
return nil if active_client.nil?
|
|
584
|
+
|
|
585
|
+
subscriber_for(active_client).report(
|
|
586
|
+
error,
|
|
587
|
+
handled: true,
|
|
588
|
+
severity: severity,
|
|
589
|
+
context: safe_context(context),
|
|
590
|
+
source: bounded_source(source),
|
|
591
|
+
**options
|
|
592
|
+
)
|
|
593
|
+
rescue StandardError => capture_error
|
|
594
|
+
@runtime.report_error("handled_error_capture", capture_error)
|
|
595
|
+
nil
|
|
596
|
+
end
|
|
597
|
+
|
|
598
|
+
private
|
|
599
|
+
|
|
600
|
+
def subscriber_for(active_client)
|
|
601
|
+
@mutex.synchronize do
|
|
602
|
+
return @subscriber if @subscriber_client.equal?(active_client) && !@subscriber.nil?
|
|
603
|
+
|
|
604
|
+
configuration = @runtime.configuration
|
|
605
|
+
@subscriber = LogBrew::RailsErrorSubscriber.new(
|
|
606
|
+
client: active_client,
|
|
607
|
+
flush_on_report: false,
|
|
608
|
+
metadata: {
|
|
609
|
+
"service" => configuration.service_name,
|
|
610
|
+
"environment" => configuration.app_environment,
|
|
611
|
+
"framework" => "rails",
|
|
612
|
+
"framework.version" => configuration.rails_version
|
|
613
|
+
},
|
|
614
|
+
include_exception_message: configuration.capture_exception_messages?,
|
|
615
|
+
include_exception_backtrace: configuration.include_exception_backtrace?,
|
|
616
|
+
on_error: ->(error) { @runtime.report_error("handled_error_capture", error) }
|
|
617
|
+
)
|
|
618
|
+
@subscriber_client = active_client
|
|
619
|
+
@subscriber
|
|
620
|
+
end
|
|
621
|
+
end
|
|
622
|
+
|
|
623
|
+
def safe_context(context)
|
|
624
|
+
return nil unless context.is_a?(Hash)
|
|
625
|
+
|
|
626
|
+
CONTEXT_KEYS.each_with_object({}) do |key, safe|
|
|
627
|
+
value = context[key] || context[key.to_sym]
|
|
628
|
+
next if value.nil?
|
|
629
|
+
|
|
630
|
+
text = value.to_s
|
|
631
|
+
safe[key] = text if text.match?(/\A[a-zA-Z0-9_\/.-]{1,128}\z/)
|
|
632
|
+
end
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
def bounded_source(source)
|
|
636
|
+
return nil if source.nil?
|
|
637
|
+
|
|
638
|
+
value = source.to_s
|
|
639
|
+
value.match?(/\A[a-zA-Z0-9_.:-]{1,64}\z/) ? value : "rails"
|
|
640
|
+
end
|
|
641
|
+
end
|
|
642
|
+
end
|
|
643
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LogBrew
|
|
4
|
+
# Idempotent owner for one active shared-context scope.
|
|
5
|
+
class TelemetryScope
|
|
6
|
+
def initialize(activation_id)
|
|
7
|
+
@activation_id = activation_id
|
|
8
|
+
@closed = false
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def close
|
|
12
|
+
return if @closed
|
|
13
|
+
|
|
14
|
+
Telemetry.close_scope(@activation_id)
|
|
15
|
+
@closed = true
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Fiber/thread-local shared context for request, job, and operation boundaries.
|
|
20
|
+
module Telemetry
|
|
21
|
+
STACK_KEY = :logbrew_telemetry_context_stack
|
|
22
|
+
private_constant :STACK_KEY
|
|
23
|
+
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
def current_context
|
|
27
|
+
entry = stack.last
|
|
28
|
+
entry && entry[:context]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def activate_context(context)
|
|
32
|
+
unless context.is_a?(TelemetryContext)
|
|
33
|
+
raise TelemetryContextValue.invalid("context must be a LogBrew::TelemetryContext")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
merged = TelemetryContext.merge(current_context, context)
|
|
37
|
+
activation_id = Object.new
|
|
38
|
+
stack << { id: activation_id, context: merged }
|
|
39
|
+
TelemetryScope.new(activation_id)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def with_context(context)
|
|
43
|
+
scope = activate_context(context)
|
|
44
|
+
yield context
|
|
45
|
+
ensure
|
|
46
|
+
scope.close if scope
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def close_scope(activation_id)
|
|
50
|
+
entries = stack
|
|
51
|
+
index = entries.rindex { |entry| entry[:id].equal?(activation_id) }
|
|
52
|
+
entries.delete_at(index) unless index.nil?
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def stack
|
|
56
|
+
Thread.current[STACK_KEY] ||= []
|
|
57
|
+
end
|
|
58
|
+
private_class_method :stack
|
|
59
|
+
end
|
|
60
|
+
end
|