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.
@@ -3,6 +3,7 @@
3
3
  require_relative 'config'
4
4
  require_relative 'sidecar_transport'
5
5
  require_relative 'direct'
6
+ require_relative 'logs'
6
7
 
7
8
  # Configuration and transport selection (docs/SERVER_SDK_SPEC.md,
8
9
  # "Transports: direct (default) or sidecar (optional)").
@@ -17,8 +18,6 @@ require_relative 'direct'
17
18
  # Never both: a sidecar on the same host still reads logs, but writing to
18
19
  # its socket as well would count every report twice.
19
20
  module Devbench
20
- # Raised (and rescued) by Devbench.test! to make its synthetic report.
21
- class TestException < StandardError; end
22
21
 
23
22
  TRANSPORT_LOCK = Mutex.new
24
23
  CONFIG_LOCK = Mutex.new
@@ -42,6 +41,9 @@ module Devbench
42
41
  def configure
43
42
  yield config if block_given?
44
43
  reset_transport!
44
+ # Hooks already in place follow the new configuration: on in direct
45
+ # mode, inert otherwise.
46
+ Logs.hooked? && direct? ? Logs.activate! : Logs.deactivate!
45
47
  nil
46
48
  rescue StandardError, SystemStackError => e
47
49
  warn_once(:configure, "Devbench.configure raised #{e.class}; reporting is unchanged")
@@ -60,6 +62,64 @@ module Devbench
60
62
  @transport || TRANSPORT_LOCK.synchronize { @transport ||= build_transport }
61
63
  end
62
64
 
65
+ # The DSN the browser sensor may hold — https://<public>@host — derived
66
+ # from DEVBENCH_DSN, so a page needs no setting of its own. Nil when the
67
+ # DSN has no public part (a 0.5 single-key DSN: that key is a server
68
+ # secret), does not parse, or reporting is disabled. Never raises, and
69
+ # never returns anything carrying the secret.
70
+ def browser_dsn
71
+ cfg = config
72
+ return nil unless cfg.enabled?
73
+
74
+ raw = cfg.dsn.to_s
75
+ cached = @browser_dsn
76
+ return cached[1] if cached && cached[0] == raw
77
+
78
+ value = raw.strip.empty? ? nil : DSN.parse(raw).browser_dsn
79
+ @browser_dsn = [raw, value]
80
+ value
81
+ rescue StandardError, SystemStackError
82
+ nil
83
+ end
84
+
85
+ # Where the browser sensor connects, for the app's CSP connect_src:
86
+ # the ingest host from DEVBENCH_DSN and evidence storage. Read when the
87
+ # CSP initializer runs, so the policy follows the DSN instead of a host
88
+ # frozen at generate time; empty when there is no usable DSN, so an app
89
+ # without Dev Bench configured (e.g. production) keeps its policy as is.
90
+ # `bin/rails generate devbench` adds `*Devbench.csp_connect_sources`.
91
+ STORAGE_SOURCE = 'https://*.storage.supabase.co'
92
+
93
+ def csp_connect_sources
94
+ return [] if browser_dsn.nil?
95
+
96
+ [DSN.parse(config.dsn.to_s).base, STORAGE_SOURCE]
97
+ rescue StandardError, SystemStackError
98
+ []
99
+ end
100
+
101
+ # True when this process reports straight to Dev Bench (a DSN is set
102
+ # and parses). Builds the transport if it was not built yet.
103
+ def direct?
104
+ transport.is_a?(DirectTransport)
105
+ rescue StandardError, SystemStackError
106
+ false
107
+ end
108
+
109
+ # Keeps recent lines from these loggers, per trace, for triage to ask
110
+ # for (direct mode only; elsewhere a no-op). The Railtie does this for
111
+ # Rails.logger and Sidekiq.logger; a Rack app without Rails calls it
112
+ # itself. Returns true when capture is on. Never raises.
113
+ def capture_logs(*loggers)
114
+ return false unless enabled? && direct?
115
+
116
+ hooked = loggers.flatten.map { |l| Logs.install(l) }.compact
117
+ Logs.activate! unless hooked.empty?
118
+ Logs.active?
119
+ rescue StandardError, SystemStackError
120
+ false
121
+ end
122
+
63
123
  # Sends whatever direct mode has counted, now, waiting at most `timeout`
64
124
  # seconds. A no-op in sidecar mode. Returns true when nothing is left
65
125
  # unsent. Never raises.
@@ -81,7 +141,7 @@ module Devbench
81
141
  end
82
142
  if cfg.dsn.nil? || cfg.dsn.strip.empty?
83
143
  io.puts 'DEVBENCH_DSN is not set. Set it to the DSN Dev Bench gave you for this ' \
84
- 'environment (https://<key>@<host>), or call Devbench.configure { |c| c.dsn = ... }.'
144
+ 'environment (https://<public>:<secret>@<host>, from `adt dsn create`), or call Devbench.configure { |c| c.dsn = ... }.'
85
145
  return false
86
146
  end
87
147
 
@@ -92,14 +152,8 @@ module Devbench
92
152
  return false
93
153
  end
94
154
 
95
- error = begin
96
- raise TestException, 'Dev Bench test exception: if you can read this, the DSN works'
97
- rescue TestException => e
98
- e
99
- end
100
- payload = Reporter.report_for(error, context: 'explicit', handled: true, symbol: 'devbench:test')
101
155
  client = DirectTransport.new(dsn: dsn, service: cfg.resolved_service, release: cfg.resolved_release)
102
- client.self_test(payload, io)
156
+ client.self_test(io)
103
157
  rescue StandardError, SystemStackError => e
104
158
  io.puts "Dev Bench test failed: #{e.class}: #{e.message}"
105
159
  false
@@ -110,6 +164,7 @@ module Devbench
110
164
  # Devbench.configure.
111
165
  def reset!
112
166
  reset_transport!
167
+ Logs.deactivate!
113
168
  CONFIG_LOCK.synchronize { @config = nil }
114
169
  nil
115
170
  end
@@ -6,5 +6,5 @@ module Devbench
6
6
  # The wire format this speaks — the trace header, the handled header, the
7
7
  # control-socket message shapes — is what a customer's deployment depends
8
8
  # on. See docs/SERVER_SDK_SPEC.md.
9
- VERSION = '0.5.0'
9
+ VERSION = '0.6.0'
10
10
  end
@@ -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.6.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,7 +1,7 @@
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.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dev Bench
@@ -15,7 +15,9 @@ description: |
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