devbench 0.7.0 → 0.8.1

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: dd716a7741353f88f381775e58d7d613d7a5d50ff69287feb65f86f25c38e13d
4
- data.tar.gz: '03358a6c9d097c8b4adc3daacc8c032699aba2a81814284caa0287254134f95c'
3
+ metadata.gz: ec8b8b2c35e830db27e1c4136ea53cc039274d5e12c7947a38918d0ae8084591
4
+ data.tar.gz: '09b1b3a42c848c2fe6391913bcb1ed91592a2c522523bff4a807658ee0e56151'
5
5
  SHA512:
6
- metadata.gz: bdc43a37a987bcc745a17bc957d704bbf93835a6ef475c66e7228acb9c64edcad1ee2ab4acd9b68020f21435d16f6cf263e378db615cd49242e20407115e6d9a
7
- data.tar.gz: b0c9a3d9884106853d587c2944cb8436bd333ed197fd4630ae17a713e4f959068e96faaf40ea54bfe1cc87f21532f6b482686dd6451ee39d022d9d683e6229d5
6
+ metadata.gz: a51bedbbd9b518641a9956170b023dc713395b5da72b722fcb2545019c2697d4883be45cbcafea2d38bfc3dcf975c86a2b5dc94ba717be85fdd957b4ea9ae965
7
+ data.tar.gz: 5421b8ee58441f9b2f57f8c573e66494cd0b4fefd0f64ae6e30efbef08fd5882d1e413b0fbf15388cde8657cb5dc548300130324c3cccc22b19be2455424b954
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.7.0/dist/devbench.min.js"
53
+ <script src="https://unpkg.com/devbench@0.8.1/dist/devbench.min.js"
54
54
  data-dsn="https://<public>@adt-ingest.onrender.com" data-release="<release>" defer></script>
55
55
  ```
56
56
 
@@ -78,6 +78,7 @@ All optional except the DSN.
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
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`
@@ -130,11 +130,16 @@ module Devbench
130
130
  # Seconds between background flushes in direct mode (spec: 60).
131
131
  attr_accessor :flush_interval
132
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
133
137
 
134
138
  def initialize(env = ENV)
135
139
  @dsn = presence(env['DEVBENCH_DSN']) || presence(env['ADT_DSN'])
136
140
  @service = presence(env['DEVBENCH_SERVICE'])
137
141
  @release = presence(env['DEVBENCH_RELEASE'])
142
+ @widget_secret = presence(env['DEVBENCH_WIDGET_SECRET'])
138
143
  @release_env = RELEASE_FALLBACKS.lazy.map { |name| presence(env[name]) }.find(&:itself)
139
144
  @enabled = !OFF.include?(env['DEVBENCH_ENABLED'].to_s.strip.downcase)
140
145
  @flush_interval = 60
@@ -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
@@ -125,13 +126,12 @@ module Devbench
125
126
  @lock.synchronize { @window.transform_values { |c| c.n } }
126
127
  end
127
128
 
128
- # rake devbench:test. Sends one synthetic exception straight to ingest
129
- # (not via the background thread), uploads its evidence if asked, and
130
- # prints what happened. Returns true when ingest accepted it.
131
- # The self-test (Devbench.test!): asks ingest whether this DSN's key is
132
- # valid, and for which tenant and environment. Creates nothing — it used
133
- # to send a synthetic exception, which became a real issue on a new
134
- # customer's punch list.
129
+ # The self-test behind `rake devbench:test` (Devbench.test!): POSTs
130
+ # /v1/check straight to ingest (not via the background thread) to ask
131
+ # whether this DSN's key is valid, and for which tenant and environment,
132
+ # and prints the answer. Returns true when ingest accepted the key.
133
+ # Creates nothing — it used to send a synthetic exception, which became a
134
+ # real issue on a new customer's punch list.
135
135
  def self_test(io)
136
136
  io.puts "Dev Bench: checking the DSN against #{@dsn} (service #{@service.inspect})"
137
137
  response, error = attempt(:post, @check_uri, '{}', flush_headers, HTTP_TIMEOUT)
@@ -176,9 +176,26 @@ module Devbench
176
176
  case payload[:kind]
177
177
  when 'exception' then exception_signal(payload)
178
178
  when 'handled_failure' then handled_signal(payload)
179
+ when 'request' then request_signal(payload)
179
180
  end
180
181
  end
181
182
 
183
+ # recordRequest (internal/reqsignal): {error|degradation, server,
184
+ # service, http_<status>|slow_request|http_429_spike, "<METHOD> <route>"},
185
+ # no frames. A signal/status pair the sidecar would drop is dropped.
186
+ def request_signal(payload)
187
+ built = RequestSignals.build(signal: payload[:signal], method: payload[:method], route: payload[:route],
188
+ status: payload[:status], duration_ms: payload[:duration_ms],
189
+ count: payload[:count], total: payload[:total])
190
+ return nil if built.nil?
191
+
192
+ fp = Fingerprint.compute(kind: built.kind, source: 'server', service: @service,
193
+ type: built.type, message: built.message)
194
+ return nil if fp.nil?
195
+
196
+ Signal.new(fp, built.kind, built.text, user(payload[:user]), [], built.exemplar)
197
+ end
198
+
182
199
  # recordException: {error, server, service, type, message[0,2000], frames}.
183
200
  def exception_signal(payload)
184
201
  type = payload[:error].to_s
@@ -338,6 +355,9 @@ module Devbench
338
355
  # (a rule arriving with a request for the shape it governs applies to
