devbench 0.5.0 → 0.6.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/README.md +106 -19
- data/lib/devbench/config.rb +66 -21
- data/lib/devbench/direct.rb +172 -23
- data/lib/devbench/egress_policy.rb +101 -0
- data/lib/devbench/logs.rb +342 -0
- data/lib/devbench/railtie.rb +21 -0
- data/lib/devbench/scrub.rb +46 -8
- data/lib/devbench/sidekiq_hooks.rb +4 -0
- data/lib/devbench/transport.rb +65 -10
- data/lib/devbench/version.rb +1 -1
- data/lib/devbench/view_helper.rb +62 -0
- data/lib/devbench.rb +7 -2
- data/lib/generators/devbench/devbench_generator.rb +210 -0
- metadata +8 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 61008f8defbff11fd3e079f568739cc25114bc05fb0f8981c5084f83d4770de4
|
|
4
|
+
data.tar.gz: d0304643c20cdb7fc413771188d60ee1881ae9e760129c22fd32d63aebea0bd5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8af3f77395bf0f10fddf36e0cdfcfdfd85aa2e1528d08c4abc170eeffd318021d657824d24b8f1dfb8fdba1da71b904760ffffdee84a4d50944ab63f41a3f5db
|
|
7
|
+
data.tar.gz: c59ffb19a9739467182f6e4bf15b4ad68ebeb18d2eaabb79a17b15a24de40bde6230247bc58d8085368ef7be2b562986360dd0d5e90a45334d63adf739976938
|
data/README.md
CHANGED
|
@@ -1,24 +1,27 @@
|
|
|
1
1
|
# Dev Bench for Ruby and Rails
|
|
2
2
|
|
|
3
3
|
Everything Sentry's Rails SDK captures by default, plus who was affected,
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
the server log lines of a failing request, the browser sensor, trace
|
|
5
|
+
propagation and the handled-failure header — so an app can turn Sentry
|
|
6
|
+
off. Zero runtime dependencies; loads in plain Ruby, hooks itself
|
|
6
7
|
into Rails when Rails is there.
|
|
7
8
|
|
|
8
9
|
## Install (Rails)
|
|
9
10
|
|
|
10
|
-
```
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
```sh
|
|
12
|
+
bundle add devbench
|
|
13
|
+
bin/rails generate devbench
|
|
13
14
|
```
|
|
14
15
|
|
|
16
|
+
Then set **one** value in the app's environment — the DSN `adt dsn create`
|
|
17
|
+
printed for this environment:
|
|
18
|
+
|
|
15
19
|
```sh
|
|
16
|
-
|
|
17
|
-
DEVBENCH_DSN=https://<key>@adt-ingest.onrender.com
|
|
20
|
+
DEVBENCH_DSN=https://<public>:<secret>@adt-ingest.onrender.com
|
|
18
21
|
```
|
|
19
22
|
|
|
20
|
-
That is the whole install
|
|
21
|
-
|
|
23
|
+
That is the whole install: no browser key, no sidecar, no service names,
|
|
24
|
+
no initializer, no middleware line. Check it:
|
|
22
25
|
|
|
23
26
|
```sh
|
|
24
27
|
bin/rails devbench:test
|
|
@@ -30,16 +33,51 @@ It prints the HTTP result, or says plainly what is wrong (`DEVBENCH_DSN is
|
|
|
30
33
|
not set`, `HTTP 401: the key in DEVBENCH_DSN was rejected`, `could not reach
|
|
31
34
|
…`) and exits non-zero. Outside Rake: `Devbench.test!`.
|
|
32
35
|
|
|
36
|
+
What the generator does, and nothing else (run it again and nothing
|
|
37
|
+
changes):
|
|
38
|
+
|
|
39
|
+
- puts `<%= devbench_script_tag %>` in `app/views/layouts/application.html.erb`
|
|
40
|
+
just before `</head>` (`.haml` / `.slim`: `= devbench_script_tag` as the
|
|
41
|
+
last line of `head`). Without that layout it prints what to add where.
|
|
42
|
+
- if `config/initializers/content_security_policy.rb` defines a policy,
|
|
43
|
+
adds `https://unpkg.com` to `script_src`, and the ingest host and
|
|
44
|
+
`https://*.storage.supabase.co` (evidence uploads) to `connect_src`.
|
|
45
|
+
|
|
46
|
+
### The browser tag
|
|
47
|
+
|
|
48
|
+
`devbench_script_tag` renders the browser sensor, version-pinned to this
|
|
49
|
+
gem (upgrading the gem upgrades the sensor), with **only the public part**
|
|
50
|
+
of `DEVBENCH_DSN`:
|
|
51
|
+
|
|
52
|
+
```html
|
|
53
|
+
<script src="https://unpkg.com/devbench@0.6.0/dist/devbench.min.js"
|
|
54
|
+
data-dsn="https://<public>@adt-ingest.onrender.com" data-release="<release>" defer></script>
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The secret never reaches a page. Without a DSN (or with a 0.5 single-key
|
|
58
|
+
DSN, or `DEVBENCH_ENABLED=false`) it renders nothing at all, so a
|
|
59
|
+
development or test environment without a DSN serves no tag. If the app
|
|
60
|
+
uses CSP nonces (`content_security_policy_nonce_generator`), the tag
|
|
61
|
+
carries the request's nonce.
|
|
62
|
+
|
|
63
|
+
**CSP:** if your policy is defined somewhere other than the standard
|
|
64
|
+
initializer, add these yourself:
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
policy.script_src ..., "https://unpkg.com"
|
|
68
|
+
policy.connect_src ..., "https://adt-ingest.onrender.com", "https://*.storage.supabase.co"
|
|
69
|
+
```
|
|
70
|
+
|
|
33
71
|
## Configuration
|
|
34
72
|
|
|
35
73
|
All optional except the DSN.
|
|
36
74
|
|
|
37
75
|
| Variable | Default | |
|
|
38
76
|
|---|---|---|
|
|
39
|
-
| `DEVBENCH_DSN` | — (falls back to `ADT_DSN`) | `https://<
|
|
40
|
-
| `DEVBENCH_SERVICE` | your app's module, underscored (`AcmeShop` → `acme_shop`); `app` outside Rails | Groups this app's issues. |
|
|
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
|
+
| `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. |
|
|
41
79
|
| `DEVBENCH_RELEASE` | `GIT_SHA`, `SOURCE_VERSION`, `RENDER_GIT_COMMIT`, else empty | Which deploy an occurrence came from. |
|
|
42
|
-
| `DEVBENCH_ENABLED` | on | `false` turns everything off: no middleware, no hooks, nothing sent. |
|
|
80
|
+
| `DEVBENCH_ENABLED` | on | `false` turns everything off: no middleware, no hooks, no tag, nothing sent. |
|
|
43
81
|
|
|
44
82
|
Or from code, e.g. `config/initializers/devbench.rb`:
|
|
45
83
|
|
|
@@ -51,7 +89,7 @@ end
|
|
|
51
89
|
```
|
|
52
90
|
|
|
53
91
|
A DSN that does not parse logs one `[devbench]` warning and reports
|
|
54
|
-
nothing; it never raises into the app.
|
|
92
|
+
nothing; it never raises into the app. Neither key is ever printed.
|
|
55
93
|
|
|
56
94
|
## What is captured automatically
|
|
57
95
|
|
|
@@ -94,8 +132,9 @@ Like the browser sensor, the gem does not send one request per error:
|
|
|
94
132
|
Dev Bench sensor). Repeats cost a counter increment.
|
|
95
133
|
- **Once a minute** a background thread sends the counts — fingerprint,
|
|
96
134
|
how many, first/last seen, and up to 20 affected users each — in one
|
|
97
|
-
small request. Nothing is sent for a quiet minute
|
|
98
|
-
|
|
135
|
+
small request. Nothing is sent for a quiet minute (except a small poll
|
|
136
|
+
while the process holds log lines, below), and what is left is sent
|
|
137
|
+
when the process exits (waiting at most 2 seconds).
|
|
99
138
|
- **Detail only on request.** When Dev Bench sees a fingerprint for the
|
|
100
139
|
first time it asks for evidence, and the gem uploads the stack and one
|
|
101
140
|
example message — **templated and scrubbed first** (emails, numbers,
|
|
@@ -107,6 +146,33 @@ Like the browser sensor, the gem does not send one request per error:
|
|
|
107
146
|
counted). Safe under forking servers (Puma, Unicorn, Sidekiq): each
|
|
108
147
|
worker reports on its own.
|
|
109
148
|
|
|
149
|
+
## Server log lines
|
|
150
|
+
|
|
151
|
+
Triage reads the server's log lines for the user action that failed. With
|
|
152
|
+
`DEVBENCH_DSN` set, the gem keeps them itself — no sidecar:
|
|
153
|
+
|
|
154
|
+
- **What:** every line written to `Rails.logger` and `Sidekiq.logger`
|
|
155
|
+
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).
|
|
157
|
+
Untraced lines are not kept. Rails 7.1+: a capturing logger joins
|
|
158
|
+
`Rails.logger`'s broadcast (it follows the app's level and `silence`,
|
|
159
|
+
and writes nothing); older Rails and `Sidekiq.logger`: a tee after the
|
|
160
|
+
logger's own write. **What your logger writes is unchanged.**
|
|
161
|
+
- **Bounded:** per process, the newest 10,000 lines or 4 MiB, nothing older
|
|
162
|
+
than 15 minutes, 4 KiB per line. A logging call is never blocked on I/O
|
|
163
|
+
and never raises because of this.
|
|
164
|
+
- **Sent only when asked:** when Dev Bench asks for a trace, the flush
|
|
165
|
+
thread sends that trace's lines from this process — the newest ≤ 200
|
|
166
|
+
lines / 64 KiB — **redacted first** with the same strict rules as
|
|
167
|
+
evidence (quoted values, numbers, emails, cards, tokens, credentials,
|
|
168
|
+
IPs and two-word names removed), plus any redaction rules Dev Bench has
|
|
169
|
+
learned for your app. A process holding lines it was not asked about
|
|
170
|
+
sends nothing but a small poll once a minute while it holds them.
|
|
171
|
+
- **Forking servers:** each Puma worker and Sidekiq process keeps and
|
|
172
|
+
answers for its own lines; Dev Bench assembles them.
|
|
173
|
+
|
|
174
|
+
A Rack app without Rails: `Devbench.capture_logs(logger)`.
|
|
175
|
+
|
|
110
176
|
## Who was affected
|
|
111
177
|
|
|
112
178
|
Set it once per request, typically in `ApplicationController`:
|
|
@@ -198,11 +264,12 @@ browser cannot read `x-adt-handled` unless the server lists it in
|
|
|
198
264
|
than overwrites. **A CORS layer that replaces the response header set will
|
|
199
265
|
strip it** and detection will silently never fire; check the ordering.
|
|
200
266
|
|
|
201
|
-
## Optional: the sidecar
|
|
267
|
+
## Optional: the sidecar
|
|
202
268
|
|
|
203
269
|
The Dev Bench sidecar is a separate process that reads your app's log
|
|
204
270
|
stream on the host and answers triage's requests for the log lines of one
|
|
205
|
-
user action. It is optional
|
|
271
|
+
user action. It is optional: with a DSN the gem keeps those lines itself
|
|
272
|
+
(above). It remains for setups that cannot load the gem.
|
|
206
273
|
|
|
207
274
|
Without a DSN, the gem reports to the sidecar's unix socket instead
|
|
208
275
|
(`$ADT_SIDECAR_SOCKET`, default `/tmp/adt-sidecar.sock`), exactly as 0.4
|
|
@@ -212,6 +279,26 @@ gem does not also write to its socket, so nothing is counted twice.
|
|
|
212
279
|
Fingerprints are identical in both modes, so moving between them keeps
|
|
213
280
|
every issue.
|
|
214
281
|
|
|
282
|
+
## Upgrading from 0.5
|
|
283
|
+
|
|
284
|
+
Nothing is required: a 0.5 single-key `DEVBENCH_DSN` keeps reporting
|
|
285
|
+
exactly as before. To get server log lines and the browser tag from the
|
|
286
|
+
same value, mint a pair and replace the DSN:
|
|
287
|
+
|
|
288
|
+
```sh
|
|
289
|
+
adt dsn create --tenant <tenant> --environment production
|
|
290
|
+
# https://<public>:<secret>@adt-ingest.onrender.com
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
then run `bin/rails generate devbench` for the tag and CSP. A page that
|
|
294
|
+
loaded the browser sensor with its own DSN can drop that and use the tag.
|
|
295
|
+
|
|
296
|
+
One default changed: a Sidekiq process without `DEVBENCH_SERVICE` now
|
|
297
|
+
reports as `<app>-sidekiq` (it was `<app>`), so job failures are grouped
|
|
298
|
+
apart from web failures — and an existing job issue reappears once under
|
|
299
|
+
the new name. Set `DEVBENCH_SERVICE` in the Sidekiq process to keep the
|
|
300
|
+
old name.
|
|
301
|
+
|
|
215
302
|
## Upgrading from `adt` (0.4)
|
|
216
303
|
|
|
217
304
|
- Change the Gemfile line to `gem 'devbench'`. `require 'adt'` and every
|
|
@@ -221,5 +308,5 @@ every issue.
|
|
|
221
308
|
- `config.middleware.insert_before 0, ADT::Middleware` can stay or go: the
|
|
222
309
|
Railtie sees it and does not insert a second one.
|
|
223
310
|
- Running the sidecar and want to keep it that way? Change nothing else.
|
|
224
|
-
To report directly instead, set `DEVBENCH_DSN` (
|
|
225
|
-
|
|
311
|
+
To report directly instead, set `DEVBENCH_DSN` (from `adt dsn create`);
|
|
312
|
+
see "Upgrading from 0.5".
|
data/lib/devbench/config.rb
CHANGED
|
@@ -4,23 +4,31 @@ require 'uri'
|
|
|
4
4
|
|
|
5
5
|
module Devbench
|
|
6
6
|
# Where reports go, parsed from DEVBENCH_DSN (docs/SERVER_SDK_SPEC.md, "Dev
|
|
7
|
-
# Bench naming and configuration"):
|
|
7
|
+
# Bench naming and configuration"; DECISIONS #161):
|
|
8
8
|
#
|
|
9
|
-
# https://<
|
|
9
|
+
# https://<public>:<secret>@<host>[:port] from `adt dsn create`
|
|
10
|
+
# https://<key>@<host>[:port] 0.5 and earlier: one key
|
|
10
11
|
#
|
|
11
|
-
# scheme://host[:port] is the ingest base
|
|
12
|
-
#
|
|
13
|
-
#
|
|
12
|
+
# scheme://host[:port] is the ingest base. The secret (a server key) is
|
|
13
|
+
# what this process authenticates with; the public part (a browser key) is
|
|
14
|
+
# only ever handed to the page, via #browser_dsn. A single-key DSN keeps
|
|
15
|
+
# working with its one key and has no browser part — that key is a server
|
|
16
|
+
# secret and must never reach a page. A path or query, if present, is
|
|
17
|
+
# ignored: the keys alone identify tenant, environment and source.
|
|
14
18
|
class DSN
|
|
15
19
|
class Invalid < StandardError; end
|
|
16
20
|
|
|
17
21
|
LOOPBACK = /\A(?:localhost|127(?:\.\d{1,3}){3}|::1|\[::1\])\z|\.localhost\z/i
|
|
22
|
+
SHAPE = 'https://<public>:<secret>@<host>'
|
|
18
23
|
|
|
19
|
-
attr_reader :base, :key
|
|
24
|
+
attr_reader :base, :key, :public_key
|
|
20
25
|
|
|
21
|
-
|
|
26
|
+
# key: what the server sends as X-ADT-Key (the secret, or a 0.5 DSN's
|
|
27
|
+
# single key). public_key: the browser key, or nil.
|
|
28
|
+
def initialize(base, key, public_key = nil)
|
|
22
29
|
@base = base.freeze
|
|
23
30
|
@key = key.freeze
|
|
31
|
+
@public_key = public_key&.freeze
|
|
24
32
|
freeze
|
|
25
33
|
end
|
|
26
34
|
|
|
@@ -28,8 +36,26 @@ module Devbench
|
|
|
28
36
|
"#{@base}/v1/flush"
|
|
29
37
|
end
|
|
30
38
|
|
|
31
|
-
# The
|
|
32
|
-
|
|
39
|
+
# The self-test endpoint: validates the key, stores nothing.
|
|
40
|
+
def check_url
|
|
41
|
+
"#{@base}/v1/check"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def logs_url
|
|
45
|
+
"#{@base}/v1/logs"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The DSN a browser may hold: https://<public>@host. Nil when there is
|
|
49
|
+
# no public part.
|
|
50
|
+
def browser_dsn
|
|
51
|
+
return nil if @public_key.nil?
|
|
52
|
+
|
|
53
|
+
scheme, rest = @base.split('://', 2)
|
|
54
|
+
"#{scheme}://#{URI.encode_www_form_component(@public_key)}@#{rest}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The DSN without its keys, for messages and logs. The secret must never
|
|
58
|
+
# be printed.
|
|
33
59
|
def to_s
|
|
34
60
|
@base
|
|
35
61
|
end
|
|
@@ -37,7 +63,7 @@ module Devbench
|
|
|
37
63
|
alias inspect to_s
|
|
38
64
|
|
|
39
65
|
# Raises DSN::Invalid with a message that says what to fix. Never echoes
|
|
40
|
-
#
|
|
66
|
+
# either key back.
|
|
41
67
|
def self.parse(raw)
|
|
42
68
|
text = raw.to_s.strip
|
|
43
69
|
raise Invalid, 'is empty' if text.empty?
|
|
@@ -45,28 +71,32 @@ module Devbench
|
|
|
45
71
|
uri = begin
|
|
46
72
|
URI.parse(text)
|
|
47
73
|
rescue URI::Error
|
|
48
|
-
raise Invalid,
|
|
74
|
+
raise Invalid, "is not a URL (expected #{SHAPE})"
|
|
49
75
|
end
|
|
50
76
|
|
|
51
77
|
scheme = uri.scheme.to_s.downcase
|
|
52
|
-
raise Invalid,
|
|
78
|
+
raise Invalid, "must start with https:// (expected #{SHAPE})" unless %w[http https].include?(scheme)
|
|
53
79
|
|
|
54
80
|
host = uri.host.to_s
|
|
55
|
-
raise Invalid,
|
|
81
|
+
raise Invalid, "has no host (expected #{SHAPE})" if host.empty?
|
|
56
82
|
|
|
57
|
-
|
|
58
|
-
|
|
83
|
+
user = URI.decode_www_form_component(uri.user.to_s).strip
|
|
84
|
+
secret = URI.decode_www_form_component(uri.password.to_s).strip
|
|
85
|
+
raise Invalid, "has no key (expected #{SHAPE})" if user.empty? && secret.empty?
|
|
59
86
|
|
|
60
|
-
# The
|
|
61
|
-
# the network readable by anything on the path, so http is
|
|
62
|
-
# only for a loopback ingest (local development and tests).
|
|
87
|
+
# The secret is a real secret on a server. Over plain http it would
|
|
88
|
+
# cross the network readable by anything on the path, so http is
|
|
89
|
+
# accepted only for a loopback ingest (local development and tests).
|
|
63
90
|
if scheme == 'http' && !LOOPBACK.match?(host)
|
|
64
91
|
raise Invalid, "uses http:// for #{host}: the key would cross the network in plaintext; use https://"
|
|
65
92
|
end
|
|
66
93
|
|
|
67
94
|
port = uri.port && uri.port != uri.default_port ? ":#{uri.port}" : ''
|
|
68
95
|
host = "[#{host}]" if host.include?(':') && !host.start_with?('[')
|
|
69
|
-
|
|
96
|
+
base = "#{scheme}://#{host}#{port}"
|
|
97
|
+
return new(base, user) if secret.empty?
|
|
98
|
+
|
|
99
|
+
new(base, secret, user.empty? ? nil : user)
|
|
70
100
|
end
|
|
71
101
|
end
|
|
72
102
|
|
|
@@ -77,7 +107,8 @@ module Devbench
|
|
|
77
107
|
# sidecar's socket, as before 0.5.
|
|
78
108
|
# DEVBENCH_SERVICE this app's name. Default: the Rails application's
|
|
79
109
|
# module, underscored (AcmeShop -> acme_shop);
|
|
80
|
-
# else "app"
|
|
110
|
+
# else "app"; with "-sidekiq" appended in a
|
|
111
|
+
# Sidekiq process.
|
|
81
112
|
# DEVBENCH_RELEASE the deployed version. Default: GIT_SHA,
|
|
82
113
|
# SOURCE_VERSION, RENDER_GIT_COMMIT, else "".
|
|
83
114
|
# DEVBENCH_ENABLED "false" turns everything off.
|
|
@@ -106,8 +137,16 @@ module Devbench
|
|
|
106
137
|
|
|
107
138
|
# The service name sent with every flush and folded into every
|
|
108
139
|
# fingerprint. Resolved when reporting starts, after Rails has booted.
|
|
140
|
+
#
|
|
141
|
+
# A Sidekiq process (Sidekiq.server?) defaults to "<app>-sidekiq", so a
|
|
142
|
+
# typical app's web and job processes are told apart with no
|
|
143
|
+
# DEVBENCH_SERVICE at all. An explicit service always wins.
|
|
109
144
|
def resolved_service
|
|
110
|
-
presence(@service)
|
|
145
|
+
explicit = presence(@service)
|
|
146
|
+
return explicit if explicit
|
|
147
|
+
|
|
148
|
+
base = rails_service || 'app'
|
|
149
|
+
sidekiq_server? ? "#{base}-sidekiq" : base
|
|
111
150
|
end
|
|
112
151
|
|
|
113
152
|
def resolved_release
|
|
@@ -116,6 +155,12 @@ module Devbench
|
|
|
116
155
|
|
|
117
156
|
private
|
|
118
157
|
|
|
158
|
+
def sidekiq_server?
|
|
159
|
+
defined?(::Sidekiq) && ::Sidekiq.respond_to?(:server?) && ::Sidekiq.server? ? true : false
|
|
160
|
+
rescue StandardError, SystemStackError
|
|
161
|
+
false
|
|
162
|
+
end
|
|
163
|
+
|
|
119
164
|
def presence(value)
|
|
120
165
|
text = value.to_s.strip
|
|
121
166
|
text.empty? ? nil : text
|
data/lib/devbench/direct.rb
CHANGED
|
@@ -6,11 +6,15 @@ require 'securerandom'
|
|
|
6
6
|
require 'uri'
|
|
7
7
|
require_relative 'fingerprint'
|
|
8
8
|
require_relative 'scrub'
|
|
9
|
+
require_relative 'egress_policy'
|
|
10
|
+
require_relative 'logs'
|
|
9
11
|
|
|
10
12
|
module Devbench
|
|
11
13
|
# Direct mode (docs/SERVER_SDK_SPEC.md, "Direct mode"): fingerprint and
|
|
12
14
|
# count in-process, flush counts to ingest once a minute, upload one
|
|
13
|
-
# redacted evidence bundle per fingerprint only when ingest asks
|
|
15
|
+
# redacted evidence bundle per fingerprint only when ingest asks, and
|
|
16
|
+
# answer ingest's requests for the log lines of a trace (need_logs) from
|
|
17
|
+
# the in-process buffer (Devbench::Logs), redacted. The same
|
|
14
18
|
# phase-1/phase-2 protocol as the browser sensor, and the same signals and
|
|
15
19
|
# bundle the sidecar builds from the equivalent control message, so a
|
|
16
20
|
# customer can move between modes without forking an issue.
|
|
@@ -37,6 +41,13 @@ module Devbench
|
|
|
37
41
|
MAX_FRAMES = 50
|
|
38
42
|
MAX_IDENTITY = 255
|
|
39
43
|
MAX_RESPONSE_BYTES = 64 * 1024
|
|
44
|
+
# Log slices (spec: <= 500 lines per slice). 200 and 64 KiB are what the
|
|
45
|
+
# store keeps of a slice; sending more would spend the customer's
|
|
46
|
+
# bandwidth on lines truncated on arrival. 25 slices is what one
|
|
47
|
+
# delivery may carry.
|
|
48
|
+
MAX_SLICE_LINES = 200
|
|
49
|
+
MAX_SLICE_BYTES = 64 * 1024
|
|
50
|
+
MAX_SLICES_PER_POST = 25
|
|
40
51
|
HTTP_TIMEOUT = 5.0
|
|
41
52
|
EXIT_TIMEOUT = 2.0
|
|
42
53
|
|
|
@@ -52,6 +63,11 @@ module Devbench
|
|
|
52
63
|
@release = release.to_s
|
|
53
64
|
@interval = interval.to_f.positive? ? interval.to_f : 60.0
|
|
54
65
|
@flush_uri = URI(dsn.flush_url)
|
|
66
|
+
@logs_uri = URI(dsn.logs_url)
|
|
67
|
+
@check_uri = URI(dsn.check_url)
|
|
68
|
+
# Learned redaction rules from the last response that carried a set.
|
|
69
|
+
# Not per process: a forked child keeps them until ingest re-sends.
|
|
70
|
+
@policy = nil
|
|
55
71
|
@fork_lock = Mutex.new
|
|
56
72
|
@stopped = false
|
|
57
73
|
reset_state
|
|
@@ -100,6 +116,10 @@ module Devbench
|
|
|
100
116
|
nil
|
|
101
117
|
end
|
|
102
118
|
|
|
119
|
+
# The learned rules in force (an EgressPolicy), or nil before any
|
|
120
|
+
# arrived. For tests and diagnostics.
|
|
121
|
+
attr_reader :policy
|
|
122
|
+
|
|
103
123
|
# Counts not yet flushed, by fingerprint. For tests and diagnostics.
|
|
104
124
|
def pending
|
|
105
125
|
@lock.synchronize { @window.transform_values { |c| c.n } }
|
|
@@ -108,14 +128,13 @@ module Devbench
|
|
|
108
128
|
# rake devbench:test. Sends one synthetic exception straight to ingest
|
|
109
129
|
# (not via the background thread), uploads its evidence if asked, and
|
|
110
130
|
# prints what happened. Returns true when ingest accepted it.
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
response, error = attempt(:post, @flush_uri, body, flush_headers, HTTP_TIMEOUT)
|
|
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.
|
|
135
|
+
def self_test(io)
|
|
136
|
+
io.puts "Dev Bench: checking the DSN against #{@dsn} (service #{@service.inspect})"
|
|
137
|
+
response, error = attempt(:post, @check_uri, '{}', flush_headers, HTTP_TIMEOUT)
|
|
119
138
|
if error
|
|
120
139
|
io.puts " could not reach #{@dsn}: #{error.class}: #{error.message}"
|
|
121
140
|
return false
|
|
@@ -127,14 +146,26 @@ module Devbench
|
|
|
127
146
|
return false
|
|
128
147
|
end
|
|
129
148
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
149
|
+
info = begin
|
|
150
|
+
JSON.parse(response.body.to_s)
|
|
151
|
+
rescue JSON::ParserError
|
|
152
|
+
{}
|
|
134
153
|
end
|
|
154
|
+
io.puts " HTTP #{code}: accepted — tenant #{info['tenant'].inspect}, environment " \
|
|
155
|
+
"#{info['environment'].inspect}. Nothing was recorded; real errors will appear as issues."
|
|
135
156
|
true
|
|
136
157
|
end
|
|
137
158
|
|
|
159
|
+
# Starts the background flush thread if it is not running (in this
|
|
160
|
+
# process). Called when this process first holds a log line, so a
|
|
161
|
+
# worker that reports nothing still polls for log requests.
|
|
162
|
+
def start
|
|
163
|
+
ensure_running
|
|
164
|
+
nil
|
|
165
|
+
rescue StandardError, SystemStackError
|
|
166
|
+
nil
|
|
167
|
+
end
|
|
168
|
+
|
|
138
169
|
private
|
|
139
170
|
|
|
140
171
|
# ---- Signals: exactly what the sidecar builds (internal/sidecar/control.go)
|
|
@@ -303,6 +334,9 @@ module Devbench
|
|
|
303
334
|
|
|
304
335
|
# ---- Flushing
|
|
305
336
|
|
|
337
|
+
# Phase 1, then whatever the responses asked for: learned rules first
|
|
338
|
+
# (a rule arriving with a request for the shape it governs applies to
|
|
339
|
+
# that request), then log slices, then evidence.
|
|
306
340
|
def flush_cycle(deadline)
|
|
307
341
|
counts, overflowed = @lock.synchronize do
|
|
308
342
|
taken = [@window, @overflowed]
|
|
@@ -310,11 +344,22 @@ module Devbench
|
|
|
310
344
|
@overflowed = 0
|
|
311
345
|
taken
|
|
312
346
|
end
|
|
313
|
-
|
|
347
|
+
# Nothing counted: poll only while this process holds traced lines,
|
|
348
|
+
# since need_logs only rides a flush response (the sidecar's poll).
|
|
349
|
+
# Holding none, send nothing.
|
|
350
|
+
if counts.empty?
|
|
351
|
+
return true unless Logs.holding?
|
|
352
|
+
|
|
353
|
+
posts = [envelope([], 0)]
|
|
354
|
+
else
|
|
355
|
+
posts = bodies(counts, overflowed)
|
|
356
|
+
end
|
|
314
357
|
|
|
315
358
|
ok = true
|
|
316
359
|
wanted = []
|
|
317
|
-
|
|
360
|
+
log_keys = []
|
|
361
|
+
rules = nil
|
|
362
|
+
posts.each do |body|
|
|
318
363
|
response = request(:post, @flush_uri, body, flush_headers, deadline)
|
|
319
364
|
if response.nil?
|
|
320
365
|
ok = false
|
|
@@ -322,12 +367,17 @@ module Devbench
|
|
|
322
367
|
end
|
|
323
368
|
code = response.code.to_i
|
|
324
369
|
if (200..299).cover?(code)
|
|
325
|
-
|
|
370
|
+
answer = answers(response)
|
|
371
|
+
wanted.concat(answer[:evidence])
|
|
372
|
+
log_keys.concat(answer[:logs])
|
|
373
|
+
rules = answer[:redaction] unless answer[:redaction].nil?
|
|
326
374
|
else
|
|
327
375
|
ok = false
|
|
328
376
|
Devbench.warn_once(:"http_#{code}", "Dev Bench refused a flush: HTTP #{code}: #{explain(code, response)}")
|
|
329
377
|
end
|
|
330
378
|
end
|
|
379
|
+
@policy = EgressPolicy.compile(rules) unless rules.nil?
|
|
380
|
+
deliver_logs(log_keys.uniq, deadline) unless log_keys.empty?
|
|
331
381
|
wanted.each { |ask| upload(ask, deadline) }
|
|
332
382
|
ok
|
|
333
383
|
end
|
|
@@ -369,15 +419,114 @@ module Devbench
|
|
|
369
419
|
end
|
|
370
420
|
|
|
371
421
|
def asks(response)
|
|
422
|
+
answers(response)[:evidence]
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
# What a flush response asked for: evidence (need_evidence), log slices
|
|
426
|
+
# (need_logs trace keys) and the learned rule set (redaction: an array,
|
|
427
|
+
# or nil when the response carried none — which leaves the rules in
|
|
428
|
+
# force, unlike an empty array, which withdraws them).
|
|
429
|
+
def answers(response)
|
|
430
|
+
none = { evidence: [], logs: [], redaction: nil }
|
|
372
431
|
body = response.body.to_s
|
|
373
|
-
return
|
|
432
|
+
return none if body.empty? || body.bytesize > MAX_RESPONSE_BYTES
|
|
374
433
|
|
|
375
434
|
parsed = JSON.parse(body)
|
|
376
|
-
|
|
377
|
-
|
|
435
|
+
return none unless parsed.is_a?(Hash)
|
|
436
|
+
|
|
437
|
+
evidence = parsed['need_evidence']
|
|
438
|
+
logs = parsed['need_logs']
|
|
439
|
+
redaction = parsed['redaction']
|
|
440
|
+
{
|
|
441
|
+
evidence: evidence.is_a?(Array) ? evidence.select { |a| a.is_a?(Hash) } : [],
|
|
442
|
+
logs: logs.is_a?(Array) ? logs.filter_map { |l| log_key(l) } : [],
|
|
443
|
+
redaction: redaction.is_a?(Array) ? redaction : nil
|
|
444
|
+
}
|
|
378
445
|
rescue JSON::ParserError
|
|
379
|
-
# Counts were accepted; an unreadable answer only loses the
|
|
380
|
-
|
|
446
|
+
# Counts were accepted; an unreadable answer only loses the asks.
|
|
447
|
+
none
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
def log_key(ask)
|
|
451
|
+
key = ask.is_a?(Hash) ? ask['trace'] : nil
|
|
452
|
+
key.is_a?(String) && !key.empty? && key.bytesize <= 256 ? key : nil
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
# Phase 3: the lines this process holds for each requested trace,
|
|
456
|
+
# redacted with the exemplars' strict egress plus the learned rules,
|
|
457
|
+
# POSTed with the secret key. A key with no lines here sends nothing:
|
|
458
|
+
# another process (a Puma worker, a Sidekiq process) may hold them, and
|
|
459
|
+
# ingest does not let a server key's empty answer settle a request.
|
|
460
|
+
def deliver_logs(keys, deadline)
|
|
461
|
+
policy = @policy
|
|
462
|
+
slices = keys.filter_map do |key|
|
|
463
|
+
lines = Logs.lookup(key, MAX_SLICE_LINES)
|
|
464
|
+
next if lines.empty?
|
|
465
|
+
|
|
466
|
+
{ trace: key, lines: bound_slice(lines.map { |line| egress_line(line, policy) }) }
|
|
467
|
+
end
|
|
468
|
+
slice_bodies(slices).each do |body|
|
|
469
|
+
response = request(:post, @logs_uri, body, flush_headers, deadline)
|
|
470
|
+
next if response.nil? || (200..299).cover?(response.code.to_i)
|
|
471
|
+
|
|
472
|
+
code = response.code.to_i
|
|
473
|
+
Devbench.warn_once(:"logs_http_#{code}", "Dev Bench refused log lines: HTTP #{code}: #{explain(code, response)}")
|
|
474
|
+
end
|
|
475
|
+
nil
|
|
476
|
+
rescue StandardError, SystemStackError
|
|
477
|
+
nil
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
# A held line is "[<trace>] <SEVERITY> <message>" (Logs). The bracketed
|
|
481
|
+
# trace is our own framing, kept as is; the rest is redacted exactly as
|
|
482
|
+
# the sidecar redacts a line's body.
|
|
483
|
+
OWN_TRACE = %r{\A\[(v1/[A-Za-z0-9_-]{1,64}/[A-Za-z0-9_-]{1,64}/\d{1,3})\] }
|
|
484
|
+
|
|
485
|
+
def egress_line(line, policy)
|
|
486
|
+
own = OWN_TRACE.match(line)
|
|
487
|
+
out = if own.nil?
|
|
488
|
+
Scrub.egress(line, policy, @service)
|
|
489
|
+
else
|
|
490
|
+
rest = line[own.end(0)..]
|
|
491
|
+
redacted = Scrub.egress(rest, policy, @service)
|
|
492
|
+
# A message that already carries the trace (Rails embeds its
|
|
493
|
+
# log tags in a multi-line error) comes back prefixed with it.
|
|
494
|
+
redacted.start_with?("[#{own[1]}] ") ? redacted : "[#{own[1]}] #{redacted}"
|
|
495
|
+
end
|
|
496
|
+
out.bytesize > Logs::MAX_LINE_BYTES ? out.byteslice(0, Logs::MAX_LINE_BYTES).scrub('') : out
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
# The most recent lines that fit the store's per-slice byte bound.
|
|
500
|
+
def bound_slice(lines)
|
|
501
|
+
total = 0
|
|
502
|
+
kept = []
|
|
503
|
+
lines.reverse_each do |line|
|
|
504
|
+
total += line.bytesize
|
|
505
|
+
break if total > MAX_SLICE_BYTES
|
|
506
|
+
|
|
507
|
+
kept << line
|
|
508
|
+
end
|
|
509
|
+
kept.reverse
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
# At most MAX_SLICES_PER_POST slices and MAX_BODY_BYTES per request.
|
|
513
|
+
def slice_bodies(slices)
|
|
514
|
+
out = []
|
|
515
|
+
current = []
|
|
516
|
+
size = 0
|
|
517
|
+
budget = MAX_BODY_BYTES - JSON.generate({ slices: [] }).bytesize
|
|
518
|
+
slices.each do |slice|
|
|
519
|
+
bytes = JSON.generate(slice).bytesize + 1
|
|
520
|
+
if !current.empty? && (current.length >= MAX_SLICES_PER_POST || size + bytes > budget)
|
|
521
|
+
out << JSON.generate({ slices: current })
|
|
522
|
+
current = []
|
|
523
|
+
size = 0
|
|
524
|
+
end
|
|
525
|
+
current << slice
|
|
526
|
+
size += bytes
|
|
527
|
+
end
|
|
528
|
+
out << JSON.generate({ slices: current }) unless current.empty?
|
|
529
|
+
out
|
|
381
530
|
end
|
|
382
531
|
|
|
383
532
|
# Phase 2: the sidecar's bundle shape, PUT to the presigned URL with no
|
|
@@ -395,8 +544,8 @@ module Devbench
|
|
|
395
544
|
|
|
396
545
|
bundle = {
|
|
397
546
|
v: 1, fp: fp, kind: detail.kind,
|
|
398
|
-
template: Scrub.template_text(detail.text), service: @service,
|
|
399
|
-
exemplars: detail.exemplar.empty? ? [] : [Scrub.egress(detail.exemplar)]
|
|
547
|
+
template: Scrub.template_text(detail.text, @policy, fp), service: @service,
|
|
548
|
+
exemplars: detail.exemplar.empty? ? [] : [Scrub.egress(detail.exemplar, @policy, @service)]
|
|
400
549
|
}
|
|
401
550
|
bundle[:frames] = detail.frames unless detail.frames.empty?
|
|
402
551
|
response = request(:put, URI(url), JSON.generate(bundle), { 'content-type' => 'application/json' }, deadline)
|