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,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "pathname"
5
+ require "zlib"
6
+
7
+ module Railwatch
8
+ # Railwatch.attach: ship an arbitrary blob -- the JSON payload that failed to
9
+ # parse, a rendered PDF, the webhook body a customer swears they sent -- as
10
+ # its own `attachment` record. Sentry's `Sentry.add_attachment` equivalent,
11
+ # except an attachment is a first-class record linked to the execution it
12
+ # was made in, and optionally to the exception it explains.
13
+ #
14
+ # Railwatch.attach("payload.json", request.raw_post)
15
+ # Railwatch.attach("invoice.pdf", Rails.root.join("tmp/invoice.pdf"))
16
+ # Railwatch.attach("payload.json", body, exception: error)
17
+ #
18
+ # The wire field `data` is base64 of gzip, so a text payload costs a
19
+ # fraction of its size in the batch.
20
+ module Attachments
21
+ DEFAULT_CONTENT_TYPE = "application/octet-stream"
22
+ MAX_NAME = 255
23
+ MAX_CONTENT_TYPE = 128
24
+
25
+ module_function
26
+
27
+ def attach(name, data, content_type: nil, exception: nil)
28
+ return nil unless Railwatch.enabled?
29
+
30
+ # cap + 1 so "was it truncated?" is still answerable without ever
31
+ # holding more than the cap in memory. A 2GB log file used to be read
32
+ # whole and then sliced.
33
+ cap = Railwatch.config.max_attachment_bytes
34
+ bytes = read(data, cap + 1)
35
+ return nil if bytes.nil? || bytes.empty?
36
+
37
+ name = name.to_s[0, MAX_NAME]
38
+ truncated = bytes.bytesize > cap
39
+ bytes = bytes.byteslice(0, cap) if truncated
40
+
41
+ fields = {
42
+ name: name,
43
+ content_type: (content_type || content_type_for(name)).to_s[0, MAX_CONTENT_TYPE],
44
+ bytes: bytes.bytesize,
45
+ data: Base64.strict_encode64(Zlib.gzip(bytes)),
46
+ exception_group_hash: exception && Subscribers::Exceptions.group_for(exception)
47
+ }
48
+ fields[:truncated] = true if truncated
49
+ # `attachment` is in Railwatch::STANDALONE_TYPES, so this buffers as a
50
+ # child of the current execution when one is recording and ships on its
51
+ # own (from a boot hook, a console, a rescue with nothing executing)
52
+ # when there isn't one.
53
+ Railwatch.record(:attachment, group: Record.group_hash(name), **fields)
54
+ end
55
+
56
+ # A String is the data itself; a Pathname is a file to read; anything else
57
+ # that responds to #read (File, StringIO, an uploaded file) is read.
58
+ def read(data, limit)
59
+ case data
60
+ when nil then nil
61
+ when String then data.byteslice(0, limit)
62
+ when Pathname then File.binread(data, limit)
63
+ else
64
+ value = data.respond_to?(:read) ? data.read(limit) : data.to_s
65
+ value.to_s.byteslice(0, limit)
66
+ end
67
+ rescue StandardError => e
68
+ Railwatch.debug { "attachment read failed: #{e.class}: #{e.message}" }
69
+ nil
70
+ end
71
+
72
+ # Marcel comes with Rails (Active Storage depends on it), but Railwatch's
73
+ # only declared dependency is rails itself, so an app that has somehow
74
+ # dropped it still attaches -- just with the generic type.
75
+ def content_type_for(name)
76
+ return DEFAULT_CONTENT_TYPE unless defined?(::Marcel::MimeType)
77
+
78
+ ::Marcel::MimeType.for(name: name) || DEFAULT_CONTENT_TYPE
79
+ rescue StandardError
80
+ DEFAULT_CONTENT_TYPE
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ # Locates the app frame that caused a query or outgoing request, and
5
+ # serialises exception frames with source snippets for app code.
6
+ module Backtrace
7
+ module_function
8
+
9
+ def app_root
10
+ @app_root ||= (defined?(Rails) && Rails.root ? Rails.root.to_s + "/" : Dir.pwd + "/")
11
+ end
12
+
13
+ def clean(frames)
14
+ cleaner = defined?(Rails) && Rails.respond_to?(:backtrace_cleaner) ? Rails.backtrace_cleaner : nil
15
+ cleaner ? cleaner.clean(frames) : frames
16
+ end
17
+
18
+ # First application frame as "app/models/user.rb:12", or nil. 100 frames
19
+ # clears framework internals in practice (Rails 8.1's instrumentation and
20
+ # query-cache wrapping alone run 40+ frames deep before reaching app code,
21
+ # more once a view template is on the stack) and keeps the walk (and the
22
+ # array it allocates) cheap on the hot query path.
23
+ # The gem's own lib/ directory, so frames inside Railwatch are skipped by
24
+ # prefix rather than by a substring that would also match any app whose
25
+ # checkout happens to live under a folder named "railwatch" (including
26
+ # this repo's own spec/dummy, which lives under the repo root but not
27
+ # under its lib/).
28
+ GEM_ROOT = File.expand_path("..", __dir__) + "/"
29
+
30
+ def gem_root
31
+ GEM_ROOT
32
+ end
33
+
34
+ def caller_location(skip: 2)
35
+ locations = caller_locations(skip, 100) or return nil
36
+ locations.each do |loc|
37
+ path = loc.path
38
+ next if path.start_with?(gem_root)
39
+ next if installed_gem_path?(path)
40
+ return "#{path.delete_prefix(app_root)}:#{loc.lineno}"
41
+ end
42
+ nil
43
+ end
44
+
45
+ # True for frames inside an installed gem (net-http, webmock, rspec-core,
46
+ # etc). Railwatch itself is excluded separately via gem_root because in
47
+ # this repo's own test suite (and in a `path:`/`git:` Gemfile checkout)
48
+ # it is loaded straight from a working tree, not from a Gem.path install.
49
+ def installed_gem_path?(path)
50
+ Gem.path.any? { |p| path.start_with?("#{p}/") }
51
+ end
52
+
53
+ # One line of a String backtrace: "path:line:in 'label'" (Ruby 3.4),
54
+ # "path:line:in `label'" (earlier), or a bare "path:line".
55
+ BACKTRACE_LINE = /\A(.+?):(\d+)(?::in [`'](.*)')?\z/
56
+
57
+ def frames(exception, with_source: true, limit: 50)
58
+ raw_frames(exception, limit).map do |path, lineno, label|
59
+ in_app = path.to_s.start_with?(app_root)
60
+ frame = {
61
+ file: in_app ? path.delete_prefix(app_root) : path,
62
+ line: lineno,
63
+ function: label,
64
+ in_app: in_app
65
+ }
66
+ frame[:code] = source_snippet(path, lineno) if with_source && in_app
67
+ frame
68
+ end
69
+ end
70
+
71
+ # [path, line, label] per frame. backtrace_locations is nil for any
72
+ # exception whose backtrace was assigned rather than raised into it --
73
+ # ActiveRecord::StatementInvalid (set_backtrace from the driver error),
74
+ # Faraday::Error (delegates #backtrace to the wrapped exception) -- which
75
+ # are the most common production exceptions, so fall back to parsing the
76
+ # strings rather than shipping them with no frames, no culprit, and a
77
+ # fingerprint of nothing but class and message.
78
+ def raw_frames(exception, limit)
79
+ locations = exception.backtrace_locations
80
+ if locations
81
+ locations.first(limit).map { |loc| [ loc.absolute_path || loc.path, loc.lineno, loc.label ] }
82
+ else
83
+ Array(exception.backtrace).first(limit).filter_map do |line|
84
+ match = BACKTRACE_LINE.match(line.to_s) or next
85
+ [ match[1], match[2].to_i, match[3] ]
86
+ end
87
+ end
88
+ end
89
+
90
+ # --- Browser stacks ---------------------------------------------------
91
+
92
+ # One frame of a JavaScript stack, in either of the two shapes engines
93
+ # write: V8's "at fn (https://host/assets/app-abc.js:1:2)" (and the same
94
+ # line without the function name), or SpiderMonkey and JavaScriptCore's
95
+ # "fn@https://host/assets/app-abc.js:1:2". A line with no file:line on it
96
+ # -- V8's leading "TypeError: ..." header, "at new Promise (<anonymous>)"
97
+ # -- matches neither and is dropped.
98
+ JS_FRAME = /
99
+ \A
100
+ (?:at\s+)? # V8 indents every frame with "at "
101
+ (?:(?<function>[^@]*?)\s*[@(])? # "fn@" (Firefox, Safari) or "fn (" (V8)
102
+ (?<file>\S+?)
103
+ :(?<line>\d+)(?::(?<column>\d+))? # line and column (both one-based)
104
+ \)?
105
+ \z
106
+ /x
107
+
108
+ # Frames from the app's own origin that are still not the app's code.
109
+ VENDOR_PATH = %r{(?:\A|/)(?:node_modules|vendor)\b}
110
+
111
+ MAX_JS_FRAMES = 50
112
+
113
+ # A browser stack, exactly as the engine wrote it, in the same frame
114
+ # shape as a Ruby backtrace. `origin` is the app's own scheme and host: a
115
+ # script served from it is the app's own, so its file is stored relative
116
+ # to that origin the way a Ruby frame is stored relative to Rails.root,
117
+ # and anything from a CDN, an extension, or a third-party tag keeps its
118
+ # whole URL and is not in_app.
119
+ def js_frames(stack, origin: nil, limit: MAX_JS_FRAMES)
120
+ frames = []
121
+ stack.to_s.each_line do |raw|
122
+ break if frames.size >= limit
123
+ match = JS_FRAME.match(raw.strip) or next
124
+ url = match[:file].split("?", 2).first.to_s
125
+ own = js_own_origin?(url, origin)
126
+ file = own ? url.delete_prefix(origin.to_s).delete_prefix("/") : url
127
+ function = match[:function].to_s.strip
128
+ frame = {
129
+ file: file[0, 255],
130
+ line: match[:line].to_i,
131
+ function: function.empty? ? "(anonymous)" : function[0, 255],
132
+ in_app: own && !VENDOR_PATH.match?(file)
133
+ }
134
+ frame[:column] = match[:column].to_i if match[:column]
135
+ frames << frame
136
+ end
137
+ frames
138
+ end
139
+
140
+ # A bare path ("/assets/app.js") can only be the app's own; an absolute
141
+ # URL is only the app's own when it is on the app's origin.
142
+ def js_own_origin?(url, origin)
143
+ return true if url.start_with?("/")
144
+ return false if origin.nil? || origin.empty?
145
+ url.start_with?("#{origin}/")
146
+ end
147
+
148
+ def source_snippet(path, line, context: 5)
149
+ return nil unless path && File.readable?(path)
150
+ lines = File.readlines(path, chomp: true)
151
+ from = [ line - context - 1, 0 ].max
152
+ to = [ line + context - 1, lines.size - 1 ].min
153
+ (from..to).to_h { |i| [ i + 1, lines[i].to_s[0, 200] ] }
154
+ rescue StandardError
155
+ nil
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ # Bounded, thread-safe queue of records. When full, the oldest record is
5
+ # dropped and counted; the drop count is reported with the next batch so
6
+ # loss is visible on the platform instead of silent.
7
+ #
8
+ # "Full" is two limits, not one. A record count alone does not bound memory:
9
+ # 10,000 records is a few megabytes of ordinary telemetry and a gigabyte of
10
+ # captured attachments or a query record carrying a multi-megabyte SQL
11
+ # string. Each record's weight is measured once, when it is pushed, and
12
+ # carried alongside it so shifting one out is arithmetic rather than a
13
+ # re-measure.
14
+ class Buffer
15
+ def initialize(capacity, byte_capacity: Float::INFINITY)
16
+ @capacity = capacity
17
+ @byte_capacity = byte_capacity
18
+ @records = []
19
+ @record_bytes = []
20
+ @bytes = 0
21
+ @dropped = 0
22
+ @dropped_bytes = 0
23
+ @mutex = Mutex.new
24
+ end
25
+
26
+ # `bytes` is the caller's already-measured weight (Execution weighed the
27
+ # record when it buffered it); anything else is weighed here. Weighing
28
+ # stops at the ceiling, so the dropped-byte counter is a floor for an
29
+ # over-ceiling record; the dropped-record counter is always exact.
30
+ def push(record, bytes = nil)
31
+ bytes ||= Record.buffered_bytes(record, limit: @byte_capacity)
32
+ @mutex.synchronize do
33
+ # A single record heavier than the whole queue can only be dropped:
34
+ # making room for it would mean emptying the queue and still not
35
+ # fitting.
36
+ if bytes > @byte_capacity
37
+ drop(bytes)
38
+ next @records.size
39
+ end
40
+
41
+ drop_oldest while @records.any? && (@records.size >= @capacity || @bytes + bytes > @byte_capacity)
42
+ @records << record
43
+ @record_bytes << bytes
44
+ @bytes += bytes
45
+ @records.size
46
+ end
47
+ end
48
+
49
+ def size
50
+ @mutex.synchronize { @records.size }
51
+ end
52
+
53
+ def dropped
54
+ @mutex.synchronize { @dropped }
55
+ end
56
+
57
+ def bytes
58
+ @mutex.synchronize { @bytes }
59
+ end
60
+
61
+ def stats
62
+ @mutex.synchronize { [ @records.size, @dropped, @bytes, @dropped_bytes ] }
63
+ end
64
+
65
+ def account_dropped(count, bytes: 0)
66
+ @mutex.synchronize do
67
+ @dropped += count
68
+ @dropped_bytes += bytes
69
+ end
70
+ end
71
+
72
+ def full?(threshold)
73
+ size >= threshold
74
+ end
75
+
76
+ # Atomically take everything, resetting the drop counters. Returns the
77
+ # records with the weights measured when they were pushed, so the reporter
78
+ # can split a batch by bytes without weighing anything twice.
79
+ def drain
80
+ @mutex.synchronize do
81
+ drained = [ @records, @dropped, @dropped_bytes, @record_bytes ]
82
+ @records = []
83
+ @record_bytes = []
84
+ @bytes = 0
85
+ @dropped = 0
86
+ @dropped_bytes = 0
87
+ drained
88
+ end
89
+ end
90
+
91
+ # Put an unsuccessfully delivered batch back ahead of records written
92
+ # while it was in flight. The queue stays bounded: if both generations no
93
+ # longer fit, the oldest restored records are discarded first so fresh
94
+ # application telemetry wins under sustained ingest failure.
95
+ def restore(records, sizes = nil, dropped: 0, dropped_bytes: 0)
96
+ sizes ||= records.map { |record| Record.buffered_bytes(record, limit: @byte_capacity) }
97
+ @mutex.synchronize do
98
+ @records = records + @records
99
+ @record_bytes = sizes + @record_bytes
100
+ @bytes += sizes.sum
101
+ @dropped += dropped
102
+ @dropped_bytes += dropped_bytes
103
+ drop_oldest while @records.any? && (@records.size > @capacity || @bytes > @byte_capacity)
104
+ @records.size
105
+ end
106
+ end
107
+
108
+ private
109
+
110
+ def drop(bytes)
111
+ @dropped += 1
112
+ @dropped_bytes += bytes
113
+ end
114
+
115
+ def drop_oldest
116
+ @records.shift
117
+ bytes = @record_bytes.shift
118
+ @bytes -= bytes
119
+ drop(bytes)
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ # Wall time for timestamps, monotonic time for durations. Durations are
5
+ # integers in microseconds everywhere, matching Nightwatch.
6
+ module Clock
7
+ module_function
8
+
9
+ def now
10
+ Process.clock_gettime(Process::CLOCK_REALTIME)
11
+ end
12
+
13
+ def monotonic
14
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
15
+ end
16
+
17
+ def micros_since(monotonic_start)
18
+ ((monotonic - monotonic_start) * 1_000_000).round
19
+ end
20
+
21
+ def ms_to_micros(ms)
22
+ (ms.to_f * 1_000).round
23
+ end
24
+ end
25
+ end