339
356
  # that request), then log slices, then evidence.
340
357
  def flush_cycle(deadline)
358
+ # A 429 spike window the clock has left closes at a flush: counted
359
+ # here, so it goes out with this one.
360
+ RequestSignals.tick!(self)
341
361
  counts, overflowed = @lock.synchronize do
342
362
  taken = [@window, @overflowed]
343
363
  @window = {}
@@ -406,9 +426,14 @@ module Devbench
406
426
  out
407
427
  end
408
428
 
429
+ # Which SDK sent a flush, on every request of it (#308): counts, each
430
+ # request of a split window and the empty poll are all built here.
431
+ SDK_FIELD = { name: SDK_NAME, version: VERSION }.freeze
432
+ private_constant :SDK_FIELD
433
+
409
434
  def envelope(counts, overflowed)
410
- body = { v: 1, tenant: '', source: 'server', service: @service, release: @release,
411
- sensor_id: @sensor_id, counts: counts }
435
+ body = { v: PROTOCOL_VERSION, tenant: '', source: 'server', service: @service, release: @release,
436
+ sensor_id: @sensor_id, sdk: SDK_FIELD, counts: counts }
412
437
  body[:overflowed] = overflowed if overflowed.positive?
413
438
  JSON.generate(body)
414
439
  end
@@ -543,7 +568,7 @@ module Devbench
543
568
  return nil if detail.nil?
544
569
 
545
570
  bundle = {
546
- v: 1, fp: fp, kind: detail.kind,
571
+ v: PROTOCOL_VERSION, fp: fp, kind: detail.kind,
547
572
  template: Scrub.template_text(detail.text, @policy, fp), service: @service,
548
573
  exemplars: detail.exemplar.empty? ? [] : [Scrub.egress(detail.exemplar, @policy, @service)]
549
574
  }
@@ -39,7 +39,9 @@ module Devbench
39
39
  [go_re('\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b', Regexp::IGNORECASE), '<uuid>'],
40
40
  [go_re('\b\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?'), '<ts>'],
41
41
  [go_re('\b\d{4}-\d{2}-\d{2}\b'), '<date>'],
42
- [go_re('\b[^\t\n\f\r <>@]+@[^\t\n\f\r <>@]+\.[A-Za-z]{2,}\b'), '<email>'],
42
+ # The local part stops at `=` and `:` (#148): `customer=sam@x.com`
43
+ # templates to `customer=<email>`. Same class as Scrub's email rule.
44
+ [go_re('\b[^\t\n\f\r <>@=:]+@[^\t\n\f\r <>@]+\.[A-Za-z]{2,}\b'), '<email>'],
43
45
  [go_re("\\b[a-z][a-z0-9+.-]*://#{GO_NOT_WS}+"), '<url>'],
44
46
  [go_re('\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b'), '<ip>'],
45
47
  [go_re('\b(?:0x)?[0-9a-fA-F]{8,}\b'), '<hex>'],
@@ -55,9 +57,18 @@ module Devbench
55
57
 
56
58
  DEPENDENCY_FRAGMENTS = [
57
59
  '/node_modules/', '/vendor/', '/gems/', '/ruby/gems/', '/usr/local/go/src/',
58
- '/go/pkg/mod/', '/.bundle/', '<anonymous>'
60
+ '/go/pkg/mod/', '/.bundle/', '<anonymous>',
61
+ # Python (#235): pip packages (site-packages, Debian's dist-packages)
62
+ # and the standard library (lib/python3.X, RHEL's lib64/python3.X);
63
+ # frozen stdlib modules report a file like "<frozen runpy>".
64
+ '/site-packages/', '/dist-packages/', '/lib/python3.', '/lib64/python3.', '<frozen '
59
65
  ].freeze
60
66
 
67
+ # .NET framework frames (#235) ship without PDBs: no file, only a
68
+ # namespace-qualified function. A prefix of the trimmed function, so
69
+ # `Shop.System.Clock.Now` stays in-app.
70
+ DEPENDENCY_FUNCTION_PREFIXES = ['System.', 'Microsoft.'].freeze
71
+
61
72
  ASSET_DIGEST = /\A(.+)[-.]([A-Za-z0-9_]{8,})((?:\.chunk|\.bundle)?\.(?:js|mjs|cjs|css)(?:\.map)?)\z/
62
73
 
63
74
  class << self
@@ -145,7 +156,10 @@ module Devbench
145
156
 
146
157
  def dependency?(file, function)
147
158
  hay = "#{file} #{function}"
148
- DEPENDENCY_FRAGMENTS.any? { |frag| hay.include?(frag) }
159
+ return true if DEPENDENCY_FRAGMENTS.any? { |frag| hay.include?(frag) }
160
+
161
+ fn = trim(function)
162
+ DEPENDENCY_FUNCTION_PREFIXES.any? { |prefix| fn.start_with?(prefix) }
149
163
  end
150
164
 
151
165
  def strip_asset_digest(name)
@@ -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
data/lib/devbench/logs.rb CHANGED
@@ -4,7 +4,7 @@ require 'logger'
4
4
  require_relative 'current'
5
5
 
6
6
  module Devbench
7
- # In-process log capture (docs/SERVER_SDK_SPEC.md, "In-process log capture
7
+ # In-process log capture (docs/server-sdk/configuration.md, "In-process log capture
8
8
  # (direct mode)"; DECISIONS #161): recent log lines kept in memory, keyed
9
9
  # by the trace that was current when each was written, so triage can be
10
10
  # handed the server lines of one user action without a sidecar.