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.
@@ -12,9 +12,11 @@ module Devbench
12
12
  # trace propagation mints one and injects it into HTML responses.
13
13
  #
14
14
  # This must agree byte for byte with the Go SDK, because a token minted by a
15
- # Rails app is verified by ingest. Divergence produces a request that succeeds
16
- # locally and is rejected in production, with nothing to say why —
17
- # testdata/session_vectors.json is what holds the two together.
15
+ # Rails app is verified by ingest, which uses the Go SDK's VerifySession.
16
+ # Divergence produces a request that succeeds locally and is rejected in
17
+ # production, with nothing to say why. Both SDKs are held to the shared
18
+ # vectors in testdata/session_vectors.json (#181, #563): test/session_test.rb
19
+ # here, sdk/server/go/session_kat_test.go there.
18
20
  module Session
19
21
  HEADER = 'x-adt-session'
20
22
  META_NAME = 'adt-session'
@@ -25,6 +27,7 @@ module Devbench
25
27
  # rarely — a short TTL would expire mid-session and force a refresh endpoint,
26
28
  # which is more surface in the customer's app for no security gain.
27
29
  DEFAULT_TTL = 3600
30
+ # Bytes, not characters, as in the Go SDK.
28
31
  MAX_SUBJECT = 128
29
32
 
30
33
  class Expired < StandardError; end
@@ -38,7 +41,7 @@ module Devbench
38
41
  raise ArgumentError, 'secret is required' if secret.nil? || secret.strip.empty?
39
42
  raise ArgumentError, 'tenant is required' if tenant.nil? || tenant.strip.empty?
40
43
 
41
- subject = subject.to_s[0, MAX_SUBJECT]
44
+ subject = truncate_subject(subject.to_s)
42
45
  ttl = DEFAULT_TTL if ttl.nil? || ttl <= 0
43
46
 
44
47
  # The payload is pipe-delimited, so a pipe in a field would let a crafted
@@ -47,7 +50,9 @@ module Devbench
47
50
  raise ArgumentError, "tenant and subject must not contain '|'"
48
51
  end
49
52
 
50
- payload = "#{tenant}|#{Time.now.to_i + ttl}|#{subject}"
53
+ # Bytes throughout: the signature covers the base64 of these exact bytes,
54
+ # whatever encoding the caller's strings carry.
55
+ payload = [tenant.b, (Time.now.to_i + ttl).to_s, subject.b].join('|')
51
56
  encoded = b64(payload)
52
57
 
53
58
  "#{PREFIX}.#{encoded}.#{sign(secret, encoded)}"
@@ -58,7 +63,9 @@ module Devbench
58
63
  raise Invalid if secret.nil? || secret.strip.empty?
59
64
  raise Invalid unless token.is_a?(String)
60
65
 
61
- parts = token.strip.split('.')
66
+ # -1 keeps trailing empty fields, as Go's strings.Split does: without
67
+ # it "adts1.<payload>.<sig>." is three parts here and four in ingest.
68
+ parts = token.strip.split('.', -1)
62
69
  raise Invalid unless parts.length == 3 && parts[0] == PREFIX
63
70
 
64
71
  # Constant time: a signature check that leaks timing can be brute-forced
@@ -81,6 +88,22 @@ module Devbench
81
88
  Token.new(fields[0], fields[2], Time.at(expires))
82
89
  end
83
90
 
91
+ # At most MAX_SUBJECT bytes without splitting a UTF-8 character: backs off
92
+ # over at most three continuation bytes, exactly as the Go SDK's
93
+ # truncateSubject does, so a long subject mints the same token in both.
94
+ def self.truncate_subject(subject)
95
+ bytes = subject.b
96
+ return subject if bytes.bytesize <= MAX_SUBJECT
97
+
98
+ cut = MAX_SUBJECT
99
+ 3.times do
100
+ break if cut.zero? || (bytes.getbyte(cut) & 0xC0) != 0x80
101
+
102
+ cut -= 1
103
+ end
104
+ bytes.byteslice(0, cut).force_encoding(subject.encoding)
105
+ end
106
+
84
107
  def self.sign(secret, encoded_payload)
