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.
- checksums.yaml +7 -0
- data/README.md +225 -0
- data/lib/adt.rb +5 -0
- data/lib/devbench/backtrace.rb +61 -0
- data/lib/devbench/config.rb +146 -0
- data/lib/devbench/current.rb +74 -0
- data/lib/devbench/direct.rb +470 -0
- data/lib/devbench/fingerprint.rb +180 -0
- data/lib/devbench/http.rb +34 -0
- data/lib/devbench/identity.rb +53 -0
- data/lib/devbench/middleware.rb +135 -0
- data/lib/devbench/rails_hooks.rb +126 -0
- data/lib/devbench/railtie.rb +107 -0
- data/lib/devbench/reporter.rb +247 -0
- data/lib/devbench/scrub.rb +154 -0
- data/lib/devbench/session.rb +99 -0
- data/lib/devbench/session_middleware.rb +73 -0
- data/lib/devbench/sidecar_transport.rb +95 -0
- data/lib/devbench/sidekiq_hooks.rb +208 -0
- data/lib/devbench/trace.rb +79 -0
- data/lib/devbench/transport.rb +181 -0
- data/lib/devbench/version.rb +10 -0
- data/lib/devbench.rb +37 -0
- metadata +75 -0
|
@@ -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: []
|