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.
- checksums.yaml +7 -0
- data/AGENTS.md +122 -0
- data/CHANGELOG.md +462 -0
- data/MIT-LICENSE +20 -0
- data/README.md +226 -0
- data/app/controllers/railwatch/beacon_controller.rb +254 -0
- data/config/routes.rb +5 -0
- data/docs/ai-and-mcp.md +227 -0
- data/docs/configuration.md +931 -0
- data/docs/faq.md +230 -0
- data/docs/getting-started.md +279 -0
- data/docs/records.md +834 -0
- data/docs/replacing-nightwatch.md +216 -0
- data/docs/replacing-sentry.md +573 -0
- data/docs/security.md +94 -0
- data/docs/self-hosting.md +60 -0
- data/docs/source-maps.md +60 -0
- data/docs/testing.md +175 -0
- data/docs/troubleshooting.md +319 -0
- data/lib/generators/railwatch/install/install_generator.rb +280 -0
- data/lib/generators/railwatch/install/templates/initializer.rb +54 -0
- data/lib/generators/railwatch/install/templates/post-deploy +98 -0
- data/lib/generators/railwatch/install/templates/railwatch.ts +658 -0
- data/lib/railwatch/attachments.rb +83 -0
- data/lib/railwatch/backtrace.rb +158 -0
- data/lib/railwatch/buffer.rb +122 -0
- data/lib/railwatch/clock.rb +25 -0
- data/lib/railwatch/configuration.rb +334 -0
- data/lib/railwatch/console.rb +48 -0
- data/lib/railwatch/context.rb +125 -0
- data/lib/railwatch/controller_helpers.rb +21 -0
- data/lib/railwatch/current.rb +32 -0
- data/lib/railwatch/engine.rb +144 -0
- data/lib/railwatch/execution.rb +367 -0
- data/lib/railwatch/faraday.rb +73 -0
- data/lib/railwatch/health.rb +188 -0
- data/lib/railwatch/job_tracing.rb +49 -0
- data/lib/railwatch/middleware/request.rb +289 -0
- data/lib/railwatch/minitest.rb +43 -0
- data/lib/railwatch/patches/inertia.rb +34 -0
- data/lib/railwatch/patches/net_http.rb +102 -0
- data/lib/railwatch/patches/rake_task.rb +88 -0
- data/lib/railwatch/patches/runner_command.rb +120 -0
- data/lib/railwatch/patches.rb +43 -0
- data/lib/railwatch/profiler.rb +270 -0
- data/lib/railwatch/record.rb +119 -0
- data/lib/railwatch/redactor.rb +67 -0
- data/lib/railwatch/release_detector.rb +97 -0
- data/lib/railwatch/reporter.rb +539 -0
- data/lib/railwatch/rspec.rb +139 -0
- data/lib/railwatch/sampler.rb +17 -0
- data/lib/railwatch/secret_safety.rb +62 -0
- data/lib/railwatch/sessions.rb +162 -0
- data/lib/railwatch/source_maps.rb +59 -0
- data/lib/railwatch/spec_helper.rb +147 -0
- data/lib/railwatch/sql_normalizer.rb +398 -0
- data/lib/railwatch/subscribers/base.rb +54 -0
- data/lib/railwatch/subscribers/broadcasts.rb +107 -0
- data/lib/railwatch/subscribers/cache.rb +107 -0
- data/lib/railwatch/subscribers/deprecations.rb +26 -0
- data/lib/railwatch/subscribers/exceptions.rb +304 -0
- data/lib/railwatch/subscribers/jobs.rb +282 -0
- data/lib/railwatch/subscribers/logs.rb +137 -0
- data/lib/railwatch/subscribers/mail.rb +42 -0
- data/lib/railwatch/subscribers/notifications.rb +36 -0
- data/lib/railwatch/subscribers/process_info.rb +98 -0
- data/lib/railwatch/subscribers/queries.rb +183 -0
- data/lib/railwatch/subscribers/requests.rb +94 -0
- data/lib/railwatch/subscribers/storage.rb +35 -0
- data/lib/railwatch/subscribers/users.rb +159 -0
- data/lib/railwatch/subscribers/views.rb +54 -0
- data/lib/railwatch/subscribers.rb +34 -0
- data/lib/railwatch/transport/http.rb +208 -0
- data/lib/railwatch/version.rb +5 -0
- data/lib/railwatch.rb +550 -0
- data/lib/tasks/railwatch_tasks.rake +289 -0
- data/llms.txt +38 -0
- 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
|