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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 61008f8defbff11fd3e079f568739cc25114bc05fb0f8981c5084f83d4770de4
4
- data.tar.gz: d0304643c20cdb7fc413771188d60ee1881ae9e760129c22fd32d63aebea0bd5
3
+ metadata.gz: 733817dd1561a76917c0426c041129aa4fefe9200c21ffb462bdaf60c6db801c
4
+ data.tar.gz: 9f17ddc4d783f8c666737406df6ddc5c79ff776b299af8b25f2e6d31b43baa9d
5
5
  SHA512:
6
- metadata.gz: 8af3f77395bf0f10fddf36e0cdfcfdfd85aa2e1528d08c4abc170eeffd318021d657824d24b8f1dfb8fdba1da71b904760ffffdee84a4d50944ab63f41a3f5db
7
- data.tar.gz: c59ffb19a9739467182f6e4bf15b4ad68ebeb18d2eaabb79a17b15a24de40bde6230247bc58d8085368ef7be2b562986360dd0d5e90a45334d63adf739976938
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.6.0/dist/devbench.min.js"
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`, else empty | Which deploy an occurrence came from. |
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
@@ -3,7 +3,7 @@
3
3
  require 'uri'
4
4
 
5
5
  module Devbench
6
- # Where reports go, parsed from DEVBENCH_DSN (docs/SERVER_SDK_SPEC.md, "Dev
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: GIT_SHA,
113
- # SOURCE_VERSION, RENDER_GIT_COMMIT, else "".
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
- RELEASE_FALLBACKS = %w[GIT_SHA SOURCE_VERSION RENDER_GIT_COMMIT].freeze
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
- RELEASE_FALLBACKS.lazy.map { |name| presence(env[name]) }.find(&:itself)
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.to_s
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
@@ -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
@@ -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/SERVER_SDK_SPEC.md, "Direct mode"): fingerprint and
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: 1, tenant: '', source: 'server', service: @service, release: @release,
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: 1, fp: fp, kind: detail.kind,
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
  }
@@ -3,7 +3,7 @@
3
3
  require_relative 'current'
4
4
 
5
5
  module Devbench
6
- # Who was affected (docs/SERVER_SDK_SPEC.md, Capability 5).
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
- def self.set_user(email: nil, account: nil)
48
- Current.user = Identity.normalize(email, account)
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