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.
@@ -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