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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: be38d75135b0a120747e5abf3889afadb0997377f8d58510abb5a3baadf41d64
4
- data.tar.gz: d2791c38e21e79d204e71a22aa3337793b9641976827aeb0f5e6dd54a7f811dc
3
+ metadata.gz: 61008f8defbff11fd3e079f568739cc25114bc05fb0f8981c5084f83d4770de4
4
+ data.tar.gz: d0304643c20cdb7fc413771188d60ee1881ae9e760129c22fd32d63aebea0bd5
5
5
  SHA512:
6
- metadata.gz: 3f323a0cd9aa89ed80cee8fa78c8598c992f8ac15200bc7bf98ee5cc211079dc1458237c782479c150643a944683b27fe3b295222d1f7b2e8ec5a3bf0bacab5c
7
- data.tar.gz: ec896fbc0a0d4d65d6259a83186f81df942cc09d0f437995cc34f80a4ab66c0864b90c948b944054c8404fa1f16047b002f7f5d2c56f84a9500dbaf304bc4d25
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
- trace propagation and the handled-failure header — so an app can turn
5
- Sentry off. Zero runtime dependencies; loads in plain Ruby, hooks itself
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
- ```ruby
11
- # Gemfile
12
- gem 'devbench'
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
- # The DSN Dev Bench gave you for this environment
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. No middleware line, no initializer, no process
21
- to run next to the app. Check it:
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://<key>@<host>[:port]`. Plain `http://` is accepted only for localhost. |
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, and what is left is
98
- sent when the process exits (waiting at most 2 seconds).
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 (server logs)
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; nothing above needs it.
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` (a key minted with
225
- `--source server`); the sidecar then only serves logs.
311
+ To report directly instead, set `DEVBENCH_DSN` (from `adt dsn create`);
312
+ see "Upgrading from 0.5".
@@ -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://<ingest key>@<host>[:port]
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 and the userinfo is the key. A
12
- # path or query, if present, is ignored: the key alone identifies tenant,
13
- # environment and source.
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
- def initialize(base, key)
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 DSN without its key, for messages and logs. The key is a secret
32
- # on a server: it must never be printed.
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
- # the key back.
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, 'is not a URL (expected https://<key>@<host>)'
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, 'must start with https:// (expected https://<key>@<host>)' unless %w[http https].include?(scheme)
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, 'has no host (expected https://<key>@<host>)' if host.empty?
81
+ raise Invalid, "has no host (expected #{SHAPE})" if host.empty?
56
82
 
57
- key = URI.decode_www_form_component(uri.user.to_s)
58
- raise Invalid, 'has no key (expected https://<key>@<host>)' if key.strip.empty?
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 key is a real secret on a server. Over plain http it would cross
61
- # the network readable by anything on the path, so http is accepted
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
- new("#{scheme}://#{host}#{port}", key.strip)
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) || rails_service || 'app'
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
@@ -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. The same
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
- def self_test(payload, io)
112
- signal = signal_for(payload)
113
- now = Time.now.to_i
114
- body = bodies({ signal.fp => Count.new(signal.kind, 1, now, now, [], 0) }, 0).first
115
- remember(signal)
116
-
117
- io.puts "Dev Bench: sending a test exception to #{@dsn} (service #{@service.inspect})"
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
- io.puts " HTTP #{code}: accepted (fingerprint #{signal.fp[0, 12]})"
131
- asks(response).each do |ask|
132
- res = upload(ask, monotonic + HTTP_TIMEOUT)
133
- io.puts(res ? " evidence uploaded (HTTP #{res.code})" : ' evidence upload failed')
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
- return true if counts.empty?
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
- bodies(counts, overflowed).each do |body|
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
- wanted.concat(asks(response))
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 [] if body.empty? || body.bytesize > MAX_RESPONSE_BYTES
432
+ return none if body.empty? || body.bytesize > MAX_RESPONSE_BYTES
374
433
 
375
434
  parsed = JSON.parse(body)
376
- list = parsed.is_a?(Hash) ? parsed['need_evidence'] : nil
377
- list.is_a?(Array) ? list.select { |a| a.is_a?(Hash) } : []
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 ask.
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)