85
108
  b64(OpenSSL::HMAC.digest('SHA256', secret, encoded_payload))
86
109
  end
@@ -90,10 +113,16 @@ module Devbench
90
113
  Base64.urlsafe_encode64(bytes, padding: false)
91
114
  end
92
115
 
116
+ # Decodes as Go's base64.RawURLEncoding does, and refuses what it refuses:
117
+ # padding (=), the standard alphabet's + and /, whitespace, and a length
118
+ # that leaves one character over. (Base64.urlsafe_decode64 accepts the
119
+ # first two, so a padded payload verified here but not in ingest, #563.)
120
+ # Like Go, non-zero bits left over in the last character are ignored.
93
121
  def self.unb64(text)
94
- Base64.urlsafe_decode64(text)
95
- rescue ArgumentError
96
- nil
122
+ return nil unless text.match?(/\A[A-Za-z0-9_-]*\z/) && text.length % 4 != 1
123
+
124
+ std = text.tr('-_', '+/')
125
+ (std + ('=' * ((4 - (std.length % 4)) % 4))).unpack1('m')
97
126
  end
98
127
  end
99
128
  end
@@ -5,7 +5,7 @@ require 'socket'
5
5
 
6
6
  module Devbench
7
7
  # Sidecar mode: each report is one line of JSON on the local sidecar's
8
- # unix socket (docs/SERVER_SDK_SPEC.md, "Control socket — v1"). Used when no
8
+ # unix socket (docs/server-sdk/control-socket.md, "Control socket — v1"). Used when no
9
9
  # DSN is set; unchanged from 0.4.
10
10
  #
11
11
  # Never blocks for long: this runs inside rescue blocks and exception
@@ -5,7 +5,7 @@ require_relative 'current'
5
5
  require_relative 'reporter'
6
6
 
7
7
  module Devbench
8
- # Native Sidekiq jobs (docs/SERVER_SDK_SPEC.md, Capability 2, `job`).
8
+ # Native Sidekiq jobs (docs/server-sdk/unhandled-failures.md, Capability 2, `job`).
9
9
  #
10
10
  # A class that includes Sidekiq::Job never touches ActiveJob, so the
11
11
  # perform.active_job hook sees none of its failures. This is the
@@ -3,7 +3,7 @@
3
3
  module Devbench
4
4
  # The correlation id that ties a user action together across every service.
5
5
  #
6
- # Wire form (docs/SERVER_SDK_SPEC.md): v1/<session>/<intent>/<hop>
6
+ # Wire form (docs/server-sdk/trace-propagation.md): v1/<session>/<intent>/<hop>
7
7
  #
8
8
  # This must agree exactly with the Go SDK and the sidecar's extraction regex.
9
9
  # A trace the browser writes and Rails mangles is worse than no trace: the
@@ -4,8 +4,9 @@ require_relative 'config'
4
4
  require_relative 'sidecar_transport'
5
5
  require_relative 'direct'
6
6
  require_relative 'logs'
7
+ require_relative 'request_signals'
7
8
 
8
- # Configuration and transport selection (docs/SERVER_SDK_SPEC.md,
9
+ # Configuration and transport selection (docs/server-sdk/transports.md,
9
10
  # "Transports: direct (default) or sidecar (optional)").
10
11
  #
11
12
  # Exactly one transport per process, decided at the first report:
@@ -124,15 +125,18 @@ module Devbench
124
125
  # seconds. A no-op in sidecar mode. Returns true when nothing is left
125
126
  # unsent. Never raises.
126
127
  def flush!(timeout: 5)
128
+ # Closes a 429 spike window the clock has left, in either transport
129
+ # (sidecar mode has no flush thread of its own to do it).
130
+ RequestSignals.tick!
127
131
  transport.flush!(timeout: timeout) ? true : false
