Repository navigation
Conversation
clutch.collect read cl.ctx and cl.m.Level after releasing the clutch lock to call the collector. If the collector doesn't stop at its CMR, the next interval's collect detects the fault and overwrites cl.ctx and cl.m under the lock while the first goroutine may still be reading them. Use the context and level captured under the lock instead, like the existing interval and cancel locals. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Collection faults were silent: domains that didn't start or return
before the engine max runtime (EMR), dropped level collector ticks,
and sink retry buffer overflow were logged only at debug level or not
at all. When a host is CPU-starved or a sink is slow, nothing showed
that a monitor was collecting partial metrics or dropping them.
Add cumulative per-monitor counters:
engine-emr-timeouts collections that hit the EMR
engine-domains-not-started domains not started before the EMR
engine-domains-not-returned domains started but not returned by the EMR
collector-faults domains still running at their next
interval (event collector-fault)
collector-flush-drops domain metrics dropped because the engine
collection channel was full
(event drop-metrics-flush)
level-ticks-dropped level collector ticks dropped while a
collection blocked the loop
level-metrics-dropped intervals dropped because sinks weren't
keeping up (event lco-metrics-fault)
sink-retry-dropped:<sink> metrics pushed off a full retry buffer
Design:
- Exposure: monitor status. Blip doesn't emit metrics about itself;
it reports internal state per monitor through the status package and
GET /status/monitors. The status package already had an unused
per-monitor counters map, so this implements it rather than adding
self-metrics to the sink path. Self-metrics would also be produced by
the same starved collection loop they're meant to diagnose.
- Cost: components get stable *atomic.Uint64 counters from
status.Counter once, when they're created. Increments are lock-free
and don't allocate; the collect path only adds a bool and two ints.
- Reading: status.ReportCounters(ids...) returns
map[monitorId]map[counter]uint64. New endpoint
GET /status/monitors/counters returns the same as JSON. A host that
embeds Blip can poll ReportCounters and forward each counter as a
monotonic count tagged with the monitor ID, without scraping logs.
- Lifetime: counters only increase and persist across monitor stop
and start, because sinks and status outlive a monitor restart. They
are removed with the rest of the monitor status on unload. Counters
are created at zero, so they're reported before they first increment.
- Events: collector-fault, drop-metrics-flush, and lco-metrics-fault
each mark lost or late metrics, so each has a counter.
drop-metrics-fence isn't counted: it's the later drop of the metrics
already counted by collector-faults, and it fires only if the stuck
collector ever returns. The existing, unused engine-emr-timeout
event is now sent (not as an error, because a CPU-starved host can
hit the EMR every interval) with the not-started and not-returned
domain counts.
- Dropped ticks are detected from the gap between consecutive tick
times, rounded to whole ticks; the level collector doesn't catch up
on dropped ticks, so each one delays every level by one tick.
Collection behavior is unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4e3c5bd to
fd13992
Compare
zyin-squareup
left a comment
There was a problem hiding this comment.
🤖 This review was generated by an AI agent on behalf of @zyin-squareup.
Summary. The counter design (stable *atomic.Uint64 per component, read through ReportCounters) is solid. The clutch.collect race fix is correct: m is the same value assigned to cl.m under the lock. There is one blocking problem: the sink-retry-dropped:<sink> counter stops being reported after a monitor's config changes and is reloaded.
BLOCKING
1. sink-retry-dropped:<sink> stops being reported after a config-change reload (sink/retry.go:107, monitor/loader.go:259-264, 389-413, 647)
The sink counter is registered when the sink is built. The loader builds the new sinks before it removes the old monitor's status, so the removal deletes the new counter too.
Call sequence for POST /monitors/reload (or Loader.Load) when an existing monitor's config changed:
ml.load()→makeMonitor(cfg)→sink.Make→NewRetry→status.Counter(id, "sink-retry-dropped:<sink>"). This writes the new counter pointer intos.counters[id], which already exists for the old monitor.Loadthen callsml.Unload(id)fordiff.changed→status.RemoveMonitor(id)→delete(s.counters, id). The new monitor'sRetrystill holds that pointer, but it is no longer in the map.StartMonitors→startup→newLevelCollectorWithDBProvider/newEngineWithDBProvidercreate a freshs.counters[id]map with only the engine and level-collector counters.
Result: for that monitor, sink-retry-dropped:* is missing from GET /status/monitors/counters and status.ReportCounters until Blip restarts. The retry buffer keeps dropping metrics, but nothing reports it. That is the silent loss this PR is meant to expose. The engine and LCO counters are fine only because they're created at Start, after the unload. The Counter doc comment ("valid until RemoveMonitor is called") describes this hazard, but the loader's ordering violates it.
Related, same root cause: load() says it "has no side effects on error", but makeMonitor now registers counters. If load() fails partway, or Load returns ErrStopLoss, monitors that never get loaded still appear in /status/monitors/counters with sink-retry-dropped:*: 0.
Suggested fix (either):
- Register the sink counter lazily, the first time it's needed (in
pushor the firstSend). Or have the monitor register it atStart, like the engine and LCO counters. - Or split removal, so that unloading a changed monitor removes its status but not its counters, while unloading a removed monitor removes both. This also fits "counters persist across stop/start": a changed config for the same monitor ID would keep counting.
Please add a loader-level test: load a monitor with a Retry-wrapped sink (e.g. chronosphere with no options), change its config, call Load again, and assert the sink counter is still in ReportCounters(id) and still increments.
NON-BLOCKING
monitor/engine.go:609: the panic recovery inclutch.collectstill readscl.m.MonitorIdwithout the lock. It's the same pattern the race-fix commit removes, thoughMonitorIdnever changes in practice.cl.event.MonitorIdor the localm.MonitorIdwould avoid it.monitor/engine.go:337-342:breakexits only theselect, so after the EMR expires the start loop keeps going, and Go's random choice between readyselectcases can still start collectors. This is not new, but it meansengine-domains-not-startedcan undercount, and an interval can count asengine-emr-timeoutswith 0 not started and 0 not returned. Worth a comment, or a labeled break if "skip pending" is intended.
Validation
go test -race -count=1 ./status/ ./sink/: pass.go test -race -count=3 ./monitor/ -run 'TestEngineCounts|TestClutchCountsFlushDrops|TestDroppedTicks|TestLevelCollectorCounts': pass.go vet ./status/ ./sink/ ./monitor/ ./server/: clean.- The broader
./monitor/run failed only inTestLevelCollector_RGB_Fault, which needs a local MySQL (dial tcp 127.0.0.1:33800: connection refused). That's environmental, not caused by this PR. - I couldn't run a scratch repro for finding 1 in this environment. It is established by the call trace above.
Retry registered its sink-retry-dropped counter when the sink was made. When a monitor's config changes, the loader makes the new monitor and its sinks first, then unloads the old monitor, which removes the status and counters for that monitor ID. The new Retry kept incrementing a counter that was no longer reported, so retry buffer drops were silent again until Blip restarted. A load that failed or hit the stop-loss also left zero counters for monitors that were made but never loaded. Add sink.CounterSink, an optional interface with RegisterCounters. Retry implements it, and Delta forwards it to the sink it wraps. The monitor calls it on its sinks at startup, where the engine and level collector get their counters, which is always after the loader has unloaded any earlier monitor with the same ID. Retry doesn't count drops before it's registered. Also, in clutch.collect panic recovery, use the local m.MonitorId rather than reading cl.m without the lock, and document that the EMR break in Engine.Collect exits only the select, so domains can still start after the EMR and engine-domains-not-started counts domains that actually didn't start. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
🤖 Thanks, all three are addressed in ef1fd3d. Blocking: the sink counter is now registered when the monitor starts. There's a new optional Tests: engine.go:609: now uses |
zyin-squareup
left a comment
There was a problem hiding this comment.
🤖 This review was generated by an AI agent on behalf of @zyin-squareup.
Summary. Re-review of ef1fd3d. It fixes the earlier blocking issue: sink-retry-dropped:<sink> was lost after a config-change reload. Both earlier non-blocking notes are also addressed. I found no new blocking issues. Approving.
Earlier findings
| # | Finding | Status | Evidence |
|---|---|---|---|
| 1 (blocking) | Sink counter deleted by RemoveMonitor on config-change reload |
Fixed | Retry no longer registers in NewRetry. Monitor.startup calls registerSinkCounters(m.sinks) (monitor/monitor.go:413), and that runs only from StartMonitors. Loader.Load unloads changed monitors first (loader.go:262-264 → Unload → status.RemoveMonitor), so registration always lands in the new map. Delta forwards RegisterCounters, which covers both shapes sink/factory.go:178-180 returns. push reads rb.dropped under stackMux, the same lock RegisterCounters writes it under, so there's no race. Stop/Start without unload re-registers into the existing map, so counts persist as the docs say. |
| 1b | load() side effects: counters left for monitors made but not loaded (error/stop-loss) |
Fixed | makeMonitor no longer touches status. TestLoaderUnloadedMonitorsHaveNoCounters needs no MySQL, so it runs in CI. It would fail if registration moved back into NewRetry. |
| 2 | Panic recovery read cl.m.MonitorId without the lock |
Fixed | Uses the local m.MonitorId (engine.go:~611). |
| 3 | EMR break exits only the select |
Addressed | Comment added. notStarted := len(domains) - len(running) now counts domains that actually didn't start, so the undercount I described is gone. |
NON-BLOCKING
- The reload regression test is skipped in CI.
TestLoaderReloadKeepsSinkCounters(monitor/loader_counters_test.go:649-655) skips when MySQL isn't running andtest.Build(GITHUB_ACTION) is set..github/workflows/ci.ymlruns onlygo test ./...with no MySQL, so CI never runs it. The CI-run stop-loss test catches a move back to registering in the constructor. Nothing in CI catches a droppedregisterSinkCounterscall instartup. Nothing on the path toregisterSinkCountersappears to connect to MySQL:makeDBdoesn't connect, the plan comes from the default loader, and HA is off. If that holds, the MySQL gate could be dropped. I haven't verified that by running it. - The PR description is slightly stale after the fix. It says "Components get stable
*atomic.Uint64counters once, at creation."Retrynow gets its counter at monitor start through the new exportedsink.CounterSinkinterface, which the description doesn't mention. The docs and code comments are accurate.
Validation
- I couldn't run
go vetorgo testin this session: Go commands need interactive approval. The findings above come from reading the code atef1fd3d. The CIbuildjob was still pending when I checked. - Coverage: correctness, concurrency (
stackMux, clutch locals), loader lifecycle (load/unload/start/stop-loss), contracts (CounterSink, API, docs), test effectiveness. Security and performance aren't materially affected.
A collector panic was counted only as an EMR timeout and a domain not returned, so it looked like a CPU-starved host. Count it in collector-panics. Also count collectors that return context.DeadlineExceeded at their CMR in collector-timeouts. Flushing on panic is unchanged: the engine still waits for the domain until the EMR, so a panic is also an EMR timeout, as documented. The exporter engine registered the same counters as the level collector engine, so Prometheus scrapes were counted as LCO collections. The exporter engine now uses the same counters prefixed "exporter-", like exporter-engine-emr-timeouts. Count collections dropped because TransformMetrics returned an error in level-transform-dropped. Metrics lost to a sink or TransformMetrics panic are still not counted, as documented. Reword level-metrics-dropped: it counts collections, which can include late metrics from more than one interval. clutch.collect panic recovery read cl.bg without the lock. After a collector fault, the next interval's goroutine sets cl.bg while the faulted goroutine can still panic. Read cl.bg into a local while the lock is held at the start, and set the local where this goroutine sets cl.bg, so the recovery keeps its foreground or background behavior. Document that a sink wrapping another sink must forward RegisterCounters, and tighten the dropped ticks test to expect exactly 2 with half a tick of margin. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
zyin-squareup
left a comment
There was a problem hiding this comment.
🤖 This review was generated by an AI agent on behalf of @zyin-squareup.
Summary. This is a re-review of 54b2026, which adds collector-panics, collector-timeouts, level-transform-dropped and the exporter- counter prefix, and fixes the cl.bg read in panic recovery. I found no blocking issues. Approving.
Earlier findings
| No. | Finding | Status | Evidence |
|---|---|---|---|
| 1 (blocking) | Sink counter deleted on config-change reload | Fixed (unchanged since ef1fd3d) |
registerSinkCounters still runs from startup. TestLoaderUnloadedMonitorsHaveNoCounters passes with -race. |
| 2 | Reload regression test is skipped in CI | Still open (non-blocking) | TestLoaderReloadKeepsSinkCounters still requires MySQL (loader_counters_test.go:69-75). Locally it fails with dial tcp 127.0.0.1:33800: connection refused; in CI it is skipped. |
| 3 | PR description stale about counter registration | Fixed | The description now says counters are created "when the monitor starts" and explains sink.CounterSink. |
New code in 54b2026
bglocal inclutch.collect(engine.go:618,679): correct.cl.bgis never set back tofalseanywhere inmonitor/, so reading it under the lock at the start and setting the local where this goroutine setscl.bgkeeps the old semaphore-release behavior and removes the unlocked read.TestClutchPanicAfterCollectorFaultpasses 3/3 with-race.countTimeout: it is called after the fence check and under the lock in both the foreground and background paths, so a run is counted once and a fenced run is not counted.- Exporter prefix: the exporter engine now registers its own
exporter-*counters, and its clutches gete.counters(engine.go:227), so scrapes no longer increment the LCO counters.
NON-BLOCKING
collector-timeoutsmisses CMR timeouts fromtrxandstmt.current(inline onengine.go:701).- The exporter prefix test calls
newExporterEnginedirectly (inline onmonitor.go:368). - Docs overstate the panic/EMR link (
docs/content/api/status.md:60,status/counters.goCOLLECTOR_PANICS). They say every panic is also counted inengine-emr-timeoutsandengine-domains-not-returned. That is true only for a panic in the foregroundCollectof the current interval. A panic in theErrMorebackground loop, or in a fenced run, happens after the domain was already flushed or after its interval was swept, so the EMR counters do not change. Suggest "a foreground panic is also counted in ...".
Validation
go vet ./status/ ./sink/ ./monitor/ ./server/: clean.go test -race -count=1 ./status/ ./sink/ ./server/: all pass.go test -race -count=3 ./monitor/ -run "TestEngineCounts|TestClutch|TestDroppedTicks|TestLevelCollectorCounts|TestExporterEngine|TestLoader": everything passes 3/3 exceptTestLoaderReloadKeepsSinkCounters, which needs a local MySQL (environmental).- I could not run mutation checks in this session. Pin coverage comes from reading the assertions: the panic, timeout, transform and
-racepanic-after-fault tests each assert their counter exactly. The exporter wiring instartupis not pinned (note 2). - CI (
build, Semgrep, zizmor, DCO) is green on54b2026.
Coverage: correctness, concurrency (clutch locals, fence), contracts and docs, test effectiveness, observability. Security and performance are not materially affected.
Only a panic in the foreground Collect of the current interval is also counted in engine-emr-timeouts and engine-domains-not-returned. A panic in the ErrMore background loop, or in a run behind the collector fault fence, is counted only in collector-panics. collector-timeouts counts only collectors that return the context error or wrap it with %w. Some built-in collectors wrap query errors with %s, so their CMR timeouts aren't counted yet. On a CPU-starved host, collector-timeouts rises almost one for one with engine-emr-timeouts, so document level-ticks-dropped as the counter to watch for starvation. Also comment that startup must make the exporter engine with newExporterEngine so its counters are prefixed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Why
When a host is short on CPU or a sink is slow, Blip loses or delays metrics without saying so. Domains that miss the engine max runtime (EMR, the per-collection deadline), dropped level collector ticks, and sink retry buffer overflow are logged at debug level or not at all. An operator can't tell that a monitor is collecting partial metrics.
What
GET /status/monitors/countersto read them as JSON.clutch.collect(commit3d85f5f).How
*atomic.Uint64counters once, when the monitor starts. Increments are lock-free and don't allocate.engine-emr-timeouts,engine-domains-not-started,engine-domains-not-returned,collector-faults,collector-panics,collector-timeouts,collector-flush-drops,level-ticks-dropped,level-metrics-dropped,level-transform-dropped, andsink-retry-dropped:<sink>.engine-*andcollector-*counters prefixed withexporter-, so scrapes aren't counted as level collector collections.sink.CounterSinkinterface when the monitor starts. That's after the loader unloads any earlier monitor with the same ID, so a config reload keeps the counters, and a failed load or stop-loss leaves none behind.engine-emr-timeoutevent is now sent, as info rather than an error.status.ReportCounters(ids...)lets a host that embeds Blip poll the counters and forward them.clutch.collectreadcl.ctxandcl.m.Levelafter it released the lock. It now uses the values captured under the lock.runLooprestarts it afterStopcan recreate its counters. That restart is fixed up-stack in Stop removed monitors in parallel outside the loader mutex #190, whererunLoopchecksstopChanbefore every restart.Risk
Low. Collection behavior is unchanged. The new code adds counters and one read-only API route.
Testing
A local benchmark ran 400 monitors in one process against 4 MySQL 8.0 servers. It read
GET /status/monitors/countersthe whole time.Unit tests cover each counter, including a collector panic, a CMR timeout, the exporter prefix,
TransformMetricsdrops, a panic after a collector fault (run with-race), and counters across a config reload and a stop-loss.Bigger picture
This is the first of 5 fixes found by that benchmark. The counters make the faults the other PRs fix visible.
🤖 Generated with Claude Code