v0.19.0
A minor release, not a patch: Diagnosis gains fields and two tools gain output.
It also carries a security fix — 0.18.1 and earlier can leak the VM Service
credential into tool output when the observed app logs its own debug-service URI,
which a web target does on startup. That token authorises evaluate, so
upgrade rather than pin.
Added - Phase D, evaluation
The diagnosis engine is now measured against recorded sessions instead of only
against its own unit tests. Every other test here builds a store by hand and
checks what the engine does with it; that measures the code against its author's
expectations, which is not the same as measuring it against an app that actually
misbehaved.
-
eval/incidents/*.json- golden incidents: a real session captured from a
running app withexport_sessioninfullmode, plus what the right answer
is. Eight so far, across three targets — an Android device over adb, a Windows
desktop build, and Chrome — because a target is part of what a diagnosis has to
cope with, not an implementation detail. Two deliberately straddle the jank threshold: 74/381 frames
(19.4%) must stayunknown, 48/240 (20.0%) must be diagnosed asjank. The
pairing is the point - together they pin the boundary, which is the part of a
heuristic that drifts. Two unrelated incidents would not. The other two are a
ranking pair: both contain an exception and jank over threshold, and their
right answers are opposite, so no fixed ordering of the two hypotheses can
satisfy both. The fifth coversnetwork: one endpoint returns 500 four times
while another returns 200 four times, so connectivity demonstrably works and
the finding is specific to an endpoint. The sixth coversmemory: twelve heap
samples rising monotonically from 89MB to 217MB with no drop anywhere, which
is the argument — a healthy app under load produces a sawtooth, and twelve
consecutive rises is not one.externalUsageMBis 0 throughout, so the growth
is Dart heap rather than external allocation accounted elsewhere, and
heapCapacityMBclimbs with it, so the VM was expanding for live data rather
than sitting on uncollected garbage. EveryCauseKindnow has a recording.The seventh is a second negative, and it pairs with the first on a different
axis. One abstains because the jank ratio is too low (19.4% of 381 frames);
the new one abstains despite a ratio of 100% — one janky frame out of one —
because the sample is too small. Dropping the three-frame floor to one turns
a single 90.4ms startup frame into a confident jank verdict, which would be the
most embarrassing false positive available: technically accurate, useless, and
true of every app that has ever started. Neither negative catches the other's
bound.The eighth is the first recorded on a non-native target: the same workload on
Chrome, where two collectors genuinely cannot see their domain.jankis still
the right answer (30 of 52 frames, build-bound at 48.1ms against 5.4ms), and
the diagnosis must also reportexceptionandnetworkas unobservable
rather than merely empty.Expectationgains an optionalunobservablefield
and replay feeds the recorded collector health back in, so that is scored
behaviour rather than a claim in prose. -
Two ranking policies pinned by unit test, both found by mutation rather
than by reading the code. Sustained heap growth must not outrank an evidenced
jank pattern — promotingmemoryabovejankpassed the entire eval suite
unnoticed, because no recording constrains that edge and a recording that
would needs a session where the ground truth is genuinely arguable (if
allocation pressure caused the collection pauses that caused the jank, memory
is the cause and jank the symptom). And heap growth is never described as a
leak: the restraint inGrowth is not proof of a leakis part of the
contract, not a wording accident. -
probe/flaky-server.mjsand anetworkscenario inbloc_probe. Offline
and deterministic — an incident that depends on someone else's server is not
reproducible, and a probe should not make outbound requests to record a
fixture. -
Header redaction is now proven on a recorded artifact, not only in a unit
test. The probe sendsAuthorization: Bearer …on every request; the recorded
incident contains"authorization": "[REDACTED]", listsauthorizationunder
redactedHeaders, and the token string appears nowhere in the file. The
untested claim was never the redaction function — it was that redaction runs
at capture, so the artifact a developer pastes into a chat is clean. -
src/eval/replay.ts- hydrates a recorded session and re-runs the
diagnosers. Nothing new had to be invented to record an incident:
export_sessionwas already versioned, carried every event and diagnosis, and
had its shape pinned by a test. -
src/eval/score.ts- top-1 accuracy, evidence recall, false-confidence
rate, unknown precision, dangling-evidence count. -
A CI gate, asymmetric on purpose. Accuracy has a floor; false confidence
has a ceiling of zero. A tool that says "unknown" when it cannot tell costs a
developer nothing - they go and look themselves, which is what they were doing
anyway. One that says "the network call caused your jank" at 85% confidence
when it did not sends them where the bug is not, and spends the credibility
that makes the correct answers worth reading. -
RuntimeStore.hydrate- loads already-stamped events back in unchanged.
Notadd(), which mints fresh ids from a counter: replaying through it would
renumber everything, so everyexc_00042a diagnosis cited would point
somewhere else and evidence precision would be meaningless.
Verified by mutation rather than assumed, because a gate that only ever passes
is decoration. Loosening the jank threshold to 15% makes the 19.4% session get a
confident jank verdict and trips three tests including false confidence.
Tightening it to 25% makes the 20.0% session abstain and trips two - notably
not false confidence, because abstaining when jank was real is wrong, not
confidently wrong. On the exception path: blinding the exception detector, or
letting jank outrank it, trips false confidence both times; removing only the
stack-trace confidence bonus trips the band and leaves false confidence at zero.
Same asymmetry, from the other side. On the ranking: restoring the unconditional
exception priority trips false confidence at 25%, while dropping jank's strength
below the confidence threshold trips status and band with false confidence still
at 0% - abstention and confident wrongness scored apart, which is the whole
point of the metric.
Verified
-
A 25-minute session, past every ring capacity. Every recorded incident is
under a minute and reportsevicted: 0, so retention had never been exercised
against a real app — and the frame ring holds 1,000 events, roughly 100 seconds
at 10fps. Held a session for 25 minutes, sampling every 60s: frames cap at
1,000 at minute 4, eviction climbs to 6,168 frames and 5,167 state events by
minute 25, and from the moment it starts the diagnosis carries the retention
note. Truncation is not silent. The diagnosis stayed stable across 75 tool
calls with no drift.Two things it does not show, stated because the run looks more conclusive than
it is. The jank ratio held at 31% throughout, but the workload is uniform — so
this shows the mechanism survives eviction, not that the ratio would reflect a
session whose behaviour changed once the denominator wraps. And the probe for
the server's own memory returned nothing, so there is no data on process
growth over the run.
Added - verification
-
probe/sweep-live.mjs— calls every read-only tool against a running app
and exits non-zero if any errored or if the VM Service credential turned up in
a response.verify-release.shchecks the package; nothing checked the
behaviour, and both defects that made 0.18.0 unusable were found by hand
exactly this way. Verified to fail, not assumed to: removing the credential
scrub makes it reportcredential leaks: 1 -> get_logson a web target, and it
is what caught theget_timelinedefect above.Its limits are written down rather than left to be discovered. The credential
check is opportunistic — it can only see a leak the app actually logged, and
only while that line is still in the backlog DDS replays on connect. Reading
collector health offruntime_statusinstead ofruntime_healthmade it
silently print nothing at first, which is the same class of bug it exists to
catch.
Added - coverage
-
coverage.unobservableon every diagnosis: of the empty categories, those
nothing could have seen, because the collector responsible is degraded or
unavailable on this target.emptyis unchanged, so nothing about its meaning
moved — the split is the point. An agent readingempty: [exception, network]
on a web target would conclude the app threw nothing and made no failing
requests; both are unfounded there, and the second is reassurance drawn from a
question that was never asked.Measured rather than reasoned about: on Chrome
networkisunavailable(no
dart:io) andexceptionisdegraded(structured error reporting is off off
native), whilenavigationis empty simply because the app did not navigate.
Three empty categories, two different reasons, and the diagnosis now says
which is which. A category that is blind but still holds events is not counted
as a hole.
Added - cost
-
What this server costs the caller is now measured. Every response is input
tokens on the agent's next turn, and that was previously guessed at. Measured
against a live app with ~1,700 events retained:tools/listis 16.7kB
(~4,200 tokens) before any work happens,diagnose_runtimeis 8.2kB,
get_framesis 19.8kB for its default 50 frames, andexport_sessionis 36kB
inbriefagainst 247kB infull— roughly 62,000 tokens, about a third of
a 200k context window in a single call.full's description now says so, so
the choice between the two modes is informed rather than a coin flip.runtime_statusreports the running total:cost.calls,
cost.responseBytes,cost.estimatedTokensand a per-tool breakdown ranked by
bytes. Registration is wrapped rather than each handler edited, so a tool
cannot be added later without being counted.Bytes are counted; tokens are an estimate at bytes/4 and labelled as such
everywhere, because the real count depends on a tokenizer this server cannot
see. Deliberately not added toexport_session: a tool cannot include its own
cost — the cost is the size of the response being built — so the number inside
an archived artifact would mean "before this export", which is more confusing
than useful.
Changed
-
An exception no longer outranks a performance pattern unconditionally.
exceptionHypothesiscarried priority 3 whatever it was made of, so any
exception beat any jank evidence. Measured against a real session, that is a
confident wrong answer:A RenderFlex overflowed by 390 pixels on the bottom.
arrives as aFlutter.Error, in theexceptioncategory, at severity
error- indistinguishable from a crash by category - and it was named as the
root cause of a session whose actual fault was 45ms of work insidebuild.The ranking now turns on whether the exception carries a stack trace, which is
the one discriminator actually present in the captured payload (libraryand
typeare identical between the two). With a stack it names a line to go and
fix and still outranks jank; without one it is a framework diagnostic and does
not outrank an explanation the evidence supports more strongly. Demoted, never
discarded: it stays inalternativeCauses, and when nothing else fires it is
still the primary hypothesis. -
A jank verdict now cites its own worst frame. Evidence was sliced from a
most-recent-first list, so it could cite eight frames from the tail of a burst
and omit the frame the verdict was built on. -
Diagnosisgainscause, a stableCauseKind(exception|jank|
network|memory|unknown), andAlternativeCausegains the same.
Additive - nothing renamed or reshaped.This was the actual blocker for Phase D.
rootCauseis prose written for a
human and carries live numbers - an exception message, "worst was 85ms" - so
two runs over the same fault produce different strings and nothing can be
scored against it. Callers that were branching onrootCausetext should
branch oncauseinstead.
Fixed
-
what_changedsliced its windows on the wrong clock, a consequence of
moving events onto the app's clock in the same release. With no exception to
anchor on, the window ended atDate.now()— this process's time — while every
event in the store now carries the VM's. Measured against a phone over adb the
two are 839ms apart, which only trims the window slightly; but the offset is
whatever the device's clock says, and a device an hour out puts
[now - 30s, now]entirely after every event. Both windows come back empty,
which reads as "nothing changed" rather than "I compared the wrong interval".It now ends at the newest captured event, and the note says so.
Date.now()
survives only for a session with no events at all, where there is nothing to be
wrong about.The first version of that fix took the head of the query result, which was also
wrong:query()orders by insertion, and since events carry post-time rather
than arrival time those orders have come apart — DDS drains a backlog in
arrival order while each event keeps the time the app posted it. A test that
inserts events the way a backlog actually arrives put the window's end 7.8s
before the newest event, so it takes the maximum instead. -
The VM Service credential could leak through the observed app's own logs.
On a web target the app printsThis app is linked to the debug service: ws://127.0.0.1:60106/<token>=/wsto its console, and the log collector stored
that line verbatim — from where it travelled intoget_logs, into
export_session, and into any artifact a developer pasted somewhere. The path
segment authorisesevaluate: arbitrary Dart execution inside the app. 0.18.1
stopped this server leaking the token in its own output; this is the same
credential arriving through the app's output instead, and it was found by
inspecting a candidate fixture before committing it.redactTextnow scrubs it two ways: the session's own token wherever it
appears (registered on connect, before any collector subscribes, because that
log line is among the first things a web target prints), and the VM Service
endpoint shapews://host/<segment>/wsfor tokens from anywhere else. Ordinary
websocket URLs keep their paths, because over-redaction destroys evidence.Deliberately not gated on
config.enabled.FLUTTER_LAMP_REDACT=off
exists so a developer can read their own app's headers and log text — a choice
about their data. This is the key to the app being debugged, and no switch
hands it out. The test for that was itself false-passing at first: the config
is read once at import, so setting the variable without
reloadRedactionConfig()exercised the enabled path and proved nothing. -
get_timelinehanded a raw protocol error to the agent. On a target
without a VM timeline it surfaced
VM Service error -32601: Unknown method "getVMTimeline"— measured on Chrome,
which runs on DWDS rather than a Dart VM. Every collector in this codebase
explains an absent capability instead of leaking a protocol code, and this tool
did not. It now answers{available: false, detail: …}with the reason, in the
same shape as an empty-but-healthy read, so a caller does not have to
special-case a failure to learn a fact about its target. Release builds are
likely to hit the same path. -
An empty exception list could mean blind, and said
active.
ExceptionCollectorwas the one collector with nohealth(). Subscribing to a
stream always succeeds, so it reported healthy on targets where
Flutter.Errorcan never fire — the widget inspector posts it only while
FlutterError.presentErroris its structured reporter, and the framework
leaves that off in profile mode and on the web
(isStructuredErrorsEnableddefaults to!kIsWeb). An agent reading "no
exceptions" there was reading blindness as health, which is exactly the
confusionCollectorHealthwas introduced to prevent.It now asks the app via
ext.flutter.inspector.structuredErrorsand reports
degradedwith a detail naming what is lost and what still works. A failed
check claims nothing either way — a false alarm on a healthy app would be as
misleading as silence on a blind one.One measured detail this turns on: that extension replies
{enabled: "true"}
with a string, whileext.dart.io.httpEnableTimelineLoggingreturns a real
boolean from the same protocol. Comparing againsttruewould have reported
every healthy app as blind, and a mutation test pins it. Verified end to end by
flipping the extension off on a running app (degraded, with the detail) and
back on (active).docs/Implementation-Notes.mdclaimed "Flutter.Error(framework) is always
captured". That was wrong; it now says when it is not. -
Events are stamped with the app's clock, not with ours. Every collector
usedDate.now()at the moment of receipt. A VM ServiceEventcarries the
time the VM posted it, and the difference is not cosmetic: DDS hands over a
backlog the instant a stream subscription is accepted, and a slow link stalls
and then flushes. Measured against a running app — six backlog frames arrived
within 1ms of each other by receipt while their posted times spanned 331ms,
and a nine-second session lost 1.1s of its span. Over adb/WiFi it is far
worse: 111 frame events inside one second, above any refresh rate.Everything that reasons about order or windows was wrong by exactly that
much: the 3s correlation window,what_changed's baseline-versus-incident
comparison, the timeline handed to a human, and the ordering arguments a
recorded incident rests on. After the fix, a live capture shows at most 10
frames in any second against a 100ms tick, with a median inter-frame gap of
103.0ms.Two stamps stay on this machine's clock on purpose — this server's own notes
about the connection, and polled memory samples, which are our observations
rather than the app's. Because that mixes two clocks in one timeline, the
status tool and every export now reportclockOffsetMs(VM minus ours, last
observed; 0ms measured on a loopback target). Reported, not corrected: a
correction never measured against a genuinely skewed device would be a guess
applied to every timestamp. -
A chatty app could starve the exception out of its own diagnosis.
diagnose()read a flat most-recent-2,000-event window across all categories,
so a category that bursts could crowd out a rare one. Measured, not
hypothesised:bloc_probepushed 2,000 provider events in 30 seconds, which
put the session's only exception 2,436th newest. The engine never saw it and
reported that no exceptions were found - a confident false negative
contradicted by the store it was reading, which is the exact failure this
engine exists to avoid. The cap is gone; retention is the only truncation, and
coverage.evictedalready reports that. -
dist/eval/was shipping in the npm tarball (79 -> 83 files). Development
tooling in a runtime package. Excluded, andverify-release.shnow refuses
/eval/so it cannot come back silently.