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,398 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
# Turns a SQL string into its shape so identical queries with different
|
|
5
|
+
# literals group together. Adapter-aware where syntax differs (SQLite,
|
|
6
|
+
# Postgres, MySQL/Trilogy).
|
|
7
|
+
module SqlNormalizer
|
|
8
|
+
IN_LIST = /\bIN\s*\(\s*(?:\?|\$\d+|\d+)(?:\s*,\s*(?:\?|\$\d+|\d+))*\s*\)/i
|
|
9
|
+
WHITESPACE = /\s+/
|
|
10
|
+
LITERAL_PREFIX_BYTES = [ 66, 69, 78, 88, 98, 101, 110, 120 ].freeze # B, E, N, X (both cases)
|
|
11
|
+
BASE_PREFIX_BYTES = [ 66, 79, 88, 98, 111, 120 ].freeze # B, O, X (both cases)
|
|
12
|
+
|
|
13
|
+
# Query payloads are eventually capped at 16,384 characters. Scan enough
|
|
14
|
+
# bytes to preserve substantially more diagnostic shape than can be sent,
|
|
15
|
+
# but never let a hostile or accidentally enormous SQL comment monopolize
|
|
16
|
+
# the notification thread. The scanner below is byte-oriented and linear.
|
|
17
|
+
MAX_NORMALIZE_BYTES = 262_144
|
|
18
|
+
MAX_CACHE_KEY_BYTES = 65_536
|
|
19
|
+
TRUNCATED = " [SQL TRUNCATED]"
|
|
20
|
+
|
|
21
|
+
CACHE_LIMIT = 2_048
|
|
22
|
+
# Three-level cache (adapter => connection_name => SQL => value) avoids a
|
|
23
|
+
# composite-key allocation on every query. Adapter is part of the lookup:
|
|
24
|
+
# identical SQL can mean a quoted identifier on Postgres and a string
|
|
25
|
+
# literal on MySQL, so sharing a cached normalization would leak values.
|
|
26
|
+
@cache = Hash.new { |adapters, adapter| adapters[adapter] = Hash.new { |connections, name| connections[name] = {} } }
|
|
27
|
+
@cache_mutex = Mutex.new
|
|
28
|
+
|
|
29
|
+
module_function
|
|
30
|
+
|
|
31
|
+
# Same SQL text always maps to the same group, so the regex passes and the
|
|
32
|
+
# digest run once per distinct statement per process.
|
|
33
|
+
def group(sql, adapter: nil, connection_name: nil)
|
|
34
|
+
group_and_normalized(sql, adapter: adapter, connection_name: connection_name)[0]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Returns [group_hash, normalized_sql] from a single cache lookup, so
|
|
38
|
+
# callers that need both (the query record and its possible n+1 sibling)
|
|
39
|
+
# never normalize or digest the same SQL twice.
|
|
40
|
+
def group_and_normalized(sql, adapter: nil, connection_name: nil)
|
|
41
|
+
unless sql.is_a?(String) && sql.bytesize <= MAX_CACHE_KEY_BYTES
|
|
42
|
+
normalized = normalize(sql, adapter: adapter).freeze
|
|
43
|
+
return [ Record.group_hash(connection_name, normalized).freeze, normalized ].freeze
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
bucket = @cache[adapter.to_s][connection_name]
|
|
47
|
+
cached = bucket[sql]
|
|
48
|
+
return cached if cached
|
|
49
|
+
|
|
50
|
+
normalized = normalize(sql, adapter: adapter).freeze
|
|
51
|
+
value = [ Record.group_hash(connection_name, normalized).freeze, normalized ].freeze
|
|
52
|
+
@cache_mutex.synchronize do
|
|
53
|
+
bucket.clear if bucket.size >= CACHE_LIMIT
|
|
54
|
+
bucket[sql] = value
|
|
55
|
+
end
|
|
56
|
+
value
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def normalize(sql, adapter: nil)
|
|
60
|
+
input, truncated = safe_utf8_prefix(sql, MAX_NORMALIZE_BYTES)
|
|
61
|
+
s = mask_literals_and_comments(input, adapter: adapter)
|
|
62
|
+
s = s.gsub(IN_LIST, "IN (?)")
|
|
63
|
+
s = s.gsub(WHITESPACE, " ").strip
|
|
64
|
+
truncated ? "#{s}#{TRUNCATED}" : s
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Returns a valid UTF-8 prefix without transcoding an unbounded input. A
|
|
68
|
+
# byteslice may end inside a multibyte codepoint; scrub/encode replaces that
|
|
69
|
+
# incomplete tail rather than letting observability raise while unwinding a
|
|
70
|
+
# database error.
|
|
71
|
+
def safe_utf8_prefix(value, max_bytes)
|
|
72
|
+
string = value.to_s
|
|
73
|
+
truncated = string.bytesize > max_bytes
|
|
74
|
+
prefix = truncated ? string.byteslice(0, max_bytes) : string
|
|
75
|
+
utf8 = if prefix.encoding == Encoding::UTF_8
|
|
76
|
+
prefix.scrub
|
|
77
|
+
else
|
|
78
|
+
prefix.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "�")
|
|
79
|
+
end
|
|
80
|
+
[ utf8, truncated ]
|
|
81
|
+
rescue EncodingError
|
|
82
|
+
[ prefix.to_s.b.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "�"), truncated ]
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Safe text for the explicit raw-value mode. It is still bounded and valid
|
|
86
|
+
# UTF-8 so malformed driver strings cannot break JSON generation.
|
|
87
|
+
def raw_for_record(sql, max_characters:)
|
|
88
|
+
text, = safe_utf8_prefix(sql, max_characters * 4)
|
|
89
|
+
text[0, max_characters]
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# A byte-oriented lexical pass keeps the cold/dynamic query path linear for
|
|
93
|
+
# UTF-8 and malformed encodings. It masks values and comments while keeping
|
|
94
|
+
# useful SQL shape. Unterminated value tokens fail closed.
|
|
95
|
+
def mask_literals_and_comments(sql, adapter: nil)
|
|
96
|
+
adapter_name = adapter.to_s.downcase
|
|
97
|
+
mysql = adapter_name.include?("mysql") || adapter_name.include?("trilogy")
|
|
98
|
+
# PostgreSQL-compatible adapters do not all expose "postgres" in
|
|
99
|
+
# adapter_name. In particular activerecord-postgis-adapter reports
|
|
100
|
+
# "PostGIS", and CockroachDB adapters commonly report "CockroachDB".
|
|
101
|
+
postgres = adapter_name.include?("postgres") || adapter_name.include?("postgis") ||
|
|
102
|
+
adapter_name.include?("cockroach")
|
|
103
|
+
sqlite = adapter_name.include?("sqlite")
|
|
104
|
+
unknown_adapter = !mysql && !postgres && !sqlite
|
|
105
|
+
mysql_ambiguous = mysql || unknown_adapter
|
|
106
|
+
bytes = sql.b
|
|
107
|
+
length = bytes.bytesize
|
|
108
|
+
out = +"".b
|
|
109
|
+
i = 0
|
|
110
|
+
|
|
111
|
+
while i < length
|
|
112
|
+
byte = bytes.getbyte(i)
|
|
113
|
+
following = bytes.getbyte(i + 1)
|
|
114
|
+
if (byte == 45 && following == 45) || (mysql_ambiguous && byte == 35) # -- or MySQL #
|
|
115
|
+
i += (byte == 35 ? 1 : 2)
|
|
116
|
+
i += 1 while i < length && bytes.getbyte(i) != 10 && bytes.getbyte(i) != 13
|
|
117
|
+
out << " "
|
|
118
|
+
elsif byte == 47 && following == 42 # /*
|
|
119
|
+
depth = 1
|
|
120
|
+
i += 2
|
|
121
|
+
while i < length && depth.positive?
|
|
122
|
+
if bytes.getbyte(i) == 47 && bytes.getbyte(i + 1) == 42
|
|
123
|
+
depth += 1
|
|
124
|
+
i += 2
|
|
125
|
+
elsif bytes.getbyte(i) == 42 && bytes.getbyte(i + 1) == 47
|
|
126
|
+
depth -= 1
|
|
127
|
+
i += 2
|
|
128
|
+
else
|
|
129
|
+
i += 1
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
out << " "
|
|
133
|
+
elsif byte == 39 # '
|
|
134
|
+
# E'', N'', B'', and X'' are value introducers rather than part of
|
|
135
|
+
# the query shape. Other introducers (for example _utf8mb4'') stay
|
|
136
|
+
# visible, but their contents are still hidden.
|
|
137
|
+
prefix = literal_prefix_byte(bytes, i)
|
|
138
|
+
out.chop! if prefix
|
|
139
|
+
explicit_escape_string = prefix == 69 || prefix == 101
|
|
140
|
+
# Whether a backslash escapes the quote after it is a per-dialect
|
|
141
|
+
# default the notification does not carry: MySQL escapes (unless the
|
|
142
|
+
# session sets NO_BACKSLASH_ESCAPES), PostgreSQL does not (unless it
|
|
143
|
+
# sets standard_conforming_strings off, deprecated since 9.1), E''
|
|
144
|
+
# always does, SQLite never does. Scan with each dialect's default,
|
|
145
|
+
# and treat an unknown adapter as MySQL-like -- getting that wrong
|
|
146
|
+
# only masks more than necessary, while the other way round exposes
|
|
147
|
+
# the tail of the value currently being masked.
|
|
148
|
+
#
|
|
149
|
+
# This deliberately no longer abandons the rest of the statement on
|
|
150
|
+
# seeing \'. That was safe but destroyed the shape, and on MySQL --
|
|
151
|
+
# where backslash escaping IS the quoting Active Record emits --
|
|
152
|
+
# every string containing an apostrophe truncated the statement, so
|
|
153
|
+
# `... WHERE name = 'O\'Brien' AND id = 1` and `... AND state = 'x'`
|
|
154
|
+
# hashed into one query group. Values are masked either way; only
|
|
155
|
+
# the tail's shape was being thrown away.
|
|
156
|
+
backslash_escapes = mysql || unknown_adapter || explicit_escape_string
|
|
157
|
+
i += 1
|
|
158
|
+
while i < length
|
|
159
|
+
if backslash_escapes && bytes.getbyte(i) == 92
|
|
160
|
+
i += 2
|
|
161
|
+
elsif bytes.getbyte(i) == 39
|
|
162
|
+
if bytes.getbyte(i + 1) == 39
|
|
163
|
+
i += 2
|
|
164
|
+
else
|
|
165
|
+
i += 1
|
|
166
|
+
break
|
|
167
|
+
end
|
|
168
|
+
else
|
|
169
|
+
i += 1
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
out << "?"
|
|
173
|
+
# When adapter metadata is unavailable, privacy wins over preserving
|
|
174
|
+
# ANSI quoted-identifier shape: this may be a MySQL-compatible string.
|
|
175
|
+
elsif mysql_ambiguous && byte == 34 # MySQL/Trilogy default: "..." is a string.
|
|
176
|
+
i = skip_quoted_value(bytes, i, 34, backslash_escapes: true)
|
|
177
|
+
out << "?"
|
|
178
|
+
elsif sqlite && byte == 34 # SQLite DQS is ambiguous; mask unless syntax proves identifier use.
|
|
179
|
+
close_at = quoted_identifier_end(bytes, i, 34)
|
|
180
|
+
if close_at && sqlite_identifier_position?(bytes, i, close_at)
|
|
181
|
+
out << bytes.byteslice(i, close_at - i)
|
|
182
|
+
else
|
|
183
|
+
out << "?"
|
|
184
|
+
end
|
|
185
|
+
i = close_at || length
|
|
186
|
+
elsif byte == 34 || byte == 96 || (sqlite && byte == 91) # ANSI, backtick, or SQLite [identifier]
|
|
187
|
+
closer = byte == 91 ? 93 : byte
|
|
188
|
+
close_at = quoted_identifier_end(bytes, i, closer)
|
|
189
|
+
if close_at
|
|
190
|
+
out << bytes.byteslice(i, close_at - i)
|
|
191
|
+
i = close_at
|
|
192
|
+
else
|
|
193
|
+
out << "?"
|
|
194
|
+
i = length
|
|
195
|
+
end
|
|
196
|
+
elsif byte == 36 && (delimiter_end = dollar_delimiter_end(bytes, i))
|
|
197
|
+
delimiter = bytes.byteslice(i, delimiter_end - i)
|
|
198
|
+
close_at = bytes.index(delimiter, delimiter_end)
|
|
199
|
+
i = close_at ? close_at + delimiter.bytesize : length
|
|
200
|
+
out << "?"
|
|
201
|
+
elsif byte == 36 && digit?(following) # PostgreSQL/SQLite $1 bind
|
|
202
|
+
i += 2
|
|
203
|
+
i += 1 while digit?(bytes.getbyte(i))
|
|
204
|
+
out << "?"
|
|
205
|
+
elsif byte == 63 && digit?(following) # SQLite ?NNN bind
|
|
206
|
+
i += 2
|
|
207
|
+
i += 1 while digit?(bytes.getbyte(i))
|
|
208
|
+
out << "?"
|
|
209
|
+
elsif numeric_boundary?(bytes, i) && (number_end = numeric_end(bytes, i))
|
|
210
|
+
out << "?"
|
|
211
|
+
i = number_end
|
|
212
|
+
else
|
|
213
|
+
out << byte
|
|
214
|
+
i += 1
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
out.force_encoding(Encoding::UTF_8)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def skip_quoted_value(bytes, index, quote, backslash_escapes:)
|
|
222
|
+
length = bytes.bytesize
|
|
223
|
+
i = index + 1
|
|
224
|
+
while i < length
|
|
225
|
+
if backslash_escapes && bytes.getbyte(i) == 92
|
|
226
|
+
i += 2
|
|
227
|
+
elsif bytes.getbyte(i) == quote
|
|
228
|
+
if bytes.getbyte(i + 1) == quote
|
|
229
|
+
i += 2
|
|
230
|
+
else
|
|
231
|
+
return i + 1
|
|
232
|
+
end
|
|
233
|
+
else
|
|
234
|
+
i += 1
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
length
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def quoted_identifier_end(bytes, index, closer)
|
|
241
|
+
length = bytes.bytesize
|
|
242
|
+
i = index + 1
|
|
243
|
+
while i < length
|
|
244
|
+
if bytes.getbyte(i) == closer
|
|
245
|
+
if bytes.getbyte(i + 1) == closer
|
|
246
|
+
i += 2
|
|
247
|
+
else
|
|
248
|
+
return i + 1
|
|
249
|
+
end
|
|
250
|
+
else
|
|
251
|
+
i += 1
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
nil
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
SQLITE_IDENTIFIER_PRECEDERS = %w[as alter create delete drop from index into join table trigger update vacuum].freeze
|
|
258
|
+
|
|
259
|
+
# SQLite retains a legacy double-quoted-string fallback when a token does
|
|
260
|
+
# not resolve as an identifier. Qualification and object-name grammar are
|
|
261
|
+
# unambiguous; a bare expression token is not, so privacy wins and it is
|
|
262
|
+
# masked. Rails-generated `"table"."column"` and `FROM "table"` keep their
|
|
263
|
+
# diagnostic value.
|
|
264
|
+
def sqlite_identifier_position?(bytes, opening, after_closing)
|
|
265
|
+
before = previous_nonspace(bytes, opening - 1)
|
|
266
|
+
after = next_nonspace(bytes, after_closing)
|
|
267
|
+
return true if before && bytes.getbyte(before) == 46
|
|
268
|
+
return true if after && bytes.getbyte(after) == 46
|
|
269
|
+
|
|
270
|
+
word = previous_word(bytes, opening - 1)
|
|
271
|
+
SQLITE_IDENTIFIER_PRECEDERS.include?(word)
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def previous_nonspace(bytes, index)
|
|
275
|
+
index -= 1 while index >= 0 && whitespace_byte?(bytes.getbyte(index))
|
|
276
|
+
index >= 0 ? index : nil
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def next_nonspace(bytes, index)
|
|
280
|
+
length = bytes.bytesize
|
|
281
|
+
index += 1 while index < length && whitespace_byte?(bytes.getbyte(index))
|
|
282
|
+
index < length ? index : nil
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def previous_word(bytes, index)
|
|
286
|
+
finish = previous_nonspace(bytes, index)
|
|
287
|
+
return nil unless finish
|
|
288
|
+
start = finish
|
|
289
|
+
start -= 1 while start >= 0 && ascii_identifier_byte?(bytes.getbyte(start))
|
|
290
|
+
bytes.byteslice(start + 1, finish - start).downcase
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def dollar_delimiter_end(bytes, index)
|
|
294
|
+
following = bytes.getbyte(index + 1)
|
|
295
|
+
return index + 2 if following == 36
|
|
296
|
+
# PostgreSQL dollar-quote tags follow unquoted-identifier rules, whose
|
|
297
|
+
# letters include non-ASCII characters. The input is valid UTF-8 by the
|
|
298
|
+
# time it reaches this scanner. Treat every high byte conservatively as
|
|
299
|
+
# an identifier byte: accepting a little more than PostgreSQL does can
|
|
300
|
+
# only hide extra text, while rejecting a valid tag can expose its body.
|
|
301
|
+
return nil unless ascii_letter?(following) || following == 95 || non_ascii_byte?(following)
|
|
302
|
+
|
|
303
|
+
i = index + 2
|
|
304
|
+
i += 1 while ascii_identifier_byte?(bytes.getbyte(i)) || non_ascii_byte?(bytes.getbyte(i))
|
|
305
|
+
bytes.getbyte(i) == 36 ? i + 1 : nil
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def literal_prefix_byte(bytes, quote_index)
|
|
309
|
+
return nil if quote_index.zero?
|
|
310
|
+
prefix = bytes.getbyte(quote_index - 1)
|
|
311
|
+
return nil unless LITERAL_PREFIX_BYTES.include?(prefix)
|
|
312
|
+
return prefix if quote_index == 1
|
|
313
|
+
ascii_identifier_byte?(bytes.getbyte(quote_index - 2)) || bytes.getbyte(quote_index - 2) == 36 ? nil : prefix
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def numeric_end(bytes, index)
|
|
317
|
+
length = bytes.bytesize
|
|
318
|
+
i = index
|
|
319
|
+
i += 1 if bytes.getbyte(i) == 45
|
|
320
|
+
return nil if i >= length
|
|
321
|
+
|
|
322
|
+
base_prefix = bytes.getbyte(i + 1)
|
|
323
|
+
if bytes.getbyte(i) == 48 && BASE_PREFIX_BYTES.include?(base_prefix)
|
|
324
|
+
base = base_prefix
|
|
325
|
+
i += 2
|
|
326
|
+
digits_start = i
|
|
327
|
+
i += 1 while base_digit_or_underscore?(bytes.getbyte(i), base)
|
|
328
|
+
return i > digits_start ? i : nil
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
digits = false
|
|
332
|
+
while digit?(bytes.getbyte(i)) || bytes.getbyte(i) == 95
|
|
333
|
+
digits ||= digit?(bytes.getbyte(i))
|
|
334
|
+
i += 1
|
|
335
|
+
end
|
|
336
|
+
if bytes.getbyte(i) == 46
|
|
337
|
+
i += 1
|
|
338
|
+
while digit?(bytes.getbyte(i)) || bytes.getbyte(i) == 95
|
|
339
|
+
digits ||= digit?(bytes.getbyte(i))
|
|
340
|
+
i += 1
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
return nil unless digits
|
|
344
|
+
|
|
345
|
+
if bytes.getbyte(i) == 69 || bytes.getbyte(i) == 101
|
|
346
|
+
exponent = i
|
|
347
|
+
i += 1
|
|
348
|
+
i += 1 if bytes.getbyte(i) == 43 || bytes.getbyte(i) == 45
|
|
349
|
+
exponent_digits = false
|
|
350
|
+
while digit?(bytes.getbyte(i)) || bytes.getbyte(i) == 95
|
|
351
|
+
exponent_digits ||= digit?(bytes.getbyte(i))
|
|
352
|
+
i += 1
|
|
353
|
+
end
|
|
354
|
+
i = exponent unless exponent_digits
|
|
355
|
+
end
|
|
356
|
+
i
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def base_digit_or_underscore?(byte, base)
|
|
360
|
+
return true if byte == 95
|
|
361
|
+
case base
|
|
362
|
+
when 88, 120 then digit?(byte) || (byte && ((65..70).cover?(byte) || (97..102).cover?(byte)))
|
|
363
|
+
when 66, 98 then byte == 48 || byte == 49
|
|
364
|
+
when 79, 111 then byte && (48..55).cover?(byte)
|
|
365
|
+
else false
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def numeric_boundary?(bytes, index)
|
|
370
|
+
byte = bytes.getbyte(index)
|
|
371
|
+
return false unless digit?(byte) || byte == 46 || byte == 45
|
|
372
|
+
return true if index.zero?
|
|
373
|
+
|
|
374
|
+
previous = bytes.getbyte(index - 1)
|
|
375
|
+
!ascii_identifier_byte?(previous) && previous != 36 && previous != 46
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
def digit?(byte)
|
|
379
|
+
byte && (48..57).cover?(byte)
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
def ascii_letter?(byte)
|
|
383
|
+
byte && ((65..90).cover?(byte) || (97..122).cover?(byte))
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
def ascii_identifier_byte?(byte)
|
|
387
|
+
ascii_letter?(byte) || digit?(byte) || byte == 95
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def non_ascii_byte?(byte)
|
|
391
|
+
byte && byte >= 128
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def whitespace_byte?(byte)
|
|
395
|
+
byte == 32 || (byte && (9..13).cover?(byte))
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
module Subscribers
|
|
5
|
+
# Shared helpers. Subscribers use ActiveSupport::Notifications event
|
|
6
|
+
# objects (duration in ms, allocations) and must do no I/O.
|
|
7
|
+
module Base
|
|
8
|
+
def subscribe(name, &block)
|
|
9
|
+
ActiveSupport::Notifications.subscribe(name) do |event|
|
|
10
|
+
block.call(event)
|
|
11
|
+
rescue StandardError => e
|
|
12
|
+
subscriber_failed(name, e)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# For a subscriber that only reads the payload (a counter, say). The
|
|
17
|
+
# five-argument block form makes Rails skip building an Event object
|
|
18
|
+
# -- six clock and GC reads -- for every notification it delivers.
|
|
19
|
+
def subscribe_payload(name, &block)
|
|
20
|
+
ActiveSupport::Notifications.monotonic_subscribe(name) do |_name, _start, _finish, _id, payload|
|
|
21
|
+
block.call(payload)
|
|
22
|
+
rescue StandardError => e
|
|
23
|
+
subscriber_failed(name, e)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def subscriber_failed(name, error)
|
|
28
|
+
Railwatch.debug { "#{name} subscriber raised #{error.class}: #{error.message}" }
|
|
29
|
+
Railwatch.notify_unrecoverable(error)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def execution
|
|
33
|
+
Railwatch.execution
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Whether a child record built now would be kept. With no execution
|
|
37
|
+
# there is nothing to attach it to and Railwatch.push drops it, so the
|
|
38
|
+
# subscriber should not build it in the first place.
|
|
39
|
+
def recording?
|
|
40
|
+
exe = Railwatch.execution
|
|
41
|
+
!exe.nil? && exe.recording?
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def micros(event)
|
|
45
|
+
Clock.ms_to_micros(event.duration)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def started_at(event)
|
|
49
|
+
# event.time is monotonic in Rails >= 7; derive wall time from now minus duration.
|
|
50
|
+
Clock.now - (event.duration / 1_000.0)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
module Subscribers
|
|
5
|
+
# Action Cable broadcasts and transmits. Covers inertia_cable and Turbo
|
|
6
|
+
# Streams since both go through broadcast.action_cable.
|
|
7
|
+
module Broadcasts
|
|
8
|
+
extend Base
|
|
9
|
+
|
|
10
|
+
EXECUTION_KEY = :__railwatch_channel_execution
|
|
11
|
+
|
|
12
|
+
# An event-object subscriber receives #start before Action Cable invokes
|
|
13
|
+
# the channel method and #finish after it returns or raises. A normal
|
|
14
|
+
# notification block runs only at the end, which is too late for SQL,
|
|
15
|
+
# logs, broadcasts, and transmits inside the action to have a parent.
|
|
16
|
+
class ActionExecution
|
|
17
|
+
def start(_name, _id, payload)
|
|
18
|
+
channel = payload[:channel_class].to_s
|
|
19
|
+
action = payload[:action].to_s
|
|
20
|
+
exe = Railwatch.start_execution(source: :channel_action, sample_kind: :channels,
|
|
21
|
+
preview: "#{channel}##{action}")
|
|
22
|
+
# Handed to #finish before anything that could raise. Action Cable
|
|
23
|
+
# workers are a pool: an execution started here and not closed there
|
|
24
|
+
# would stay on Current for whichever channel action that thread
|
|
25
|
+
# picks up next, silently adopting its records.
|
|
26
|
+
payload[EXECUTION_KEY] = exe
|
|
27
|
+
exe.enter_stage(:action)
|
|
28
|
+
exe.user_id = Users.resolve_from_current
|
|
29
|
+
rescue StandardError => e
|
|
30
|
+
Railwatch.debug { "perform_action.action_cable start subscriber raised #{e.class}: #{e.message}" }
|
|
31
|
+
Railwatch.notify_unrecoverable(e)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def finish(_name, _id, payload)
|
|
35
|
+
exe = payload.delete(EXECUTION_KEY)
|
|
36
|
+
return unless exe
|
|
37
|
+
|
|
38
|
+
Railwatch::Current.execution = exe
|
|
39
|
+
exe.finish_stages
|
|
40
|
+
channel = payload[:channel_class].to_s
|
|
41
|
+
action = payload[:action].to_s
|
|
42
|
+
error = payload[:exception_object]
|
|
43
|
+
if error
|
|
44
|
+
Exceptions.capture(error, handled: false, severity: :error,
|
|
45
|
+
source: "application.action_cable",
|
|
46
|
+
context: { channel: channel, action: action })
|
|
47
|
+
end
|
|
48
|
+
exe.count(:broadcasts)
|
|
49
|
+
Railwatch.record(:broadcast, group: Record.group_hash(channel, action),
|
|
50
|
+
timestamp: exe.started_at, kind: "perform_action", channel: channel,
|
|
51
|
+
action: action, duration: exe.duration, failed: error ? true : false)
|
|
52
|
+
Railwatch.finish_execution(:channel_action, group: Record.group_hash(channel, action),
|
|
53
|
+
channel: channel, action: action,
|
|
54
|
+
status: error ? "failed" : "processed", failed: error ? true : false)
|
|
55
|
+
rescue StandardError => e
|
|
56
|
+
Railwatch.debug { "perform_action.action_cable finish subscriber raised #{e.class}: #{e.message}" }
|
|
57
|
+
Railwatch.notify_unrecoverable(e)
|
|
58
|
+
ensure
|
|
59
|
+
# ActiveSupport guards subscriber errors, so an internal failure
|
|
60
|
+
# must also restore the execution explicitly rather than leave the
|
|
61
|
+
# worker fiber attached to a completed channel action.
|
|
62
|
+
Railwatch::Current.execution = exe.parent_execution if exe && Railwatch.execution.equal?(exe)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
module_function
|
|
67
|
+
|
|
68
|
+
def install!(_app)
|
|
69
|
+
subscribe("broadcast.action_cable") do |event|
|
|
70
|
+
exe = execution
|
|
71
|
+
exe&.count(:broadcasts)
|
|
72
|
+
next unless recording?
|
|
73
|
+
p = event.payload
|
|
74
|
+
stream = p[:broadcasting].to_s
|
|
75
|
+
Railwatch.record(:broadcast,
|
|
76
|
+
group: Record.group_hash(stream_shape(stream)),
|
|
77
|
+
timestamp: started_at(event),
|
|
78
|
+
kind: "broadcast",
|
|
79
|
+
stream: stream[0, 255],
|
|
80
|
+
bytes: (p[:message].to_s.bytesize rescue nil),
|
|
81
|
+
coder: p[:coder]&.name,
|
|
82
|
+
duration: micros(event))
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
subscribe("transmit.action_cable") do |event|
|
|
86
|
+
exe = execution
|
|
87
|
+
# Before the recording? gate, like every other counter here: the
|
|
88
|
+
# parent's counters are meant to be true even for a sampled-out
|
|
89
|
+
# execution, so aggregate rates do not depend on the sample rate
|
|
90
|
+
# (docs/records.md, `counters`).
|
|
91
|
+
exe&.count(:broadcasts)
|
|
92
|
+
next unless recording?
|
|
93
|
+
p = event.payload
|
|
94
|
+
Railwatch.record(:broadcast, group: Record.group_hash(p[:channel_class].to_s),
|
|
95
|
+
timestamp: started_at(event), kind: "transmit", channel: p[:channel_class].to_s,
|
|
96
|
+
via: p[:via].to_s[0, 255], bytes: (p[:data].to_s.bytesize rescue nil), duration: micros(event))
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
ActiveSupport::Notifications.subscribe("perform_action.action_cable", ActionExecution.new)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def stream_shape(stream)
|
|
103
|
+
stream.gsub(/\b\d+\b/, "?").gsub(/[0-9a-f]{16,}/i, "?")
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
module Subscribers
|
|
5
|
+
# Every cache_*.active_support event. Keys are truncated and vendor
|
|
6
|
+
# prefixes (rack-attack, flipper, solid_cable) are rejected by default.
|
|
7
|
+
module Cache
|
|
8
|
+
extend Base
|
|
9
|
+
|
|
10
|
+
EVENTS = {
|
|
11
|
+
"cache_read.active_support" => ->(p) { p[:hit] ? "hit" : "miss" },
|
|
12
|
+
"cache_read_multi.active_support" => ->(_p) { "read_multi" },
|
|
13
|
+
"cache_fetch_hit.active_support" => ->(_p) { "hit" },
|
|
14
|
+
"cache_generate.active_support" => ->(_p) { "generate" },
|
|
15
|
+
"cache_write.active_support" => ->(_p) { "write" },
|
|
16
|
+
"cache_write_multi.active_support" => ->(_p) { "write_multi" },
|
|
17
|
+
"cache_delete.active_support" => ->(_p) { "delete" },
|
|
18
|
+
"cache_delete_multi.active_support" => ->(_p) { "delete_multi" },
|
|
19
|
+
"cache_delete_matched.active_support" => ->(_p) { "delete_matched" },
|
|
20
|
+
"cache_increment.active_support" => ->(_p) { "increment" },
|
|
21
|
+
"cache_decrement.active_support" => ->(_p) { "decrement" },
|
|
22
|
+
"cache_exist?.active_support" => ->(_p) { "exist" }
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
# Bounded cache of store -> key -> group hash (see group_for).
|
|
26
|
+
GROUP_CACHE_LIMIT = 2_048
|
|
27
|
+
@group_cache = Hash.new { |h, k| h[k] = {} }
|
|
28
|
+
@group_mutex = Mutex.new
|
|
29
|
+
|
|
30
|
+
# Computed once so the hot cache_event record doesn't look this up per call.
|
|
31
|
+
CACHE_EVENT_VERSION = Record::VERSIONS.fetch(:cache_event)
|
|
32
|
+
|
|
33
|
+
module_function
|
|
34
|
+
|
|
35
|
+
def install!(_app)
|
|
36
|
+
EVENTS.each do |name, type_of|
|
|
37
|
+
subscribe(name) do |event|
|
|
38
|
+
p = event.payload
|
|
39
|
+
# cache_read inside a fetch is reported by cache_fetch_hit/generate; skip the inner read.
|
|
40
|
+
next if name == "cache_read.active_support" && p[:super_operation] == :fetch
|
|
41
|
+
key = key_string(p[:key])
|
|
42
|
+
next if ignored_key?(key)
|
|
43
|
+
exe = execution
|
|
44
|
+
exe&.count(:cache_events)
|
|
45
|
+
next unless recording?
|
|
46
|
+
|
|
47
|
+
store = p[:store].to_s.demodulize
|
|
48
|
+
cfg = Railwatch.config
|
|
49
|
+
# One hash literal instead of kwargs-packing into Railwatch.record --
|
|
50
|
+
# cache_event is a high-frequency type.
|
|
51
|
+
Railwatch.push(:cache_event, {
|
|
52
|
+
v: CACHE_EVENT_VERSION,
|
|
53
|
+
t: "cache_event",
|
|
54
|
+
timestamp: started_at(event),
|
|
55
|
+
deploy: cfg.deploy,
|
|
56
|
+
server: cfg.server,
|
|
57
|
+
_group: group_for(store, key),
|
|
58
|
+
**(exe ? exe.envelope : Record::EMPTY_ENVELOPE),
|
|
59
|
+
store: store,
|
|
60
|
+
key: key[0, 255],
|
|
61
|
+
type: p[:exception] ? "fail" : type_of.call(p),
|
|
62
|
+
duration: micros(event),
|
|
63
|
+
ttl: p[:expires_in].to_i,
|
|
64
|
+
hits: p[:hits].is_a?(Array) ? p[:hits].size : nil
|
|
65
|
+
})
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def key_string(key)
|
|
71
|
+
case key
|
|
72
|
+
when Array then key.map { |k| key_string(k) }.join("/")
|
|
73
|
+
when Hash then key.map { |k, v| "#{k}=#{key_string(v)}" }.join("&")
|
|
74
|
+
else
|
|
75
|
+
key.respond_to?(:cache_key) ? key.cache_key : key.to_s
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Group hash for a (store, key): ids are stripped so "users/123" and
|
|
80
|
+
# "users/456" share a group, then the digest is taken. Both run once per
|
|
81
|
+
# distinct key; keys repeat heavily (the same fetch in a loop). The
|
|
82
|
+
# cached string is frozen because it is handed to every record as
|
|
83
|
+
# _group, and a redactor that mutated it in place would poison every
|
|
84
|
+
# later record for that key.
|
|
85
|
+
def group_for(store, key)
|
|
86
|
+
bucket = @group_cache[store]
|
|
87
|
+
cached = bucket[key]
|
|
88
|
+
return cached if cached
|
|
89
|
+
|
|
90
|
+
shape = key.gsub(/\b\d+\b/, "?").gsub(/[0-9a-f]{16,}/i, "?")
|
|
91
|
+
group = Record.group_hash(store, shape).freeze
|
|
92
|
+
@group_mutex.synchronize do
|
|
93
|
+
bucket.clear if bucket.size >= GROUP_CACHE_LIMIT
|
|
94
|
+
bucket[key] = group
|
|
95
|
+
end
|
|
96
|
+
group
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def ignored_key?(key)
|
|
100
|
+
config = Railwatch.config
|
|
101
|
+
return true if config.ignored_cache_key_prefixes.any? { |pattern| Configuration.match_cache_key?(pattern, key) }
|
|
102
|
+
return false if config.capture_default_vendor_cache_keys
|
|
103
|
+
Configuration::DEFAULT_VENDOR_CACHE_KEY.match?(key)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
module Subscribers
|
|
5
|
+
module Deprecations
|
|
6
|
+
extend Base
|
|
7
|
+
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def install!(_app)
|
|
11
|
+
subscribe("deprecation.rails") do |event|
|
|
12
|
+
exe = execution
|
|
13
|
+
exe&.count(:deprecations)
|
|
14
|
+
next unless recording?
|
|
15
|
+
p = event.payload
|
|
16
|
+
Railwatch.record(:deprecation,
|
|
17
|
+
group: Record.group_hash(p[:gem_name], p[:message].to_s[0, 120]),
|
|
18
|
+
message: p[:message].to_s[0, 2048],
|
|
19
|
+
gem_name: p[:gem_name].to_s,
|
|
20
|
+
horizon: p[:deprecation_horizon].to_s,
|
|
21
|
+
source: Array(p[:callstack]).find { |f| f.to_s.start_with?(Backtrace.app_root) }&.to_s&.delete_prefix(Backtrace.app_root))
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|