devbench 0.5.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.
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Devbench
4
+ # The correlation id that ties a user action together across every service.
5
+ #
6
+ # Wire form (docs/SERVER_SDK_SPEC.md): v1/<session>/<intent>/<hop>
7
+ #
8
+ # This must agree exactly with the Go SDK and the sidecar's extraction regex.
9
+ # A trace the browser writes and Rails mangles is worse than no trace: the
10
+ # request still succeeds, so nothing looks wrong, and the evidence simply
11
+ # cannot be joined.
12
+ class Trace
13
+ HEADER = 'x-adt-trace'
14
+ RACK_HEADER = 'HTTP_X_ADT_TRACE'
15
+ MAX_HOP = 99
16
+ ID = /\A[A-Za-z0-9_-]{1,64}\z/
17
+
18
+ attr_reader :session, :intent, :hop
19
+
20
+ def initialize(session, intent, hop)
21
+ @session = session
22
+ @intent = intent
23
+ @hop = hop
24
+ end
25
+
26
+ # Parses a header value. Returns nil rather than raising: ADT must never
27
+ # fail a customer's request, and a request carrying a mangled correlation
28
+ # id is still a request the user wants served.
29
+ def self.parse(value)
30
+ return nil unless value.is_a?(String)
31
+
32
+ value = value.strip
33
+ return nil unless value.start_with?('v1/')
34
+
35
+ parts = value[3..].split('/', -1)
36
+ return nil unless parts.length == 3
37
+
38
+ session, intent, hop = parts
39
+ return nil unless session =~ ID && intent =~ ID
40
+ return nil unless hop =~ /\A\d{1,3}\z/
41
+
42
+ hop = hop.to_i
43
+ return nil if hop.negative? || hop > MAX_HOP
44
+
45
+ new(session, intent, hop)
46
+ end
47
+
48
+ def to_s
49
+ "v1/#{@session}/#{@intent}/#{@hop}"
50
+ end
51
+
52
+ # Identifies the user action, independent of which service saw it.
53
+ def key
54
+ "#{@session}/#{@intent}"
55
+ end
56
+
57
+ # The trace to send to a downstream service.
58
+ def next_hop
59
+ return nil if @hop >= MAX_HOP
60
+
61
+ Trace.new(@session, @intent, @hop + 1)
62
+ end
63
+
64
+ def ==(other)
65
+ other.is_a?(Trace) && other.session == @session && other.intent == @intent && other.hop == @hop
66
+ end
67
+ end
68
+
69
+ # A Rails log tag (config.log_tags) carrying the request's x-adt-trace, or
70
+ # nothing when there is none — Rails drops blank tags. The Railtie adds it;
71
+ # apps without Rails::Railtie can add it themselves. Never raises: a log
72
+ # tag that fails takes every log line of the request with it.
73
+ LOG_TAG = lambda do |request|
74
+ raw = request.respond_to?(:get_header) ? request.get_header(Trace::RACK_HEADER) : nil
75
+ Devbench::Trace.parse(raw)&.to_s
76
+ rescue StandardError, SystemStackError
77
+ nil
78
+ end
79
+ end
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'config'
4
+ require_relative 'sidecar_transport'
5
+ require_relative 'direct'
6
+
7
+ # Configuration and transport selection (docs/SERVER_SDK_SPEC.md,
8
+ # "Transports: direct (default) or sidecar (optional)").
9
+ #
10
+ # Exactly one transport per process, decided at the first report:
11
+ #
12
+ # DEVBENCH_ENABLED=false -> none
13
+ # DEVBENCH_DSN (or ADT_DSN) -> direct to Dev Bench; a DSN that does not
14
+ # parse logs one warning and reports nothing
15
+ # neither -> the sidecar socket, as before 0.5
16
+ #
17
+ # Never both: a sidecar on the same host still reads logs, but writing to
18
+ # its socket as well would count every report twice.
19
+ module Devbench
20
+ # Raised (and rescued) by Devbench.test! to make its synthetic report.
21
+ class TestException < StandardError; end
22
+
23
+ TRANSPORT_LOCK = Mutex.new
24
+ CONFIG_LOCK = Mutex.new
25
+ private_constant :TRANSPORT_LOCK, :CONFIG_LOCK
26
+
27
+ class << self
28
+ # The configuration, read from the environment on first use.
29
+ def config
30
+ @config || CONFIG_LOCK.synchronize { @config ||= Configuration.new }
31
+ end
32
+
33
+ # Configures from code instead of (or on top of) the environment, e.g.
34
+ # in config/initializers/devbench.rb:
35
+ #
36
+ # Devbench.configure do |c|
37
+ # c.dsn = Rails.application.credentials.devbench_dsn
38
+ # c.service = 'billing'
39
+ # end
40
+ #
41
+ # Takes effect from the next report. Never raises into the caller.
42
+ def configure
43
+ yield config if block_given?
44
+ reset_transport!
45
+ nil
46
+ rescue StandardError, SystemStackError => e
47
+ warn_once(:configure, "Devbench.configure raised #{e.class}; reporting is unchanged")
48
+ nil
49
+ end
50
+
51
+ def enabled?
52
+ config.enabled?
53
+ rescue StandardError, SystemStackError
54
+ false
55
+ end
56
+
57
+ # The active transport. Built once, on first use, so a DSN set from an
58
+ # initializer is seen.
59
+ def transport
60
+ @transport || TRANSPORT_LOCK.synchronize { @transport ||= build_transport }
61
+ end
62
+
63
+ # Sends whatever direct mode has counted, now, waiting at most `timeout`
64
+ # seconds. A no-op in sidecar mode. Returns true when nothing is left
65
+ # unsent. Never raises.
66
+ def flush!(timeout: 5)
67
+ transport.flush!(timeout: timeout) ? true : false
68
+ rescue StandardError, SystemStackError
69
+ false
70
+ end
71
+
72
+ # Sends one synthetic exception straight to Dev Bench and prints the
73
+ # result, so an operator can check the DSN without waiting for a flush
74
+ # (`rake devbench:test` under Rails). Returns true when ingest accepted
75
+ # it. Never raises.
76
+ def test!(io: $stdout)
77
+ cfg = config
78
+ unless cfg.enabled?
79
+ io.puts 'Dev Bench is disabled (DEVBENCH_ENABLED=false); nothing was sent.'
80
+ return false
81
+ end
82
+ if cfg.dsn.nil? || cfg.dsn.strip.empty?
83
+ io.puts 'DEVBENCH_DSN is not set. Set it to the DSN Dev Bench gave you for this ' \
84
+ 'environment (https://<key>@<host>), or call Devbench.configure { |c| c.dsn = ... }.'
85
+ return false
86
+ end
87
+
88
+ dsn = begin
89
+ DSN.parse(cfg.dsn)
90
+ rescue DSN::Invalid => e
91
+ io.puts "DEVBENCH_DSN #{e.message}."
92
+ return false
93
+ end
94
+
95
+ error = begin
96
+ raise TestException, 'Dev Bench test exception: if you can read this, the DSN works'
97
+ rescue TestException => e
98
+ e
99
+ end
100
+ payload = Reporter.report_for(error, context: 'explicit', handled: true, symbol: 'devbench:test')
101
+ client = DirectTransport.new(dsn: dsn, service: cfg.resolved_service, release: cfg.resolved_release)
102
+ client.self_test(payload, io)
103
+ rescue StandardError, SystemStackError => e
104
+ io.puts "Dev Bench test failed: #{e.class}: #{e.message}"
105
+ false
106
+ end
107
+
108
+ # Drops the active transport (stopping a direct client's thread) and the
109
+ # configuration, so both are rebuilt from scratch. For tests and for
110
+ # Devbench.configure.
111
+ def reset!
112
+ reset_transport!
113
+ CONFIG_LOCK.synchronize { @config = nil }
114
+ nil
115
+ end
116
+
117
+ # Logs a problem with Dev Bench itself, once per kind per process. Never
118
+ # raises: the logger is the application's.
119
+ def warn_once(kind, message)
120
+ @warned ||= {}
121
+ return if @warned[kind]
122
+
123
+ @warned[kind] = true
124
+ line = "[devbench] #{message}"
125
+ if defined?(::Rails) && ::Rails.respond_to?(:logger) && ::Rails.logger
126
+ ::Rails.logger.warn(line)
127
+ else
128
+ Kernel.warn(line)
129
+ end
130
+ nil
131
+ rescue StandardError, SystemStackError
132
+ nil
133
+ end
134
+
135
+ private
136
+
137
+ def reset_transport!
138
+ old = TRANSPORT_LOCK.synchronize do
139
+ previous = @transport
140
+ @transport = nil
141
+ previous
142
+ end
143
+ old.stop if old.respond_to?(:stop)
144
+ end
145
+
146
+ def build_transport
147
+ cfg = config
148
+ return NullTransport unless cfg.enabled?
149
+ return SidecarTransport if cfg.dsn.nil? || cfg.dsn.strip.empty?
150
+
151
+ dsn = begin
152
+ DSN.parse(cfg.dsn)
153
+ rescue DSN::Invalid => e
154
+ warn_once(:dsn, "DEVBENCH_DSN #{e.message}; nothing will be reported")
155
+ return NullTransport
156
+ end
157
+
158
+ install_exit_flush
159
+ DirectTransport.new(dsn: dsn, service: cfg.resolved_service, release: cfg.resolved_release,
160
+ interval: cfg.flush_interval)
161
+ rescue StandardError, SystemStackError
162
+ NullTransport
163
+ end
164
+
165
+ # One last flush when the process exits, bounded to 2 s (spec). Inherited
166
+ # by forked children, where it flushes the child's own counts. Not
167
+ # destructive: a later report (e.g. from a test runner's at_exit) still
168
+ # works.
169
+ def install_exit_flush
170
+ return if @exit_flush_installed
171
+
172
+ @exit_flush_installed = true
173
+ at_exit do
174
+ current = @transport
175
+ current.flush!(timeout: DirectTransport::EXIT_TIMEOUT) if current.is_a?(DirectTransport)
176
+ rescue StandardError, SystemStackError
177
+ nil
178
+ end
179
+ end
180
+ end
181
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Devbench
4
+ # Tracks the protocol, not the gem's own churn.
5
+ #
6
+ # The wire format this speaks — the trace header, the handled header, the
7
+ # control-socket message shapes — is what a customer's deployment depends
8
+ # on. See docs/SERVER_SDK_SPEC.md.
9
+ VERSION = '0.5.0'
10
+ end
data/lib/devbench.rb ADDED
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Dev Bench server SDK for Ruby/Rails (formerly "ADT"; DECISIONS #160).
4
+ #
5
+ # gem 'devbench' # Gemfile
6
+ # DEVBENCH_DSN=https://<key>@<ingest host>
7
+ #
8
+ # What it does:
9
+ # * accepts and forwards the correlation id (Devbench::Middleware, Devbench::HTTP)
10
+ # * reports handled failures (Devbench.report_handled)
11
+ # * records who was affected (Devbench.set_user)
12
+ # * reports unhandled exceptions (Devbench::Middleware, Devbench::Railtie,
13
+ # Devbench::SidekiqHooks,
14
+ # Devbench.capture_exception)
15
+ #
16
+ # The old name is kept: `require 'adt'` and every `ADT.` / `ADT::` call in an
17
+ # existing install resolve to this module.
18
+ require_relative 'devbench/version'
19
+ require_relative 'devbench/trace'
20
+ require_relative 'devbench/current'
21
+ require_relative 'devbench/backtrace'
22
+ require_relative 'devbench/identity'
23
+ require_relative 'devbench/middleware'
24
+ require_relative 'devbench/http'
25
+ require_relative 'devbench/reporter'
26
+ require_relative 'devbench/session'
27
+ require_relative 'devbench/session_middleware'
28
+ require_relative 'devbench/rails_hooks'
29
+ require_relative 'devbench/sidekiq_hooks'
30
+
31
+ # The pre-0.5 name. The same module object, not a copy: ADT::Middleware *is*
32
+ # Devbench::Middleware, so a stack holding either holds the one class.
33
+ ADT = Devbench unless defined?(::ADT)
34
+
35
+ # Only under Rails. Bundler requires gems after `require 'rails'` in
36
+ # config/application.rb, so Rails::Railtie is defined by the time this runs.
37
+ require_relative 'devbench/railtie' if defined?(::Rails::Railtie)
metadata ADDED
@@ -0,0 +1,75 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: devbench
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.5.0
5
+ platform: ruby
6
+ authors:
7
+ - Dev Bench
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-10-05 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: |
14
+ Reports unhandled exceptions, Rails.error reports, failed ActiveJob and
15
+ Sidekiq jobs, and failures the application handled, with who was
16
+ affected, to Dev Bench. Fingerprints and counts in-process and sends one
17
+ small request a minute; detail is uploaded only when Dev Bench asks for
18
+ it, scrubbed first. Set DEVBENCH_DSN and it hooks itself into Rails.
19
+
20
+ Adds no dependencies beyond the standard library, never alters a response
21
+ body, and never raises: a diagnostics gem that can fail a request is worse
22
+ than no diagnostics.
23
+ email:
24
+ executables: []
25
+ extensions: []
26
+ extra_rdoc_files: []
27
+ files:
28
+ - README.md
29
+ - lib/adt.rb
30
+ - lib/devbench.rb
31
+ - lib/devbench/backtrace.rb
32
+ - lib/devbench/config.rb
33
+ - lib/devbench/current.rb
34
+ - lib/devbench/direct.rb
35
+ - lib/devbench/fingerprint.rb
36
+ - lib/devbench/http.rb
37
+ - lib/devbench/identity.rb
38
+ - lib/devbench/middleware.rb
39
+ - lib/devbench/rails_hooks.rb
40
+ - lib/devbench/railtie.rb
41
+ - lib/devbench/reporter.rb
42
+ - lib/devbench/scrub.rb
43
+ - lib/devbench/session.rb
44
+ - lib/devbench/session_middleware.rb
45
+ - lib/devbench/sidecar_transport.rb
46
+ - lib/devbench/sidekiq_hooks.rb
47
+ - lib/devbench/trace.rb
48
+ - lib/devbench/transport.rb
49
+ - lib/devbench/version.rb
50
+ homepage: https://github.com/pasperry/devbench-sdk
51
+ licenses:
52
+ - LicenseRef-Proprietary
53
+ metadata:
54
+ rubygems_mfa_required: 'true'
55
+ source_code_uri: https://github.com/pasperry/devbench-sdk
56
+ post_install_message:
57
+ rdoc_options: []
58
+ require_paths:
59
+ - lib
60
+ required_ruby_version: !ruby/object:Gem::Requirement
61
+ requirements:
62
+ - - ">="
63
+ - !ruby/object:Gem::Version
64
+ version: '3.0'
65
+ required_rubygems_version: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: '0'
70
+ requirements: []
71
+ rubygems_version: 3.5.22
72
+ signing_key:
73
+ specification_version: 4
74
+ summary: Exceptions, handled failures and who they hit, from a Rails app to Dev Bench.
75
+ test_files: []