railwatch 0.1.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 +7 -0
- data/AGENTS.md +122 -0
- data/CHANGELOG.md +462 -0
- data/MIT-LICENSE +20 -0
- data/README.md +226 -0
- data/app/controllers/railwatch/beacon_controller.rb +254 -0
- data/config/routes.rb +5 -0
- data/docs/ai-and-mcp.md +227 -0
- data/docs/configuration.md +931 -0
- data/docs/faq.md +230 -0
- data/docs/getting-started.md +279 -0
- data/docs/records.md +834 -0
- data/docs/replacing-nightwatch.md +216 -0
- data/docs/replacing-sentry.md +573 -0
- data/docs/security.md +94 -0
- data/docs/self-hosting.md +60 -0
- data/docs/source-maps.md +60 -0
- data/docs/testing.md +175 -0
- data/docs/troubleshooting.md +319 -0
- data/lib/generators/railwatch/install/install_generator.rb +280 -0
- data/lib/generators/railwatch/install/templates/initializer.rb +54 -0
- data/lib/generators/railwatch/install/templates/post-deploy +98 -0
- data/lib/generators/railwatch/install/templates/railwatch.ts +658 -0
- data/lib/railwatch/attachments.rb +83 -0
- data/lib/railwatch/backtrace.rb +158 -0
- data/lib/railwatch/buffer.rb +122 -0
- data/lib/railwatch/clock.rb +25 -0
- data/lib/railwatch/configuration.rb +334 -0
- data/lib/railwatch/console.rb +48 -0
- data/lib/railwatch/context.rb +125 -0
- data/lib/railwatch/controller_helpers.rb +21 -0
- data/lib/railwatch/current.rb +32 -0
- data/lib/railwatch/engine.rb +144 -0
- data/lib/railwatch/execution.rb +367 -0
- data/lib/railwatch/faraday.rb +73 -0
- data/lib/railwatch/health.rb +188 -0
- data/lib/railwatch/job_tracing.rb +49 -0
- data/lib/railwatch/middleware/request.rb +289 -0
- data/lib/railwatch/minitest.rb +43 -0
- data/lib/railwatch/patches/inertia.rb +34 -0
- data/lib/railwatch/patches/net_http.rb +102 -0
- data/lib/railwatch/patches/rake_task.rb +88 -0
- data/lib/railwatch/patches/runner_command.rb +120 -0
- data/lib/railwatch/patches.rb +43 -0
- data/lib/railwatch/profiler.rb +270 -0
- data/lib/railwatch/record.rb +119 -0
- data/lib/railwatch/redactor.rb +67 -0
- data/lib/railwatch/release_detector.rb +97 -0
- data/lib/railwatch/reporter.rb +539 -0
- data/lib/railwatch/rspec.rb +139 -0
- data/lib/railwatch/sampler.rb +17 -0
- data/lib/railwatch/secret_safety.rb +62 -0
- data/lib/railwatch/sessions.rb +162 -0
- data/lib/railwatch/source_maps.rb +59 -0
- data/lib/railwatch/spec_helper.rb +147 -0
- data/lib/railwatch/sql_normalizer.rb +398 -0
- data/lib/railwatch/subscribers/base.rb +54 -0
- data/lib/railwatch/subscribers/broadcasts.rb +107 -0
- data/lib/railwatch/subscribers/cache.rb +107 -0
- data/lib/railwatch/subscribers/deprecations.rb +26 -0
- data/lib/railwatch/subscribers/exceptions.rb +304 -0
- data/lib/railwatch/subscribers/jobs.rb +282 -0
- data/lib/railwatch/subscribers/logs.rb +137 -0
- data/lib/railwatch/subscribers/mail.rb +42 -0
- data/lib/railwatch/subscribers/notifications.rb +36 -0
- data/lib/railwatch/subscribers/process_info.rb +98 -0
- data/lib/railwatch/subscribers/queries.rb +183 -0
- data/lib/railwatch/subscribers/requests.rb +94 -0
- data/lib/railwatch/subscribers/storage.rb +35 -0
- data/lib/railwatch/subscribers/users.rb +159 -0
- data/lib/railwatch/subscribers/views.rb +54 -0
- data/lib/railwatch/subscribers.rb +34 -0
- data/lib/railwatch/transport/http.rb +208 -0
- data/lib/railwatch/version.rb +5 -0
- data/lib/railwatch.rb +550 -0
- data/lib/tasks/railwatch_tasks.rake +289 -0
- data/llms.txt +38 -0
- metadata +157 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module Railwatch
|
|
6
|
+
# Read-only Git checks used by the installer and doctor. Tokens are never
|
|
7
|
+
# returned in diagnostics: callers get a path or a short prefix only.
|
|
8
|
+
module SecretSafety
|
|
9
|
+
TOKEN_PATTERN = /\blt_[A-Za-z0-9_-]{6,}\b/
|
|
10
|
+
TOKEN_FILE_GLOBS = [ ".env", ".env.*", ".kamal/secrets", "config/deploy.yml",
|
|
11
|
+
"config/initializers/*.rb" ].freeze
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def token_preview(token)
|
|
16
|
+
value = token.to_s
|
|
17
|
+
return "unset" if value.empty?
|
|
18
|
+
|
|
19
|
+
"#{value.byteslice(0, 6)}... (#{value.length} chars)"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def git_tracked?(path, root: Dir.pwd)
|
|
23
|
+
_output, status = git(root, "ls-files", "--error-unmatch", "--", path)
|
|
24
|
+
status.success?
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def git_ignored?(path, root: Dir.pwd)
|
|
28
|
+
_output, status = git(root, "check-ignore", "-q", "--", path)
|
|
29
|
+
status.success?
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def tracked_plaintext_token_files(root: Dir.pwd)
|
|
33
|
+
output, status = git(root, "ls-files", "-z", "--", *TOKEN_FILE_GLOBS)
|
|
34
|
+
return [] unless status.success?
|
|
35
|
+
|
|
36
|
+
output.split("\0").filter_map do |relative|
|
|
37
|
+
next if relative.empty?
|
|
38
|
+
|
|
39
|
+
path = File.join(root.to_s, relative)
|
|
40
|
+
next unless File.file?(path) && !File.symlink?(path)
|
|
41
|
+
next unless File.binread(path).match?(TOKEN_PATTERN)
|
|
42
|
+
|
|
43
|
+
relative
|
|
44
|
+
rescue SystemCallError
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def git(root, *arguments)
|
|
50
|
+
output, _error, status = Open3.capture3("git", "-C", root.to_s, *arguments)
|
|
51
|
+
[ output, status ]
|
|
52
|
+
rescue SystemCallError
|
|
53
|
+
[ "", NullStatus.new ]
|
|
54
|
+
end
|
|
55
|
+
private_class_method :git
|
|
56
|
+
|
|
57
|
+
class NullStatus
|
|
58
|
+
def success? = false
|
|
59
|
+
end
|
|
60
|
+
private_constant :NullStatus
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
# Server-side sessions, the fallback half of release health. Every request
|
|
5
|
+
# that resolves a user or carries the browser client's session id updates
|
|
6
|
+
# one in-memory entry per session key; a background thread ships each entry
|
|
7
|
+
# as a `session` record every config.session_flush_interval and retires the
|
|
8
|
+
# ones idle for longer than config.session_timeout.
|
|
9
|
+
#
|
|
10
|
+
# Same thread shape as Railwatch::Health -- one thread per process, parked on
|
|
11
|
+
# a ConditionVariable, re-armed in every forked child -- and started from
|
|
12
|
+
# the same engine initializer.
|
|
13
|
+
#
|
|
14
|
+
# The browser client emits `session` records of its own through the beacon
|
|
15
|
+
# carrying the same id (it sets the cookie this reads), so a session seen
|
|
16
|
+
# from both ends dedupes on the platform instead of counting twice.
|
|
17
|
+
module Sessions
|
|
18
|
+
# Only a request creates a session, so a worker/console/rake process has
|
|
19
|
+
# nothing to flush and does not start a thread.
|
|
20
|
+
ROLES = %w[web].freeze
|
|
21
|
+
# A process that keeps meeting new keys -- an app that sets no cookie
|
|
22
|
+
# being crawled, say -- must not grow without bound. Oldest first, and
|
|
23
|
+
# counted so the drop is visible rather than silent.
|
|
24
|
+
MAX_KEYS = 10_000
|
|
25
|
+
# The cookie the browser client sets (railwatch.ts), so every request from
|
|
26
|
+
# a tab carries the id its beacons already use.
|
|
27
|
+
COOKIE = /(?:\A|;\s*)railwatch_session=([^;]+)/
|
|
28
|
+
KEY_LIMIT = 64
|
|
29
|
+
|
|
30
|
+
@mutex = Mutex.new
|
|
31
|
+
@wakeup = ConditionVariable.new
|
|
32
|
+
@sessions = {}
|
|
33
|
+
@dropped = 0
|
|
34
|
+
@thread = nil
|
|
35
|
+
@pid = nil
|
|
36
|
+
@stopping = false
|
|
37
|
+
|
|
38
|
+
module_function
|
|
39
|
+
|
|
40
|
+
# Session keys dropped because the process was already tracking MAX_KEYS.
|
|
41
|
+
def dropped
|
|
42
|
+
@dropped
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Records one finished request against its session. A request that is
|
|
46
|
+
# neither authenticated nor carrying a session id is not a session, and
|
|
47
|
+
# returns here having touched nothing.
|
|
48
|
+
def touch(exe, env, status)
|
|
49
|
+
key = key_for(exe, env) or return nil
|
|
50
|
+
exe.session_key = key
|
|
51
|
+
now = Clock.now
|
|
52
|
+
@mutex.synchronize do
|
|
53
|
+
entry = @sessions[key] ||= begin
|
|
54
|
+
drop_oldest if @sessions.size >= MAX_KEYS
|
|
55
|
+
{ started_at: now, last_seen_at: now, requests: 0, errors: 0, crashed: false, user: exe.user_id }
|
|
56
|
+
end
|
|
57
|
+
entry[:last_seen_at] = now
|
|
58
|
+
entry[:requests] += 1
|
|
59
|
+
entry[:errors] += 1 if status.to_i >= 500 || exe.counters[:exceptions].positive?
|
|
60
|
+
entry[:crashed] = true if exe.session_crashed
|
|
61
|
+
entry[:user] ||= exe.user_id
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Ships one record per tracked session. Keys idle for longer than
|
|
66
|
+
# config.session_timeout ship with `ended` and are dropped; the rest stay
|
|
67
|
+
# and are shipped again next interval, so a long session is one row per
|
|
68
|
+
# interval that the platform dedupes by id.
|
|
69
|
+
def flush
|
|
70
|
+
now = Clock.now
|
|
71
|
+
timeout = Railwatch.config.session_timeout
|
|
72
|
+
due = @mutex.synchronize do
|
|
73
|
+
rows = @sessions.map { |key, entry| [ key, entry, now - entry[:last_seen_at] > timeout ] }
|
|
74
|
+
rows.each { |key, _entry, ended| @sessions.delete(key) if ended }
|
|
75
|
+
rows
|
|
76
|
+
end
|
|
77
|
+
due.each { |key, entry, ended| emit(key, entry, ended) }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The browser client's id when the request carries one -- so the two
|
|
81
|
+
# halves of the same session share a key -- else the resolved user.
|
|
82
|
+
def key_for(exe, env)
|
|
83
|
+
id = env["HTTP_X_RAILWATCH_SESSION"] || COOKIE.match(env["HTTP_COOKIE"])&.[](1)
|
|
84
|
+
return id[0, KEY_LIMIT] unless id.nil? || id.empty?
|
|
85
|
+
|
|
86
|
+
user = exe.user_id
|
|
87
|
+
user && "user:#{user}"[0, KEY_LIMIT]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def emit(key, entry, ended)
|
|
91
|
+
Railwatch.record(:session, group: Record.group_hash(key),
|
|
92
|
+
id: key, source: "server", status: status_of(entry),
|
|
93
|
+
started_at: entry[:started_at], duration: ((entry[:last_seen_at] - entry[:started_at]) * 1_000_000).round,
|
|
94
|
+
requests: entry[:requests], errors: entry[:errors], ended: ended, user: entry[:user])
|
|
95
|
+
rescue StandardError => e
|
|
96
|
+
Railwatch.debug { "session flush failed: #{e.class}: #{e.message}" }
|
|
97
|
+
nil
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def status_of(entry)
|
|
101
|
+
return "crashed" if entry[:crashed]
|
|
102
|
+
entry[:errors].positive? ? "errored" : "ok"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def drop_oldest
|
|
106
|
+
@sessions.shift
|
|
107
|
+
@dropped += 1
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# --- background thread (mirrors Railwatch::Health) ------------------------
|
|
111
|
+
|
|
112
|
+
def start!
|
|
113
|
+
return unless Railwatch.enabled? && Railwatch.config.track_sessions
|
|
114
|
+
return if defined?(Rails) && Rails.env.test?
|
|
115
|
+
return unless ROLES.include?(Subscribers::ProcessInfo.role)
|
|
116
|
+
return if @thread&.alive? && @pid == Process.pid
|
|
117
|
+
|
|
118
|
+
@mutex.synchronize do
|
|
119
|
+
return if @thread&.alive? && @pid == Process.pid
|
|
120
|
+
|
|
121
|
+
@pid = Process.pid
|
|
122
|
+
@stopping = false
|
|
123
|
+
@thread = Thread.new { run }
|
|
124
|
+
@thread.name = "railwatch-sessions"
|
|
125
|
+
@thread.abort_on_exception = false
|
|
126
|
+
@thread.report_on_exception = false
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# A forked child inherits a dead thread, the parent's pid, and the
|
|
131
|
+
# parent's half-finished session map; both are replaced here.
|
|
132
|
+
def restart_after_fork!
|
|
133
|
+
@mutex = Mutex.new
|
|
134
|
+
@wakeup = ConditionVariable.new
|
|
135
|
+
@thread = nil
|
|
136
|
+
@pid = nil
|
|
137
|
+
@stopping = false
|
|
138
|
+
@sessions = {}
|
|
139
|
+
@dropped = 0
|
|
140
|
+
start!
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def stop!
|
|
144
|
+
return unless @thread
|
|
145
|
+
|
|
146
|
+
@stopping = true
|
|
147
|
+
@mutex.synchronize { @wakeup.signal }
|
|
148
|
+
@thread.join(1)
|
|
149
|
+
@thread = nil
|
|
150
|
+
# Shutting down without this would throw away every session opened
|
|
151
|
+
# since the last interval, on every deploy.
|
|
152
|
+
flush
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def run
|
|
156
|
+
until @stopping
|
|
157
|
+
@mutex.synchronize { @wakeup.wait(@mutex, Railwatch.config.session_flush_interval) unless @stopping }
|
|
158
|
+
flush unless @stopping
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "pathname"
|
|
6
|
+
require "json"
|
|
7
|
+
|
|
8
|
+
module Railwatch
|
|
9
|
+
# Upload locally built source maps to private telemetry storage. This runs
|
|
10
|
+
# during release preparation; it never follows map URLs or HTTP redirects.
|
|
11
|
+
class SourceMaps
|
|
12
|
+
MAX_BYTES = 10 * 1024 * 1024
|
|
13
|
+
|
|
14
|
+
def initialize(config)
|
|
15
|
+
@config = config
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def upload(directory: "public", delete: false)
|
|
19
|
+
raise ArgumentError, "RAILWATCH_TOKEN is not set" if @config.token.to_s.empty?
|
|
20
|
+
raise ArgumentError, "RAILWATCH_DEPLOY (or KAMAL_VERSION) is not set" if @config.deploy.to_s.empty?
|
|
21
|
+
raise ArgumentError, "plain HTTP ingest is disabled; use HTTPS or set RAILWATCH_ALLOW_HTTP=true" unless @config.ingest_url_allowed?
|
|
22
|
+
root = Pathname.new(directory).realpath
|
|
23
|
+
files = Dir[root.join("**/*.map").to_s].sort.select { |path| File.file?(path) }
|
|
24
|
+
raise ArgumentError, "no .map files found in #{root}" if files.empty?
|
|
25
|
+
files.each do |path|
|
|
26
|
+
file = Pathname.new(path)
|
|
27
|
+
unless file.realpath.to_s.start_with?(root.to_s + File::SEPARATOR) && !file.symlink?
|
|
28
|
+
raise ArgumentError, "source map must be a regular file within the upload directory"
|
|
29
|
+
end
|
|
30
|
+
data = File.binread(file, MAX_BYTES + 1)
|
|
31
|
+
raise ArgumentError, "source map exceeds 10 MiB: #{file.basename}" if data.bytesize > MAX_BYTES
|
|
32
|
+
filename = file.relative_path_from(root).to_s.delete_suffix(".map")
|
|
33
|
+
upload_file(filename, data)
|
|
34
|
+
File.delete(file) if delete
|
|
35
|
+
end
|
|
36
|
+
files.size
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def upload_file(filename, data)
|
|
42
|
+
uri = URI.join(@config.ingest_url, "/ingest/sourcemaps")
|
|
43
|
+
request = Net::HTTP::Post.new(uri)
|
|
44
|
+
request["Authorization"] = "Bearer #{@config.token}"
|
|
45
|
+
request["Content-Type"] = "application/octet-stream"
|
|
46
|
+
request["X-Railwatch-Deploy"] = @config.deploy
|
|
47
|
+
request["X-Railwatch-Filename"] = filename
|
|
48
|
+
request.body = data
|
|
49
|
+
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 5, read_timeout: 30, write_timeout: 30) { |http| http.request(request) }
|
|
50
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
51
|
+
raise "Source map upload failed (HTTP #{response.code}) for #{filename}"
|
|
52
|
+
end
|
|
53
|
+
result = JSON.parse(response.body)
|
|
54
|
+
unless result["ok"] == true && result["filename"] == filename && result["bytes"] == data.bytesize
|
|
55
|
+
raise "Source map upload was not acknowledged for #{filename}"
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Test helpers for apps using Railwatch. Add to spec/rails_helper.rb:
|
|
4
|
+
# require "railwatch/spec_helper"
|
|
5
|
+
# config.include Railwatch::SpecHelper
|
|
6
|
+
#
|
|
7
|
+
# `require "railwatch/rspec"` does both of those and adds the block matchers;
|
|
8
|
+
# `require "railwatch/minitest"` is the Minitest equivalent.
|
|
9
|
+
module Railwatch
|
|
10
|
+
module SpecHelper
|
|
11
|
+
# Raised instead of silently reporting zero records, which would turn a
|
|
12
|
+
# CI performance gate into a no-op that always passes.
|
|
13
|
+
class Disabled < StandardError; end
|
|
14
|
+
|
|
15
|
+
class MemoryTransport
|
|
16
|
+
attr_reader :batches
|
|
17
|
+
def initialize = @batches = []
|
|
18
|
+
def deliver(records, dropped: 0, batch_id: nil)
|
|
19
|
+
@batches << records
|
|
20
|
+
Transport::Http::Result.new(ok: true, status: 200, accepted: records.size, rejected: 0)
|
|
21
|
+
end
|
|
22
|
+
def ping = true
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def railwatch_records(type = nil)
|
|
26
|
+
Railwatch.flush
|
|
27
|
+
all = railwatch_transport.batches.flatten
|
|
28
|
+
type ? all.select { |r| r[:t] == type.to_s } : all
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def railwatch_transport
|
|
32
|
+
@railwatch_transport ||= begin
|
|
33
|
+
transport = MemoryTransport.new
|
|
34
|
+
Railwatch.instance_variable_set(:@reporter, Railwatch::Reporter.new(Railwatch.config, transport: transport))
|
|
35
|
+
transport
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Runs the block and returns only the records it produced. Backs every
|
|
40
|
+
# matcher in railwatch/rspec and every assertion in railwatch/minitest.
|
|
41
|
+
#
|
|
42
|
+
# Child records sit on their execution until it finishes, so there are
|
|
43
|
+
# three cases. A block that opens and closes its own execution (a request
|
|
44
|
+
# spec's `get "/widgets"`) needs no help — its records reach the transport
|
|
45
|
+
# by the time the block returns. A block with nothing executing (a model or
|
|
46
|
+
# service spec) is wrapped in an execution here. A block running *inside*
|
|
47
|
+
# an already-open execution has its records read straight off that
|
|
48
|
+
# execution's buffer, since nothing will flush them until it ends.
|
|
49
|
+
def railwatch_capture
|
|
50
|
+
unless Railwatch.enabled?
|
|
51
|
+
raise Disabled, "Railwatch is disabled (config.enabled is false or config.token is blank), " \
|
|
52
|
+
"so this block would always look empty. Set RAILWATCH_TOKEN in your test environment."
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
Railwatch.flush
|
|
56
|
+
batch_offset = railwatch_transport.batches.size
|
|
57
|
+
buffered = with_railwatch_execution { yield }
|
|
58
|
+
Railwatch.flush
|
|
59
|
+
railwatch_transport.batches[batch_offset..].flatten + buffered
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
# Returns the records buffered on a pre-existing execution by the block
|
|
65
|
+
# (empty when this opened its own, since finishing it ships them).
|
|
66
|
+
def with_railwatch_execution
|
|
67
|
+
exe = Railwatch.execution
|
|
68
|
+
if exe
|
|
69
|
+
offset = exe.records.size
|
|
70
|
+
yield
|
|
71
|
+
exe.records[offset..] || []
|
|
72
|
+
else
|
|
73
|
+
# :command is the closest of Execution::SOURCES to a test body.
|
|
74
|
+
# Sampling is forced on so a fractional sample rate in the app's test
|
|
75
|
+
# config can't quietly turn an assertion into one that never fires.
|
|
76
|
+
Railwatch.start_execution(source: :command, sample_kind: :requests).sampled = true
|
|
77
|
+
begin
|
|
78
|
+
yield
|
|
79
|
+
ensure
|
|
80
|
+
# No parent type: the wrapper itself must not add a `command` record.
|
|
81
|
+
Railwatch.finish_execution
|
|
82
|
+
end
|
|
83
|
+
[]
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# --- shared by railwatch/rspec and railwatch/minitest -------------------------
|
|
88
|
+
|
|
89
|
+
SQL_PREVIEW_CHARS = 120
|
|
90
|
+
|
|
91
|
+
class << self
|
|
92
|
+
# Exactly one of exactly:/at_most:/at_least: describes the bound.
|
|
93
|
+
def count_satisfied?(count, exactly: nil, at_most: nil, at_least: nil)
|
|
94
|
+
bound = check_bound(exactly: exactly, at_most: at_most, at_least: at_least)
|
|
95
|
+
case bound.first
|
|
96
|
+
when :exactly then count == bound.last
|
|
97
|
+
when :at_most then count <= bound.last
|
|
98
|
+
else count >= bound.last
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def bound_description(exactly: nil, at_most: nil, at_least: nil)
|
|
103
|
+
kind, value = check_bound(exactly: exactly, at_most: at_most, at_least: at_least)
|
|
104
|
+
"#{kind.to_s.tr('_', ' ')} #{value}"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Every failure message ends in the offending records, so CI output says
|
|
108
|
+
# which queries to go and fix rather than just "expected 5, got 9".
|
|
109
|
+
def sql_lines(records)
|
|
110
|
+
lines(records) { |r| truncate(r[:sql]) }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def n_plus_one_lines(records)
|
|
114
|
+
lines(records) { |r| "#{r[:count]}x #{truncate(r[:sql])}#{" at #{r[:source]}" if r[:source]}" }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def outgoing_lines(records)
|
|
118
|
+
lines(records) { |r| "#{r[:method]} #{truncate(r[:url])}" }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def exception_lines(records)
|
|
122
|
+
lines(records) { |r| "#{r[:class]}: #{truncate(r[:message])}" }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def record_names(records, key)
|
|
126
|
+
records.map { |r| r[key].to_s }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def truncate(value)
|
|
130
|
+
value.to_s.gsub(/\s+/, " ").strip[0, SQL_PREVIEW_CHARS]
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
private
|
|
134
|
+
|
|
135
|
+
def lines(records)
|
|
136
|
+
return " (none)" if records.empty?
|
|
137
|
+
records.each_with_index.map { |r, i| "\n #{i + 1}. #{yield(r)}" }.join
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def check_bound(exactly:, at_most:, at_least:)
|
|
141
|
+
given = { exactly: exactly, at_most: at_most, at_least: at_least }.compact
|
|
142
|
+
raise ArgumentError, "pass exactly one of exactly:, at_most:, at_least:" unless given.size == 1
|
|
143
|
+
given.first
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|