dispatch-rails 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.
- checksums.yaml +4 -4
- data/README.md +22 -0
- data/app/assets/javascripts/dispatch/error_tracker.js +54 -0
- data/app/helpers/dispatch/widget_helper.rb +3 -1
- data/lib/dispatch/rails/configuration.rb +19 -0
- data/lib/dispatch/rails/engine.rb +8 -0
- data/lib/dispatch/rails/reporting_endpoint_middleware.rb +172 -0
- data/lib/dispatch/rails/version.rb +1 -1
- data/lib/dispatch-rails.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: eb09ad358ca9afa12624a37603693b0892b3816a1733852a6e5bddec3f7e0ad8
|
|
4
|
+
data.tar.gz: 6d877756e23368e5bc033fb4696709b464eb957a1b76a3106e37e1c876cb91db
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 02e6876ac14a61b0215ae79eb52bf47fe632e3dd3ed676c9706921552e1fc73d3a208f21b07047484b9ee8c3e9a5aa9eb58d018b83f17bf80ce6c0609ac95ebe
|
|
7
|
+
data.tar.gz: c22f0667ea5fa682f27f2273f1c3630cc54ffa657694830342dea94b7f5e6c693492b0e82cb006438c7373a2cf6be1a7be3e16203f5927440a9ebb5f54963a24
|
data/README.md
CHANGED
|
@@ -128,6 +128,28 @@ quote. With `c.annotate_error_body = true`, the same fields (`dispatch_request_i
|
|
|
128
128
|
they sit beside `type`/`title`/`detail`). The middleware never changes status codes
|
|
129
129
|
and passes through anything it can't safely parse.
|
|
130
130
|
|
|
131
|
+
### Browser CSP & Reporting API
|
|
132
|
+
|
|
133
|
+
CSP violations and other browser-native reports (NEL, deprecation, intervention)
|
|
134
|
+
never reach `window.onerror`, so the SDK captures them two ways — pick **one** for
|
|
135
|
+
CSP, or a violation reported through both is counted twice. Both are opt-in.
|
|
136
|
+
|
|
137
|
+
```ruby
|
|
138
|
+
# 1. Client-side: the JS error tracker also listens for SecurityPolicyViolationEvents.
|
|
139
|
+
# Needs dispatch_error_tracker_tag in your layout (it already loads the tracker).
|
|
140
|
+
c.capture_csp_violations = true
|
|
141
|
+
|
|
142
|
+
# 2. Server-side: a Rack endpoint accepts the browser's NATIVE report POSTs — point a
|
|
143
|
+
# CSP report-uri/report-to (or any Reporting-Endpoints group) at the path below and
|
|
144
|
+
# the browser posts straight to Dispatch. No host controller required.
|
|
145
|
+
c.capture_browser_reports = true
|
|
146
|
+
c.reporting_endpoint_path = "/dispatch/reports" # default; must be a path no route uses
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Option 2 also ingests non-CSP Reporting-API types (deprecation, intervention, NEL).
|
|
150
|
+
Reports flow through `error_sample_rate` and `before_send` like any other event, so
|
|
151
|
+
host-specific noise (browser-extension schemes, autofill) is best filtered there.
|
|
152
|
+
|
|
131
153
|
### Curated reports (programmatic / agent)
|
|
132
154
|
|
|
133
155
|
A consumer (or an AI agent inside your app) turns a failure into a tracked report:
|
|
@@ -106,6 +106,45 @@
|
|
|
106
106
|
};
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
// CSP violations never reach window.onerror — the browser fires a dedicated
|
|
110
|
+
// SecurityPolicyViolationEvent. We synthesize a single stack frame from the
|
|
111
|
+
// violation's source location so the dashboard can point at the offending file.
|
|
112
|
+
function buildCspEvent(e) {
|
|
113
|
+
var directive = e.effectiveDirective || e.violatedDirective || "policy";
|
|
114
|
+
var blocked = e.blockedURI || "inline";
|
|
115
|
+
var frames = (e.sourceFile && e.lineNumber) ? [{
|
|
116
|
+
function: "?", filename: e.sourceFile, abs_path: e.sourceFile,
|
|
117
|
+
lineno: parseInt(e.lineNumber, 10) || 0, colno: parseInt(e.columnNumber, 10) || 0,
|
|
118
|
+
in_app: String(e.sourceFile).indexOf("http") === 0
|
|
119
|
+
}] : [];
|
|
120
|
+
return {
|
|
121
|
+
event_id: uuid(),
|
|
122
|
+
timestamp: now(),
|
|
123
|
+
platform: "javascript",
|
|
124
|
+
// report-only disposition is monitoring, not a live block — keep it quieter.
|
|
125
|
+
level: e.disposition === "report" ? "info" : "warning",
|
|
126
|
+
environment: cfg.environment,
|
|
127
|
+
release: cfg.release,
|
|
128
|
+
exception: { values: [{
|
|
129
|
+
type: "SecurityPolicyViolation",
|
|
130
|
+
value: (directive + " blocked " + blocked).slice(0, 2000),
|
|
131
|
+
mechanism: { type: "securitypolicyviolation", handled: false },
|
|
132
|
+
stacktrace: { frames: frames }
|
|
133
|
+
}] },
|
|
134
|
+
breadcrumbs: { values: breadcrumbs.slice() },
|
|
135
|
+
user_path: userPath(),
|
|
136
|
+
request: { url: e.documentURI || location.href, headers: { "User-Agent": navigator.userAgent } },
|
|
137
|
+
user: cfg.user || null,
|
|
138
|
+
tags: Object.assign({
|
|
139
|
+
csp: "true",
|
|
140
|
+
blocked_uri: blocked,
|
|
141
|
+
violated_directive: e.violatedDirective || directive,
|
|
142
|
+
effective_directive: directive,
|
|
143
|
+
disposition: e.disposition || "enforce"
|
|
144
|
+
}, cfg.tags || {})
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
109
148
|
function sampledOut() {
|
|
110
149
|
var rate = cfg.sampleRate == null ? 1 : cfg.sampleRate;
|
|
111
150
|
return Math.random() > rate;
|
|
@@ -143,6 +182,21 @@
|
|
|
143
182
|
send(buildEvent(r.name || "UnhandledRejection", value, r.stack));
|
|
144
183
|
});
|
|
145
184
|
|
|
185
|
+
// Opt-in (cfg.captureCsp): a permissive or report-only policy can fire these in
|
|
186
|
+
// bulk, so we dedupe per page — a violation repeated on every render (e.g. a
|
|
187
|
+
// blocked image) is reported once. Bounded so a page spraying unique blocked
|
|
188
|
+
// URIs can't grow the key set without limit.
|
|
189
|
+
if (cfg.captureCsp) {
|
|
190
|
+
var seenCsp = {};
|
|
191
|
+
var seenCspCount = 0;
|
|
192
|
+
document.addEventListener("securitypolicyviolation", function (e) {
|
|
193
|
+
var key = [e.effectiveDirective, e.blockedURI, e.sourceFile, e.lineNumber].join("|");
|
|
194
|
+
if (seenCsp[key]) return;
|
|
195
|
+
if (seenCspCount < 200) { seenCsp[key] = 1; seenCspCount++; }
|
|
196
|
+
send(buildCspEvent(e));
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
146
200
|
// Manual capture API: window.Dispatch.captureException(error)
|
|
147
201
|
window.Dispatch = window.Dispatch || {};
|
|
148
202
|
window.Dispatch.captureException = function (err) {
|
|
@@ -35,7 +35,8 @@ module Dispatch
|
|
|
35
35
|
end
|
|
36
36
|
|
|
37
37
|
# Render the browser exception tracker (captures uncaught JS errors and
|
|
38
|
-
# unhandled promise rejections
|
|
38
|
+
# unhandled promise rejections — and, when config.capture_csp_violations is on,
|
|
39
|
+
# SecurityPolicyViolationEvents). Place once in your layout's <head>.
|
|
39
40
|
def dispatch_error_tracker_tag(tags: {})
|
|
40
41
|
config = Dispatch::Rails.configuration
|
|
41
42
|
return nil if config.errors_only? # no browser surface in an API-only app
|
|
@@ -55,6 +56,7 @@ module Dispatch
|
|
|
55
56
|
release: config.release,
|
|
56
57
|
sampleRate: config.error_sample_rate,
|
|
57
58
|
captureClicks: config.capture_clicks,
|
|
59
|
+
captureCsp: config.capture_csp_violations,
|
|
58
60
|
user: normalized_user,
|
|
59
61
|
tags: tags
|
|
60
62
|
}.to_json
|
|
@@ -16,6 +16,8 @@ module Dispatch
|
|
|
16
16
|
# Exception tracking
|
|
17
17
|
attr_accessor :capture_exceptions, :capture_browser_errors, :error_endpoint,
|
|
18
18
|
:environment, :release, :enabled_environments, :error_sample_rate, :before_send
|
|
19
|
+
# Browser security/reporting capture (both opt-in; high-volume + noise-prone)
|
|
20
|
+
attr_accessor :capture_csp_violations, :capture_browser_reports, :reporting_endpoint_path
|
|
19
21
|
# Process lifecycle (crash-at-exit capture, rake failures, shutdown flush)
|
|
20
22
|
attr_accessor :capture_at_exit, :shutdown_timeout
|
|
21
23
|
# Structured error responses (API-only)
|
|
@@ -49,6 +51,16 @@ module Dispatch
|
|
|
49
51
|
@error_sample_rate = 1.0
|
|
50
52
|
@before_send = nil # ->(event) { event or nil to drop }
|
|
51
53
|
|
|
54
|
+
# Browser security/reporting capture. Both opt-in: a permissive or
|
|
55
|
+
# report-only CSP can emit these in bulk, and capture_browser_reports also
|
|
56
|
+
# makes the gem own a URL path. Pick ONE CSP mechanism — the JS
|
|
57
|
+
# securitypolicyviolation listener (capture_csp_violations) OR the native
|
|
58
|
+
# report endpoint (capture_browser_reports) — or a violation reported
|
|
59
|
+
# through both is counted twice.
|
|
60
|
+
@capture_csp_violations = false # JS: listen for SecurityPolicyViolationEvent
|
|
61
|
+
@capture_browser_reports = false # Server: accept native browser report POSTs
|
|
62
|
+
@reporting_endpoint_path = "/dispatch/reports"
|
|
63
|
+
|
|
52
64
|
# Process lifecycle. Report the exception killing the process (a crash
|
|
53
65
|
# during boot, a dying runner) and drain the send queue before exit so
|
|
54
66
|
# deploys/restarts don't drop captured events.
|
|
@@ -120,6 +132,13 @@ module Dispatch
|
|
|
120
132
|
configured? && @capture_exceptions
|
|
121
133
|
end
|
|
122
134
|
|
|
135
|
+
# Fast-path guard for ReportingEndpointMiddleware: own the reporting path only
|
|
136
|
+
# when the host opted in and we have credentials to deliver. Otherwise the
|
|
137
|
+
# middleware passes the request straight through (no surprise 204s).
|
|
138
|
+
def browser_reports_enabled?
|
|
139
|
+
configured? && @capture_browser_reports
|
|
140
|
+
end
|
|
141
|
+
|
|
123
142
|
# Heartbeats piggyback on the same gating as error capture, plus their own
|
|
124
143
|
# toggle. Off in non-enabled environments (so dev/test never phone home).
|
|
125
144
|
def traffic_tracking_enabled?
|
|
@@ -31,6 +31,14 @@ module Dispatch
|
|
|
31
31
|
initializer "dispatch-rails.error_capture" do |app|
|
|
32
32
|
app.config.middleware.use Dispatch::Rails::Middleware
|
|
33
33
|
|
|
34
|
+
# Native browser Reporting API endpoint (CSP report-uri/report-to, NEL,
|
|
35
|
+
# deprecation, …). Mounted unconditionally; owns
|
|
36
|
+
# config.reporting_endpoint_path only when the host opts in via
|
|
37
|
+
# config.capture_browser_reports, and passes through otherwise. Sits just
|
|
38
|
+
# inside the capture middleware so a report POST short-circuits to 204
|
|
39
|
+
# before the heartbeat/router layers below it.
|
|
40
|
+
app.config.middleware.use Dispatch::Rails::ReportingEndpointMiddleware
|
|
41
|
+
|
|
34
42
|
# Catch background/non-request errors (ActiveJob, runners, handle blocks).
|
|
35
43
|
if ::Rails.respond_to?(:error) && ::Rails.error.respond_to?(:subscribe)
|
|
36
44
|
::Rails.error.subscribe(Dispatch::Rails::ErrorSubscriber.new)
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module Dispatch
|
|
4
|
+
module Rails
|
|
5
|
+
# A report the browser delivered through the native Reporting API (deprecation,
|
|
6
|
+
# intervention, NEL, …) — not a Ruby error and not a JS exception.
|
|
7
|
+
class BrowserReport < StandardError; end
|
|
8
|
+
# A Content-Security-Policy violation delivered by the browser's native
|
|
9
|
+
# report-uri/report-to channel. CSP violations never reach window.onerror, so
|
|
10
|
+
# nothing surfaces them unless the browser is told to POST a report here.
|
|
11
|
+
class CspViolation < BrowserReport; end
|
|
12
|
+
|
|
13
|
+
# Terminal Rack endpoint for the browser Reporting API. Point a CSP
|
|
14
|
+
# `report-uri`/`report-to` group (or any Reporting-Endpoints group) at
|
|
15
|
+
# config.reporting_endpoint_path and the browser POSTs its reports straight
|
|
16
|
+
# here — no host controller required. Each report becomes a synthetic exception
|
|
17
|
+
# pushed through Reporter.capture, so it lands as a first-class event with the
|
|
18
|
+
# usual sampling, before_send, and transport applied.
|
|
19
|
+
#
|
|
20
|
+
# Opt-in (config.capture_browser_reports). When disabled — or for any request
|
|
21
|
+
# that isn't a POST to the configured path — it passes straight through. When
|
|
22
|
+
# it does handle a request it answers 204 itself and never calls downstream, so
|
|
23
|
+
# the configured path must be one no host route uses.
|
|
24
|
+
#
|
|
25
|
+
# Pick ONE CSP mechanism: this endpoint OR the JS securitypolicyviolation
|
|
26
|
+
# listener (config.capture_csp_violations). Enabling both, with report-uri
|
|
27
|
+
# aimed here, double-counts every violation.
|
|
28
|
+
class ReportingEndpointMiddleware
|
|
29
|
+
MAX_BODY_BYTES = 64_000
|
|
30
|
+
MAX_REPORTS = 50
|
|
31
|
+
|
|
32
|
+
def initialize(app)
|
|
33
|
+
@app = app
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def call(env)
|
|
37
|
+
config = Dispatch::Rails.configuration
|
|
38
|
+
return @app.call(env) unless handles?(config, env)
|
|
39
|
+
|
|
40
|
+
# @app.call above is deliberately OUTSIDE any rescue — a normal request's
|
|
41
|
+
# exceptions must propagate to Rails' exception handling, never be
|
|
42
|
+
# swallowed here. Only the report-handling path is rescued.
|
|
43
|
+
handle_report(env)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def handle_report(env)
|
|
49
|
+
capture_reports(env)
|
|
50
|
+
no_content
|
|
51
|
+
rescue StandardError => e
|
|
52
|
+
# A report endpoint must never error the browser's beacon — always 204.
|
|
53
|
+
warn "[dispatch-rails] reporting endpoint failed: #{e.class}: #{e.message}"
|
|
54
|
+
no_content
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# A FRESH Rack triple per call — downstream middleware mutates the array and
|
|
58
|
+
# headers, so a shared/frozen response would raise or corrupt across requests.
|
|
59
|
+
def no_content
|
|
60
|
+
[204, { "Content-Type" => "text/plain" }, []]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def handles?(config, env)
|
|
64
|
+
config.browser_reports_enabled? &&
|
|
65
|
+
env["REQUEST_METHOD"] == "POST" &&
|
|
66
|
+
path_match?(config, env["PATH_INFO"])
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def path_match?(config, path)
|
|
70
|
+
target = config.reporting_endpoint_path.to_s
|
|
71
|
+
return false if target.empty?
|
|
72
|
+
|
|
73
|
+
path == target || path == "#{target}/"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def capture_reports(env)
|
|
77
|
+
raw = read_body(env)
|
|
78
|
+
return if raw.empty?
|
|
79
|
+
|
|
80
|
+
parse_reports(content_type(env), raw).first(MAX_REPORTS).each do |report|
|
|
81
|
+
capture_one(report, env)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def capture_one(report, env)
|
|
86
|
+
type = report[:type].to_s
|
|
87
|
+
body = report[:body].is_a?(Hash) ? report[:body] : {}
|
|
88
|
+
csp?(type) ? capture_csp(body, env) : capture_generic(type, body, env)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def capture_csp(body, env)
|
|
92
|
+
fields = csp_fields(body)
|
|
93
|
+
directive = fields[:effective_directive] || fields[:violated_directive] || "policy"
|
|
94
|
+
blocked = fields[:blocked_uri] || "inline"
|
|
95
|
+
Reporter.capture(
|
|
96
|
+
CspViolation.new("#{directive} blocked #{blocked}"),
|
|
97
|
+
handled: true, env: env,
|
|
98
|
+
# report-only disposition is monitoring, not a live block — keep it quieter.
|
|
99
|
+
level: fields[:disposition].to_s == "report" ? "info" : "warning",
|
|
100
|
+
context: { tags: { csp: "true", report_type: "csp-violation" }.merge(fields) }
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def capture_generic(type, body, env)
|
|
105
|
+
label = type.empty? ? "unknown" : type
|
|
106
|
+
Reporter.capture(
|
|
107
|
+
BrowserReport.new("Browser report: #{label}"),
|
|
108
|
+
handled: true, env: env, level: "info",
|
|
109
|
+
context: { tags: { report_type: label }.merge(stringify(body)) }
|
|
110
|
+
)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def csp?(type)
|
|
114
|
+
type == "csp-violation" || type == "csp"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Normalize the two CSP spellings: report-uri's hyphen-case
|
|
118
|
+
# (blocked-uri/document-uri) and the Reporting API's camelCase, which also
|
|
119
|
+
# renamed the *-uri fields to *URL.
|
|
120
|
+
def csp_fields(body)
|
|
121
|
+
{
|
|
122
|
+
blocked_uri: body["blocked-uri"] || body["blockedURI"] || body["blockedURL"],
|
|
123
|
+
document_uri: body["document-uri"] || body["documentURI"] || body["documentURL"],
|
|
124
|
+
violated_directive: body["violated-directive"] || body["violatedDirective"],
|
|
125
|
+
effective_directive: body["effective-directive"] || body["effectiveDirective"],
|
|
126
|
+
source_file: body["source-file"] || body["sourceFile"],
|
|
127
|
+
line_number: body["line-number"] || body["lineNumber"],
|
|
128
|
+
column_number: body["column-number"] || body["columnNumber"],
|
|
129
|
+
disposition: body["disposition"]
|
|
130
|
+
}.compact
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Legacy report-uri sends a single { "csp-report": {...} } object; the
|
|
134
|
+
# Reporting API sends an array of { "type", "body" } under reports+json.
|
|
135
|
+
def parse_reports(content_type, raw)
|
|
136
|
+
data = JSON.parse(raw)
|
|
137
|
+
if content_type.include?("reports+json") || data.is_a?(Array)
|
|
138
|
+
Array(data).map { |r| { type: r["type"], body: r["body"] } }
|
|
139
|
+
elsif data.is_a?(Hash) && data.key?("csp-report")
|
|
140
|
+
[{ type: "csp-violation", body: data["csp-report"] }]
|
|
141
|
+
elsif data.is_a?(Hash)
|
|
142
|
+
[{ type: "csp-violation", body: data }]
|
|
143
|
+
else
|
|
144
|
+
[]
|
|
145
|
+
end
|
|
146
|
+
rescue JSON::ParserError
|
|
147
|
+
[]
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def read_body(env)
|
|
151
|
+
input = env["rack.input"]
|
|
152
|
+
return "" unless input
|
|
153
|
+
|
|
154
|
+
raw = input.read(MAX_BODY_BYTES).to_s
|
|
155
|
+
input.rewind if input.respond_to?(:rewind)
|
|
156
|
+
raw
|
|
157
|
+
rescue StandardError
|
|
158
|
+
""
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def content_type(env)
|
|
162
|
+
(env["CONTENT_TYPE"] || env["HTTP_CONTENT_TYPE"]).to_s
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def stringify(hash)
|
|
166
|
+
return {} unless hash.is_a?(Hash)
|
|
167
|
+
|
|
168
|
+
hash.each_with_object({}) { |(k, v), out| out[k.to_s] = v }
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
data/lib/dispatch-rails.rb
CHANGED
|
@@ -4,6 +4,7 @@ require "dispatch/rails/event_builder"
|
|
|
4
4
|
require "dispatch/rails/transport"
|
|
5
5
|
require "dispatch/rails/reporter"
|
|
6
6
|
require "dispatch/rails/middleware"
|
|
7
|
+
require "dispatch/rails/reporting_endpoint_middleware"
|
|
7
8
|
require "dispatch/rails/response_annotator"
|
|
8
9
|
require "dispatch/rails/heartbeat_aggregator"
|
|
9
10
|
require "dispatch/rails/heartbeat_middleware"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: dispatch-rails
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.8.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Dispatch Team
|
|
@@ -55,6 +55,7 @@ files:
|
|
|
55
55
|
- lib/dispatch/rails/middleware.rb
|
|
56
56
|
- lib/dispatch/rails/rake_handler.rb
|
|
57
57
|
- lib/dispatch/rails/reporter.rb
|
|
58
|
+
- lib/dispatch/rails/reporting_endpoint_middleware.rb
|
|
58
59
|
- lib/dispatch/rails/response_annotator.rb
|
|
59
60
|
- lib/dispatch/rails/transport.rb
|
|
60
61
|
- lib/dispatch/rails/version.rb
|