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,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ # One background thread per web/worker process, shipping a single `health`
5
+ # record every config.health_interval seconds: Puma's thread pool, the
6
+ # Active Record connection pool, and Solid Queue's backlog. Started from the
7
+ # engine's "railwatch.health" initializer and re-armed in every forked child
8
+ # from Railwatch.restart_after_fork!.
9
+ #
10
+ # A sample must never be visible to the app: the whole thing runs inside
11
+ # Railwatch.ignore and rescues everything, so a missing constant, an
12
+ # unmigrated queue database, or a checkout timeout degrades to nil fields
13
+ # rather than raising on a thread nobody is watching.
14
+ module Health
15
+ ROLES = %w[web worker].freeze
16
+
17
+ @mutex = Mutex.new
18
+ @wakeup = ConditionVariable.new
19
+ @thread = nil
20
+ @pid = nil
21
+ @stopping = false
22
+
23
+ module_function
24
+
25
+ # Idempotent; Railwatch.restart_after_fork! calls it again in every forked
26
+ # child.
27
+ def start!
28
+ return unless Railwatch.enabled?
29
+ return if defined?(Rails) && Rails.env.test?
30
+ return unless ROLES.include?(Subscribers::ProcessInfo.role)
31
+ return if @thread&.alive? && @pid == Process.pid
32
+
33
+ @mutex.synchronize do
34
+ return if @thread&.alive? && @pid == Process.pid
35
+
36
+ @pid = Process.pid
37
+ @stopping = false
38
+ @thread = Thread.new { run }
39
+ @thread.name = "railwatch-health"
40
+ @thread.abort_on_exception = false
41
+ @thread.report_on_exception = false
42
+ end
43
+ end
44
+
45
+ # A forked child (Puma cluster worker, Solid Queue forked worker) inherits
46
+ # a dead thread and may inherit a mutex held by a vanished parent thread,
47
+ # so every synchronization primitive must be replaced before start!.
48
+ def restart_after_fork!
49
+ @mutex = Mutex.new
50
+ @wakeup = ConditionVariable.new
51
+ @thread = nil
52
+ @pid = nil
53
+ @stopping = false
54
+ remove_instance_variable(:@puma_server) if defined?(@puma_server)
55
+ start!
56
+ end
57
+
58
+ def stop!
59
+ return unless @thread
60
+
61
+ @stopping = true
62
+ @mutex.synchronize { @wakeup.signal }
63
+ @thread.join(1)
64
+ @thread = nil
65
+ end
66
+
67
+ # Sleeps on a ConditionVariable rather than Kernel#sleep so stop! (from
68
+ # at_exit) wakes the thread immediately instead of waiting out the
69
+ # remainder of the interval. @stopping is re-read while holding the mutex
70
+ # so a stop! that lands just before the wait can't have its signal missed
71
+ # and leave the process hanging for a full interval.
72
+ def run
73
+ until @stopping
74
+ @mutex.synchronize { @wakeup.wait(@mutex, Railwatch.config.health_interval) unless @stopping }
75
+ sample unless @stopping
76
+ end
77
+ end
78
+
79
+ def sample
80
+ Railwatch.ignore do
81
+ puma = puma_stats
82
+ pool = pool_stats
83
+ queue = solid_queue_stats
84
+ Railwatch.record(:health,
85
+ pid: Process.pid,
86
+ role: Subscribers::ProcessInfo.role,
87
+ memory: Execution.sampled_memory,
88
+ threads_max: puma[:threads_max],
89
+ threads_busy: puma[:threads_busy],
90
+ backlog: puma[:backlog],
91
+ pool_size: pool[:size],
92
+ pool_busy: pool[:busy],
93
+ pool_waiting: pool[:waiting],
94
+ queue_depth: queue[:queue_depth],
95
+ queue_latency: queue[:queue_latency],
96
+ detail: JSON.generate(detail(puma, queue)))
97
+ end
98
+ rescue StandardError => e
99
+ Railwatch.debug { "health sample failed: #{e.class}: #{e.message}" }
100
+ nil
101
+ end
102
+
103
+ EMPTY = {}.freeze
104
+
105
+ def detail(puma, queue)
106
+ detail = {
107
+ queues: queue[:queues],
108
+ workers: queue[:workers],
109
+ requests_count: puma[:requests_count],
110
+ running: puma[:running],
111
+ max_threads_reached: puma[:max_threads_reached]
112
+ }
113
+ tasks = recurring_tasks
114
+ detail[:recurring_tasks] = tasks if tasks
115
+ detail
116
+ end
117
+
118
+ # The recurring tasks this process's Solid Queue knows about, key =>
119
+ # schedule, so the platform can tell a task that was removed from
120
+ # config/recurring.yml apart from one that stopped running. Read from
121
+ # the Jobs subscriber's cache (one query a minute per process, shared
122
+ # with scheduled-task detection). Left out rather than sent empty when
123
+ # there are none or the table could not be read: Jobs folds a failed
124
+ # read into an empty set, and "no manifest" must not read as "no tasks".
125
+ def recurring_tasks
126
+ schedules = Subscribers::Jobs.recurring_tasks[:schedules]
127
+ schedules.empty? ? nil : schedules
128
+ rescue StandardError
129
+ nil
130
+ end
131
+
132
+ # Puma::Server#stats is the only public API exposing busy_threads, so it is
133
+ # used in preference to the individual readers. It also resets Puma's
134
+ # own since-last-read backlog_max/reactor_max gauges, which Railwatch does
135
+ # not report.
136
+ def puma_stats
137
+ server = puma_server or return EMPTY
138
+ s = server.stats
139
+ # Puma's busy_threads counts queued requests too (spawned - waiting +
140
+ # todo), so it can exceed max_threads under load; the pool cannot,
141
+ # and that is what a utilisation percentage should describe.
142
+ {
143
+ threads_max: s[:max_threads],
144
+ threads_busy: s[:busy_threads] && s[:max_threads] ? [ s[:busy_threads], s[:max_threads] ].min : s[:busy_threads],
145
+ backlog: s[:backlog],
146
+ running: s[:running],
147
+ requests_count: s[:requests_count],
148
+ # No idle capacity left in the pool at sample time.
149
+ max_threads_reached: s[:pool_capacity]&.zero?
150
+ }
151
+ rescue StandardError
152
+ EMPTY
153
+ end
154
+
155
+ # Looked up once per process: ObjectSpace.each_object walks the whole heap,
156
+ # so it must not run on every sample. Puma constructs its Server while
157
+ # booting, long before the first sample fires.
158
+ def puma_server
159
+ return @puma_server if defined?(@puma_server)
160
+ @puma_server = defined?(::Puma::Server) ? ObjectSpace.each_object(::Puma::Server).first : nil
161
+ rescue StandardError
162
+ @puma_server = nil
163
+ end
164
+
165
+ def pool_stats
166
+ ActiveRecord::Base.connection_pool.stat
167
+ rescue StandardError
168
+ EMPTY
169
+ end
170
+
171
+ # Read-only counts against the queue database. Solid Queue keeps one row
172
+ # per ready job, so `queue_latency` (the age of the oldest ready job) is
173
+ # the backlog's head-of-line wait, in microseconds.
174
+ def solid_queue_stats
175
+ return EMPTY unless defined?(::SolidQueue)
176
+
177
+ oldest = ::SolidQueue::ReadyExecution.minimum(:created_at)
178
+ {
179
+ queue_depth: ::SolidQueue::ReadyExecution.count,
180
+ queue_latency: oldest && ((Clock.now - oldest.to_time.utc.to_f) * 1_000_000).round,
181
+ queues: ::SolidQueue::ReadyExecution.group(:queue_name).count,
182
+ workers: ::SolidQueue::Process.where(kind: "Worker").count
183
+ }
184
+ rescue StandardError
185
+ EMPTY
186
+ end
187
+ end
188
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ # Carries trace_id, the enqueuing execution id, and that execution's user
5
+ # and tenant inside the Active Job payload, the same way Rails carries
6
+ # locale and timezone, so a job attempt links back to the request that
7
+ # enqueued it and is attributed to the same person and tenant.
8
+ module JobTracing
9
+ extend ActiveSupport::Concern
10
+
11
+ included do
12
+ attr_accessor :railwatch_trace_id, :railwatch_parent_id, :railwatch_user, :railwatch_tenant
13
+ end
14
+
15
+ def serialize
16
+ exe = Railwatch.execution
17
+ data = super.merge(
18
+ "railwatch_trace_id" => railwatch_trace_id || exe&.trace_id,
19
+ "railwatch_parent_id" => railwatch_parent_id || exe&.id
20
+ )
21
+ # Identifier strings only -- never a user or tenant record -- and only
22
+ # when there is one, so a job enqueued with no identity produces the
23
+ # same payload it did before these keys existed.
24
+ #
25
+ # A request resolves its user lazily, at the end (Middleware::Request),
26
+ # so exe.user_id is usually still nil while the action is enqueuing;
27
+ # resolving here is what gives such a job its user. The result is
28
+ # memoised onto the execution with the same `||=` the middleware uses,
29
+ # so an action that enqueues fifty jobs resolves once, not fifty
30
+ # times -- resolving a present user costs ~8us. Inside a job the
31
+ # restored value is already on the execution and wins, so identity
32
+ # flows on unchanged through jobs that enqueue jobs. No execution means
33
+ # nothing to attribute (and Railwatch disabled), so nothing is resolved.
34
+ user = railwatch_user || (exe && (exe.user_id ||= Subscribers::Users.resolve_from_current))
35
+ tenant = railwatch_tenant || exe&.tenant || Context.current_tenant
36
+ data["railwatch_user"] = user if user
37
+ data["railwatch_tenant"] = tenant if tenant
38
+ data
39
+ end
40
+
41
+ def deserialize(job_data)
42
+ super
43
+ self.railwatch_trace_id = job_data["railwatch_trace_id"]
44
+ self.railwatch_parent_id = job_data["railwatch_parent_id"]
45
+ self.railwatch_user = job_data["railwatch_user"]
46
+ self.railwatch_tenant = job_data["railwatch_tenant"]
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,289 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ module Middleware
5
+ # Outermost Rack middleware. Opens the request execution, times the
6
+ # lifecycle stages, catches anything that escapes the stack as an
7
+ # unhandled exception, and emits the request record at the end.
8
+ class Request
9
+ # Env keys repeat request after request (same client/proxy headers), so
10
+ # the Rack key -> "Header-Name" conversion is cached instead of
11
+ # split/map/capitalize/join-ing on every request.
12
+ HEADER_NAME_CACHE_LIMIT = 512
13
+ # W3C trace context: version-trace_id-parent_id-flags, all lower-case hex.
14
+ TRACEPARENT = /\A([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})(.*)\z/
15
+ # Reverse proxies stamp the moment the request was accepted; the gap to
16
+ # our own start is how long it waited for a worker. Anything beyond this
17
+ # is clock skew between the proxy and this box, not a real wait.
18
+ MAX_QUEUE_TIME = 60_000_000 # microseconds
19
+ # Only a multipart request can carry an UploadedFile; same raw
20
+ # CONTENT_TYPE test Subscribers::Requests uses before its params walk.
21
+ MULTIPART = "multipart/form-data"
22
+ NO_FILES = [].freeze
23
+
24
+ # Returns [trace_id, parent_id, sampled] from an inbound traceparent,
25
+ # or nil when the header is absent or malformed.
26
+ def self.traceparent(value)
27
+ match = value && TRACEPARENT.match(value)
28
+ return nil unless match
29
+ return nil if match[1] == "ff"
30
+ # Version 00 has exactly 55 characters. Future versions may append
31
+ # opaque fields, but W3C requires the byte after trace-flags to be a
32
+ # dash; do not inspect or make assumptions about the fields beyond it.
33
+ return nil if match[1] == "00" && !match[5].empty?
34
+ return nil unless match[5].empty? || match[5].start_with?("-")
35
+ return nil if match[2] == "0" * 32 || match[3] == "0" * 16
36
+
37
+ [ match[2], match[3], match[4].to_i(16).odd? ]
38
+ end
39
+
40
+ def initialize(app)
41
+ @app = app
42
+ @header_name_cache = {}
43
+ @header_name_mutex = Mutex.new
44
+ end
45
+
46
+ def call(env)
47
+ return @app.call(env) unless Railwatch.enabled?
48
+ return @app.call(env) if ignored_request?(env)
49
+
50
+ trace_id, parent_id, upstream_sampled = self.class.traceparent(env["HTTP_TRACEPARENT"])
51
+ exe = Railwatch.start_execution(source: :request, sample_kind: :requests,
52
+ trace_id: trace_id, parent_id: parent_id)
53
+ # The upstream service sampled this trace in, so keep our end of it
54
+ # too -- otherwise the trace has a hole where this request should be.
55
+ exe.keep! if upstream_sampled
56
+ exe.enter_stage(:middleware_before)
57
+ env["railwatch.execution"] = exe
58
+ status = headers = body = nil
59
+ begin
60
+ status, headers, body = @app.call(env)
61
+ rescue Exception => e # rubocop:disable Lint/RescueException
62
+ Subscribers::Exceptions.capture(e, handled: false, severity: :error, source: "railwatch.middleware")
63
+ raise
64
+ ensure
65
+ exe.enter_stage(:middleware_after) unless exe.stage == :middleware_after
66
+ finish(env, exe, status, headers)
67
+ end
68
+ [ status, headers, body ]
69
+ end
70
+
71
+ private
72
+
73
+ def ignored_request?(env)
74
+ path = env["PATH_INFO"].to_s
75
+ Railwatch.config.ignored_request_paths.any? do |pattern|
76
+ pattern.is_a?(Regexp) ? pattern.match?(path) : pattern.to_s == path
77
+ end || self_ingest_request?(env, path)
78
+ end
79
+
80
+ # Railwatch Cloud monitors itself. Its reporter therefore POSTs back into
81
+ # the same Rails process, and recording that POST would put another
82
+ # request record in the reporter forever: flush -> /ingest -> flush.
83
+ #
84
+ # Path alone is not enough: a customer application can own an unrelated
85
+ # /ingest route. This exemption needs the exact transport method, bearer
86
+ # token and public origin. Rack::Request normalizes Forwarded and
87
+ # X-Forwarded-* headers (including a non-default forwarded port), so the
88
+ # comparison still works behind a TLS-terminating reverse proxy without
89
+ # confusing the proxy's internal host with the public ingest origin.
90
+ def self_ingest_request?(env, path)
91
+ return false unless env["REQUEST_METHOD"] == "POST"
92
+ return false unless env["HTTP_AUTHORIZATION"] == "Bearer #{Railwatch.config.token}"
93
+
94
+ endpoint = URI.join(Railwatch.config.ingest_url, "/ingest")
95
+ return false unless path == endpoint.path
96
+
97
+ request = Rack::Request.new(env)
98
+ request.scheme.casecmp?(endpoint.scheme) &&
99
+ request.hostname.casecmp?(endpoint.host) &&
100
+ request.port == endpoint.port
101
+ rescue StandardError
102
+ false
103
+ end
104
+
105
+ def finish(env, exe, status, headers)
106
+ exe.finish_stages
107
+ # Resolved here, once, for both the request record and the session
108
+ # key below (the start_processing subscriber ran before the app's
109
+ # before_actions, so it usually found no user yet).
110
+ exe.user_id ||= Subscribers::Users.resolve_id(env)
111
+ # The block is only called when the request record is going to ship;
112
+ # a head-sampled-out request nothing rescued skips the
113
+ # ActionDispatch::Request and the header walk entirely.
114
+ Railwatch.finish_execution(:request) { parent_fields(env, exe, status, headers) }
115
+ # A request with no user and no session cookie has no session, and
116
+ # Sessions.touch returns without writing anything.
117
+ Sessions.touch(exe, env, status) if Railwatch.config.track_sessions
118
+ rescue StandardError => e
119
+ Railwatch.debug { "request finish failed: #{e.class}: #{e.message}" }
120
+ Railwatch.finish_execution
121
+ end
122
+
123
+ def parent_fields(env, exe, status, headers)
124
+ req = ActionDispatch::Request.new(env)
125
+ route = env["railwatch.route"] || {}
126
+ pattern = route[:pattern] || (req.respond_to?(:route_uri_pattern) ? (req.route_uri_pattern rescue nil) : nil) || "unmatched"
127
+ controller = route[:controller]
128
+ action = route[:action]
129
+ method = req.request_method
130
+ exe.preview ||= "#{method} #{pattern}"
131
+
132
+ inertia = inertia_fields(env, headers)
133
+ payload = Railwatch.config.capture_request_payload && exe.counters[:exceptions].positive? ? Railwatch.redactor.params(req.filtered_parameters.except("controller", "action")) : nil
134
+
135
+ {
136
+ group: Record.group_hash(method, pattern),
137
+ method: method,
138
+ url: Record.url_without_sensitive_components(req.original_url, limit: 2048),
139
+ path: req.path,
140
+ route: pattern,
141
+ route_methods: [ route[:verb] ].compact,
142
+ route_domain: req.host,
143
+ controller: controller,
144
+ action: action,
145
+ format: request_format(req, env),
146
+ ip: req.remote_ip,
147
+ status_code: status.to_i,
148
+ request_size: req.content_length.to_i,
149
+ response_size: headers && (headers["Content-Length"] || headers["content-length"]).to_i,
150
+ view_runtime: env["railwatch.view_runtime"],
151
+ db_runtime: env["railwatch.db_runtime"],
152
+ redirect_to: env["railwatch.redirect_to"],
153
+ halted_callback: env["railwatch.halted_callback"],
154
+ unpermitted_parameters: env["railwatch.unpermitted_parameters"],
155
+ rate_limited: env["railwatch.rate_limited"],
156
+ inertia: inertia,
157
+ headers: request_headers(env),
158
+ payload: payload,
159
+ queue_time: queue_time(env, exe),
160
+ user_agent: req.user_agent.to_s[0, 256],
161
+ files: env["railwatch.files"] || uploaded_files_fallback(req, env)
162
+ }
163
+ end
164
+
165
+ # Rack parses the request body the first time anything asks it for
166
+ # params, and both of the reads below ask -- `uploaded_files` walks
167
+ # `request.params`, and ActionDispatch::Request#format goes through
168
+ # `parameters[:format]`.
169
+ #
170
+ # Once a controller has run, that parse has already happened and its
171
+ # result is memoized on env, so both reads are free (and the files were
172
+ # captured back in Subscribers::Requests, before Rack::TempfileReaper
173
+ # unlinked the tempfiles). When no controller ran -- a routing 404, a
174
+ # rack-attack block, a middleware that rejected the request -- doing it
175
+ # here would make Railwatch the only component that ever reads that body,
176
+ # at teardown, after the response has been decided. A streaming upload,
177
+ # or simply megabytes we would immediately throw away.
178
+ #
179
+ # So the fallback is narrow: multipart only, because nothing else can
180
+ # contain a file, and no format symbol is worth parsing a body for.
181
+ def uploaded_files_fallback(req, env)
182
+ return NO_FILES unless multipart?(env)
183
+
184
+ uploaded_files(req.params)
185
+ end
186
+
187
+ def request_format(req, env)
188
+ return "" unless env.key?("action_dispatch.request.formats") || multipart?(env)
189
+
190
+ (req.format&.symbol rescue nil).to_s
191
+ end
192
+
193
+ def multipart?(env)
194
+ content_type = env["CONTENT_TYPE"]
195
+ !content_type.nil? && content_type.start_with?(MULTIPART)
196
+ end
197
+
198
+ # Microseconds this request waited in the proxy/web-server queue before
199
+ # the execution started, from X-Request-Start (nginx, Heroku, HAProxy)
200
+ # or X-Queue-Start. nil when absent, unparseable, or implausible.
201
+ def queue_time(env, exe)
202
+ raw = env["HTTP_X_REQUEST_START"] || env["HTTP_X_QUEUE_START"]
203
+ started = raw && request_start_seconds(raw)
204
+ return nil unless started
205
+
206
+ micros = ((exe.started_at - started) * 1_000_000).round
207
+ return nil if micros > MAX_QUEUE_TIME
208
+
209
+ # A proxy clock running slightly ahead reads as a negative wait.
210
+ micros.negative? ? 0 : micros
211
+ end
212
+
213
+ # "t=1700000000.123" (seconds), "t=1700000000123" (ms),
214
+ # "t=1700000000123456" (microseconds), or the same values bare. A proxy
215
+ # chain can collapse several into one comma-separated header; the first
216
+ # is the outermost. Unit is decided by magnitude, like Sentry's
217
+ # extract_queue_time.
218
+ def request_start_seconds(value)
219
+ raw = value.to_s.split(",").first.to_s.strip.delete_prefix("t=").strip
220
+ return nil unless /\A\d+(?:\.\d+)?\z/.match?(raw)
221
+
222
+ seconds = raw.to_f
223
+ if seconds > 10_000_000_000_000 then seconds / 1_000_000
224
+ elsif seconds > 10_000_000_000 then seconds / 1_000
225
+ else seconds
226
+ end
227
+ end
228
+
229
+ def inertia_fields(env, headers)
230
+ # The X-Inertia response header is only set on the XHR-follow-up
231
+ # branch; a full-page (SSR or not) Inertia render never sets it, so
232
+ # the presence of the component env var (set on every Inertia
233
+ # render) also has to open this gate or SSR timing is silently lost.
234
+ return nil unless env["HTTP_X_INERTIA"] == "true" || (headers && (headers["X-Inertia"] || headers["x-inertia"])) || env["railwatch.inertia_component"]
235
+
236
+ {
237
+ component: env["railwatch.inertia_component"],
238
+ version: env["HTTP_X_INERTIA_VERSION"],
239
+ partial_component: env["HTTP_X_INERTIA_PARTIAL_COMPONENT"],
240
+ partial_only: env["HTTP_X_INERTIA_PARTIAL_DATA"],
241
+ partial_except: env["HTTP_X_INERTIA_PARTIAL_EXCEPT"],
242
+ props_bytes: env["railwatch.inertia_props_bytes"],
243
+ ssr_ms: env["railwatch.inertia_ssr_ms"]
244
+ }.compact
245
+ end
246
+
247
+ # Recursively pulls ActionDispatch::Http::UploadedFile metadata out of
248
+ # request.params -- never its contents. Handles both a single file
249
+ # field and array-of-files fields (e.g. `attachments[]`).
250
+ def uploaded_files(value, name = nil)
251
+ case value
252
+ when ActionDispatch::Http::UploadedFile
253
+ [ { name: name, size: (value.tempfile.size rescue nil), content_type: value.content_type, error: nil } ]
254
+ when Hash
255
+ value.flat_map { |k, v| uploaded_files(v, k.to_s) }
256
+ when Array
257
+ value.flat_map { |v| uploaded_files(v, name) }
258
+ else
259
+ []
260
+ end
261
+ end
262
+
263
+ # Builds the (already redacted) header hash in one pass over env so the
264
+ # redactor does not have to walk a second, intermediate hash.
265
+ def request_headers(env)
266
+ redactor = Railwatch.redactor
267
+ out = {}
268
+ env.each_pair do |k, v|
269
+ next unless k.start_with?("HTTP_") || k == "CONTENT_TYPE" || k == "CONTENT_LENGTH"
270
+ name = header_name(k)
271
+ out[name] = redactor.redact_header?(name) ? Redactor::FILTERED : v.to_s[0, 512]
272
+ end
273
+ out
274
+ end
275
+
276
+ def header_name(key)
277
+ cached = @header_name_cache[key]
278
+ return cached if cached
279
+
280
+ name = key.delete_prefix("HTTP_").split("_").map(&:capitalize).join("-")
281
+ @header_name_mutex.synchronize do
282
+ @header_name_cache.clear if @header_name_cache.size >= HEADER_NAME_CACHE_LIMIT
283
+ @header_name_cache[key] = name
284
+ end
285
+ name
286
+ end
287
+ end
288
+ end
289
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "railwatch/spec_helper"
4
+
5
+ module Railwatch
6
+ # The Minitest half of railwatch/rspec: the same block assertions, phrased as
7
+ # assert_/refute_. Add to test/test_helper.rb:
8
+ #
9
+ # require "railwatch/minitest"
10
+ # class ActiveSupport::TestCase
11
+ # include Railwatch::Minitest
12
+ # end
13
+ #
14
+ # Includes Railwatch::SpecHelper, so `railwatch_records(:query)` is available
15
+ # too. See docs/testing.md.
16
+ module Minitest
17
+ include SpecHelper
18
+
19
+ # assert_railwatch_queries(at_most: 5) { Order.find(id).total }
20
+ # Also takes exactly: or at_least:.
21
+ def assert_railwatch_queries(exactly: nil, at_most: nil, at_least: nil, &block)
22
+ bounds = { exactly: exactly, at_most: at_most, at_least: at_least }
23
+ queries = railwatch_capture(&block).select { |r| r[:t] == "query" }
24
+ assert SpecHelper.count_satisfied?(queries.size, **bounds),
25
+ "Expected the block to run #{SpecHelper.bound_description(**bounds)} database queries, " \
26
+ "but it ran #{queries.size}:#{SpecHelper.sql_lines(queries)}"
27
+ end
28
+
29
+ def refute_railwatch_n_plus_one(&block)
30
+ n_plus_ones = railwatch_capture(&block).select { |r| r[:t] == "n_plus_one" }
31
+ assert n_plus_ones.empty?,
32
+ "Expected no N+1 queries, but #{n_plus_ones.size} were " \
33
+ "detected:#{SpecHelper.n_plus_one_lines(n_plus_ones)}"
34
+ end
35
+
36
+ def assert_railwatch_span(name, &block)
37
+ spans = railwatch_capture(&block).select { |r| r[:t] == "span" }
38
+ assert spans.any? { |s| s[:name] == name },
39
+ "Expected the block to record a #{name.inspect} span, but it recorded " \
40
+ "#{spans.size}: #{SpecHelper.record_names(spans, :name).inspect}"
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ module Patches
5
+ # Records the Inertia component and SSR time per request. inertia_rails
6
+ # has no instrumentation of its own, so this is a small prepend on the
7
+ # renderer; skipped entirely if the gem is not loaded.
8
+ module Inertia
9
+ module Renderer
10
+ def render
11
+ env = @request&.env
12
+ env["railwatch.inertia_component"] = @component.to_s if env
13
+ super
14
+ end
15
+
16
+ # Private on InertiaRails::Renderer, only called when SSR is enabled
17
+ # and the request isn't itself an Inertia XHR visit -- so this adds
18
+ # no cost to the common (non-SSR) render path.
19
+ def ssr_render
20
+ start = Clock.monotonic
21
+ super
22
+ ensure
23
+ env = @request&.env
24
+ env["railwatch.inertia_ssr_ms"] = ((Clock.monotonic - start) * 1000).round(2) if env
25
+ end
26
+ end
27
+
28
+ def self.install!
29
+ return unless defined?(::InertiaRails::Renderer)
30
+ ::InertiaRails::Renderer.prepend(Renderer) unless ::InertiaRails::Renderer.ancestors.include?(Renderer)
31
+ end
32
+ end
33
+ end
34
+ end