devbench 0.5.0 → 0.7.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.
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'erb'
4
+ require_relative 'version'
5
+
6
+ module Devbench
7
+ # The browser sensor's script tag, for the layout's <head>:
8
+ #
9
+ # <%= devbench_script_tag %>
10
+ #
11
+ # renders
12
+ #
13
+ # <script src="https://unpkg.com/devbench@0.7.0/dist/devbench.min.js"
14
+ # data-dsn="https://<public>@<host>" data-release="<release>" defer></script>
15
+ #
16
+ # The DSN is the public part of DEVBENCH_DSN only (Devbench.browser_dsn),
17
+ # so the page needs no setting of its own and the secret never reaches
18
+ # HTML. No public part (no DSN, a 0.5 single-key DSN, reporting disabled)
19
+ # renders nothing at all. The src is pinned to this gem's version, so a
20
+ # gem upgrade upgrades the browser sensor with it. Where the app uses a
21
+ # CSP nonce (content_security_policy_nonce_generator), the tag carries it,
22
+ # as Rails' own `javascript_include_tag nonce: true` does.
23
+ #
24
+ # Included into ActionView by the Railtie. Never raises.
25
+ module ViewHelper
26
+ CDN = 'https://unpkg.com'
27
+
28
+ def self.src
29
+ "#{CDN}/devbench@#{Devbench::VERSION}/dist/devbench.min.js"
30
+ end
31
+
32
+ def devbench_script_tag
33
+ dsn = Devbench.browser_dsn
34
+ return devbench_safe('') if dsn.nil?
35
+
36
+ attrs = [['src', ViewHelper.src], ['data-dsn', dsn], ['data-release', Devbench.config.resolved_release]]
37
+ nonce = devbench_nonce
38
+ attrs << ['nonce', nonce] if nonce
39
+ html = attrs.map { |name, value| %(#{name}="#{ERB::Util.html_escape(value)}") }.join(' ')
40
+ devbench_safe("<script #{html} defer></script>")
41
+ rescue StandardError, SystemStackError
42
+ devbench_safe('')
43
+ end
44
+
45
+ private
46
+
47
+ # Rails' content_security_policy_nonce: nil unless the app configured a
48
+ # nonce generator.
49
+ def devbench_nonce
50
+ return nil unless respond_to?(:content_security_policy_nonce, true)
51
+
52
+ nonce = content_security_policy_nonce
53
+ nonce.nil? || nonce.to_s.empty? ? nil : nonce.to_s
54
+ rescue StandardError, SystemStackError
55
+ nil
56
+ end
57
+
58
+ def devbench_safe(html)
59
+ html.respond_to?(:html_safe) ? html.html_safe : html
60
+ end
61
+ end
62
+ end
data/lib/devbench.rb CHANGED
@@ -2,8 +2,9 @@
2
2
 
3
3
  # Dev Bench server SDK for Ruby/Rails (formerly "ADT"; DECISIONS #160).
4
4
  #
5
- # gem 'devbench' # Gemfile
6
- # DEVBENCH_DSN=https://<key>@<ingest host>
5
+ # bundle add devbench
6
+ # bin/rails generate devbench
7
+ # DEVBENCH_DSN=https://<public>:<secret>@<ingest host> (from `adt dsn create`)
7
8
  #
8
9
  # What it does:
9
10
  # * accepts and forwards the correlation id (Devbench::Middleware, Devbench::HTTP)
@@ -12,6 +13,8 @@
12
13
  # * reports unhandled exceptions (Devbench::Middleware, Devbench::Railtie,
13
14
  # Devbench::SidekiqHooks,
14
15
  # Devbench.capture_exception)
16
+ # * keeps recent log lines per trace (Devbench::Logs; direct mode)
17
+ # * renders the browser sensor's tag (devbench_script_tag)
15
18
  #
16
19
  # The old name is kept: `require 'adt'` and every `ADT.` / `ADT::` call in an
17
20
  # existing install resolve to this module.
@@ -27,6 +30,8 @@ require_relative 'devbench/session'
27
30
  require_relative 'devbench/session_middleware'
28
31
  require_relative 'devbench/rails_hooks'
29
32
  require_relative 'devbench/sidekiq_hooks'
33
+ require_relative 'devbench/logs'
34
+ require_relative 'devbench/view_helper'
30
35
 
31
36
  # The pre-0.5 name. The same module object, not a copy: ADT::Middleware *is*
32
37
  # Devbench::Middleware, so a stack holding either holds the one class.
@@ -0,0 +1,210 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+ require 'devbench'
5
+
6
+ module Devbench
7
+ # bin/rails generate devbench
8
+ #
9
+ # Step 2 of the install (DECISIONS #161):
10
+ #
11
+ # 1. bundle add devbench
12
+ # 2. bin/rails generate devbench
13
+ # 3. set DEVBENCH_DSN
14
+ #
15
+ # Does two things, and touches nothing else:
16
+ #
17
+ # * puts <%= devbench_script_tag %> in the application layout's <head>
18
+ # (.erb, .haml or .slim), or prints exactly what to add where;
19
+ # * if config/initializers/content_security_policy.rb defines a policy,
20
+ # allows the sensor's script (https://unpkg.com) in script_src, and
21
+ # the ingest host plus evidence storage (https://*.storage.supabase.co)
22
+ # in connect_src.
23
+ #
24
+ # Idempotent: a second run changes nothing. Prints what it changed.
25
+ class DevbenchGenerator < ::Rails::Generators::Base
26
+ namespace 'devbench'
27
+ desc 'Adds the Dev Bench browser tag to your layout and allows it in your CSP.'
28
+
29
+ TAG = 'devbench_script_tag'
30
+ SCRIPT_HOST = 'https://unpkg.com'
31
+ STORAGE_HOST = 'https://*.storage.supabase.co'
32
+ # Ruby code, not a host: evaluated when the CSP initializer runs, so the
33
+ # policy follows DEVBENCH_DSN (Devbench.csp_connect_sources).
34
+ RUNTIME_CONNECT = '*Devbench.csp_connect_sources'
35
+ LAYOUTS = %w[erb haml slim].map { |ext| "app/views/layouts/application.html.#{ext}" }.freeze
36
+ CSP = 'config/initializers/content_security_policy.rb'
37
+
38
+ def add_script_tag
39
+ path = LAYOUTS.find { |p| File.exist?(File.join(destination_root, p)) }
40
+ return manual_tag('no app/views/layouts/application.html.{erb,haml,slim} found') if path.nil?
41
+
42
+ full = File.join(destination_root, path)
43
+ text = File.read(full)
44
+ if text.include?(TAG)
45
+ say_status :identical, "#{path} (already has #{TAG})", :blue
46
+ return
47
+ end
48
+
49
+ updated = path.end_with?('.erb') ? insert_erb(text) : insert_indented(text, path.end_with?('.haml') ? /%head\b/ : /head\b/)
50
+ return manual_tag("could not find the <head> of #{path}") if updated.nil?
51
+
52
+ File.write(full, updated)
53
+ say_status :insert, "#{path}: <%= #{TAG} %> in <head>", :green
54
+ end
55
+
56
+ def allow_in_content_security_policy
57
+ full = File.join(destination_root, CSP)
58
+ unless File.exist?(full)
59
+ say_status :skip, "#{CSP} not found: no CSP to change", :blue
60
+ return
61
+ end
62
+
63
+ text = File.read(full)
64
+ var = text[/^[ \t]*(?:Rails\.application\.)?config\.content_security_policy[ \t]+do[ \t]*\|[ \t]*(\w+)[ \t]*\|/, 1]
65
+ if var.nil?
66
+ say_status :skip, "#{CSP} defines no policy (all commented out): nothing to change", :blue
67
+ return
68
+ end
69
+
70
+ changes = []
71
+ text = allow(text, var, 'script_src', [SCRIPT_HOST], changes)
72
+ text = allow(text, var, 'connect_src', [RUNTIME_CONNECT], changes)
73
+ if changes.empty?
74
+ say_status :identical, "#{CSP} (already allows Dev Bench)", :blue
75
+ return
76
+ end
77
+
78
+ File.write(full, text)
79
+ changes.each { |c| say_status :csp, "#{CSP}: #{c}", :green }
80
+ end
81
+
82
+ def print_next_steps
83
+ say ''
84
+ say 'Next:'
85
+ say ' 1. Set DEVBENCH_DSN in this app\'s environment (the value `adt dsn create` printed).'
86
+ say ' 2. Check it: bin/rails devbench:test'
87
+ end
88
+
89
+ private
90
+
91
+ def manual_tag(reason)
92
+ say_status :manual, reason, :yellow
93
+ say " Add this to your layout, just before </head>:\n\n <%= #{TAG} %>\n\n" \
94
+ " (Haml or Slim: `= #{TAG}` as the last line inside head.)"
95
+ end
96
+
97
+ # Before </head>, indented one step deeper than it when it is on a line
98
+ # of its own.
99
+ def insert_erb(text)
100
+ at = text =~ %r{</head\s*>}i
101
+ return nil if at.nil?
102
+
103
+ line_start = text.rindex("\n", at - 1)
104
+ line_start = line_start.nil? ? 0 : line_start + 1
105
+ before = text[line_start...at]
106
+ if before.strip.empty?
107
+ "#{text[0...line_start]}#{before} <%= #{TAG} %>\n#{text[line_start..]}"
108
+ else
109
+ "#{text[0...at]}<%= #{TAG} %>#{text[at..]}"
110
+ end
111
+ end
112
+
113
+ # Haml / Slim: `= devbench_script_tag` as the last child of the head
114
+ # element, at its children's indentation.
115
+ def insert_indented(text, head)
116
+ lines = text.lines
117
+ index = lines.index { |l| l =~ /\A([ \t]*)#{head}/ }
118
+ return nil if index.nil?
119
+
120
+ head_indent = lines[index][/\A[ \t]*/]
121
+ last = index
122
+ child_indent = nil
123
+ (index + 1...lines.length).each do |i|
124
+ line = lines[i]
125
+ next if line.strip.empty?
126
+
127
+ indent = line[/\A[ \t]*/]
128
+ break if indent.length <= head_indent.length
129
+
130
+ child_indent ||= indent
131
+ last = i
132
+ end
133
+ child_indent ||= "#{head_indent} "
134
+ lines[last] = "#{lines[last]}\n" unless lines[last].end_with?("\n")
135
+ lines.insert(last + 1, "#{child_indent}= #{TAG}\n")
136
+ lines.join
137
+ end
138
+
139
+ # Adds each host to `var.directive`, or — when the directive is absent
140
+ # but default_src is set — adds the directive as default_src's sources
141
+ # plus the hosts, which is what the browser was falling back to. Leaves
142
+ # a policy that restricts neither alone.
143
+ def allow(text, var, directive, hosts, changes)
144
+ lines = text.lines
145
+ range = statement(lines, var, directive)
146
+ if range
147
+ hosts.each do |host|
148
+ stmt = lines[range].join
149
+ next if stmt.include?(host)
150
+
151
+ lines[range.last] = append_source(lines[range.last], host)
152
+ changes << "added #{host} to #{directive}"
153
+ end
154
+ return lines.join
155
+ end
156
+
157
+ # Neither: the policy does not restrict this kind of request at all.
158
+ default = statement(lines, var, 'default_src')
159
+ return text if default.nil?
160
+
161
+ first = lines[default.first]
162
+ indent = first[/\A[ \t]*/]
163
+ sources = lines[default].map { |l| strip_comment(l.chomp) }.join(' ')
164
+ .sub(/\A[ \t]*#{var}\.default_src/, '').gsub(/\s+/, ' ').strip
165
+ new_line = "#{indent}#{var}.#{directive} #{[sources, *hosts.map { |h| source_code(h) }].reject(&:empty?).join(', ')}\n"
166
+ lines.insert(default.last + 1, new_line)
167
+ changes << "added #{directive} (default_src's sources + #{hosts.join(', ')})"
168
+ lines.join
169
+ end
170
+
171
+ # The line range of an uncommented `var.directive ...` statement,
172
+ # following continuation lines (a trailing comma or backslash).
173
+ def statement(lines, var, directive)
174
+ start = lines.index { |l| l =~ /\A[ \t]*#{Regexp.escape(var)}\.#{directive}\b/ }
175
+ return nil if start.nil?
176
+
177
+ last = start
178
+ last += 1 while last + 1 < lines.length && strip_comment(lines[last]).rstrip.end_with?(',', '\\')
179
+ start..last
180
+ end
181
+
182
+ def append_source(line, host)
183
+ body = line.chomp
184
+ newline = line.end_with?("\n") ? "\n" : ''
185
+ code = strip_comment(body).rstrip
186
+ comment = body[code.length..]
187
+ "#{code}, #{source_code(host)}#{comment}#{newline}"
188
+ end
189
+
190
+ # A trailing `# comment`, not counting '#' inside a string or #{}.
191
+ def strip_comment(line)
192
+ quote = nil
193
+ line.each_char.with_index do |ch, i|
194
+ if quote
195
+ quote = nil if ch == quote && line[i - 1] != '\\'
196
+ elsif ch == '"' || ch == "'"
197
+ quote = ch
198
+ elsif ch == '#' && line[i + 1] != '{'
199
+ return line[0...i]
200
+ end
201
+ end
202
+ line
203
+ end
204
+
205
+ # A host is written as a string literal; a runtime splat as code.
206
+ def source_code(source)
207
+ source.start_with?('*') ? source : source.inspect
208
+ end
209
+ end
210
+ end
metadata CHANGED
@@ -1,21 +1,23 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: devbench
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.7.0
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-05 00:00:00.000000000 Z
11
+ date: 2026-10-10 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: |
14
14
  Reports unhandled exceptions, Rails.error reports, failed ActiveJob and
15
15
  Sidekiq jobs, and failures the application handled, with who was
16
16
  affected, to Dev Bench. Fingerprints and counts in-process and sends one
17
17
  small request a minute; detail is uploaded only when Dev Bench asks for
18
- it, scrubbed first. Set DEVBENCH_DSN and it hooks itself into Rails.
18
+ it, scrubbed first. Keeps recent server log lines per user action for
19
+ triage, renders the browser sensor's tag, and hooks itself into Rails:
20
+ bundle add devbench, bin/rails generate devbench, set DEVBENCH_DSN.
19
21
 
20
22
  Adds no dependencies beyond the standard library, never alters a response
21
23
  body, and never raises: a diagnostics gem that can fail a request is worse
@@ -32,9 +34,11 @@ files:
32
34
  - lib/devbench/config.rb
33
35
  - lib/devbench/current.rb
34
36
  - lib/devbench/direct.rb
37
+ - lib/devbench/egress_policy.rb
35
38
  - lib/devbench/fingerprint.rb
36
39
  - lib/devbench/http.rb
37
40
  - lib/devbench/identity.rb
41
+ - lib/devbench/logs.rb
38
42
  - lib/devbench/middleware.rb
39
43
  - lib/devbench/rails_hooks.rb
40
44
  - lib/devbench/railtie.rb
@@ -47,6 +51,8 @@ files:
47
51
  - lib/devbench/trace.rb
48
52
  - lib/devbench/transport.rb
49
53
  - lib/devbench/version.rb
54
+ - lib/devbench/view_helper.rb
55
+ - lib/generators/devbench/devbench_generator.rb
50
56
  homepage: https://github.com/pasperry/devbench-sdk
51
57
  licenses:
52
58
  - LicenseRef-Proprietary