devbench 0.6.0 → 0.8.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 +4 -4
- data/LICENSE +21 -0
- data/README.md +83 -4
- data/lib/devbench/active_job_hooks.rb +121 -0
- data/lib/devbench/config.rb +49 -7
- data/lib/devbench/current.rb +49 -0
- data/lib/devbench/direct.rb +30 -4
- data/lib/devbench/identity.rb +17 -3
- data/lib/devbench/logs.rb +84 -13
- data/lib/devbench/middleware.rb +58 -2
- data/lib/devbench/rails_hooks.rb +1 -1
- data/lib/devbench/railtie.rb +7 -1
- data/lib/devbench/reporter.rb +17 -5
- data/lib/devbench/request_signals.rb +359 -0
- data/lib/devbench/scrub.rb +69 -3
- data/lib/devbench/session.rb +26 -5
- data/lib/devbench/sidecar_transport.rb +1 -1
- data/lib/devbench/sidekiq_hooks.rb +1 -1
- data/lib/devbench/trace.rb +1 -1
- data/lib/devbench/transport.rb +9 -1
- data/lib/devbench/version.rb +18 -6
- data/lib/devbench/view_helper.rb +24 -1
- data/lib/devbench/widget_identity.rb +125 -0
- data/lib/devbench.rb +5 -0
- metadata +7 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 733817dd1561a76917c0426c041129aa4fefe9200c21ffb462bdaf60c6db801c
|
|
4
|
+
data.tar.gz: 9f17ddc4d783f8c666737406df6ddc5c79ff776b299af8b25f2e6d31b43baa9d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 82993e99341857d99a11475d9805b1ba7d9dda794e5415a8b27ed121c8eb2f4c8a0ca63df58dcb89b1824a094a8d8f84cd43a1bae7e1d8aaa019e91a44827fda
|
|
7
|
+
data.tar.gz: 44038eba715ea44b2423beb8d0c763f7c20430daf272d5e82be7b5e6af1993fbc655160a4d854520d91a27c3e0da6d0185103a5b71fac2092b226cfd9886a839
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 RedpointRack LLC
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
CHANGED
|
@@ -50,7 +50,7 @@ gem (upgrading the gem upgrades the sensor), with **only the public part**
|
|
|
50
50
|
of `DEVBENCH_DSN`:
|
|
51
51
|
|
|
52
52
|
```html
|
|
53
|
-
<script src="https://unpkg.com/devbench@0.
|
|
53
|
+
<script src="https://unpkg.com/devbench@0.8.0/dist/devbench.min.js"
|
|
54
54
|
data-dsn="https://<public>@adt-ingest.onrender.com" data-release="<release>" defer></script>
|
|
55
55
|
```
|
|
56
56
|
|
|
@@ -76,8 +76,9 @@ All optional except the DSN.
|
|
|
76
76
|
|---|---|---|
|
|
77
77
|
| `DEVBENCH_DSN` | — (falls back to `ADT_DSN`) | `https://<public>:<secret>@<host>[:port]` from `adt dsn create`. The server authenticates with the secret; the page gets the public part. A 0.5 `https://<key>@<host>` still works (no browser tag, see below). Plain `http://` is accepted only for localhost. |
|
|
78
78
|
| `DEVBENCH_SERVICE` | your app's module, underscored (`AcmeShop` → `acme_shop`); `app` outside Rails; **`<that>-sidekiq` in a Sidekiq process** | Groups this app's issues. Set it and it wins everywhere. |
|
|
79
|
-
| `DEVBENCH_RELEASE` | `GIT_SHA`, `SOURCE_VERSION`, `RENDER_GIT_COMMIT
|
|
79
|
+
| `DEVBENCH_RELEASE` | the first found of: a `REVISION` file in `Rails.root` (Capistrano writes one), `HEROKU_SLUG_COMMIT`, `KAMAL_VERSION`, `GITHUB_SHA`, `GIT_SHA`, `SOURCE_VERSION`, `RENDER_GIT_COMMIT`; else empty | Which deploy an occurrence came from. `c.release` in code wins over all of these. |
|
|
80
80
|
| `DEVBENCH_ENABLED` | on | `false` turns everything off: no middleware, no hooks, no tag, nothing sent. |
|
|
81
|
+
| `DEVBENCH_WIDGET_SECRET` | — | The chat widget's secret (`dbws_…`, from the account page). Set, signed-in pages carry the signed identity the widget needs (below). Never reaches the page. |
|
|
81
82
|
|
|
82
83
|
Or from code, e.g. `config/initializers/devbench.rb`:
|
|
83
84
|
|
|
@@ -123,6 +124,39 @@ frames with paths relative to `Rails.root`, the trace, and the user.
|
|
|
123
124
|
reporting is dropped, and the application's own exception always comes
|
|
124
125
|
out unchanged.
|
|
125
126
|
|
|
127
|
+
## Failed and slow requests
|
|
128
|
+
|
|
129
|
+
`Devbench::Middleware` also reports what a request does *without*
|
|
130
|
+
raising. Nothing to set up, and no new API:
|
|
131
|
+
|
|
132
|
+
| Signal | When | Reported as |
|
|
133
|
+
|---|---|---|
|
|
134
|
+
| **Failed** | the app answered 500–599 and no exception was reported during the request | `http_503 at PUT DealsController#update` |
|
|
135
|
+
| **Slow** | the app answered 100–499 after **more than 3 seconds** (3,000 ms exactly is not slow) | `slow_request at GET DealsController#index` |
|
|
136
|
+
| **429 spike** | one action answered at least ten 429s in one minute, and at least 10% of its requests | `http_429_spike at GET DealsController#index` |
|
|
137
|
+
|
|
138
|
+
- **The exception wins.** A 500 page Rails rendered for an exception, or a
|
|
139
|
+
5xx after `Rails.error.report` / `Devbench.capture_exception`, is
|
|
140
|
+
reported once, as the exception. An exception you ignore
|
|
141
|
+
(`Devbench.ignore_exceptions`) does not come back as an `http_500`. A
|
|
142
|
+
slow request is slow whatever else happened in it.
|
|
143
|
+
- **`report_handled` changes nothing here:** a 5xx after it is still
|
|
144
|
+
failed, and the `x-adt-handled` header is unchanged.
|
|
145
|
+
- **Other 4xx are not reported.** A 404 or 422 is a status code, not a
|
|
146
|
+
problem; only a burst of 429s is.
|
|
147
|
+
- The route is Rails' `Controller#action`, never the path (paths carry
|
|
148
|
+
ids). A request no route matched, and every request in a plain Rack
|
|
149
|
+
app, is `*`. The duration runs until the app hands back its response;
|
|
150
|
+
streaming the body after that does not count, and streaming responses
|
|
151
|
+
(`text/event-stream`, a websocket upgrade, a hijacked connection) are
|
|
152
|
+
never slow.
|
|
153
|
+
- Failed and slow requests carry the trace and the user; a spike, which
|
|
154
|
+
is many requests, carries neither. Spikes are counted per process
|
|
155
|
+
(each Puma worker on its own) and reported when the minute is over, at
|
|
156
|
+
the next flush, or when the process exits.
|
|
157
|
+
- The 3-second threshold is the same in every Dev Bench SDK and is not a
|
|
158
|
+
setting yet.
|
|
159
|
+
|
|
126
160
|
## What is sent, and when
|
|
127
161
|
|
|
128
162
|
Like the browser sensor, the gem does not send one request per error:
|
|
@@ -153,7 +187,8 @@ Triage reads the server's log lines for the user action that failed. With
|
|
|
153
187
|
|
|
154
188
|
- **What:** every line written to `Rails.logger` and `Sidekiq.logger`
|
|
155
189
|
while a request or job carries a trace (`x-adt-trace` from the browser
|
|
156
|
-
sensor; a job inherits the trace of the request that enqueued it
|
|
190
|
+
sensor; a job inherits the trace of the request that enqueued it — see
|
|
191
|
+
[Background jobs](#background-jobs)).
|
|
157
192
|
Untraced lines are not kept. Rails 7.1+: a capturing logger joins
|
|
158
193
|
`Rails.logger`'s broadcast (it follows the app's level and `silence`,
|
|
159
194
|
and writes nothing); older Rails and `Sidekiq.logger`: a tee after the
|
|
@@ -190,6 +225,30 @@ end
|
|
|
190
225
|
- The middleware clears it when the request ends (Sidekiq: when the job
|
|
191
226
|
ends), so the next request on the same thread never inherits it.
|
|
192
227
|
|
|
228
|
+
### Chat widget identity
|
|
229
|
+
|
|
230
|
+
The chat widget (off by default) is shown only to signed-in users your
|
|
231
|
+
server vouches for. With `DEVBENCH_WIDGET_SECRET` set, `devbench_script_tag`
|
|
232
|
+
signs the user `set_user` recorded for the request into
|
|
233
|
+
`data-widget-identity`, at render time. Nothing else to write. A signed-out
|
|
234
|
+
page (no `set_user`) gets no attribute and no widget.
|
|
235
|
+
|
|
236
|
+
Optionally, say what this user may do in the chat:
|
|
237
|
+
|
|
238
|
+
```ruby
|
|
239
|
+
Devbench.set_user(email: current_user.email, account: current_dealer.id,
|
|
240
|
+
role: current_user.role, # your role name; mapped in the widget settings
|
|
241
|
+
chat: [:report, :howto, :handoff]) # overrides the mapping; [] = may not chat
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
Capabilities: `:report`, `:status`, `:howto`, `:feature_requests`,
|
|
245
|
+
`:handoff`. `role` and `chat` go only into the signed token, never with
|
|
246
|
+
sensor data. An identity that can't be signed exactly (a field over 255
|
|
247
|
+
characters, a control character such as a line break, an unknown
|
|
248
|
+
capability) gets no token rather than a truncated one, and one
|
|
249
|
+
`[devbench]` warning. Pages carrying a token are
|
|
250
|
+
per-user: don't serve them from a shared cache.
|
|
251
|
+
|
|
193
252
|
## Reporting what you caught
|
|
194
253
|
|
|
195
254
|
An exception you rescued but want seen, like Sentry's `capture_exception`:
|
|
@@ -216,6 +275,25 @@ browser already knows whether the user was shown an error; this is the one
|
|
|
216
275
|
fact it cannot know on its own. The header carries a count and nothing else,
|
|
217
276
|
and it is added even when reporting is down.
|
|
218
277
|
|
|
278
|
+
## Background jobs
|
|
279
|
+
|
|
280
|
+
A job inherits the trace of the request that enqueued it, so its failure
|
|
281
|
+
and its log lines join the user action that caused them:
|
|
282
|
+
|
|
283
|
+
- **ActiveJob, on any adapter** — Solid Queue (Rails 8's default), GoodJob,
|
|
284
|
+
Sidekiq, Resque, Delayed Job, `:async`, `:inline` and the rest. The trace
|
|
285
|
+
rides in the job's own payload (`"adt_trace"`), so it survives the queue,
|
|
286
|
+
a process restart and `retry_on`. Nothing to add: the Railtie installs it.
|
|
287
|
+
ActiveJob without Rails: `Devbench::ActiveJobHooks.install(ActiveJob::Base)`.
|
|
288
|
+
- **Native Sidekiq jobs** (`Sidekiq::Job`), below.
|
|
289
|
+
|
|
290
|
+
The trace is the request's, unchanged; the job's identity is its own
|
|
291
|
+
(`set_user` inside the job; it ends with the job, and a worker thread's
|
|
292
|
+
previous job never lends it one). A job enqueued outside a request runs
|
|
293
|
+
untraced, as does one enqueued by an earlier version of this gem. On Sidekiq, an
|
|
294
|
+
ActiveJob job runs inside the Sidekiq middleware's scope — traced once, not
|
|
295
|
+
twice — and `:inline` runs inside the request's.
|
|
296
|
+
|
|
219
297
|
## Sidekiq
|
|
220
298
|
|
|
221
299
|
Under Rails there is nothing to add: the Railtie installs the hooks at boot
|
|
@@ -244,7 +322,8 @@ use Devbench::Middleware # Rack
|
|
|
244
322
|
```
|
|
245
323
|
|
|
246
324
|
Everything else (`set_user`, `capture_exception`, `report_handled`, the
|
|
247
|
-
DSN) works the same.
|
|
325
|
+
DSN, failed and slow requests) works the same. Plain Rack has no route
|
|
326
|
+
pattern, so failed and slow requests are reported under the route `*`.
|
|
248
327
|
|
|
249
328
|
## Trace propagation
|
|
250
329
|
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'trace'
|
|
4
|
+
require_relative 'current'
|
|
5
|
+
|
|
6
|
+
module Devbench
|
|
7
|
+
# Carries the request's trace into ActiveJob jobs on every adapter (Solid
|
|
8
|
+
# Queue, GoodJob, async, inline, Sidekiq, ...), so a job's failure and its
|
|
9
|
+
# log lines join the user action that enqueued it (docs/server-sdk/trace-propagation.md,
|
|
10
|
+
# Capability 1; README "Server log lines").
|
|
11
|
+
#
|
|
12
|
+
# The trace travels inside the job's own serialized payload, which every
|
|
13
|
+
# adapter stores and hands back verbatim to ActiveJob::Base.execute:
|
|
14
|
+
#
|
|
15
|
+
# * serialize — adds "adt_trace": the trace the job already carries (a
|
|
16
|
+
# job re-enqueued by retry_on keeps the request it came
|
|
17
|
+
# from), else the active one. Unchanged, as the Sidekiq
|
|
18
|
+
# client middleware carries it: a job is the same
|
|
19
|
+
# service, not a downstream hop.
|
|
20
|
+
# * deserialize — reads it back. A payload from an older gem has no key:
|
|
21
|
+
# the job runs untraced.
|
|
22
|
+
# * perform_now — a job that came through deserialize runs inside
|
|
23
|
+
# Current.with(its trace), outermost (before ActiveJob's
|
|
24
|
+
# logging and instrumentation), so the perform.active_job
|
|
25
|
+
# report and every line the job logs carry it; the
|
|
26
|
+
# thread's previous trace and identity come back after.
|
|
27
|
+
# Identity is scoped to the job, never inherited from the
|
|
28
|
+
# thread (a pool thread's last job is not this job's user).
|
|
29
|
+
#
|
|
30
|
+
# Not twice: a job that runs inside a scope already open on its thread —
|
|
31
|
+
# Sidekiq's server middleware (which scoped the trace from the outer job
|
|
32
|
+
# hash), or the request whose inline adapter runs it — belongs to that
|
|
33
|
+
# scope and opens no second one, unless it carries a different trace. A
|
|
34
|
+
# job built and run with perform_now (never serialized) is left alone: it
|
|
35
|
+
# runs in its caller's context.
|
|
36
|
+
#
|
|
37
|
+
# Plain Ruby: no ActiveJob constant is named at load time. The Railtie
|
|
38
|
+
# installs it with ActiveSupport.on_load(:active_job); ActiveJob without
|
|
39
|
+
# Rails calls Devbench::ActiveJobHooks.install(ActiveJob::Base). Every
|
|
40
|
+
# override is guarded: the job's own behaviour and its own exception are
|
|
41
|
+
# what come out, whatever happens in here.
|
|
42
|
+
module ActiveJobHooks
|
|
43
|
+
# The payload key, the same one Sidekiq jobs carry. ActiveJob ignores
|
|
44
|
+
# keys it does not know, so payloads stay readable by apps without the gem.
|
|
45
|
+
TRACE_KEY = 'adt_trace'
|
|
46
|
+
|
|
47
|
+
# Prepended to ActiveJob::Base.
|
|
48
|
+
module Job
|
|
49
|
+
def serialize
|
|
50
|
+
payload = super
|
|
51
|
+
ActiveJobHooks.tag(self, payload)
|
|
52
|
+
payload
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def deserialize(job_data)
|
|
56
|
+
super
|
|
57
|
+
ActiveJobHooks.read(self, job_data)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def perform_now
|
|
61
|
+
trace = ActiveJobHooks.scope_for(self)
|
|
62
|
+
return super if trace == :none
|
|
63
|
+
|
|
64
|
+
Current.with(trace) { super }
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
class << self
|
|
69
|
+
# Prepends the hooks to ActiveJob::Base (or any class with its
|
|
70
|
+
# serialize/deserialize/perform_now). Safe to call more than once.
|
|
71
|
+
# Returns true when installed.
|
|
72
|
+
def install(base)
|
|
73
|
+
return false unless base.is_a?(Class) && base.method_defined?(:serialize)
|
|
74
|
+
|
|
75
|
+
base.prepend(Job) unless base <= Job
|
|
76
|
+
true
|
|
77
|
+
rescue StandardError, SystemStackError
|
|
78
|
+
false
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Adds the trace to a payload being built by serialize, and remembers
|
|
82
|
+
# it on the job so a later serialize (a retry) carries the same one.
|
|
83
|
+
def tag(job, payload)
|
|
84
|
+
return nil unless payload.is_a?(Hash)
|
|
85
|
+
|
|
86
|
+
trace = job.instance_variable_get(:@devbench_trace)
|
|
87
|
+
trace ||= Current.trace&.to_s
|
|
88
|
+
return nil if trace.nil?
|
|
89
|
+
|
|
90
|
+
job.instance_variable_set(:@devbench_trace, trace)
|
|
91
|
+
payload[TRACE_KEY] = trace
|
|
92
|
+
nil
|
|
93
|
+
rescue StandardError, SystemStackError
|
|
94
|
+
nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def read(job, job_data)
|
|
98
|
+
trace = job_data.is_a?(Hash) ? Trace.parse(job_data[TRACE_KEY]) : nil
|
|
99
|
+
job.instance_variable_set(:@devbench_trace, trace&.to_s)
|
|
100
|
+
job.instance_variable_set(:@devbench_deserialized, true)
|
|
101
|
+
nil
|
|
102
|
+
rescue StandardError, SystemStackError
|
|
103
|
+
nil
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# The trace perform_now runs the job under, or :none to leave the
|
|
107
|
+
# thread's context as it is.
|
|
108
|
+
def scope_for(job)
|
|
109
|
+
return :none unless job.instance_variable_get(:@devbench_deserialized)
|
|
110
|
+
|
|
111
|
+
trace = Trace.parse(job.instance_variable_get(:@devbench_trace))
|
|
112
|
+
return trace unless Current.scoped?
|
|
113
|
+
return :none if trace.nil? || Current.trace&.to_s == trace.to_s
|
|
114
|
+
|
|
115
|
+
trace
|
|
116
|
+
rescue StandardError, SystemStackError
|
|
117
|
+
:none
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
data/lib/devbench/config.rb
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
require 'uri'
|
|
4
4
|
|
|
5
5
|
module Devbench
|
|
6
|
-
# Where reports go, parsed from DEVBENCH_DSN (docs/
|
|
6
|
+
# Where reports go, parsed from DEVBENCH_DSN (docs/server-sdk/configuration.md, "Dev
|
|
7
7
|
# Bench naming and configuration"; DECISIONS #161):
|
|
8
8
|
#
|
|
9
9
|
# https://<public>:<secret>@<host>[:port] from `adt dsn create`
|
|
@@ -109,11 +109,20 @@ module Devbench
|
|
|
109
109
|
# module, underscored (AcmeShop -> acme_shop);
|
|
110
110
|
# else "app"; with "-sidekiq" appended in a
|
|
111
111
|
# Sidekiq process.
|
|
112
|
-
# DEVBENCH_RELEASE the deployed version. Default:
|
|
113
|
-
#
|
|
112
|
+
# DEVBENCH_RELEASE the deployed version. Default, first found: the
|
|
113
|
+
# REVISION file in the app root (Capistrano),
|
|
114
|
+
# HEROKU_SLUG_COMMIT, KAMAL_VERSION, GITHUB_SHA,
|
|
115
|
+
# GIT_SHA, SOURCE_VERSION, RENDER_GIT_COMMIT,
|
|
116
|
+
# else "".
|
|
114
117
|
# DEVBENCH_ENABLED "false" turns everything off.
|
|
115
118
|
class Configuration
|
|
116
|
-
|
|
119
|
+
# Tried in order after the REVISION file when no release is configured.
|
|
120
|
+
# Common deploy tools and platforms only; never an app's own variable.
|
|
121
|
+
RELEASE_FALLBACKS = %w[HEROKU_SLUG_COMMIT KAMAL_VERSION GITHUB_SHA GIT_SHA SOURCE_VERSION RENDER_GIT_COMMIT].freeze
|
|
122
|
+
# Capistrano writes the deployed commit here, in the release's root.
|
|
123
|
+
REVISION_FILE = 'REVISION'
|
|
124
|
+
# A commit or tag, not a document: read no more than this.
|
|
125
|
+
REVISION_MAX_BYTES = 255
|
|
117
126
|
OFF = %w[false 0 no off].freeze
|
|
118
127
|
|
|
119
128
|
# Strings, or nil for "not set".
|
|
@@ -121,12 +130,17 @@ module Devbench
|
|
|
121
130
|
# Seconds between background flushes in direct mode (spec: 60).
|
|
122
131
|
attr_accessor :flush_interval
|
|
123
132
|
attr_writer :enabled
|
|
133
|
+
# The tenant's chat widget secret (dbws_…, DEVBENCH_WIDGET_SECRET), or
|
|
134
|
+
# nil. Signs the widget identity on the browser tag; never leaves the
|
|
135
|
+
# server.
|
|
136
|
+
attr_accessor :widget_secret
|
|
124
137
|
|
|
125
138
|
def initialize(env = ENV)
|
|
126
139
|
@dsn = presence(env['DEVBENCH_DSN']) || presence(env['ADT_DSN'])
|
|
127
140
|
@service = presence(env['DEVBENCH_SERVICE'])
|
|
128
|
-
@release = presence(env['DEVBENCH_RELEASE'])
|
|
129
|
-
|
|
141
|
+
@release = presence(env['DEVBENCH_RELEASE'])
|
|
142
|
+
@widget_secret = presence(env['DEVBENCH_WIDGET_SECRET'])
|
|
143
|
+
@release_env = RELEASE_FALLBACKS.lazy.map { |name| presence(env[name]) }.find(&:itself)
|
|
130
144
|
@enabled = !OFF.include?(env['DEVBENCH_ENABLED'].to_s.strip.downcase)
|
|
131
145
|
@flush_interval = 60
|
|
132
146
|
end
|
|
@@ -149,12 +163,40 @@ module Devbench
|
|
|
149
163
|
sidekiq_server? ? "#{base}-sidekiq" : base
|
|
150
164
|
end
|
|
151
165
|
|
|
166
|
+
# The release sent with every flush and on the browser tag. Explicit
|
|
167
|
+
# (configure or DEVBENCH_RELEASE) wins; else the REVISION file, read
|
|
168
|
+
# once reporting starts (after Rails has booted, so Rails.root is
|
|
169
|
+
# known); else the deploy variables in RELEASE_FALLBACKS order; else "".
|
|
152
170
|
def resolved_release
|
|
153
|
-
@release
|
|
171
|
+
explicit = presence(@release)
|
|
172
|
+
return explicit if explicit
|
|
173
|
+
|
|
174
|
+
@detected_release ||= (revision_file_release || @release_env).to_s
|
|
154
175
|
end
|
|
155
176
|
|
|
156
177
|
private
|
|
157
178
|
|
|
179
|
+
# The first line of <app root>/REVISION, or nil when there is none or it
|
|
180
|
+
# cannot be read. The app root is Rails.root, else the working directory.
|
|
181
|
+
def revision_file_release
|
|
182
|
+
path = File.join(app_root, REVISION_FILE)
|
|
183
|
+
return nil unless File.file?(path)
|
|
184
|
+
|
|
185
|
+
presence(File.open(path, 'rb') { |f| f.read(REVISION_MAX_BYTES) }.to_s.lines.first)
|
|
186
|
+
rescue StandardError
|
|
187
|
+
nil
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def app_root
|
|
191
|
+
if defined?(::Rails) && ::Rails.respond_to?(:root) && ::Rails.root
|
|
192
|
+
::Rails.root.to_s
|
|
193
|
+
else
|
|
194
|
+
Dir.pwd
|
|
195
|
+
end
|
|
196
|
+
rescue StandardError
|
|
197
|
+
Dir.pwd
|
|
198
|
+
end
|
|
199
|
+
|
|
158
200
|
def sidekiq_server?
|
|
159
201
|
defined?(::Sidekiq) && ::Sidekiq.respond_to?(:server?) && ::Sidekiq.server? ? true : false
|
|
160
202
|
rescue StandardError, SystemStackError
|
data/lib/devbench/current.rb
CHANGED
|
@@ -10,6 +10,9 @@ module Devbench
|
|
|
10
10
|
KEY = :__adt_trace
|
|
11
11
|
HANDLED_KEY = :__adt_handled
|
|
12
12
|
USER_KEY = :__adt_user
|
|
13
|
+
WIDGET_USER_KEY = :__adt_widget_user
|
|
14
|
+
SCOPE_KEY = :__adt_scope
|
|
15
|
+
EXCEPTION_KEY = :__adt_exception_reported
|
|
13
16
|
|
|
14
17
|
# Counts failures handled during this request.
|
|
15
18
|
#
|
|
@@ -24,6 +27,18 @@ module Devbench
|
|
|
24
27
|
Thread.current[HANDLED_KEY] = handled_count + 1
|
|
25
28
|
end
|
|
26
29
|
|
|
30
|
+
# Whether an `exception` was reported during this scope (any context:
|
|
31
|
+
# request, rails_error, job, explicit). A 5xx after one is not also a
|
|
32
|
+
# `failed` request: the exception is the report (Capability 2b). A
|
|
33
|
+
# handled failure (report_handled) does not set it.
|
|
34
|
+
def self.exception_reported?
|
|
35
|
+
Thread.current[EXCEPTION_KEY] == true
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.record_exception
|
|
39
|
+
Thread.current[EXCEPTION_KEY] = true
|
|
40
|
+
end
|
|
41
|
+
|
|
27
42
|
# Who this request is for, as set by Devbench.set_user: a frozen hash with
|
|
28
43
|
# :email and/or :account, or nil. Normalised before it gets here.
|
|
29
44
|
def self.user
|
|
@@ -34,6 +49,17 @@ module Devbench
|
|
|
34
49
|
Thread.current[USER_KEY] = value
|
|
35
50
|
end
|
|
36
51
|
|
|
52
|
+
# The raw identity, role and chat permission Devbench.set_user was given,
|
|
53
|
+
# for the chat widget's signed token (Devbench::WidgetIdentity); nil when
|
|
54
|
+
# no user is set. Never sent with sensor data.
|
|
55
|
+
def self.widget_user
|
|
56
|
+
Thread.current[WIDGET_USER_KEY]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.widget_user=(value)
|
|
60
|
+
Thread.current[WIDGET_USER_KEY] = value
|
|
61
|
+
end
|
|
62
|
+
|
|
37
63
|
def self.trace
|
|
38
64
|
Thread.current[KEY]
|
|
39
65
|
end
|
|
@@ -42,6 +68,14 @@ module Devbench
|
|
|
42
68
|
Thread.current[KEY] = value
|
|
43
69
|
end
|
|
44
70
|
|
|
71
|
+
# True while a scope (Current.with: a request in the middleware, a
|
|
72
|
+
# Sidekiq job, an ActiveJob job) is open on this thread, traced or not.
|
|
73
|
+
# An ActiveJob job run inside one (the inline adapter in a request,
|
|
74
|
+
# ActiveJob on Sidekiq) belongs to it and opens no second scope.
|
|
75
|
+
def self.scoped?
|
|
76
|
+
Thread.current[SCOPE_KEY] == true
|
|
77
|
+
end
|
|
78
|
+
|
|
45
79
|
# Runs the block with trace active, restoring whatever was there before.
|
|
46
80
|
#
|
|
47
81
|
# Restores rather than clearing, because servers reuse threads: clearing
|
|
@@ -50,7 +84,11 @@ module Devbench
|
|
|
50
84
|
previous = Thread.current[KEY]
|
|
51
85
|
previous_handled = Thread.current[HANDLED_KEY]
|
|
52
86
|
previous_user = Thread.current[USER_KEY]
|
|
87
|
+
previous_widget_user = Thread.current[WIDGET_USER_KEY]
|
|
88
|
+
previous_scope = Thread.current[SCOPE_KEY]
|
|
89
|
+
previous_exception = Thread.current[EXCEPTION_KEY]
|
|
53
90
|
Thread.current[KEY] = trace
|
|
91
|
+
Thread.current[SCOPE_KEY] = true
|
|
54
92
|
# Reset rather than inherit: servers reuse threads, and carrying a count
|
|
55
93
|
# across requests would mark an innocent response as having swallowed
|
|
56
94
|
# the previous request's failure.
|
|
@@ -58,17 +96,28 @@ module Devbench
|
|
|
58
96
|
# Same for identity, and more so: inheriting it would attribute one
|
|
59
97
|
# user's failure to whoever this thread served before.
|
|
60
98
|
Thread.current[USER_KEY] = nil
|
|
99
|
+
Thread.current[WIDGET_USER_KEY] = nil
|
|
100
|
+
Thread.current[EXCEPTION_KEY] = nil
|
|
61
101
|
yield
|
|
62
102
|
ensure
|
|
103
|
+
# An exception reported in a nested scope (a second middleware, a job
|
|
104
|
+
# run inside the request) was reported during the enclosing one too.
|
|
105
|
+
nested_exception = previous_scope == true && Thread.current[EXCEPTION_KEY] == true
|
|
106
|
+
Thread.current[EXCEPTION_KEY] = nested_exception ? true : previous_exception
|
|
63
107
|
Thread.current[KEY] = previous
|
|
64
108
|
Thread.current[HANDLED_KEY] = previous_handled
|
|
65
109
|
Thread.current[USER_KEY] = previous_user
|
|
110
|
+
Thread.current[WIDGET_USER_KEY] = previous_widget_user
|
|
111
|
+
Thread.current[SCOPE_KEY] = previous_scope
|
|
66
112
|
end
|
|
67
113
|
|
|
68
114
|
def self.clear
|
|
69
115
|
Thread.current[KEY] = nil
|
|
70
116
|
Thread.current[HANDLED_KEY] = nil
|
|
71
117
|
Thread.current[USER_KEY] = nil
|
|
118
|
+
Thread.current[WIDGET_USER_KEY] = nil
|
|
119
|
+
Thread.current[SCOPE_KEY] = nil
|
|
120
|
+
Thread.current[EXCEPTION_KEY] = nil
|
|
72
121
|
end
|
|
73
122
|
end
|
|
74
123
|
end
|
data/lib/devbench/direct.rb
CHANGED
|
@@ -8,9 +8,10 @@ require_relative 'fingerprint'
|
|
|
8
8
|
require_relative 'scrub'
|
|
9
9
|
require_relative 'egress_policy'
|
|
10
10
|
require_relative 'logs'
|
|
11
|
+
require_relative 'request_signals'
|
|
11
12
|
|
|
12
13
|
module Devbench
|
|
13
|
-
# Direct mode (docs/
|
|
14
|
+
# Direct mode (docs/server-sdk/transports.md, "Direct mode"): fingerprint and
|
|
14
15
|
# count in-process, flush counts to ingest once a minute, upload one
|
|
15
16
|
# redacted evidence bundle per fingerprint only when ingest asks, and
|
|
16
17
|
# answer ingest's requests for the log lines of a trace (need_logs) from
|
|
@@ -176,9 +177,26 @@ module Devbench
|
|
|
176
177
|
case payload[:kind]
|
|
177
178
|
when 'exception' then exception_signal(payload)
|
|
178
179
|
when 'handled_failure' then handled_signal(payload)
|
|
180
|
+
when 'request' then request_signal(payload)
|
|
179
181
|
end
|
|
180
182
|
end
|
|
181
183
|
|
|
184
|
+
# recordRequest (internal/reqsignal): {error|degradation, server,
|
|
185
|
+
# service, http_<status>|slow_request|http_429_spike, "<METHOD> <route>"},
|
|
186
|
+
# no frames. A signal/status pair the sidecar would drop is dropped.
|
|
187
|
+
def request_signal(payload)
|
|
188
|
+
built = RequestSignals.build(signal: payload[:signal], method: payload[:method], route: payload[:route],
|
|
189
|
+
status: payload[:status], duration_ms: payload[:duration_ms],
|
|
190
|
+
count: payload[:count], total: payload[:total])
|
|
191
|
+
return nil if built.nil?
|
|
192
|
+
|
|
193
|
+
fp = Fingerprint.compute(kind: built.kind, source: 'server', service: @service,
|
|
194
|
+
type: built.type, message: built.message)
|
|
195
|
+
return nil if fp.nil?
|
|
196
|
+
|
|
197
|
+
Signal.new(fp, built.kind, built.text, user(payload[:user]), [], built.exemplar)
|
|
198
|
+
end
|
|
199
|
+
|
|
182
200
|
# recordException: {error, server, service, type, message[0,2000], frames}.
|
|
183
201
|
def exception_signal(payload)
|
|
184
202
|
type = payload[:error].to_s
|
|
@@ -338,6 +356,9 @@ module Devbench
|
|
|
338
356
|
# (a rule arriving with a request for the shape it governs applies to
|
|
339
357
|
# that request), then log slices, then evidence.
|
|
340
358
|
def flush_cycle(deadline)
|
|
359
|
+
# A 429 spike window the clock has left closes at a flush: counted
|
|
360
|
+
# here, so it goes out with this one.
|
|
361
|
+
RequestSignals.tick!(self)
|
|
341
362
|
counts, overflowed = @lock.synchronize do
|
|
342
363
|
taken = [@window, @overflowed]
|
|
343
364
|
@window = {}
|
|
@@ -406,9 +427,14 @@ module Devbench
|
|
|
406
427
|
out
|
|
407
428
|
end
|
|
408
429
|
|
|
430
|
+
# Which SDK sent a flush, on every request of it (#308): counts, each
|
|
431
|
+
# request of a split window and the empty poll are all built here.
|
|
432
|
+
SDK_FIELD = { name: SDK_NAME, version: VERSION }.freeze
|
|
433
|
+
private_constant :SDK_FIELD
|
|
434
|
+
|
|
409
435
|
def envelope(counts, overflowed)
|
|
410
|
-
body = { v:
|
|
411
|
-
sensor_id: @sensor_id, counts: counts }
|
|
436
|
+
body = { v: PROTOCOL_VERSION, tenant: '', source: 'server', service: @service, release: @release,
|
|
437
|
+
sensor_id: @sensor_id, sdk: SDK_FIELD, counts: counts }
|
|
412
438
|
body[:overflowed] = overflowed if overflowed.positive?
|
|
413
439
|
JSON.generate(body)
|
|
414
440
|
end
|
|
@@ -543,7 +569,7 @@ module Devbench
|
|
|
543
569
|
return nil if detail.nil?
|
|
544
570
|
|
|
545
571
|
bundle = {
|
|
546
|
-
v:
|
|
572
|
+
v: PROTOCOL_VERSION, fp: fp, kind: detail.kind,
|
|
547
573
|
template: Scrub.template_text(detail.text, @policy, fp), service: @service,
|
|
548
574
|
exemplars: detail.exemplar.empty? ? [] : [Scrub.egress(detail.exemplar, @policy, @service)]
|
|
549
575
|
}
|
data/lib/devbench/identity.rb
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
require_relative 'current'
|
|
4
4
|
|
|
5
5
|
module Devbench
|
|
6
|
-
# Who was affected (docs/
|
|
6
|
+
# Who was affected (docs/server-sdk/identity.md, Capability 5).
|
|
7
7
|
#
|
|
8
8
|
# Identity travels in its own field of every control message and nowhere
|
|
9
9
|
# else: never in a message, a symbol or a log line, so it can never move a
|
|
@@ -44,8 +44,22 @@ module Devbench
|
|
|
44
44
|
#
|
|
45
45
|
# Each field is optional. Calling again replaces the whole identity, and
|
|
46
46
|
# calling with neither clears it. Never raises.
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
#
|
|
48
|
+
# role: and chat: are for the chat widget only (docs/server-sdk/chat-widget.md,
|
|
49
|
+
# Capability 6): devbench_script_tag signs them, with the email and
|
|
50
|
+
# account, into the widget's identity token when DEVBENCH_WIDGET_SECRET is
|
|
51
|
+
# set. They are never sent with sensor data. role is the app's own role
|
|
52
|
+
# name for the user (mapped to chat capabilities in the tenant's settings);
|
|
53
|
+
# chat, when given, overrides that mapping: a list of capabilities
|
|
54
|
+
# (:report, :status, :howto, :feature_requests, :handoff), [] for "may not
|
|
55
|
+
# chat", nil (the default) for "not stated".
|
|
56
|
+
def self.set_user(email: nil, account: nil, role: nil, chat: nil)
|
|
57
|
+
user = Identity.normalize(email, account)
|
|
58
|
+
Current.user = user
|
|
59
|
+
# The raw values, not the sensor identity's truncated ones: the widget
|
|
60
|
+
# signer refuses what it cannot sign exactly instead of signing a
|
|
61
|
+
# different person.
|
|
62
|
+
Current.widget_user = user && { email: email, account: account, role: role, chat: chat.is_a?(Array) ? chat.dup.freeze : chat }.freeze
|
|
49
63
|
nil
|
|
50
64
|
rescue StandardError, SystemStackError
|
|
51
65
|
nil
|