128
132
  rescue StandardError, SystemStackError
129
133
  false
130
134
  end
131
135
 
132
- # Sends one synthetic exception straight to Dev Bench and prints the
133
- # result, so an operator can check the DSN without waiting for a flush
134
- # (`rake devbench:test` under Rails). Returns true when ingest accepted
135
- # it. Never raises.
136
+ # Asks ingest whether the DSN's key is valid (`POST /v1/check`) and prints
137
+ # the answer, so an operator can check the DSN without waiting for a
138
+ # flush (`rake devbench:test` under Rails). Creates nothing: no event,
139
+ # no issue. Returns true when ingest accepted the key. Never raises.
136
140
  def test!(io: $stdout)
137
141
  cfg = config
138
142
  unless cfg.enabled?
@@ -190,6 +194,10 @@ module Devbench
190
194
  private
191
195
 
192
196
  def reset_transport!
197
+ # A replaced configuration ends the 429 spike window, as process exit
198
+ # does: evaluated as it stands and reported under the transport that
199
+ # saw its requests, before that transport's last flush.
200
+ RequestSignals.finish!(@transport)
193
201
  old = TRANSPORT_LOCK.synchronize do
194
202
  previous = @transport
195
203
  @transport = nil
@@ -1,10 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Devbench
4
- # Tracks the protocol, not the gem's own churn.
5
- #
6
- # The wire format this speaks — the trace header, the handled header, the
7
- # control-socket message shapes — is what a customer's deployment depends
8
- # on. See docs/SERVER_SDK_SPEC.md.
9
- VERSION = '0.7.0'
4
+ # The gem's release version. Each SDK has its own version and release tag;
5
+ # the Ruby release (tag ruby-vX.Y.Z in the public repo) checks this equals
6
+ # the tag. The wire format is PROTOCOL_VERSION below.
7
+ VERSION = '0.8.1'
8
+
9
+ # This SDK's name in every flush's `sdk` field: the key
10
+ # testdata/protocol_vectors.json `sdk.names` gives sdk/server/ruby
11
+ # (docs/server-sdk/wire-protocol.md, "the SDK's name and version"). The
12
+ # field's version is VERSION, never a second literal.
13
+ SDK_NAME = 'ruby'
14
+
15
+ # The wire format this speaks: the `v` every payload carries (the flush
16
+ # body, the evidence bundle, every control-socket message). Not the gem's
17
+ # release VERSION above: it changes only for a breaking wire change, and
18
+ # ingest tells formats apart by it. It must equal `current` in
19
+ # testdata/protocol_vectors.json (docs/server-sdk/protocol-version.md, "Protocol
20
+ # version"); send it, never a literal.
21
+ PROTOCOL_VERSION = 1
10
22
  end
@@ -2,6 +2,8 @@
2
2
 
3
3
  require 'erb'
4
4
  require_relative 'version'
5
+ require_relative 'current'
6
+ require_relative 'widget_identity'
5
7
 
6
8
  module Devbench
7
9
  # The browser sensor's script tag, for the layout's <head>:
@@ -10,7 +12,7 @@ module Devbench
10
12
  #
11
13
  # renders
12
14
  #
13
- # <script src="https://unpkg.com/devbench@0.7.0/dist/devbench.min.js"
15
+ # <script src="https://unpkg.com/devbench@0.8.1/dist/devbench.min.js"
14
16
  # data-dsn="https://<public>@<host>" data-release="<release>" defer></script>
15
17
  #
16
18
  # The DSN is the public part of DEVBENCH_DSN only (Devbench.browser_dsn),
@@ -21,6 +23,14 @@ module Devbench
21
23
  # CSP nonce (content_security_policy_nonce_generator), the tag carries it,
22
24
  # as Rails' own `javascript_include_tag nonce: true` does.
