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,334 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ # All settings, each overridable by a RAILWATCH_* env var. Mirrors the shape of
5
+ # Laravel Nightwatch's config so the two products document the same knobs.
6
+ class Configuration
7
+ RECORD_TYPES = %i[queries cache_events mail broadcasts notifications outgoing_requests
8
+ storage_ops view_renders logs transactions deprecations sessions].freeze
9
+
10
+ # Framework/vendor noise excluded by default so a fresh install isn't
11
+ # dominated by Rails' own housekeeping. Both lists are opt-in to disable
12
+ # via capture_default_vendor_commands / capture_default_vendor_cache_keys.
13
+ DEFAULT_VENDOR_COMMANDS = %w[
14
+ db:migrate db:schema:load db:schema:dump db:seed db:prepare
15
+ assets:precompile assets:clobber tmp:cache:clear log:clear
16
+ ].freeze
17
+
18
+ DEFAULT_VENDOR_CACHE_KEYS = [
19
+ /\Arack::attack/, /\Aflipper/, /\Asolid_cable/,
20
+ /\Aactive_storage/, /\Amigration_/, /\Aschema_cache/
21
+ ].freeze
22
+ # The same list as one anchored alternation: one regex run per cache
23
+ # event instead of six.
24
+ DEFAULT_VENDOR_CACHE_KEY = Regexp.union(DEFAULT_VENDOR_CACHE_KEYS).freeze
25
+
26
+ # Scratch roots. A deployed script ships in the image (under Rails.root,
27
+ # or wherever the ops scripts live); a `.rb` file under one of these was
28
+ # written by a human in a shell session, so `rails runner /tmp/probe.rb`
29
+ # counts as interactive. Deliberately narrow -- two literal temp roots,
30
+ # never "outside Rails.root" -- because a cron script going silent is the
31
+ # failure this must not cause.
32
+ DEFAULT_INTERACTIVE_RUNNER_PATHS = %w[/tmp/ /var/tmp/].freeze
33
+
34
+ # Requests that are part of keeping the app observable rather than the
35
+ # application itself. Monitoring these creates noise (/up) or wraps
36
+ # Railwatch's own browser transport in another request execution (the
37
+ # beacon). Apps can replace this list through RAILWATCH_IGNORED_REQUEST_PATHS
38
+ # or append exact paths/regexps in their initializer.
39
+ DEFAULT_IGNORED_REQUEST_PATHS = %w[/up /railwatch/beacon].freeze
40
+
41
+ # Exceptions that are routine 4xx plumbing rather than application bugs.
42
+ # The Rails-relevant subset of Sentry's own defaults
43
+ # (Sentry::Configuration::IGNORE_DEFAULT + PUMA_IGNORE_DEFAULT and
44
+ # Sentry::Rails::Configuration::IGNORE_DEFAULT), so a Sentry app migrating
45
+ # to Railwatch sees the same signal-to-noise out of the box.
46
+ DEFAULT_IGNORED_EXCEPTIONS = %w[
47
+ SignalException
48
+ ActionController::BadRequest
49
+ ActionController::InvalidAuthenticityToken
50
+ ActionController::RoutingError
51
+ ActionController::UnknownFormat
52
+ ActionController::UnknownHttpMethod
53
+ ActionDispatch::Http::MimeNegotiation::InvalidType
54
+ ActionDispatch::Http::Parameters::ParseError
55
+ ActiveRecord::RecordNotFound
56
+ Puma::HttpParserError
57
+ Puma::HttpParserError501
58
+ Rack::QueryParser::InvalidParameterError
59
+ Rack::QueryParser::ParameterTypeError
60
+ ].freeze
61
+
62
+ attr_accessor :enabled, :token, :ingest_url, :allow_http, :server, :environment,
63
+ :sample, :log_level, :capture_request_payload,
64
+ :capture_exception_source, :capture_exception_locals, :redact_headers, :redact_params,
65
+ :buffer_size, :buffer_bytes, :execution_buffer_bytes, :batch_bytes,
66
+ :backpressure,
67
+ :flush_interval, :flush_threshold,
68
+ :connect_timeout, :timeout, :shutdown_timeout,
69
+ :slow_query_threshold_ms, :n_plus_one_threshold,
70
+ :max_view_renders_per_execution, :ignored_cache_key_prefixes,
71
+ :beacon_enabled, :beacon_rate_limit, :debug, :capture_default_vendor_commands,
72
+ :capture_default_vendor_cache_keys, :on_unrecoverable,
73
+ :capture_framework_events,
74
+ :tail_sample_slow_ms, :failure_context, :propagate_traces, :trace_propagation_hosts,
75
+ :health_interval, :capture_query_explain, :explain_threshold_ms,
76
+ :capture_sql_values,
77
+ :ignored_exceptions, :capture_rescued_exceptions,
78
+ :profile_sample, :profile_slow_ms, :profile_interval_us, :profiler,
79
+ :capture_job_arguments, :capture_job_retry_errors, :capture_response_body_on_error, :max_attachment_bytes,
80
+ :track_sessions, :session_flush_interval, :session_timeout,
81
+ :capture_console, :interactive_runner_paths, :ignored_request_paths
82
+
83
+ attr_reader :deploy, :deploy_source, :detect_deploy, :user_resolver, :beacon_user_resolver,
84
+ :fingerprint_resolver, :redactors, :rejectors, :before_ingest, :backpressure_high_water
85
+
86
+ def initialize
87
+ @enabled = env_bool("RAILWATCH_ENABLED", true)
88
+ @token = ENV["RAILWATCH_TOKEN"]
89
+ @ingest_url = ENV.fetch("RAILWATCH_INGEST_URL", "https://railwatch.rebulk.com")
90
+ @allow_http = env_bool("RAILWATCH_ALLOW_HTTP", false)
91
+ @project_root = defined?(Rails) ? Rails.root : Dir.pwd
92
+ @detect_deploy = env_bool("RAILWATCH_DETECT_DEPLOY", true)
93
+ detect_release
94
+ # Kamal names the container after the host plus a container id, so a
95
+ # bare hostname changes on every deploy and never matches the host the
96
+ # post-deploy hook registers as expected. KAMAL_HOST, which Kamal sets
97
+ # in every container it starts, is that host.
98
+ @server = ENV["RAILWATCH_SERVER"] || ENV["KAMAL_HOST"] || Socket.gethostname
99
+ @environment = nil # resolved lazily from Rails.env
100
+ @sample = {
101
+ requests: env_float("RAILWATCH_REQUEST_SAMPLE_RATE", 1.0),
102
+ jobs: env_float("RAILWATCH_JOB_SAMPLE_RATE", 1.0),
103
+ commands: env_float("RAILWATCH_COMMAND_SAMPLE_RATE", 1.0),
104
+ scheduled_tasks: env_float("RAILWATCH_SCHEDULED_TASK_SAMPLE_RATE", 1.0),
105
+ channels: env_float("RAILWATCH_CHANNEL_SAMPLE_RATE", 1.0),
106
+ exceptions: env_float("RAILWATCH_EXCEPTION_SAMPLE_RATE", 1.0)
107
+ }
108
+ self.ignore = RECORD_TYPES.select { |t| env_bool("RAILWATCH_IGNORE_#{t.to_s.upcase}", false) }
109
+ @log_level = (ENV["RAILWATCH_LOG_LEVEL"] || "info").to_sym
110
+ @capture_request_payload = env_bool("RAILWATCH_CAPTURE_REQUEST_PAYLOAD", false)
111
+ @capture_exception_source = env_bool("RAILWATCH_CAPTURE_EXCEPTION_SOURCE_CODE", true)
112
+ @capture_exception_locals = env_bool("RAILWATCH_CAPTURE_EXCEPTION_LOCALS", false)
113
+ @redact_headers = ENV.fetch("RAILWATCH_REDACT_HEADERS", "Authorization,Cookie,Set-Cookie,Proxy-Authorization,X-CSRF-Token,X-XSRF-TOKEN").split(",").map(&:strip)
114
+ @redact_params = ENV.fetch("RAILWATCH_REDACT_PARAMS", "password,password_confirmation,authenticity_token,_token").split(",").map(&:strip)
115
+ # At least Execution::MAX_RECORDS: finish_execution writes a kept
116
+ # execution's whole tree into this queue at once, and a queue smaller
117
+ # than the tree drops the tree's own oldest records -- the outgoing
118
+ # requests and first queries at the top of a long job.
119
+ @buffer_size = env_int("RAILWATCH_BUFFER_SIZE", 10_000)
120
+ # A record count does not bound memory: 10,000 records is a few
121
+ # megabytes of ordinary telemetry, or a gigabyte of captured
122
+ # attachments. These are the byte ceilings that do -- one execution's
123
+ # tree, the reporter queue, and one delivery.
124
+ @buffer_bytes = env_int("RAILWATCH_BUFFER_BYTES", 16 * 1024 * 1024)
125
+ @execution_buffer_bytes = env_int("RAILWATCH_EXECUTION_BUFFER_BYTES", 8 * 1024 * 1024)
126
+ @batch_bytes = env_int("RAILWATCH_BATCH_BYTES", 8 * 1024 * 1024)
127
+ @backpressure = env_bool("RAILWATCH_BACKPRESSURE", true)
128
+ self.backpressure_high_water = env_float("RAILWATCH_BACKPRESSURE_HIGH_WATER", 0.8)
129
+ @flush_interval = env_float("RAILWATCH_FLUSH_INTERVAL", 2.0)
130
+ @flush_threshold = env_int("RAILWATCH_FLUSH_THRESHOLD", 500)
131
+ @connect_timeout = env_float("RAILWATCH_CONNECT_TIMEOUT", 1.0)
132
+ @timeout = env_float("RAILWATCH_TIMEOUT", 3.0)
133
+ @shutdown_timeout = env_float("RAILWATCH_SHUTDOWN_TIMEOUT", 2.0)
134
+ @slow_query_threshold_ms = env_float("RAILWATCH_SLOW_QUERY_MS", 5.0)
135
+ @n_plus_one_threshold = env_int("RAILWATCH_N_PLUS_ONE_THRESHOLD", 5)
136
+ @max_view_renders_per_execution = 20
137
+ @ignored_cache_key_prefixes = []
138
+ @capture_default_vendor_commands = env_bool("RAILWATCH_CAPTURE_DEFAULT_VENDOR_COMMANDS", false)
139
+ @capture_default_vendor_cache_keys = env_bool("RAILWATCH_CAPTURE_DEFAULT_VENDOR_CACHE_KEYS", false)
140
+ @capture_framework_events = env_bool("RAILWATCH_CAPTURE_FRAMEWORK_EVENTS", false)
141
+ @on_unrecoverable = nil
142
+ @beacon_enabled = env_bool("RAILWATCH_BEACON", true)
143
+ # The beacon is unauthenticated and forces Railwatch.keep! for browser
144
+ # errors, so without a ceiling anyone can spend an app's event quota
145
+ # from a shell. Per client IP per minute; 0 turns the limit off, and a
146
+ # negative value is normalized to 0 rather than left to mean anything.
147
+ @beacon_rate_limit = [ env_int("RAILWATCH_BEACON_RATE_LIMIT", 120), 0 ].max
148
+ @debug = env_bool("RAILWATCH_DEBUG", false)
149
+ # Tail-based sampling: a head-sampled-out execution is still kept when
150
+ # it ran at least this long, raised, or Railwatch.keep! was called. nil = off.
151
+ @tail_sample_slow_ms = ENV["RAILWATCH_TAIL_SAMPLE_SLOW_MS"]&.then { |v| Float(v) }
152
+ # Failure context: how many of a head-sampled-out execution's child
153
+ # records to hold in a ring so an unhandled exception can ship what led
154
+ # up to it. 0 = off, which is the default -- a sampled-out execution
155
+ # then builds and buffers nothing, exactly as before.
156
+ @failure_context = env_int("RAILWATCH_FAILURE_CONTEXT", 0)
157
+ @propagate_traces = env_bool("RAILWATCH_PROPAGATE_TRACES", true)
158
+ @trace_propagation_hosts = ENV["RAILWATCH_TRACE_PROPAGATION_HOSTS"]&.split(",")&.map(&:strip)
159
+ @health_interval = env_float("RAILWATCH_HEALTH_INTERVAL", 15.0)
160
+ @capture_query_explain = env_bool("RAILWATCH_CAPTURE_QUERY_EXPLAIN", false)
161
+ @explain_threshold_ms = env_float("RAILWATCH_EXPLAIN_THRESHOLD_MS", 100.0)
162
+ # SQL literals routinely carry email addresses, tokens, and other
163
+ # customer data. Query records therefore carry only the normalized
164
+ # statement shape unless an application deliberately opts in.
165
+ @capture_sql_values = env_bool("RAILWATCH_CAPTURE_SQL_VALUES", false)
166
+ @ignored_exceptions = ENV["RAILWATCH_IGNORED_EXCEPTIONS"]&.split(",")&.map(&:strip) || DEFAULT_IGNORED_EXCEPTIONS.dup
167
+ @capture_rescued_exceptions = env_bool("RAILWATCH_CAPTURE_RESCUED_EXCEPTIONS", true)
168
+ # Sampling profiler: profile this fraction of sampled-in requests/jobs
169
+ # (0 = off), and always profile ones slower than profile_slow_ms once
170
+ # tail sampling keeps them. Uses vernier when available, else stackprof.
171
+ @profile_sample = env_float("RAILWATCH_PROFILE_SAMPLE_RATE", 0.0)
172
+ @profile_slow_ms = ENV["RAILWATCH_PROFILE_SLOW_MS"]&.then { |v| Float(v) }
173
+ @profile_interval_us = env_int("RAILWATCH_PROFILE_INTERVAL_US", 1_000)
174
+ @profiler = ENV["RAILWATCH_PROFILER"]&.to_sym
175
+ @capture_job_arguments = env_bool("RAILWATCH_CAPTURE_JOB_ARGUMENTS", false)
176
+ @capture_job_retry_errors = env_bool("RAILWATCH_CAPTURE_JOB_RETRY_ERRORS", false)
177
+ @capture_response_body_on_error = env_bool("RAILWATCH_CAPTURE_RESPONSE_BODY_ON_ERROR", false)
178
+ @max_attachment_bytes = env_int("RAILWATCH_MAX_ATTACHMENT_BYTES", 1_048_576)
179
+ # Release health: one `session` record per browser tab (the beacon
180
+ # client) and per authenticated/cookied server session (Railwatch::Sessions).
181
+ @track_sessions = env_bool("RAILWATCH_TRACK_SESSIONS", true)
182
+ @session_flush_interval = env_float("RAILWATCH_SESSION_FLUSH_INTERVAL", 60.0)
183
+ @session_timeout = env_float("RAILWATCH_SESSION_TIMEOUT", 1800.0)
184
+ # Interactive sessions: a `bin/rails console` process captures nothing
185
+ # at all, and a typed/piped `bin/rails runner` ships its command record
186
+ # but not its exception. A deployed script always reports.
187
+ @capture_console = env_bool("RAILWATCH_CAPTURE_CONSOLE", false)
188
+ @interactive_runner_paths = ENV["RAILWATCH_INTERACTIVE_RUNNER_PATHS"]&.split(",")&.map(&:strip) ||
189
+ DEFAULT_INTERACTIVE_RUNNER_PATHS.dup
190
+ @ignored_request_paths = ENV["RAILWATCH_IGNORED_REQUEST_PATHS"]&.split(",")&.map(&:strip)&.reject(&:empty?) ||
191
+ DEFAULT_IGNORED_REQUEST_PATHS.dup
192
+ @user_resolver = nil
193
+ @beacon_user_resolver = nil
194
+ @fingerprint_resolver = nil
195
+ @redactors = Hash.new { |h, k| h[k] = [] }
196
+ @rejectors = Hash.new { |h, k| h[k] = [] }
197
+ @before_ingest = []
198
+ end
199
+
200
+ def deploy=(value)
201
+ @deploy = value
202
+ @deploy_source = "config/initializers/railwatch.rb"
203
+ @deploy_overridden = true
204
+ end
205
+
206
+ def detect_deploy=(value)
207
+ @detect_deploy = value
208
+ detect_release unless @deploy_overridden
209
+ end
210
+
211
+ def user(&block)
212
+ @user_resolver = block
213
+ end
214
+
215
+ # Railwatch.beacon_user { |request| ... }: who is behind a browser beacon.
216
+ # The beacon is handled by the gem's own engine controller, outside the
217
+ # app's ApplicationController, so an app that authenticates in a
218
+ # before_action (a signed session cookie looked up per request, say)
219
+ # has not run it by the time the beacon is read. Return the user object
220
+ # the `user` block understands, or nil. Not needed when the app sets
221
+ # Current.user in middleware or uses Warden, which the default resolution
222
+ # already reads.
223
+ def beacon_user(&block)
224
+ @beacon_user_resolver = block
225
+ end
226
+
227
+ # Railwatch.fingerprint { |error, default| ... }: one block, called with
228
+ # the error and the parts Railwatch would have hashed. Passing no block
229
+ # clears it.
230
+ def fingerprint(&block)
231
+ @fingerprint_resolver = block
232
+ end
233
+
234
+ def enabled?
235
+ @enabled && token.present?
236
+ end
237
+
238
+ def ingest_url_allowed?
239
+ uri = URI.parse(ingest_url.to_s)
240
+ return true if uri.scheme == "https"
241
+ return false unless uri.scheme == "http"
242
+
243
+ allow_http || %w[localhost 127.0.0.1 ::1 [::1]].include?(uri.host)
244
+ rescue URI::InvalidURIError
245
+ false
246
+ end
247
+
248
+ attr_reader :ignore
249
+
250
+ # Stored alongside a frozen Set so the per-record ignored? check is a
251
+ # single Set lookup instead of an Array#include? scan on every record.
252
+ def ignore=(value)
253
+ unknown = Array(value) - RECORD_TYPES
254
+ raise ArgumentError, "unknown record type(s): #{unknown.join(', ')}" if unknown.any?
255
+
256
+ @ignore = value
257
+ @ignored_set = Set.new(value).freeze
258
+ end
259
+
260
+ def ignored?(type)
261
+ @ignored_set.include?(type)
262
+ end
263
+
264
+ def sample_rate(kind)
265
+ @sample.fetch(kind, 1.0).to_f.clamp(0.0, 1.0)
266
+ end
267
+
268
+ def backpressure_high_water=(value)
269
+ fraction = Float(value, exception: false)
270
+ @backpressure_high_water = if fraction&.finite? && fraction.positive? && fraction <= 1.0
271
+ fraction
272
+ else
273
+ 0.8
274
+ end
275
+ end
276
+
277
+ def environment_name
278
+ @environment || (defined?(Rails) ? Rails.env.to_s : "production")
279
+ end
280
+
281
+ # Matches Railwatch.reject_cache_keys entries and DEFAULT_VENDOR_CACHE_KEYS
282
+ # against a cache key. A Regexp is used as-is. A String starting with "^"
283
+ # or containing another regex metacharacter is compiled as a Regexp; a
284
+ # String ending in "*" matches as a prefix; any other String must match
285
+ # exactly (so "session:" no longer accidentally matches "usersession:").
286
+ CACHE_KEY_METACHARS = /[.?+()|{}\[\]]/
287
+ def self.match_cache_key?(pattern, key)
288
+ case pattern
289
+ when Regexp
290
+ pattern.match?(key)
291
+ when String
292
+ if pattern.start_with?("^") || CACHE_KEY_METACHARS.match?(pattern)
293
+ Regexp.new(pattern).match?(key)
294
+ elsif pattern.end_with?("*")
295
+ key.start_with?(pattern[0..-2])
296
+ else
297
+ key == pattern
298
+ end
299
+ else
300
+ false
301
+ end
302
+ end
303
+
304
+ private
305
+
306
+ def detect_release
307
+ @deploy_source = nil
308
+ if @detect_deploy
309
+ @deploy = ReleaseDetector.detect(project_root: @project_root) { |source| @deploy_source = source }
310
+ return
311
+ end
312
+
313
+ @deploy_source = %w[RAILWATCH_DEPLOY KAMAL_VERSION].find { |key| ENV[key].to_s.strip != "" }
314
+ value = @deploy_source ? ENV[@deploy_source].to_s.strip : ""
315
+ @deploy = ReleaseDetector::SHA.match?(value) ? value[0, 12] : value
316
+ @deploy = nil if @deploy.empty?
317
+ end
318
+
319
+ def env_bool(key, default)
320
+ return default unless ENV.key?(key)
321
+ %w[1 true yes on].include?(ENV[key].to_s.downcase)
322
+ end
323
+
324
+ # String#to_f/#to_i turn a typo into 0.0/0 -- a zero buffer, timeout, or
325
+ # interval -- so parse strictly and keep the documented default instead.
326
+ def env_float(key, default)
327
+ ENV.key?(key) ? Float(ENV[key], exception: false) || default : default
328
+ end
329
+
330
+ def env_int(key, default)
331
+ ENV.key?(key) ? Integer(ENV[key], 10, exception: false) || default : default
332
+ end
333
+ end
334
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ # `bin/rails console` is a person at a prompt, not a server. sentry-rails
5
+ # never hooked the console at all, and that was right: an engineer poking at
6
+ # production types typos, and a typo is not an issue. Railwatch subscribes to
7
+ # far more than Sentry did (every query, every log line, `Rails.error`), and
8
+ # it starts reporter/health/session threads at boot -- none of which belong
9
+ # behind an IRB prompt someone leaves open for an hour.
10
+ #
11
+ # So a console process goes quiet: nothing is captured, no thread is
12
+ # started, and no `process`/`health` record is sent. Opt back in with
13
+ # `config.capture_console = true` (or RAILWATCH_CAPTURE_CONSOLE=1) for the
14
+ # rare "trace what I'm about to do in here" session.
15
+ #
16
+ # A `rails runner` script is NOT this: it is a deployed execution and keeps
17
+ # reporting -- see Railwatch::Patches::RunnerCommand for where that line is.
18
+ module Console
19
+ module_function
20
+
21
+ # railties defines Rails::Console when it loads the console command, which
22
+ # happens before the application boots, so this is already true by the
23
+ # time the engine's initializers run.
24
+ def detected?
25
+ !!defined?(::Rails::Console)
26
+ end
27
+
28
+ def quiet?
29
+ detected? && !Railwatch.config.capture_console
30
+ end
31
+
32
+ # Idempotent, and the whole of quiet mode: every record path, the
33
+ # subscriber install, the after_initialize `process` record, and the
34
+ # reporter/health/sessions threads are already gated on Railwatch.enabled?.
35
+ # Returns whether this call is what silenced the process.
36
+ def silence!
37
+ return false unless quiet? && Railwatch.config.enabled
38
+
39
+ Railwatch.debug { "console detected -- capturing nothing in this process (set config.capture_console = true, or RAILWATCH_CAPTURE_CONSOLE=1, to capture a console session)" }
40
+ Railwatch.config.enabled = false
41
+ # No-ops unless a console got here after boot (the railtie's `console`
42
+ # block) with the threads already running.
43
+ Health.stop!
44
+ Sessions.stop!
45
+ true
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ # Bridges Rails' three context stores. Anything an app already sets on
5
+ # ActiveSupport::ExecutionContext, Rails.error, or Rails.event shows up on
6
+ # Railwatch records; Railwatch.context writes to all three.
7
+ module Context
8
+ LIMIT = 65_536
9
+ EMPTY_JSON = "{}".freeze
10
+ # Present, and true, on a context that did not fit in LIMIT bytes.
11
+ TRUNCATION_KEY = "_railwatch_truncated"
12
+ TRUNCATION_MARKER = "[TRUNCATED]"
13
+
14
+ module_function
15
+
16
+ def set(**attrs)
17
+ ActiveSupport::ExecutionContext.set(**attrs)
18
+ Rails.error.set_context(**attrs) if defined?(Rails) && Rails.respond_to?(:error)
19
+ Rails.event.set_context(**attrs) if defined?(Rails) && Rails.respond_to?(:event)
20
+ # An explicit tenant is bound to the running execution here rather than
21
+ # read back out of ActiveSupport::ExecutionContext on demand: reading it
22
+ # there means ExecutionContext.to_h, which dups the whole store, and
23
+ # current_tenant is called once per record from Execution#envelope.
24
+ # Binding it also requalifies the records already buffered for this
25
+ # execution (Execution#tenant=).
26
+ tenant = attrs[:tenant]
27
+ Current.execution&.tenant = tenant.to_s if tenant
28
+ attrs
29
+ end
30
+
31
+ def current
32
+ ctx = {}
33
+ ctx.merge!(ActiveSupport::ExecutionContext.to_h.except(:controller, :job))
34
+ ctx.merge!(Rails.event.context) if defined?(Rails) && Rails.respond_to?(:event) && Rails.event.respond_to?(:context)
35
+ ctx
36
+ rescue StandardError
37
+ {}
38
+ end
39
+
40
+ def serialized
41
+ ctx = current
42
+ return EMPTY_JSON if ctx.empty?
43
+
44
+ serialize(ctx)
45
+ rescue StandardError
46
+ EMPTY_JSON
47
+ end
48
+
49
+ # Context is application data -- an app that puts an API token or a
50
+ # password in it should get the same treatment request params get, rather
51
+ # than having it written verbatim onto every record built while it is set.
52
+ def serialize(context)
53
+ filtered = Railwatch.redactor.params(context)
54
+ json = JSON.generate(filtered)
55
+ json.bytesize > LIMIT ? truncate(filtered) : json
56
+ end
57
+
58
+ # An oversized context used to be byteslice'd, which cut the JSON
59
+ # mid-string or mid-object: the platform could not parse it, so the whole
60
+ # context was lost rather than most of it. Rebuild a smaller context
61
+ # instead. Whole values are kept while they fit, an oversized String is
62
+ # cut and marked, anything that still does not fit is dropped, and
63
+ # `_railwatch_truncated` says it happened. The result is always valid JSON.
64
+ def truncate(filtered)
65
+ out = { TRUNCATION_KEY => true }
66
+ budget = LIMIT - JSON.generate(out).bytesize
67
+ filtered.each do |key, value|
68
+ next if key.to_s == TRUNCATION_KEY
69
+
70
+ cost = pair_bytes(key, value)
71
+ if cost > budget && value.is_a?(String)
72
+ value = truncated_string(value, budget - (cost - JSON.generate(value).bytesize)) or next
73
+ cost = pair_bytes(key, value)
74
+ end
75
+ next if cost > budget
76
+
77
+ budget -= cost
78
+ out[key] = value
79
+ end
80
+ JSON.generate(out)
81
+ end
82
+
83
+ # What this pair costs inside a larger object: the encoded `{"k":v}` less
84
+ # its two braces, plus the comma that separates it from the pair before.
85
+ def pair_bytes(key, value)
86
+ JSON.generate(key.to_s => value).bytesize - 1
87
+ end
88
+
89
+ # `room` is a floor rather than a fit: JSON escaping can expand one
90
+ # character into six bytes, so the caller re-measures the pair and drops
91
+ # it if the escaped result is still too large.
92
+ def truncated_string(value, room)
93
+ keep = room - TRUNCATION_MARKER.bytesize - 2
94
+ return nil if keep <= 0
95
+
96
+ "#{value.byteslice(0, keep).to_s.scrub("")}#{TRUNCATION_MARKER}"
97
+ end
98
+
99
+ def current_tenant
100
+ exe = Current.execution
101
+ return exe.tenant if exe&.tenant
102
+
103
+ if defined?(::TenantRecord) && ::TenantRecord.respond_to?(:current_tenant)
104
+ ::TenantRecord.current_tenant&.to_s
105
+ elsif defined?(::ActiveRecord::Tenanted) && ::ActiveRecord::Base.respond_to?(:current_tenant)
106
+ ::ActiveRecord::Base.current_tenant&.to_s
107
+ end
108
+ rescue StandardError
109
+ nil
110
+ end
111
+ end
112
+ end
113
+
114
+ module Railwatch
115
+ module Context
116
+ def self.serialized_with(extra)
117
+ ctx = current.merge(extra || {})
118
+ return EMPTY_JSON if ctx.empty?
119
+
120
+ serialize(ctx)
121
+ rescue StandardError
122
+ EMPTY_JSON
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ # Adds `railwatch_sample` to controllers (route-level sampling, like
5
+ # Nightwatch's Sample middleware) and captures the Inertia component name
6
+ # when the app renders through inertia_rails.
7
+ module ControllerHelpers
8
+ extend ActiveSupport::Concern
9
+
10
+ class_methods do
11
+ # railwatch_sample 0.1, only: :index
12
+ def railwatch_sample(rate, **options)
13
+ before_action(**options) { Railwatch.sample(rate) }
14
+ end
15
+
16
+ def railwatch_never_sample(**options)
17
+ before_action(**options) { Railwatch.dont_sample }
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ # Thread- or fiber-local pointer to the running Execution. Uses the same
5
+ # isolation level as Rails (config.active_support.isolation_level) so Solid
6
+ # Queue fiber workers and Puma threads both work.
7
+ module Current
8
+ KEY = :railwatch_execution
9
+
10
+ module_function
11
+
12
+ def execution
13
+ ActiveSupport::IsolatedExecutionState[KEY]
14
+ end
15
+
16
+ def execution=(value)
17
+ ActiveSupport::IsolatedExecutionState[KEY] = value
18
+ end
19
+
20
+ def with(execution)
21
+ previous = self.execution
22
+ self.execution = execution
23
+ yield execution
24
+ ensure
25
+ self.execution = previous
26
+ end
27
+
28
+ def clear
29
+ ActiveSupport::IsolatedExecutionState.delete(KEY)
30
+ end
31
+ end
32
+ end