Skip to content

feat(telemetry): preflight baseline counters (filter-diagnostics engagement + availability blocks) - #995

Merged
Dumbris merged 5 commits into
mainfrom
telemetry-preflight-baseline-counters
Aug 15, 2026
Merged

feat(telemetry): preflight baseline counters (filter-diagnostics engagement + availability blocks)#995
Dumbris merged 5 commits into
mainfrom
telemetry-preflight-baseline-counters

Conversation

@Dumbris

@Dumbris Dumbris commented Aug 15, 2026

Copy link
Copy Markdown
Member

Related #969

Phase 0 of the required-tools-preflight roadmap: baseline counters that must ship one release ahead of the preflight feature (#969 item 3) so a genuine live before/after exists for the 60-day effectiveness review.

What

Two counter families, both following the existing Phase-H 24h-counter pattern (BBolt-backed, closed enum keys, capped cardinality, opt-out respected, counts only — never tool/server names or free text), flushed into the heartbeat as payload.Preflight:

Spec 094 filter-diagnostics engagement (shipped in v0.55.0 with zero usage measurement until now):

  • filter_diag_emitted_24h, filter_diag_missing_annotation_24h, filter_diag_explicit_24h
  • filter_diag_followed_24h — in-memory per-session note (15-min TTL, 256-session cap, never persisted, never leaves the process) detecting when a diagnostics suggestion was acted on

Availability / discovery-omission substrate (what the preflight's silent-vs-reasoned metric will read):

  • emitActivityPolicyDecision gains a structured closed-enum reasonKey across all 14 call sites (no error-string parsing)
  • availability_block_24h + per-reason map (12 closed keys), discovery_omission_24h

Privacy

Four independent enforcement layers: write-time normalization (unknown → other), snapshot filter, MarshalJSON filter + cap, and a new anonymity-scanner rule (preflight_field_invalid) that blocks the heartbeat if a non-enum key ever reaches the payload — the sub-object is a closed key set pinned by test to exactly what MarshalJSON emits. Opt-out enforced at event time (strictest existing posture).

Testing

  • go test -race green: internal/telemetry, internal/server (CI-shaped), internal/runtime, serveredition (-tags server)
  • golangci-lint v2 (.github/.golangci.yml): 0 issues
  • Adversarially reviewed; one scanner-gap hardening applied (closed key set + pinned allow-list tests)
  • Known pre-existing environmental failure only: TestRegistryAddCLIE2E (reproduces identically on pristine origin/main)

Docs: docs/features/telemetry.md updated (incl. the honest caveat that followed/emitted is a lower bound — sessions without an MCP session id can never increment followed).

…gement + availability blocks)

Phase 0 of the required-tools-preflight roadmap. These counters ship ONE
RELEASE AHEAD of the preflight feature on purpose: the metric preflight is
meant to move is "how often does an agent silently fail to reach a tool that
exists", and without a live pre-feature window there is nothing to compare the
post-feature numbers against — "did preflight help?" would degrade into an
argument about anecdotes. Shipping the measurement first buys a real
before/after out of the normal release cadence.

Two counter families, both on the existing Phase-H 24h-counter substrate
(BBolt bucket, encodeCounter/readCounterWithDecay, closed enum keys, capped
cardinality, counts only):

1. Filter-diagnostics engagement (spec 094 shipped with zero usage
   measurement): filter_diag_emitted_24h at the attach site,
   filter_diag_missing_annotation_24h / filter_diag_explicit_24h summed from
   the counts the block already computes, and filter_diag_followed_24h — a
   later retrieve_tools call in the same MCP session that dropped or relaxed a
   filter the block blamed. The follow-through note is in-memory, per session,
   TTL-bounded, capacity-capped and consumed on first use; it holds only the
   filter keys the response already named, and never leaves the process.

2. Availability / discovery-omission classes, derived from the STRUCTURED
   decision points rather than from error strings: every call site of the
   single policy-decision funnel now declares a reason key from a closed enum,
   feeding availability_block_24h plus availability_block_reasons_24h; and
   discovery_omission_24h counts retrieve_tools responses that withheld locked
   or quarantined matches the caller could not see.

Privacy: counts only. The operator-facing block reason — which embeds server
and tool names — stays in the activity log; a key outside the enum is folded
into "other" at write time, filtered again at read time and in MarshalJSON,
and a new anonymity-scanner rule (preflight_field_invalid) blocks the
heartbeat if a non-enum key ever reaches the serialized payload. Counters are
gated at EVENT time, so nothing is persisted while telemetry is opted out.

The whole preflight sub-object is omitted when every counter is zero, so an
install that never trips one emits a payload shape-identical to today's.

Related #969
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 15, 2026

Copy link
Copy Markdown

Deploying mcpproxy-docs with  Cloudflare Pages  Cloudflare Pages

Latest commit: e9c5f23
Status: ✅  Deploy successful!
Preview URL: https://ce61b023.mcpproxy-docs.pages.dev
Branch Preview URL: https://telemetry-preflight-baseline.mcpproxy-docs.pages.dev

View logs

@codecov-commenter

codecov-commenter commented Aug 15, 2026

Copy link
Copy Markdown

@github-actions

github-actions Bot commented Aug 15, 2026

Copy link
Copy Markdown

📦 Build Artifacts

Workflow Run: View Run
Branch: telemetry-preflight-baseline-counters

Available Artifacts

  • archive-darwin-amd64 (29 MB)
  • archive-darwin-arm64 (26 MB)
  • archive-linux-amd64 (17 MB)
  • archive-linux-arm64 (15 MB)
  • archive-windows-amd64 (28 MB)
  • archive-windows-arm64 (25 MB)
  • frontend-dist-pr (0 MB)
  • installer-dmg-darwin-amd64 (22 MB)
  • installer-dmg-darwin-arm64 (20 MB)

How to Download

Option 1: GitHub Web UI (easiest)

  1. Go to the workflow run page linked above
  2. Scroll to the bottom "Artifacts" section
  3. Click on the artifact you want to download

Option 2: GitHub CLI

gh run download 31906076942 --repo smart-mcp-proxy/mcpproxy-go

Note: Artifacts expire in 14 days.

…l review

Related #969

Cross-model (opencode/gpt-5.6-sol) review of the Phase-0 baseline counters
found five accounting defects. All are in the *attribution* of the counters,
not their privacy posture — no payload shape changed.

Filter-diagnostics follow-through (in-memory session notes):

- Two retrieve_tools calls of one session can be in flight at once and finish
  out of order. A late-landing EARLIER call overwrote the note describing the
  block the agent most recently received, which both compared the next call
  against the wrong filter set and back-dated the TTL. noteFilterDiagnostics
  now keeps the newer note.
- The read side had the mirror defect: a call that STARTED before the note was
  written passed the TTL check (a negative age is never > TTL), counted itself
  as a follow-up to a block it never saw, and consumed the note the genuinely
  later call needed. consumeFilterDiagFollowUp now leaves such a note in place.
- Refreshing a session that already had a note ran the capacity prune even
  though replacing a key cannot grow the map, evicting an unrelated session's
  still-eligible note.

Emission accounting:

- The emitted counter and the note were hooked at the ATTACH site, before
  tool_response_limit truncation, which can cut the block back out of the
  delivered payload (SimpleTruncate is a plain tail cut). A block the agent
  never received was counted as emitted and could be "followed". Both now run
  after truncation, guarded by filterDiagnosticsSurvived. Since emitted is the
  denominator of the followed/emitted ratio, overcounting it understated the
  one number these counters exist to measure.

Availability block reasons:

- Direct mode funnels server-quarantine, pending approval, changed approval and
  plain not-callable through one emit site, so every direct-mode block was
  counted as tool_not_callable. directBlockReasonKey now derives the key from
  the gate that fired, mirroring directToolCallabilityResult's branches.
- Direct-mode agent-token scope and permission denials returned with no policy
  decision at all — no activity record and no counter. They now emit the same
  decision the call_tool_* variants emit at the equivalent gate; sessionID and
  requestID resolution moved above those gates so the records stay correlatable.

Reviewer findings not acted on: the opt-out check/send non-atomicity in
Service.sendHeartbeat is a pre-existing property of the whole telemetry service
(the preflight sink already applies the strictest existing gate, event-time
opt-out), and the synchronous BBolt writes follow the established precedent —
handleRetrieveToolsWithMode already performs two unconditional counter commits
per call on main. Both belong in their own change.
…t block

Related #969

Round-3 findings from the cross-model review, both against the round-2 fixes.

Partial blocks no longer count as emitted. Checking only that the delivered
text still contains the "filter_diagnostics" key was too weak: both truncation
paths cut the serialized response at a byte offset and append a plain-text
notice, so a cut can land inside the block and leave the key with an
unterminated value the agent cannot act on. filterDiagnosticsSurvived now
verifies the block's object is balanced and closed within the delivered text.
Parsing the payload as a whole is not an option — the appended notice means the
delivered text is never valid JSON, so that check would have reported false for
every truncated response and traded overcounting for undercounting.

Notes are stamped at DELIVERY time rather than request start. Overlapping calls
in one session can finish out of order, so start time ordered the notes
backwards: a call that started first but returned last is the response the agent
saw last, yet its note was rejected as stale. Delivery time makes the
follow-through comparison a plain causality test — a reaction can only come from
a call that started after the block reached the agent — which also removes the
equal-start-timestamp edge case, since the two sides are now sampled at
different moments by construction. The TTL is measured from delivery too, which
is what the 15-minute window was always meant to mean.
Related #969

Table-drives the hasCompleteJSONObject cases, adding the escape-sequence ones:
an escaped backslash must be consumed as data so the quote after it genuinely
closes the string, and a payload cut immediately after one must read as
incomplete. A scanner that desynchronised on those would call a truncated
diagnostics block complete and count an emission the agent never received.
Related #969

Every counter key carries its own window start, so the aggregate
availability_block_24h and the per-reason keys it summarises decayed
independently. A reason first seen at t=23h opened a window that outlived the
total's: at t=25h the total reset to 0 while that reason still reported 1,
emitting a payload whose reason counts sum to more than the total they split.
The docs call that field "the same total split by reason", so the two
disagreeing corrupts exactly the baseline ratios these counters exist to
establish.

The total is now derived at snapshot time as the sum of the reason counts
instead of being stored. Every block bumps exactly one reason key (unknown and
over-budget keys fold into "other"), so the sum IS the total by construction and
the two can no longer drift apart. This also drops one BBolt write per blocked
call. There is deliberately no availability_block_24h storage key any more.

Test: TestPreflightSnapshot_TotalAlwaysMatchesReasonSplit, which fails against
the previous stored-total implementation.
@Dumbris
Dumbris enabled auto-merge (squash) August 15, 2026 20:47

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved: CI green at this head SHA (43 pass / 6 skip / 0 fail), cross-model review (opencode/gpt-5.6-sol) returned APPROVE after 5 rounds, qa-gate posted success. Arming auto-merge.

@Dumbris
Dumbris merged commit a3a5e5b into main Aug 15, 2026
57 of 60 checks passed
Dumbris added a commit that referenced this pull request Aug 15, 2026
…y check with reason taxonomy (spec 098) (#996)

Related #969

Implements item 3 of #969 (the reporter-confirmed shape: cron/CI-first, REST+CLI, stat-only): a deterministic, side-effect-free availability check for a caller-supplied list of tool IDs, answering per-ID with a machine-readable reason instead of failing silently at discovery time.

Lands on top of #995 (baseline counters), which shipped one release ahead per the Phase-0 decision.

## What

- **`POST /api/v1/preflight`** — `{tools:[{id, pin_hash?}], profile?, policy?, wait_ms?}` → per-ID verdicts. HTTP 200 whenever the check executed (the verdict is data); 400 validation / 503 runtime-or-persistence failure. Standard `APIResponse` envelope.
- **`mcpproxy tools preflight`** — typed exit codes `0` ready · `10` retryable (backoff) · `11` blocked (page operator) · `12` unknown ID (fix manifest); worst class wins. A cron wrapper branches without parsing JSON.
- **One shared eligibility evaluator** (`internal/preflight`): closed 15-code reason taxonomy with a fixed precedence chain; existence = index ∨ approval record (the runtime de-indexes blocked/pending/changed tools — index absence alone proves nothing); zero upstream I/O and zero runtime mutation, structurally (the evaluator cannot reach a transport; asserted by instrumented-transport + state-snapshot tests).
- **Four-path dispatch consolidation** (FR-002 no-skew): call_tool variants, direct mode, code_execution and stored scripts all consume the same gate. Notable hardening: **scripts previously enforced no per-tool policy at all** — a code_execution/stored script could call a quarantined tool; now refused like every other path.
- **Transparency**: every executed preflight writes a durable activity record (synchronous, before the 200 — persistence failure ⇒ 503), request-ID-correlated and browsable via `mcpproxy activity list --request-id …` and the Web UI.
- **Disclosure tiers**: operator (API key/socket/pipe) gets full diagnosis incl. `sha256/v{N}:{hex}` hash pins; agent tokens get scope-silence (out-of-scope, unconfigured — byte-indistinguishable `not_found`; no hashes; `did_you_mean` never crosses scope). Stale token ProfilePin now intersects to deny-all.
- **Zero MCP-surface change**: `tools/list` byte-identical to merge-base across all three routing modes (golden-snapshot test, goldens captured from origin/main).

## Verification

- **Sabotage E2E matrix** (`testdata/preflight_sabotage_matrix.json`): 23 scenario cells against a real binary + live fixture upstreams — quarantine flip, rug-pull drift, block, config-deny, disable, SIGSTOP/kill, mid-indexing, annotation cells per filter, unknown id/server, hash + schema-version mismatch, PendingAuth, profile scope at both tiers — each asserting exact {reason, retryable, action} + the activity record by request ID; a reflection test forbids enum codes without a cell.
- Full gates: `go test -race ./...` (79 pkgs), server-edition build/tests/lint, 65/65 API E2E, golangci v2 both tag sets, swagger + generate-types idempotent, frontend build + 605 unit tests.
- code_execution + stored scripts: byte-identical transcripts vs main on a 9-probe harness; live no-skew check across all four dispatch paths.

## Review

Spec, plan, and full diff cross-model reviewed (opencode / gpt-5.6-sol): spec 3 rounds → APPROVE; plan 25 findings (12 P1) incorporated → APPROVE; code round 1 = 11 findings (6 P1, all genuine, each fix verified by restoring the old code), round 2 = 1 regression (startup-window 503 → `server_initializing`), round 3 → **APPROVE**.

## Docs

New `docs/features/tools-preflight.md` (taxonomy, precedence, tiers, transparency, cron/GHA/n8n recipes, code-exec composition), REST + CLI references, README "How AI Agents Work Through MCPProxy" section with a preflight-gated automation example.

Known follow-up (deliberately out of scope, pre-existing): `resolveActiveProfile` warn-skips a stale ProfilePin on the live session path — preflight is now stricter than dispatch there; the session path deserves its own reviewed fix.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants