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