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.
Files changed (78) hide show
  1. checksums.yaml +7 -0
  2. data/AGENTS.md +122 -0
  3. data/CHANGELOG.md +462 -0
  4. data/MIT-LICENSE +20 -0
  5. data/README.md +226 -0
  6. data/app/controllers/railwatch/beacon_controller.rb +254 -0
  7. data/config/routes.rb +5 -0
  8. data/docs/ai-and-mcp.md +227 -0
  9. data/docs/configuration.md +931 -0
  10. data/docs/faq.md +230 -0
  11. data/docs/getting-started.md +279 -0
  12. data/docs/records.md +834 -0
  13. data/docs/replacing-nightwatch.md +216 -0
  14. data/docs/replacing-sentry.md +573 -0
  15. data/docs/security.md +94 -0
  16. data/docs/self-hosting.md +60 -0
  17. data/docs/source-maps.md +60 -0
  18. data/docs/testing.md +175 -0
  19. data/docs/troubleshooting.md +319 -0
  20. data/lib/generators/railwatch/install/install_generator.rb +280 -0
  21. data/lib/generators/railwatch/install/templates/initializer.rb +54 -0
  22. data/lib/generators/railwatch/install/templates/post-deploy +98 -0
  23. data/lib/generators/railwatch/install/templates/railwatch.ts +658 -0
  24. data/lib/railwatch/attachments.rb +83 -0
  25. data/lib/railwatch/backtrace.rb +158 -0
  26. data/lib/railwatch/buffer.rb +122 -0
  27. data/lib/railwatch/clock.rb +25 -0
  28. data/lib/railwatch/configuration.rb +334 -0
  29. data/lib/railwatch/console.rb +48 -0
  30. data/lib/railwatch/context.rb +125 -0
  31. data/lib/railwatch/controller_helpers.rb +21 -0
  32. data/lib/railwatch/current.rb +32 -0
  33. data/lib/railwatch/engine.rb +144 -0
  34. data/lib/railwatch/execution.rb +367 -0
  35. data/lib/railwatch/faraday.rb +73 -0
  36. data/lib/railwatch/health.rb +188 -0
  37. data/lib/railwatch/job_tracing.rb +49 -0
  38. data/lib/railwatch/middleware/request.rb +289 -0
  39. data/lib/railwatch/minitest.rb +43 -0
  40. data/lib/railwatch/patches/inertia.rb +34 -0
  41. data/lib/railwatch/patches/net_http.rb +102 -0
  42. data/lib/railwatch/patches/rake_task.rb +88 -0
  43. data/lib/railwatch/patches/runner_command.rb +120 -0
  44. data/lib/railwatch/patches.rb +43 -0
  45. data/lib/railwatch/profiler.rb +270 -0
  46. data/lib/railwatch/record.rb +119 -0
  47. data/lib/railwatch/redactor.rb +67 -0
  48. data/lib/railwatch/release_detector.rb +97 -0
  49. data/lib/railwatch/reporter.rb +539 -0
  50. data/lib/railwatch/rspec.rb +139 -0
  51. data/lib/railwatch/sampler.rb +17 -0
  52. data/lib/railwatch/secret_safety.rb +62 -0
  53. data/lib/railwatch/sessions.rb +162 -0
  54. data/lib/railwatch/source_maps.rb +59 -0
  55. data/lib/railwatch/spec_helper.rb +147 -0
  56. data/lib/railwatch/sql_normalizer.rb +398 -0
  57. data/lib/railwatch/subscribers/base.rb +54 -0
  58. data/lib/railwatch/subscribers/broadcasts.rb +107 -0
  59. data/lib/railwatch/subscribers/cache.rb +107 -0
  60. data/lib/railwatch/subscribers/deprecations.rb +26 -0
  61. data/lib/railwatch/subscribers/exceptions.rb +304 -0
  62. data/lib/railwatch/subscribers/jobs.rb +282 -0
  63. data/lib/railwatch/subscribers/logs.rb +137 -0
  64. data/lib/railwatch/subscribers/mail.rb +42 -0
  65. data/lib/railwatch/subscribers/notifications.rb +36 -0
  66. data/lib/railwatch/subscribers/process_info.rb +98 -0
  67. data/lib/railwatch/subscribers/queries.rb +183 -0
  68. data/lib/railwatch/subscribers/requests.rb +94 -0
  69. data/lib/railwatch/subscribers/storage.rb +35 -0
  70. data/lib/railwatch/subscribers/users.rb +159 -0
  71. data/lib/railwatch/subscribers/views.rb +54 -0
  72. data/lib/railwatch/subscribers.rb +34 -0
  73. data/lib/railwatch/transport/http.rb +208 -0
  74. data/lib/railwatch/version.rb +5 -0
  75. data/lib/railwatch.rb +550 -0
  76. data/lib/tasks/railwatch_tasks.rake +289 -0
  77. data/llms.txt +38 -0
  78. 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