23
25
  #
26
+ # Chat widget (docs/server-sdk/chat-widget.md, Capability 6): when
27
+ # DEVBENCH_WIDGET_SECRET is set and Devbench.set_user was called for this
28
+ # request, the tag also carries data-widget-identity="<token>", the user
29
+ # (and their role and chat permission) signed at render time. No secret, no
30
+ # user (a signed-out page) or an identity the signer refuses: no
31
+ # attribute, so no widget. A page carrying one is per-user; don't serve it
32
+ # from a shared cache.
33
+ #
24
34
  # Included into ActionView by the Railtie. Never raises.
25
35
  module ViewHelper
26
36
  CDN = 'https://unpkg.com'
@@ -34,6 +44,8 @@ module Devbench
34
44
  return devbench_safe('') if dsn.nil?
35
45
 
36
46
  attrs = [['src', ViewHelper.src], ['data-dsn', dsn], ['data-release', Devbench.config.resolved_release]]
47
+ identity = devbench_widget_identity
48
+ attrs << ['data-widget-identity', identity] if identity
37
49
  nonce = devbench_nonce
38
50
  attrs << ['nonce', nonce] if nonce
39
51
  html = attrs.map { |name, value| %(#{name}="#{ERB::Util.html_escape(value)}") }.join(' ')
@@ -44,6 +56,17 @@ module Devbench
44
56
 
45
57
  private
46
58
 
59
+ # The signed widget identity for this request's user, or nil.
60
+ def devbench_widget_identity
61
+ secret = Devbench.config.widget_secret
62
+ user = Current.widget_user
63
+ return nil if secret.nil? || secret.to_s.empty? || user.nil?
64
+
65
+ WidgetIdentity.sign(secret, email: user[:email], account: user[:account], role: user[:role], chat: user[:chat])
66
+ rescue StandardError, SystemStackError
67
+ nil
68
+ end
69
+
47
70
  # Rails' content_security_policy_nonce: nil unless the app configured a
48
71
  # nonce generator.
49
72
  def devbench_nonce
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+ require 'openssl'
5
+
6
+ module Devbench
7
+ # The chat widget's signed identity (docs/server-sdk/chat-widget.md, "Capability 6
8
+ # — Chat widget").
9
+ #
10
+ # dbw1.<base64url(issued_at \n email \n account \n role \n chat)>.<base64url(HMAC-SHA256)>
11
+ #
12
+ # The app's server signs the signed-in user with the tenant's widget secret
13
+ # (DEVBENCH_WIDGET_SECRET); the browser holds only the token, and the chat
14
+ # service shows the widget only to a user whose token verifies. Every signer
15
+ # must reproduce testdata/widget_identity_vectors.json byte for byte, or the
16
+ # widget works in a unit test and never renders in production.
17
+ #
18
+ # Rails apps never call this: devbench_script_tag signs whatever
19
+ # Devbench.set_user recorded for the request.
20
+ module WidgetIdentity
21
+ PREFIX = 'dbw1'
22
+ # The closed list, in the token's canonical (byte) order. A new one is a
23
+ # contract change, made in lockstep with the vectors.
24
+ CAPABILITIES = %w[feature_requests handoff howto report status].freeze
25
+ MAX = 255
26
+ # ASCII whitespace only, as the contract names it. String#strip would
27
+ # also remove a trailing NUL, which must survive the trim to be refused
28
+ # (Go's signer refuses it): the two would disagree.
29
+ TRIM = /\A[ \t\n\r\f\v]+|[ \t\n\r\f\v]+\z/
30
+ # C0 controls and DEL, refused when left in a field after trimming (the
31
+ # trimmed whitespace is C0 too, and fine at the ends). Identities are
32
+ # compared and displayed; invisible bytes could make two look alike.
33
+ CONTROL = /[\x00-\x1f\x7f]/
34
+ NO_CHAT = 'none'
35
+
36
+ # Raised internally for an identity that cannot be signed; never escapes.
37
+ class Refused < StandardError; end
38
+
39
+ # Returns the token, or nil when this identity must not be signed: no
40
+ # secret, neither email nor account, a control character (U+0000-U+001F,
41
+ # U+007F) left in a field after trimming, a field over 255 characters,
42
+ # invalid text, or a chat capability not in CAPABILITIES (exact,
43
+ # lowercase). Refuses rather than repairs: truncating would sign a
44
+ # different person. The first refusal in the process is logged, once.
45
+ #
46
+ # chat: nil states nothing (the tenant's settings decide); [] means the
47
+ # user may not chat; otherwise their capabilities, symbols or strings,
48
+ # in any order, duplicates allowed. Never raises.
49
+ def self.sign(secret, email: nil, account: nil, role: nil, chat: nil, issued_at: Time.now.to_i)
50
+ token(secret.to_s, email, account, role, chat, issued_at)
51
+ rescue Refused => e
52
+ Devbench.warn_once(:widget_identity, "chat widget identity not signed: #{e.message}; " \
53
+ 'no chat widget for that user (further refusals are not logged)')
54
+ nil
55
+ rescue StandardError, SystemStackError
56
+ Devbench.warn_once(:widget_identity, 'chat widget identity not signed: unexpected error; ' \
57
+ 'no chat widget for that user (further refusals are not logged)')
58
+ nil
59
+ end
60
+
61
+ def self.token(secret, email, account, role, chat, issued_at)
62
+ raise Refused, 'no widget secret (DEVBENCH_WIDGET_SECRET)' if secret.empty?
63
+
64
+ email = field(email, 'email')
65
+ account = field(account, 'account')
66
+ role = field(role, 'role')
67
+ # Simple lowercase mapping, as Go's strings.ToLower: Ruby's full
68
+ # mapping turns U+0130 into two characters where Go makes one. It is
69
+ # the only code point the two disagree on.
70
+ email = email.tr("\u0130", 'i').downcase
71
+ raise Refused, 'neither email nor account' if email.empty? && account.empty?
72
+
73
+ ts = Integer(issued_at)
74
+ raise Refused, 'issued before 1970' if ts.negative?
75
+
76
+ raw = [ts.to_s, email, account, role, chat_field(chat)].join("\n")
77
+ payload = b64(raw.b)
78
+ "#{PREFIX}.#{payload}.#{b64(OpenSSL::HMAC.digest('SHA256', secret.b, "#{PREFIX}.#{payload}"))}"
79
+ end
80
+ private_class_method :token
81
+
82
+ # Trimmed, valid UTF-8, no control character, at most MAX characters; else
83
+ # Refused. nil is "".
84
+ def self.field(value, name)
85
+ text = utf8(value, name).gsub(TRIM, '')
86
+ raise Refused, "control character in #{name}" if text.match?(CONTROL)
87
+ raise Refused, "#{name} over #{MAX} characters" if text.length > MAX
88
+
89
+ text
90
+ end
91
+ private_class_method :field
92
+
93
+ def self.utf8(value, name)
94
+ text = value.nil? ? +'' : value.to_s.dup
95
+ text = text.encoding == Encoding::BINARY ? text.force_encoding(Encoding::UTF_8) : text.encode(Encoding::UTF_8)
96
+ raise Refused, "#{name} is not valid UTF-8" unless text.valid_encoding?
97
+
98
+ text
99
+ rescue EncodingError
100
+ raise Refused, "#{name} is not valid UTF-8"
101
+ end
102
+ private_class_method :utf8
103
+
104
+ # "" not stated, "none" may not chat, else known names de-duplicated,
105
+ # sorted by bytes, joined by ",".
106
+ def self.chat_field(chat)
107
+ return '' if chat.nil?
108
+ raise Refused, 'chat must be a list of capabilities' unless chat.is_a?(Array)
109
+
110
+ names = chat.map do |c|
111
+ name = c.is_a?(Symbol) || c.is_a?(String) ? c.to_s : nil
112
+ raise Refused, "unknown chat capability #{c.inspect}" unless CAPABILITIES.include?(name)
113
+
114
+ name
115
+ end
116
+ names.empty? ? NO_CHAT : names.uniq.sort.join(',')
117
+ end
118
+ private_class_method :chat_field
119
+
120
+ def self.b64(bytes)
121
+ Base64.urlsafe_encode64(bytes, padding: false)
122
+ end
123
+ private_class_method :b64
124
+ end
125
+ end
data/lib/devbench.rb CHANGED
@@ -13,6 +13,8 @@
13
13
  # * reports unhandled exceptions (Devbench::Middleware, Devbench::Railtie,
14
14
  # Devbench::SidekiqHooks,
15
15
  # Devbench.capture_exception)
16
+ # * reports failed (5xx) and slow requests (Devbench::Middleware,
17
+ # and 429 spikes Devbench::RequestSignals)
16
18
  # * keeps recent log lines per trace (Devbench::Logs; direct mode)
17
19
  # * renders the browser sensor's tag (devbench_script_tag)
18
20
  #
@@ -23,6 +25,7 @@ require_relative 'devbench/trace'
23
25
  require_relative 'devbench/current'
24
26
  require_relative 'devbench/backtrace'
25
27
  require_relative 'devbench/identity'
28
+ require_relative 'devbench/request_signals'
26
29
  require_relative 'devbench/middleware'
27
30
  require_relative 'devbench/http'
28
31
  require_relative 'devbench/reporter'
@@ -30,7 +33,9 @@ require_relative 'devbench/session'
30
33
  require_relative 'devbench/session_middleware'
31
34
  require_relative 'devbench/rails_hooks'
32
35
  require_relative 'devbench/sidekiq_hooks'
36
+ require_relative 'devbench/active_job_hooks'
33
37
  require_relative 'devbench/logs'
38
+ require_relative 'devbench/widget_identity'
34
39
  require_relative 'devbench/view_helper'
35
40
 
36
41
  # The pre-0.5 name. The same module object, not a copy: ADT::Middleware *is*
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: devbench
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.8.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dev Bench
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-10 00:00:00.000000000 Z
11
+ date: 2026-10-15 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: |
14
14
  Reports unhandled exceptions, Rails.error reports, failed ActiveJob and
@@ -27,9 +27,11 @@ executables: []
27
27
  extensions: []
28
28
  extra_rdoc_files: []
29
29
  files:
30
+ - LICENSE
30
31
  - README.md
31
32
  - lib/adt.rb
32
33
  - lib/devbench.rb
34
+ - lib/devbench/active_job_hooks.rb
33
35
  - lib/devbench/backtrace.rb
34
36
  - lib/devbench/config.rb
35
37
  - lib/devbench/current.rb
@@ -43,6 +45,7 @@ files:
43
45
  - lib/devbench/rails_hooks.rb
44
46
  - lib/devbench/railtie.rb
45
47
  - lib/devbench/reporter.rb
48
+ - lib/devbench/request_signals.rb
46
49
  - lib/devbench/scrub.rb
47
50
  - lib/devbench/session.rb
48
51
  - lib/devbench/session_middleware.rb
@@ -52,10 +55,11 @@ files:
52
55
  - lib/devbench/transport.rb
53
56
  - lib/devbench/version.rb
54
57
  - lib/devbench/view_helper.rb
58
+ - lib/devbench/widget_identity.rb
55
59
  - lib/generators/devbench/devbench_generator.rb
56
60
  homepage: https://github.com/pasperry/devbench-sdk
57
61
  licenses:
58
- - LicenseRef-Proprietary
62
+ - MIT
59
63
  metadata:
60
64
  rubygems_mfa_required: 'true'
61
65
  source_code_uri: https://github.com/pasperry/devbench-sdk