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,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