railwatch 1.1.0 → 1.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3620348235aae6e5733728931b85a9c0edd0ac31367a4fd143eceee1de28cbc0
4
- data.tar.gz: 273a578b3059f4390854d7452df971d8223c9e4f6c8c6a9e7e8d9f46a27ef1f3
3
+ metadata.gz: 072de4fbe3a2b0854d5e00b3b41b033a901d05d9f681f8e6083ae9c6882e4751
4
+ data.tar.gz: f24164cbf663302b6df3e302aadf59e7328316a4e6d1c86d1b8b5ad49ee6f85f
5
5
  SHA512:
6
- metadata.gz: 82640c29a57b4c99d890b40ef75179d635b53308879516ba4973aaed0cfa1179696e0ee14e6660be2eb59c935611eb395e9f508e583c2604fbe8a7e57da0f8ad
7
- data.tar.gz: 53ff388054d12658c7d0cb99c3d0c3dd01be8579ac2227efbb07d65f008a766851761655904b081c8f344aa5db6f3ad9d2cb1e25a349bf30b943e85cb74489bc
6
+ metadata.gz: 9d3907a21539f08dd1e71d4eb83e0305d749673a51e9eb4d8404e4cff2924665d0c0dd28c5f4e8d62b50174ff0618f7e43d41a5984cd52b24a1d6fe63745dae7
7
+ data.tar.gz: 1bc7449ec96e9c85432d9f75746a28e4f5152ba71b97d3d32e6b7a9ee4b53044abdf34281a78bf4bf546436b0b8168376b51635daf640fe396fd853163cde638
data/CHANGELOG.md CHANGED
@@ -6,6 +6,49 @@
6
6
  filled in by the release commit, which is also the only commit that
7
7
  touches lib/railwatch/version.rb and Gemfile.lock. See CONTRIBUTING.md. -->
8
8
 
9
+ ## 1.2.0 (2026-10-25)
10
+
11
+ - Executions send only the counters that fired. Every parent record
12
+ carried all 18 of `Execution::COUNTERS`, most of them zero: 281 bytes of
13
+ `counters` JSON on every request, job and command. A request that ran
14
+ two queries, a log line and a view now sends 59 bytes; a channel action
15
+ that did nothing sends `{}`. A counter missing from the hash is zero.
16
+ Needs a railwatch-rs that reads it that way (Rebulk/railwatch-rs#43);
17
+ older servers show a blank query count on the route page and `null`
18
+ from the MCP tools for a request that ran none.
19
+
20
+ - Health samples stop repeating the recurring task manifest. `detail`
21
+ carried every Solid Queue recurring task key and schedule on every
22
+ sample, every 15 seconds, from every process: one unchanging string
23
+ that was 94% of the column (1,312 of 1,417 bytes a row on a 28-task
24
+ app). It is now sent when it changes, on a process's first sample, and
25
+ otherwise once every five minutes per process. railwatch-rs's
26
+ scheduled-task check reads the newest sample that carries one from the
27
+ last ten minutes, so it always finds one.
28
+
29
+ - An execution whose record tree outgrew `execution_buffer_bytes` (8 MiB)
30
+ says so. The buffer keeps the earliest child records and drops the
31
+ rest, and the parent said nothing: a job that issued 5,048 queries
32
+ showed a plausible 3,155-query trace, and the loss was visible only as
33
+ the batch's dropped count. The parent record now carries
34
+ `dropped_records` and `dropped_bytes` when the tree lost anything.
35
+ railwatch-rs shows them on the execution page (Rebulk/railwatch-rs#44).
36
+ - Stop building records the execution is about to drop. Once its buffer
37
+ has overflowed, `Execution#recording?` answers no and counts the record
38
+ the subscriber was about to build, so the SQL normalisation, redaction,
39
+ backtrace walk and weighing for it never run.
40
+
41
+ ## 1.1.1 (2026-10-24)
42
+
43
+ - The browser client names the first page load after its Inertia
44
+ component again on inertia_rails 3.23+, which renders the page object as
45
+ `<script data-page="app" type="application/json">` instead of a
46
+ `data-page` attribute on `<div id="app">`. The client only read the
47
+ attribute, so every initial load (the only visits that carry LCP, CLS,
48
+ INP and TTFB) arrived with no component: per-page web vitals were empty.
49
+ Apps that copied the client (`app/frontend/lib/railwatch.ts`) need the
50
+ same change in their copy.
51
+
9
52
  ## 1.1.0 (2026-10-22)
10
53
 
11
54
  - A receiver that refuses the connection (nothing listening: a Railwatch
data/docs/records.md CHANGED
@@ -56,12 +56,14 @@ these fields, from `Railwatch.build_parent` in `lib/railwatch.rb`:
56
56
  |---|---|
57
57
  | `duration` | Wall time in microseconds, execution start to finish. |
58
58
  | `stages` | Hash of stage name → microseconds spent in it (e.g. `{"middleware_before" => 120, "action" => 4300, "render" => 900}`). |
59
- | `counters` | Hash of child-record counts for this execution — `queries`, `cached_queries`, `exceptions`, `logs`, `cache_events`, `jobs_enqueued`, `mail`, `broadcasts`, `notifications`, `outgoing_requests`, `storage_ops`, `view_renders`, `transactions`, `hydrated_models`, `lazy_loads`, `deprecations`, `spans` (`Execution::COUNTERS`). Counted even when the execution is sampled out, so aggregate rates don't depend on the sample rate. |
59
+ | `counters` | Hash of child-record counts for this execution — `queries`, `cached_queries`, `exceptions`, `logs`, `cache_events`, `jobs_enqueued`, `mail`, `broadcasts`, `notifications`, `outgoing_requests`, `storage_ops`, `view_renders`, `transactions`, `hydrated_models`, `lazy_loads`, `deprecations`, `spans` (`Execution::COUNTERS`). Counted even when the execution is sampled out, so aggregate rates don't depend on the sample rate. Only the counters that fired are sent: a counter missing from the hash is zero, and readers must treat it that way. |
60
60
  | `peak_memory` | RSS in bytes, sampled at most once per second process-wide (`Execution.sampled_memory`) — cheap enough to read but not per-execution-accurate to the microsecond. |
61
61
  | `allocations` | Objects allocated during the execution (`GC.stat(:total_allocated_objects)` delta). |
62
62
  | `gc_time` | GC time in the execution's window, when the Ruby build exposes `GC.stat(:time)`. |
63
63
  | `exception_preview` | First unhandled exception's `"Class: message"`, truncated to 255 chars, or nil. |
64
64
  | `context` | Serialized `Railwatch.context(...)` key/values active for this execution, parameter-filtered like request params. `"_railwatch_truncated": true` when it did not fit in 64KB. |
65
+ | `dropped_records` | Child records this execution's tree lost to `execution_buffer_bytes` (or `Execution::MAX_RECORDS`): the earliest records are kept and everything after the first overflow is counted here instead of built. Present only when non-zero, and only on a tree that shipped. View renders past `max_view_renders_per_execution` are capped, not dropped, and not counted here. |
66
+ | `dropped_bytes` | Estimated in-memory weight of those dropped records (records refused after the overflow are weighed at the buffer's mean). Present alongside `dropped_records`. |
65
67
 
66
68
  A sampled-out execution still ships its parent record if it raised an
67
69
  unhandled exception. See `Railwatch.finish_execution`. Sampling controls
@@ -870,7 +872,7 @@ watches. The record still ships with whatever it did manage to read.
870
872
  | `pool_waiting` | Threads blocked waiting for a connection — sustained non-zero means the pool is undersized for the thread count. |
871
873
  | `queue_depth` | `SolidQueue::ReadyExecution.count` — jobs ready to run right now. |
872
874
  | `queue_latency` | Microseconds since the oldest ready job was created, i.e. the backlog's head-of-line wait. nil when the queue is empty. |
873
- | `detail` | JSON string: `queues` (ready count per queue name), `workers` (`SolidQueue::Process` rows of kind `Worker`), `requests_count` (Puma's lifetime request count for this process), `running` (threads Puma has spawned), `max_threads_reached` (true when Puma's `pool_capacity` was 0 at sample time, i.e. no spare thread), `recurring_tasks` (Solid Queue recurring task key => schedule, from `SolidQueue::RecurringTask`; left out when there are none or the table could not be read, so the platform can tell a task removed from `config/recurring.yml` apart from one that stopped running). |
875
+ | `detail` | JSON string: `queues` (ready count per queue name), `workers` (`SolidQueue::Process` rows of kind `Worker`), `requests_count` (Puma's lifetime request count for this process), `running` (threads Puma has spawned), `max_threads_reached` (true when Puma's `pool_capacity` was 0 at sample time, i.e. no spare thread), `recurring_tasks` (Solid Queue recurring task key => schedule, from `SolidQueue::RecurringTask`; left out when there are none or the table could not be read, so the platform can tell a task removed from `config/recurring.yml` apart from one that stopped running). **Not on every sample.** The manifest is identical in every process and changes only with `config/recurring.yml`, so repeating it made it 94% of this column; it is re-sent when it changes and otherwise once every five minutes per process. The reader takes the newest sample that carries one, from inside a ten-minute window. |
874
876
 
875
877
  Every Puma field is nil when no `Puma::Server` exists in the process, and
876
878
  every Solid Queue field is nil when `SolidQueue` isn't loaded.
@@ -659,13 +659,19 @@ function startVitals() {
659
659
  )
660
660
  }
661
661
 
662
- // The component the server rendered this page with, off the root element's
663
- // serialized page object. Inertia's own events take over from here.
662
+ // The component the server rendered this page with, off the serialized page
663
+ // object: inertia_rails 3.23+ renders it as <script data-page="app"
664
+ // type="application/json">, older ones as <div id="app" data-page="...">.
665
+ // Inertia's own events take over from here.
664
666
  function pageComponent(): string | undefined {
665
667
  try {
666
- const page = JSON.parse(
667
- document.getElementById("app")?.dataset.page ?? "{}",
668
- ) as { component?: string }
668
+ const script = document.querySelector(
669
+ 'script[data-page="app"][type="application/json"]',
670
+ )
671
+ const raw = script
672
+ ? script.textContent
673
+ : document.getElementById("app")?.dataset.page
674
+ const page = JSON.parse(raw ?? "{}") as { component?: string }
669
675
  return page.component
670
676
  } catch {
671
677
  // Not an Inertia-rendered page, or the payload moved. Report it anyway.
@@ -114,6 +114,7 @@ module Railwatch
114
114
  @buffered_bytes = 0
115
115
  @dropped_records = 0
116
116
  @dropped_bytes = 0
117
+ @full = false
117
118
  @keep = false
118
119
  # Tail sampling keeps buffering child records for a head-sampled-out
119
120
  # execution so the ship/discard decision can be made at the end. Read
@@ -145,8 +146,28 @@ module Railwatch
145
146
  @paused_depth.positive?
146
147
  end
147
148
 
149
+ # The gate every subscriber asks before it builds a child record. Once
150
+ # the tree has overflowed (#buffer) the answer is no, and the record the
151
+ # caller was about to build is counted as dropped right here: building
152
+ # it -- SQL normalisation, redaction, a backtrace walk, weighing -- only
153
+ # to throw it away cost ~15us per query, and a job issuing 5,000 queries
154
+ # into an 8 MiB buffer drops most of them. A gate with a side effect,
155
+ # deliberately: each caller asks once per record it would build, so the
156
+ # count stays exact. Bytes are estimated at the buffer's mean record
157
+ # weight (never a division by zero: @full is only set once a record is
158
+ # held), so the dropped-bytes figure keeps meaning something.
148
159
  def recording?
149
- (sampled? || @tail_buffering) && !paused?
160
+ return false unless (sampled? || @tail_buffering) && !paused?
161
+ return true unless @full
162
+
163
+ @dropped_records += 1
164
+ @dropped_bytes += @buffered_bytes / @records.size
165
+ false
166
+ end
167
+
168
+ # Whether the buffer has overflowed and #recording? is now refusing.
169
+ def full?
170
+ @full
150
171
  end
151
172
 
152
173
  # Ship this execution's whole tree regardless of the head sampling
@@ -222,13 +243,16 @@ module Railwatch
222
243
 
223
244
  # A failure-context ring keeps the LAST record_limit records: the ones
224
245
  # just before the exception are the ones worth having. Every other
225
- # buffer keeps the earliest and rejects the overflow. Either way the
226
- # loss is counted onto the parent's batch (Railwatch.finish_execution).
246
+ # buffer keeps the earliest and rejects the overflow -- and from the
247
+ # first rejection on, refuses at the gate (#recording?) so nothing
248
+ # else is built for it. Either way the loss is counted onto the parent
249
+ # record and its batch (Railwatch.finish_execution).
227
250
  if @failure_context
228
251
  drop_oldest while @records.any? && (@records.size >= @record_limit || @buffered_bytes + bytes > @byte_limit)
229
252
  elsif @records.size >= @record_limit || @buffered_bytes + bytes > @byte_limit
230
253
  @dropped_records += 1
231
254
  @dropped_bytes += bytes
255
+ @full = true
232
256
  return
233
257
  end
234
258
 
@@ -279,6 +303,14 @@ module Railwatch
279
303
  @counters[counter] += by
280
304
  end
281
305
 
306
+ # The counters that actually fired. Most executions touch a handful of the
307
+ # 18, so sending the rest as explicit zeros costs roughly 240 bytes on the
308
+ # wire and in storage, per execution, to say nothing happened. A reader
309
+ # treats an absent counter as zero, exactly as it treats a zero.
310
+ def counted
311
+ @counters.reject { |_, value| value.zero? }
312
+ end
313
+
282
314
  # Rails.error and an outer middleware can observe the same error. Count
283
315
  # that occurrence once even when sampling or pause prevents its report.
284
316
  # Reporting has separate flags so a suppressed observation can still be
@@ -19,6 +19,8 @@ module Railwatch
19
19
  @thread = nil
20
20
  @pid = nil
21
21
  @stopping = false
22
+ @manifest_digest = nil
23
+ @manifest_sent_at = nil
22
24
 
23
25
  module_function
24
26
 
@@ -51,6 +53,8 @@ module Railwatch
51
53
  @thread = nil
52
54
  @pid = nil
53
55
  @stopping = false
56
+ @manifest_digest = nil
57
+ @manifest_sent_at = nil
54
58
  remove_instance_variable(:@puma_server) if defined?(@puma_server)
55
59
  start!
56
60
  end
@@ -81,6 +85,7 @@ module Railwatch
81
85
  puma = puma_stats
82
86
  pool = pool_stats
83
87
  queue = solid_queue_stats
88
+ fields = detail(puma, queue)
84
89
  Railwatch.record(:health,
85
90
  pid: Process.pid,
86
91
  role: Subscribers::ProcessInfo.role,
@@ -93,7 +98,10 @@ module Railwatch
93
98
  pool_waiting: pool[:waiting],
94
99
  queue_depth: queue[:queue_depth],
95
100
  queue_latency: queue[:queue_latency],
96
- detail: JSON.generate(detail(puma, queue)))
101
+ detail: JSON.generate(fields))
102
+ # Only once the sample is recorded: a sample that raised must not
103
+ # count as having sent the manifest, or the next one leaves it out.
104
+ manifest_sent(fields[:recurring_tasks]) if fields[:recurring_tasks]
97
105
  end
98
106
  rescue StandardError => e
99
107
  Railwatch.debug { "health sample failed: #{e.class}: #{e.message}" }
@@ -122,13 +130,41 @@ module Railwatch
122
130
  # with scheduled-task detection). Left out rather than sent empty when
123
131
  # there are none or the table could not be read: Jobs folds a failed
124
132
  # read into an empty set, and "no manifest" must not read as "no tasks".
133
+ #
134
+ # The manifest is a property of the deploy, not of the sample: it is
135
+ # identical in every process and changes only when config/recurring.yml
136
+ # does. Sending it every interval made it 94% of every health sample --
137
+ # 1,312 of 1,417 bytes per row on a 28-task app, the same string stored
138
+ # 32,694 times over nineteen hours. So it is re-sent when it changes, and
139
+ # otherwise only once per MANIFEST_INTERVAL. Nothing is lost: the
140
+ # server's scheduled-task check takes the newest sample that *carries* a
141
+ # manifest from the last ten minutes, and the floor is half that.
125
142
  def recurring_tasks
126
143
  schedules = Subscribers::Jobs.recurring_tasks[:schedules]
127
- schedules.empty? ? nil : schedules
144
+ return nil if schedules.empty?
145
+ return nil unless manifest_due?(schedules)
146
+
147
+ schedules
128
148
  rescue StandardError
129
149
  nil
130
150
  end
131
151
 
152
+ # Five minutes, as a duration rather than a count of samples:
153
+ # health_interval is configurable, so any count would outrun the reader's
154
+ # ten-minute window once an app slowed its sampling down.
155
+ MANIFEST_INTERVAL = 300
156
+
157
+ # Monotonic, so a clock step cannot park the manifest for five minutes or
158
+ # put it on every sample. Read and written only from the health thread.
159
+ def manifest_due?(schedules)
160
+ !(schedules.hash == @manifest_digest && @manifest_sent_at && Clock.monotonic - @manifest_sent_at < MANIFEST_INTERVAL)
161
+ end
162
+
163
+ def manifest_sent(schedules)
164
+ @manifest_digest = schedules.hash
165
+ @manifest_sent_at = Clock.monotonic
166
+ end
167
+
132
168
  # Puma::Server#stats is the only public API exposing busy_threads, so it is
133
169
  # used in preference to the individual readers. It also resets Puma's
134
170
  # own since-last-read backlog_max/reactor_max gauges, which Railwatch does
@@ -33,8 +33,10 @@ module Railwatch
33
33
  subscribe("#{kind}.action_view") do |event|
34
34
  exe = execution
35
35
  exe&.count(:view_renders)
36
- next unless recording?
36
+ # The cap first: a render past it is never built, so a full
37
+ # buffer's gate (Execution#recording?) must not count it dropped.
37
38
  next if exe && exe.counters[:view_renders] > Railwatch.config.max_view_renders_per_execution
39
+ next unless recording?
38
40
  p = event.payload
39
41
  identifier = p[:identifier].to_s.delete_prefix(Backtrace.app_root)
40
42
  Railwatch.record(:view_render,
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Railwatch
4
- VERSION = "1.1.0"
4
+ VERSION = "1.2.0"
5
5
  end
@@ -23,23 +23,9 @@
23
23
  },
24
24
  "counters": {
25
25
  "queries": 2,
26
- "cached_queries": 0,
27
- "exceptions": 0,
28
26
  "logs": 1,
29
- "cache_events": 0,
30
- "jobs_enqueued": 0,
31
- "mail": 0,
32
- "broadcasts": 0,
33
- "notifications": 0,
34
- "outgoing_requests": 0,
35
- "storage_ops": 0,
36
27
  "view_renders": 1,
37
- "transactions": 0,
38
- "hydrated_models": 1,
39
- "lazy_loads": 0,
40
- "deprecations": 0,
41
- "spans": 0,
42
- "llm_calls": 0
28
+ "hydrated_models": 1
43
29
  },
44
30
  "peak_memory": 1,
45
31
  "allocations": 1,
@@ -99,23 +85,7 @@
99
85
  },
100
86
  "counters": {
101
87
  "queries": 1,
102
- "cached_queries": 0,
103
- "exceptions": 0,
104
- "logs": 1,
105
- "cache_events": 0,
106
- "jobs_enqueued": 0,
107
- "mail": 0,
108
- "broadcasts": 0,
109
- "notifications": 0,
110
- "outgoing_requests": 0,
111
- "storage_ops": 0,
112
- "view_renders": 0,
113
- "transactions": 0,
114
- "hydrated_models": 0,
115
- "lazy_loads": 0,
116
- "deprecations": 0,
117
- "spans": 0,
118
- "llm_calls": 0
88
+ "logs": 1
119
89
  },
120
90
  "peak_memory": 1,
121
91
  "allocations": 1,
@@ -160,23 +130,7 @@
160
130
  },
161
131
  "counters": {
162
132
  "queries": 1,
163
- "cached_queries": 0,
164
- "exceptions": 0,
165
- "logs": 1,
166
- "cache_events": 0,
167
- "jobs_enqueued": 0,
168
- "mail": 0,
169
- "broadcasts": 0,
170
- "notifications": 0,
171
- "outgoing_requests": 0,
172
- "storage_ops": 0,
173
- "view_renders": 0,
174
- "transactions": 0,
175
- "hydrated_models": 0,
176
- "lazy_loads": 0,
177
- "deprecations": 0,
178
- "spans": 0,
179
- "llm_calls": 0
133
+ "logs": 1
180
134
  },
181
135
  "peak_memory": 1,
182
136
  "allocations": 1,
@@ -222,23 +176,7 @@
222
176
  "stages": {},
223
177
  "counters": {
224
178
  "queries": 1,
225
- "cached_queries": 0,
226
- "exceptions": 0,
227
- "logs": 0,
228
- "cache_events": 0,
229
- "jobs_enqueued": 0,
230
- "mail": 0,
231
- "broadcasts": 0,
232
- "notifications": 0,
233
- "outgoing_requests": 0,
234
- "storage_ops": 0,
235
- "view_renders": 0,
236
- "transactions": 1,
237
- "hydrated_models": 0,
238
- "lazy_loads": 0,
239
- "deprecations": 0,
240
- "spans": 0,
241
- "llm_calls": 0
179
+ "transactions": 1
242
180
  },
243
181
  "peak_memory": 1,
244
182
  "allocations": 1,
@@ -267,26 +205,7 @@
267
205
  "tenant": null,
268
206
  "duration": 1,
269
207
  "stages": {},
270
- "counters": {
271
- "queries": 0,
272
- "cached_queries": 0,
273
- "exceptions": 0,
274
- "logs": 0,
275
- "cache_events": 0,
276
- "jobs_enqueued": 0,
277
- "mail": 0,
278
- "broadcasts": 0,
279
- "notifications": 0,
280
- "outgoing_requests": 0,
281
- "storage_ops": 0,
282
- "view_renders": 0,
283
- "transactions": 0,
284
- "hydrated_models": 0,
285
- "lazy_loads": 0,
286
- "deprecations": 0,
287
- "spans": 0,
288
- "llm_calls": 0
289
- },
208
+ "counters": {},
290
209
  "peak_memory": 1,
291
210
  "allocations": 1,
292
211
  "gc_time": 1,
data/lib/railwatch.rb CHANGED
@@ -167,6 +167,13 @@ module Railwatch
167
167
  group ||= fields.delete(:group)
168
168
  fields[:tail_sampled] = true if tail
169
169
  fields[:profiled] = true if profiled
170
+ # What the tree lost to its own buffer (Execution#buffer), so the
171
+ # parent says so where someone opening it will look. Only on a tree
172
+ # that ships: one that is discarded lost nothing anyone would see.
173
+ if shipping && exe.dropped_records.positive?
174
+ fields[:dropped_records] = exe.dropped_records
175
+ fields[:dropped_bytes] = exe.dropped_bytes
176
+ end
170
177
  parent = build_parent(parent_type, exe, group: group, **fields)
171
178
  end
172
179
  if shipping
@@ -224,7 +231,7 @@ module Railwatch
224
231
  def build_parent(type, exe, group: nil, **fields)
225
232
  rec = Record.build(type, exe, group: group, timestamp: exe.started_at,
226
233
  duration: exe.duration, stages: exe.stage_durations.compact.transform_keys(&:to_s),
227
- counters: exe.counters, peak_memory: exe.peak_memory, allocations: exe.allocations,
234
+ counters: exe.counted, peak_memory: exe.peak_memory, allocations: exe.allocations,
228
235
  gc_time: exe.gc_time, exception_preview: exe.exception_preview,
229
236
  context: Context.serialized, **fields)
230
237
  run_redactors(type, rec)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: railwatch
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.0
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Cole Robertson