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,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
# Builds the flat hash that goes over the wire. Every record carries the same
|
|
5
|
+
# envelope keys as Nightwatch (v, t, timestamp, deploy, server, _group,
|
|
6
|
+
# trace_id, execution_*, user) plus tenant.
|
|
7
|
+
module Record
|
|
8
|
+
VERSIONS = {
|
|
9
|
+
request: 1, job_attempt: 1, scheduled_task: 1, command: 1, channel_action: 1,
|
|
10
|
+
query: 1, n_plus_one: 1, transaction: 1, exception: 1, cache_event: 1, mail: 1,
|
|
11
|
+
broadcast: 1, notification: 1, outgoing_request: 1, storage_op: 1, view_render: 1,
|
|
12
|
+
log: 1, enqueued_job: 1, user: 1, deprecation: 1, visit: 1, process: 1, span: 1, health: 1,
|
|
13
|
+
profile: 1, attachment: 1, session: 1
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
# Used in place of an execution's envelope when there is no execution, so
|
|
17
|
+
# build can splat unconditionally instead of allocating then merge!-ing.
|
|
18
|
+
EMPTY_ENVELOPE = {}.freeze
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
# One hash literal: base keys, then the execution's (memoised) envelope,
|
|
23
|
+
# then the caller's fields, each splat overriding the previous on
|
|
24
|
+
# conflict -- same precedence as the old merge!/merge! chain, but built
|
|
25
|
+
# in a single allocation instead of three.
|
|
26
|
+
def build(type, execution, group: nil, timestamp: nil, **fields)
|
|
27
|
+
config = Railwatch.config
|
|
28
|
+
{
|
|
29
|
+
v: VERSIONS.fetch(type),
|
|
30
|
+
t: type.to_s,
|
|
31
|
+
timestamp: timestamp || Clock.now,
|
|
32
|
+
deploy: config.deploy,
|
|
33
|
+
server: config.server,
|
|
34
|
+
_group: group,
|
|
35
|
+
**(execution ? execution.envelope : EMPTY_ENVELOPE),
|
|
36
|
+
**fields
|
|
37
|
+
}
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Bounds the walk below. A record is a flat-ish tree (headers, files, a
|
|
41
|
+
# filtered payload); nothing legitimate is deeper, and the bound is also
|
|
42
|
+
# what makes a self-referential structure terminate.
|
|
43
|
+
MAX_SIZING_DEPTH = 8
|
|
44
|
+
|
|
45
|
+
# How much resident memory a record costs, for the in-memory byte budgets
|
|
46
|
+
# (Execution#buffer and Buffer#push). Deliberately an estimate built from
|
|
47
|
+
# O(1) String#bytesize rather than a JSON encode: this runs on the request
|
|
48
|
+
# thread for every record, and the exact NDJSON size is measured once,
|
|
49
|
+
# later, on the reporter thread, while the batch is being written. The
|
|
50
|
+
# constants are CRuby object overhead -- a String header, a Hash entry, an
|
|
51
|
+
# Array slot -- so the estimate errs high, which is the safe direction for
|
|
52
|
+
# a memory ceiling.
|
|
53
|
+
#
|
|
54
|
+
# Counting stops as soon as `limit` is exceeded: past that the only fact
|
|
55
|
+
# the caller uses is "too big", so there is no reason to keep walking.
|
|
56
|
+
def buffered_bytes(value, limit:, depth: 0)
|
|
57
|
+
return limit + 1 if depth > MAX_SIZING_DEPTH
|
|
58
|
+
|
|
59
|
+
bytes = case value
|
|
60
|
+
when String then 40 + value.bytesize
|
|
61
|
+
when Hash then hash_bytes(value, limit, depth)
|
|
62
|
+
when Array then array_bytes(value, limit, depth)
|
|
63
|
+
when Symbol then 16
|
|
64
|
+
else 16
|
|
65
|
+
end
|
|
66
|
+
bytes > limit ? limit + 1 : bytes
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def hash_bytes(hash, limit, depth)
|
|
70
|
+
bytes = 80 + (hash.size * 40)
|
|
71
|
+
hash.each do |key, value|
|
|
72
|
+
bytes += buffered_bytes(key, limit: limit, depth: depth + 1)
|
|
73
|
+
bytes += buffered_bytes(value, limit: limit, depth: depth + 1)
|
|
74
|
+
break if bytes > limit
|
|
75
|
+
end
|
|
76
|
+
bytes
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def array_bytes(array, limit, depth)
|
|
80
|
+
bytes = 40 + (array.size * 8)
|
|
81
|
+
array.each do |value|
|
|
82
|
+
bytes += buffered_bytes(value, limit: limit, depth: depth + 1)
|
|
83
|
+
break if bytes > limit
|
|
84
|
+
end
|
|
85
|
+
bytes
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# 128-bit grouping hash. MD5 is the fastest 128-bit digest in stdlib and
|
|
89
|
+
# is only used for bucketing, never for security.
|
|
90
|
+
def group_hash(*parts)
|
|
91
|
+
Digest::MD5.hexdigest(parts.join(","))
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# URLs are metadata, not request payloads. Keep the useful origin/path
|
|
95
|
+
# while dropping authority credentials, every query value, and fragments
|
|
96
|
+
# without relying on strict parsing of application-provided redirects.
|
|
97
|
+
def url_without_sensitive_components(value, limit:)
|
|
98
|
+
url = value.to_s
|
|
99
|
+
query = url.index("?")
|
|
100
|
+
fragment = url.index("#")
|
|
101
|
+
cutoff = query && fragment ? [ query, fragment ].min : query || fragment
|
|
102
|
+
url = url[0, cutoff] if cutoff
|
|
103
|
+
|
|
104
|
+
scheme_end = url.index("://")
|
|
105
|
+
authority_start = if url.start_with?("//")
|
|
106
|
+
2
|
|
107
|
+
elsif scheme_end && /\A[A-Za-z][A-Za-z0-9+.-]*\z/.match?(url[0, scheme_end])
|
|
108
|
+
scheme_end + 3
|
|
109
|
+
end
|
|
110
|
+
if authority_start
|
|
111
|
+
authority_end = url.index("/", authority_start) || url.length
|
|
112
|
+
userinfo_end = url.rindex("@", authority_end - 1)
|
|
113
|
+
url = url[0, authority_start] + url[(userinfo_end + 1)..] if userinfo_end && userinfo_end >= authority_start
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
url[0, limit]
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
# Header and parameter redaction. Parameter redaction reuses the app's own
|
|
5
|
+
# Rails.application.config.filter_parameters plus Railwatch's list, so
|
|
6
|
+
# anything the app already hides from logs is hidden here too.
|
|
7
|
+
class Redactor
|
|
8
|
+
FILTERED = "[FILTERED]"
|
|
9
|
+
# Credentials are often carried in vendor-specific headers that an app
|
|
10
|
+
# cannot enumerate ahead of time (X-Api-Key, Stripe-Signature,
|
|
11
|
+
# X-Auth-Token, and similar). Match credential-shaped name segments in
|
|
12
|
+
# addition to the exact configurable denylist.
|
|
13
|
+
SENSITIVE_HEADER_NAME = %r{
|
|
14
|
+
(?:\A|-)
|
|
15
|
+
(?:
|
|
16
|
+
api-?(?:key|token|secret)|access-?(?:key|token|secret)|
|
|
17
|
+
private-?(?:key|token)|secret-?key|signing-?key|encryption-?key|
|
|
18
|
+
auth(?:entication|orization)?(?:-?token)?|bearer(?:-?token)?|
|
|
19
|
+
client-?(?:secret|token)|session-?token|refresh-?token|
|
|
20
|
+
security-?token|service-?token|identity-?token|id-?token|
|
|
21
|
+
csrf-?token|xsrf-?token|credential|
|
|
22
|
+
hmac(?:-?signature)?|jwt(?:-?(?:assertion|token))?|
|
|
23
|
+
webhook-?(?:hmac|secret|signature)|token|secret|signature
|
|
24
|
+
)
|
|
25
|
+
(?:-|\z)
|
|
26
|
+
}ix
|
|
27
|
+
|
|
28
|
+
def initialize(config)
|
|
29
|
+
@config = config
|
|
30
|
+
@header_keys = config.redact_headers.map { |h| h.downcase }.to_set
|
|
31
|
+
@param_filter = nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def headers(hash)
|
|
35
|
+
hash.each_with_object({}) do |(k, v), out|
|
|
36
|
+
out[k] = redact_header?(k) ? FILTERED : v.to_s[0, 512]
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Header names arrive already capitalised ("Authorization"); the lookup
|
|
41
|
+
# set is lower-case, so cache the downcased form per distinct name.
|
|
42
|
+
def redact_header?(name)
|
|
43
|
+
@header_case ||= {}
|
|
44
|
+
hit = @header_case[name]
|
|
45
|
+
return hit unless hit.nil?
|
|
46
|
+
@header_case.clear if @header_case.size > 512
|
|
47
|
+
normalized = name.to_s.downcase
|
|
48
|
+
@header_case[name] = @header_keys.include?(normalized) || SENSITIVE_HEADER_NAME.match?(normalized)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def params(hash)
|
|
52
|
+
param_filter.filter(hash)
|
|
53
|
+
rescue StandardError
|
|
54
|
+
{}
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def param_filter
|
|
60
|
+
@param_filter ||= begin
|
|
61
|
+
filters = @config.redact_params.dup
|
|
62
|
+
filters.concat(Rails.application.config.filter_parameters) if defined?(Rails) && Rails.application
|
|
63
|
+
ActiveSupport::ParameterFilter.new(filters.uniq, mask: FILTERED)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
# Finds the release already exposed by a deploy platform or checkout without
|
|
5
|
+
# spawning git during application boot. SHA releases are shortened so every
|
|
6
|
+
# source produces the same compact deploy value.
|
|
7
|
+
module ReleaseDetector
|
|
8
|
+
ENV_KEYS = %w[
|
|
9
|
+
RAILWATCH_DEPLOY KAMAL_VERSION GIT_REV GIT_SHA SOURCE_VERSION
|
|
10
|
+
HEROKU_SLUG_COMMIT RENDER_GIT_COMMIT FLY_IMAGE_REF
|
|
11
|
+
VERCEL_GIT_COMMIT_SHA CI_COMMIT_SHA GITHUB_SHA
|
|
12
|
+
].freeze
|
|
13
|
+
SHA = /\A[0-9a-f]{40}\z/i
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def detect(project_root:, env: ENV)
|
|
18
|
+
ENV_KEYS.each do |key|
|
|
19
|
+
value = env[key].to_s.strip
|
|
20
|
+
next if value.empty?
|
|
21
|
+
|
|
22
|
+
value = value.rpartition(":").last if key == "FLY_IMAGE_REF"
|
|
23
|
+
next if value.empty?
|
|
24
|
+
|
|
25
|
+
yield key if block_given?
|
|
26
|
+
return normalize(value)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
revision = read(File.join(project_root.to_s, "REVISION"))
|
|
30
|
+
if revision && !revision.strip.empty?
|
|
31
|
+
yield "REVISION" if block_given?
|
|
32
|
+
return normalize(revision.strip)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
sha = git_sha(File.join(project_root.to_s, ".git"))
|
|
36
|
+
if sha
|
|
37
|
+
yield "git" if block_given?
|
|
38
|
+
normalize(sha)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def git_sha(git_dir)
|
|
43
|
+
git_dir = resolve_gitdir(git_dir)
|
|
44
|
+
head = read(File.join(git_dir, "HEAD"))&.strip
|
|
45
|
+
return head if SHA.match?(head.to_s)
|
|
46
|
+
return unless head&.start_with?("ref: refs/")
|
|
47
|
+
|
|
48
|
+
ref = head.delete_prefix("ref: ")
|
|
49
|
+
return if ref.include?("..") || ref.include?("\\") || ref.end_with?("/")
|
|
50
|
+
|
|
51
|
+
# A worktree's gitdir holds HEAD but its refs and packed-refs live in
|
|
52
|
+
# the repository it was created from, named by its commondir file.
|
|
53
|
+
refs_dir = read(File.join(git_dir, "commondir"))&.strip
|
|
54
|
+
refs_dir = refs_dir ? File.expand_path(refs_dir, git_dir) : git_dir
|
|
55
|
+
loose = read(File.join(refs_dir, ref))&.strip
|
|
56
|
+
return loose if SHA.match?(loose.to_s)
|
|
57
|
+
|
|
58
|
+
packed_ref(refs_dir, ref)
|
|
59
|
+
end
|
|
60
|
+
private_class_method :git_sha
|
|
61
|
+
|
|
62
|
+
# A worktree's .git is a file naming its gitdir ("gitdir: ...").
|
|
63
|
+
def resolve_gitdir(git_dir)
|
|
64
|
+
return git_dir unless File.file?(git_dir)
|
|
65
|
+
|
|
66
|
+
pointer = read(git_dir).to_s.strip
|
|
67
|
+
return git_dir unless pointer.start_with?("gitdir: ")
|
|
68
|
+
|
|
69
|
+
File.expand_path(pointer.delete_prefix("gitdir: "), File.dirname(git_dir))
|
|
70
|
+
end
|
|
71
|
+
private_class_method :resolve_gitdir
|
|
72
|
+
|
|
73
|
+
def packed_ref(git_dir, ref)
|
|
74
|
+
packed = read(File.join(git_dir, "packed-refs"))
|
|
75
|
+
return unless packed
|
|
76
|
+
|
|
77
|
+
packed.each_line do |line|
|
|
78
|
+
sha, name = line.strip.split(" ", 2)
|
|
79
|
+
return sha if name == ref && SHA.match?(sha.to_s)
|
|
80
|
+
end
|
|
81
|
+
nil
|
|
82
|
+
end
|
|
83
|
+
private_class_method :packed_ref
|
|
84
|
+
|
|
85
|
+
def read(path)
|
|
86
|
+
File.read(path)
|
|
87
|
+
rescue SystemCallError, IOError
|
|
88
|
+
nil
|
|
89
|
+
end
|
|
90
|
+
private_class_method :read
|
|
91
|
+
|
|
92
|
+
def normalize(value)
|
|
93
|
+
SHA.match?(value) ? value[0, 12] : value
|
|
94
|
+
end
|
|
95
|
+
private_class_method :normalize
|
|
96
|
+
end
|
|
97
|
+
end
|