Repository navigation
Record web and composed-panel read durations into hourly latency histograms in the store (#4442) - #4451
Merged
Conversation
…ome classifier, and record web and compose reads (#4442)
…in (#4442) ReadLatencyPercentilesTests' field-shaped fixture asserted p99 strictly above 5,000 ms, but with the fixture's exact 90/9/1 split the 990th sample lands exactly on the slow tier's own upper bound -- fixed to assert the honest answer, 5,000 ms. Added ReadLatencyWebRecordingTests: drives a real /api/read/<name> request through the actual web pipeline and asserts the read-latency accumulator received the sample the dispatch loop is supposed to record, plus a second case for the 57014 timeout shape.
…n a constructor-shape pin (#4442)
erikdarlingdata
marked this pull request as ready for review
September 26, 2026 22:59
erikdarlingdata
added a commit
that referenced
this pull request
Sep 26, 2026
#4453) Records every MCP tool call's duration into the read-latency histograms that #4451 added, under the Mcp surface. - McpToolLatencyFilter is a third call-tool filter. It's registered once in DarlingMcpHostService.ConfigureMcpServices, between McpUnknownArgumentGuard and GcfCallToolFilter. It times every tools/call on / and /core. - Outcomes: - a throw goes through ReadOutcomeClassifier.Classify; - an error result goes through ClassifySentence, so a 57014 statement timeout is Timeout; - anything else is Ok. - run_custom_view_panel is skipped, because RunComposedPanelAsync already records it once as Compose. - Recording never throws into the call. - The filter records into the same ReadLatencyAccumulator singleton the web host and the worker's hourly flush use. The new constructor parameters are optional, so hosts built in tests get a private accumulator. - Tests: McpToolLatencyRecordingTests drives real calls through the MCP host's own configuration, using probe tools that exist only in the test's host. It covers four cases: - an ok call is recorded as Ok; - a statement-timeout result is recorded as Timeout; - /core list_servers is recorded; - run_custom_view_panel adds no Mcp sample. Refs #4442
This was referenced Sep 27, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Refs #4442 (scope 2).
Why
Slow reads on the
/api/read/*surface and the composed-panel runner have no stored history — the onlysignal is the 5-second slow-query log tail, which can show the newest offender but never a percentile or a
"how often does this time out" answer. This adds an in-process histogram that records every finished read,
so a later store table and read tool can answer that question from stored series instead.
What changes
ReadLatencyAccumulator: aCollectorCostAccumulator-shaped histogram keyed by(surface, route, outcome),fixed log-scale buckets (10 ms – 120 s plus overflow), thread-safe, drained (and reset) for a later hourly
flush.
ReadLatencyPercentiles.FromBuckets: pure percentile math over the buckets — an upper-bound estimate,never an interpolation, flagged
IsAtLeastwhen the true value only proven to exceed the last finite bound.ReadOutcomeClassifier: maps an exception, or a tool's own caught-and-formatted error sentence, toOk/Timeout/Cancelled/Error, reusingCollectorFaultCancelOrigin's SQLSTATE-plus-wording rulerather than a second copy of it.
DarlingWebEndpoints.MapAll's/api/read/*dispatch loop (both the binding-layercatch and the tool-result arm) and into
RunComposedPanelAsync's one call site, with a bounded-cardinalitymeasureKeylabel (never the caller's free text). Recording never throws into the request — a try/catchlogs at Debug and swallows.
Program.csandDarlingWebHostService.collect.read_latency(metric_time, surface, route, outcome, run_count, total_ms, max_ms, bucket_counts bigint[], primary key on the first four columns), a plain table — thecollector_cost(V105) shape: internal self-telemetry, not a hypertable, with its own bounded retentiondelete. It needs no per-table
GRANT(thecollectschema's blanket grant covers it once provisioningre-runs).
ReadLatencyAccumulator.FlushAsyncdrains the in-memory buckets and writes one row per(surface, route, outcome) actually hit in the hour, upserting with
ON CONFLICTwhen a second flush landsin the same hour (adding counts, totals and bucket counts element-wise, and taking the greater
max_ms),followed by a 90-day retention delete — mirroring
CollectorCostAccumulator.FlushAsyncin every way butone: Npgsql's array binding rejects a jagged
bigint[][]bulk-insert parameter, so this flush issues oneINSERTper drained key instead ofcollector_cost's singleunnest(...)bulk insert. The row count isbounded by distinct (surface, route, outcome) combinations actually hit — at most a few hundred, never one
per read — so the extra round trips are the right shape for this table's real size.
DarlingWorkertakes the sameReadLatencyAccumulatorsingleton DI hands the web host,and flushes it on the same tick and connection as
collector_cost, right after it, isolated in its owntry/catch so a failed read-latency flush costs only its own hour's rows and never the collector-cost series.
This pass finished the previous one's work: ran the three unit test classes plus
DocCommentHygieneTests,FleetSweepWebFeedTests,SharedBaselineCacheTests(all touched by the change), fixed one test bug (below),and added the product-path pin that was missing.
Test plan
The percentile fixture (
ReadLatencyPercentilesTests.Percentiles_OverAFieldShapedFixture_LandInTheExpectedBuckets):1,000 samples — 900 uniformly spread 50–200 ms (fast), 90 uniformly spread 1,000–5,000 ms (slow), 10
uniformly spread 20,000–60,000 ms (very slow). p50 lands at or under 200 ms; p95 lands strictly above 200 ms
and at or under 5,000 ms. Fixed a test bug: the original assertion expected p99 strictly above 5,000 ms,
but with this exact 90/9/1 split the 990th-of-1,000 sample (
ceil(0.99 * 1000)) is the top of the slow tieritself — the correct, honest upper-bound answer is exactly 5,000 ms, not a value past it. Assertion now reads
Assert.Equal(5_000, p99.UpperBoundMs).Total lines after the fix, in-process on this machine:
ReadLatencyAccumulatorTests+ReadLatencyPercentilesTests+ReadOutcomeClassifierTests+DocCommentHygieneTests+FleetSweepWebFeedTests+SharedBaselineCacheTests:Total: 145, Errors: 0, Failed: 0, Skipped: 0, Not Run: 0.The product-path pin (
ReadLatencyWebRecordingTests, live-Postgres,[Collection("live-postgres")]):drives a real
/api/read/get_notification_routesrequest through the ACTUALDarlingWebHostService.ConfigurePipelineDarlingWebEndpoints.MapAllwiring (the sameTestServerpatternDarlingWebFailureHandlingTestsuses),with its own freshly-constructed
ReadLatencyAccumulatorinstance passed straight to that request'sMapAllcall — captured before the request rather than read back off the shared process-lifetime static, so no
[Collection]lock against any other class's ownMapAllcall is needed. Asserts the accumulator drainedexactly one
Websample forget_notification_routeswith outcomeOk. A second case sends a request to atest-only route that throws the 57014 statement-timeout shape and asserts the response is a 503 (the existing
ToHttpResultclassificationDarlingWebFailureHandlingTestsalready pins); because that route sits outsideBuildReadDispatch, the/api/read/*loop's ownRecordcall never runs for it, so this pin asserts nothingdrained rather than asserting a
Timeoutsample that route can never produce.A third fact covers the timeout outcome through the loop itself:
BuildReadDispatchnow folds in one extra, test-only dispatch entry(
s_testOnlyExtraDispatchEntry,internal, null in every production run — no production caller ever sets it)when a test has registered one, so a real
PostgresExceptionwith SqlState57014and the statement-timeoutwording travels through the SAME
/api/read/*loop every production route uses, not a hand-calledRecordstanding in for that wiring. The fact registers the entry under a name no real tool uses, sends a request to
it, and asserts the drained accumulator holds exactly one
Websample for that route with outcomeTimeoutand count 1.
Total: 3, Errors: 0, Failed: 0, Skipped: 0, Not Run: 0.A second real mutation, for the new fact: removed the classifier's 57014-to-
Timeoutarm inReadOutcomeClassifier.Classify(falling through toErrorfor everyPostgresException), rebuilt, reran —the new fact went RED (
Assert.Single(...) Failure: The collection did not contain exactly 1 matching element,because no sample carried
Timeout). Restored the arm, rebuilt, reran: green again (Total: 3, Failed: 0).The composed-panel path (
RunComposedPanelAsync) was not given an extra assertion here: its own live pins(
DarlingComposeLivePostgresTests,RunComposedPanel_RejectsBadAbsoluteWindows) exercise validation and aplain-store round trip, not a store-side statement timeout, and building a cheap live low-timeout rig for it
was out of scope for this pin — a later change can add that fact beside these once a route/panel combination
for it exists.
RED on
origin/devat runtime:origin/devhas none of the new types (ReadLatencyAccumulator.cs,ReadLatencyPercentiles.cs,ReadOutcomeClassifier.cs, theMapAll/DarlingWebHostServiceconstructorparameters) — copying the new test files onto that commit does not build; RED there is a compile failure,
not a runtime one.
A real mutation: removed the
RecordWebReadLatency(name, webOutcome, stopwatch.ElapsedMilliseconds);call from the
/api/read/*dispatch loop's success arm, rebuilt, reranReadLatencyWebRecordingTests— thefirst fact went RED (
Assert.Single() Failure: The collection did not contain any matching items), thesecond still passed (it never depends on that line). Restored the line, rebuilt, reran: both green again
(
Total: 2, Failed: 0).Both
Darling.TestsandLite.Testsbuild 0 errors (Windows-targeting build on macOS;Lite.Testsstillcannot run here).
The V148 rung (
ReadLatencyFlushLiveTests): the top-of-ladder claim (PgMigrations.Scripts[^1].Version,StorageVersion.SchemaVersion,ViewerDataService.RequiredStoreSchemaVersionall equal 148) and the viewerprobe sentinel/arm ordering, taking over the "top rung" claim from
ComposeStatementTimeoutV147MigrationLiveTests(V147), which now asserts it sits one below the top instead. Four live facts against a fresh scratch database
per fact: a record-and-flush round trip matching counts/totals/maxes/buckets element-wise; a second same-hour
flush adding to the first; retention removing rows past 90 days while keeping newer ones; and an empty flush
writing no rows.
Total: 6, Errors: 0, Failed: 0, Skipped: 0, Not Run: 0forReadLatencyFlushLiveTestsalone.RED on
origin/devat runtime:origin/dev(e25452175) has noReadLatencyAccumulator.csat all —copying
ReadLatencyFlushLiveTests.csthere does not build; RED is a compile failure, not a runtime one.A real mutation: replaced the
bucket_countsinsert parameter with an all-zero array of the same length(skipping the real histogram), rebuilt, reran the round-trip fact — it went RED (
Assert.Equal() Failure: Values differ, the stored buckets no longer matched the recorded ones). Restored the line, rebuilt, reran:green again (
Total: 6, Failed: 0).Also run, all green:
ManagedConfVerdictsRungTests(3),ComposeStatementTimeoutV147MigrationLiveTests(5, updated now that V148 is the top rung),
MigrationDataMovingRungCensusPins(28),ViewerDataServiceTests(1),MigrationUpgradeLadderLiveTests(5),DocCommentHygieneTests(77, after moving a cross-projectcreftoplain prose —
PgMigrations.cs, in theStorageproject, cannot resolve a symbol declared inService),ReadLatencyAccumulatorTests(7),ReadLatencyPercentilesTests(9),ReadOutcomeClassifierTests(8),ReadLatencyWebRecordingTests(3), andScheduledPurgeLaunchTests(5, updated for the new constructorparameter). Both
Darling.TestsandLite.Testsbuild 0 errors after the V148 change.AlertReadFailureSurfaceTestsnow exempts the read-latencyflush's catch (it writes instrumentation, not an alerting read — losing an hour's rows can't hide an
alert condition), and
SharedBaselineCacheTestsnow matchesBaselineCache baselineCachefollowed byeither
,or)(the constructor's parameter order is arbitrary, and a later parameter after itshouldn't need a pin update). Mutating the classifier's SqlState-57014 arm out of
ReadOutcomeClassifierturnedADispatchEntryThatThrowsA57014PostgresException_RecordsOneWebSample_WithOutcomeTimeoutRED (
Outcomerecorded asError, notTimeout); restoring it brought all four classes back to greenat
a9e36ec7:Total: 112, Errors: 0, Failed: 0, Skipped: 0, Not Run: 0.Final head:
a9e36ec72onfeat/4442-read-latency-histograms.CHANGELOG entry
SECTION: None
ENTRY: None: the store table and the hourly flush have no user-visible effect until the read tool lands (a later change).
Next
MCP tool timing and the read tool.