Skip to content

v0.19.0

Choose a tag to compare

@github-actions github-actions released this 28 Aug 05:49
· 22 commits to main since this release

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 with export_session in full mode, 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 stay unknown, 48/240 (20.0%) must be diagnosed as jank. 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 covers network: 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 covers memory: 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. externalUsageMB is 0 throughout, so the growth
    is Dart heap rather than external allocation accounted elsewhere, and
    heapCapacityMB climbs with it, so the VM was expanding for live data rather
    than sitting on uncollected garbage. Every CauseKind now 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. jank is still
    the right answer (30 of 52 frames, build-bound at 48.1ms against 5.4ms), and
    the diagnosis must also report exception and network as unobservable
    rather than merely empty. Expectation gains an optional unobservable field
    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 — promoting memory above jank passed 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 in Growth is not proof of a leak is part of the
    contract, not a wording accident.

  • probe/flaky-server.mjs and a network scenario in bloc_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 sends Authorization: Bearer … on every request; the recorded
    incident contains "authorization": "[REDACTED]", lists authorization under
    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_session was 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.
    Not add(), which mints fresh ids from a counter: replaying through it would
    renumber everything, so every exc_00042 a 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 reports evicted: 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.sh checks 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 report credential leaks: 1 -> get_logs on a web target, and it
    is what caught the get_timeline defect 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 off runtime_status instead of runtime_health made it
    silently print nothing at first, which is the same class of bug it exists to
    catch.

Added - coverage

  • coverage.unobservable on every diagnosis: of the empty categories, those
    nothing could have seen, because the collector responsible is degraded or
    unavailable on this target. empty is unchanged, so nothing about its meaning
    moved — the split is the point. An agent reading empty: [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 network is unavailable (no
    dart:io) and exception is degraded (structured error reporting is off off
    native), while navigation is 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/list is 16.7kB
    (~4,200 tokens) before any work happens, diagnose_runtime is 8.2kB,
    get_frames is 19.8kB for its default 50 frames, and export_session is 36kB
    in brief against 247kB in full — 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_status reports the running total: cost.calls,
    cost.responseBytes, cost.estimatedTokens and 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 to export_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.
    exceptionHypothesis carried 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 a Flutter.Error, in the exception category, 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 inside build.

    The ranking now turns on whether the exception carries a stack trace, which is
    the one discriminator actually present in the captured payload (library and
    type are 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 in alternativeCauses, 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.

  • Diagnosis gains cause, a stable CauseKind (exception | jank |
    network | memory | unknown), and AlternativeCause gains the same.
    Additive - nothing renamed or reshaped.

    This was the actual blocker for Phase D. rootCause is 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 on rootCause text should
    branch on cause instead.

Fixed

  • what_changed sliced 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 at Date.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 prints This app is linked to the debug service: ws://127.0.0.1:60106/<token>=/ws to its console, and the log collector stored
    that line verbatim — from where it travelled into get_logs, into
    export_session, and into any artifact a developer pasted somewhere. The path
    segment authorises evaluate: 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.

    redactText now 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 shape ws://host/<segment>/ws for 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_timeline handed 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.
    ExceptionCollector was the one collector with no health(). Subscribing to a
    stream always succeeds, so it reported healthy on targets where
    Flutter.Error can never fire — the widget inspector posts it only while
    FlutterError.presentError is its structured reporter, and the framework
    leaves that off in profile mode and on the web
    (isStructuredErrorsEnabled defaults to !kIsWeb). An agent reading "no
    exceptions" there was reading blindness as health, which is exactly the
    confusion CollectorHealth was introduced to prevent.

    It now asks the app via ext.flutter.inspector.structuredErrors and reports
    degraded with 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, while ext.dart.io.httpEnableTimelineLogging returns a real
    boolean from the same protocol. Comparing against true would 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.md claimed "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
    used Date.now() at the moment of receipt. A VM Service Event carries 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 report clockOffsetMs (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_probe pushed 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.evicted already reports that.

  • dist/eval/ was shipping in the npm tarball (79 -> 83 files). Development
    tooling in a runtime package. Excluded, and verify-release.sh now refuses
    /eval/ so it cannot come back silently.