Commit 321ff89
authored
feat(telemetry): honest wizard connect metric + v7 churn instrumentation (Spec 080) (#813)
* feat(telemetry): honest wizard connect step via completed_external (Spec 080 US1)
Widens the onboarding connect-step enum with completed_external so
connections made outside the wizard (CLI, ConnectModal, manual config)
stop being recorded as skips. The decision is made server-side in the
/api/v1/onboarding/mark handler, where both evidence sources live:
a dismissal-time "skipped" on a previously untouched connect step is
upgraded to completed_external when at least one supported client is
connected (connect.Service) OR an MCP client has ever handshaked
(activation.first_mcp_client_ever). Backend-only, so every dismissal
path benefits without frontend changes.
## Changes
- storage: StepStatus* constants + widened ConnectStepStatus enum doc
(FR-001)
- httpapi: nextConnectStepStatus decision fn — evidence upgrade
(FR-002), skipped fallback that never blocks dismissal, with a
recover guard on evidence gathering (FR-002a), completed /
completed_external never regress (FR-004), historical skipped never
rewritten (FR-003); request validation accepts the widened enum for
the connect step only
- telemetry: wizard_connect_step enum comment widened (value passes
through the existing snapshot pipeline unchanged; schema bump is
separate)
- oas: regenerated for the widened request-field description
## Testing
- Pure decision-matrix table test (11 cases) + endpoint-level tests:
handshake-evidence upgrade, connected-client upgrade via a real
connect.Service on a fake home, no-evidence skip, evidence-panic
fallback to skipped with 200, non-regression for both terminal
statuses, historical-skip immutability, enum validation
- Heartbeat surfacing test: completed_external serializes as a
distinct wizard_connect_step value and passes the PII scanner
* feat(telemetry): funnel observability — wizard_shown, web_ui_opened, install age, active days (Spec 080 US2)
Adds the four heartbeat fields the day-2-cliff analysis showed are
missing (FR-005..FR-009), all additive, omitempty, and BBolt-backed with
the same nil-safety as Activation:
- wizard_shown: derived from OnboardingState.FirstShownAt, making
"shown but ignored" (wizard_shown=true, wizard_engaged absent)
observable for the first time.
- web_ui_opened: persistent lifetime counter in a new telemetry_funnel
bucket, incremented only when the embedded Web UI entrypoint (index
document) is served — extensionless SPA routes count, asset/API
requests never do (missing-asset index fallbacks included). Fully
independent of surface_requests.webui, which is unchanged.
- days_since_install: write-once first-install UTC day stamp
(independent of anonymous_id); surfaced as a non-negative whole-day
count via *int so day 0 is transmitted while "store not wired" is
omitted. Clamped at 0 on backwards clock; no timestamp on the wire.
- active_days_30d: compact persisted set of distinct active UTC day
ordinals, pruned to the trailing 30-day window on write and aged at
read time; tolerates out-of-order days and excludes future-dated
members. Only the cardinality (1..30) is transmitted.
Activity days are recorded at process start (runtime wiring, so short
sessions and first runs persist a stamp before any heartbeat) and at
each heartbeat build. SchemaVersion intentionally stays at 6 — the v7
bump and version-history entry belong to the US4 slice.
## Testing
- funnel_test.go: counter persistence, install-day boundary math +
clock-skew clamp, same-day dedup, 30d window aging (write- and
read-time), out-of-order days, 30 cap, empty-bucket zero state
- payload_funnel_test.go: shown-vs-engaged independence, store-not-wired
omission (v6 shape compat), populated-field surfacing incl.
days_since_install:0 on the wire, no per-day structure leakage,
ScanForPII pass, RecordWebUIOpen nil-safety
- web_test.go: index/SPA-route serves fire the counter callback,
asset requests (present or missing) never do, nil-callback safety
* feat(telemetry): pre-churn snapshot — previous_shutdown marker + last_error_code (Spec 080 US3)
Makes the final heartbeat before an install goes silent carry a
cause-of-death record (FR-010..FR-013):
- New BBolt bucket telemetry_prechurn (internal/telemetry/prechurn.go)
with an armed/resolved shutdown marker and a single most-recent
MCPX_* code. ArmShutdownMarker derives the previous instance's
outcome (clean|crash|unknown) and re-arms in one transaction.
- Runtime arms the marker in runtime.New immediately after storage
opens (crash loops visible) and resolves it to clean as the first
act of Runtime.Close — before the long container-cleanup phase and
its early returns, so a handled SIGTERM never reads as a crash.
A second instance never touches the marker: the BBolt file lock
fails storage.NewManager first (pinned by test).
- previous_shutdown is derived once at startup and stable across all
heartbeats of the instance (FR-011); first run is unknown/absent,
never crash (FR-013). last_error_code rides the existing supervisor
error-code notifier (same stream as error_code_counts_24h), is
strictly validated to ^MCPX_[A-Z0-9_]+$ on write AND read, and
survives restart so the post-crash heartbeat carries the pre-crash
code (FR-012).
- Heartbeat fields previous_shutdown + last_error_code, both
omitempty; SchemaVersion intentionally stays 6 (US4's slice).
## Testing
- Clean/crash/first-run detection across simulated restart sequences,
full unknown→clean→crash→clean lifecycle, locked-out second
instance cannot clobber the marker
- last_error_code enum-only (free text/paths/malformed dropped, corrupt
on-disk value never surfaced), most-recent-wins, restart persistence
- Payload: within-instance stability, omitempty shape on first run and
when store not wired, anonymity scan on populated payload
- Runtime end-to-end lifecycle test through real New/SetTelemetry/Close
* feat(telemetry): schema v7 contract — version bump, scanner enforcement, docs (Spec 080 US4)
Bumps the heartbeat SchemaVersion 6 -> 7 with a version-history entry
covering every v7 addition (completed_external enum widening, wizard_shown,
web_ui_opened, days_since_install, active_days_30d, previous_shutdown,
last_error_code), following the v3-v6 additive pattern.
Extends ScanForPII with a structural rule for the v7 fields: booleans,
non-negative bare-integer counters (quoted numbers rejected), and documented
fixed enums only — last_error_code is gated to stable MCPX_* codes so free
text or paths can never ride it even if a producer check regresses.
Documents every v7 field (type, enums, when set, privacy rationale), the
widened wizard_connect_step enum, and unknown-enum guidance for consumers in
docs/features/telemetry.md.
## Testing
- Fully-populated v7 payload passes the anonymity scanner with zero
violations; all-new-fields-zero payload serializes shape-compatible with
v6 except schema_version (omitempty discipline)
- Table-driven scanner rejection cases per field + valid-enum pass cases
- /api/v1/telemetry/payload (the `mcpproxy telemetry show-payload` source)
renders every v7 field when populated (FR-019)
- Existing suite retargeted to v7 (payload_v2, privacy, schema tripwire)
* fix(telemetry): address cross-model review round 1 (Spec 080)
Finding 1 (MAJOR, fixed): last_error_code accepted any MCPX_-shaped
string instead of the fixed diagnostic catalog required by FR-012.
isValidMCPXCode now also requires diagnostics.Has() (internal/diagnostics
has zero internal deps, so telemetry can import it cycle-free); the gate
applies on write (RecordLastErrorCode), on read (LastErrorCode), and in
the anonymity scanner's v7 rule. Tests now use real catalog codes
(MCPX_HTTP_CONN_REFUSED, MCPX_DOCKER_IMAGE_PULL_FAILED,
MCPX_OAUTH_REFRESH_EXPIRED, MCPX_CONFIG_PARSE_ERROR) and assert
shape-valid-but-uncataloged codes are rejected at write, read, and scan.
Finding 2 (MAJOR, fixed): clients could POST
connect_step_status="completed_external" and persist it with zero
evidence, violating the "never guess completed_external without positive
evidence" edge case. The request enum is back to ""/completed/skipped
(validConnectStepStatus removed); completed_external remains a valid
STORED/read value that only the server-side evidence check
(nextConnectStepStatus) can produce. Validation test flipped to expect
400; swagger regenerated for the field-comment change.
Finding 3 (MAJOR, fixed): ResolveCleanShutdown fired as the FIRST act of
runtime.Close(), so a hang/SIGKILL/panic DURING shutdown still read as
previous_shutdown="clean" — FR-010 wants an unfinished shutdown to read
as crash. The Docker container-cleanup verification loop (whose early
`return nil` branches forced the early resolve and also leaked
cacheManager/indexManager/storageManager/configSvc) is extracted into
verifyContainerCleanup; its exits now return to Close, which resolves
the marker at the END of the graceful path, just before storage closes.
Ordering and timeouts preserved; new lifecycle test drives the former
early-return branch and asserts the marker resolves clean AND storage
actually closes.
Finding 4 (MINOR, fixed): last_error_code rode the async `go notifier`
hand-off, so a crash right after classification could lose the final
pre-crash code — the one case the field exists for. The supervisor now
captures the classified code inside the stateView update and delivers it
synchronously after the stateview lock is released (notifyErrorCode);
the runtime callback writes the pre-churn code synchronously (sub-ms
BBolt Update, no supervisor re-entry, no lock cycle — callers may hold
stateMu but the callback never re-enters the supervisor) and keeps the
loss-tolerant 24h counter on its pre-existing async posture. New
race-safe test proves the notifier fires before updateStateView returns.
* fix(telemetry): tighten shutdown-marker arm/resolve window (Spec 080, review round 2)
* fix(telemetry): resolve shutdown marker after async-op drain (Spec 080, review round 3)
* fix(telemetry): drain remaining BBolt writers before shutdown-marker resolve (Spec 080, review round 4)
Two writers could still hit BBolt after Close() resolved the clean-shutdown
marker (FR-010: the marker resolve must be the final DB write of a graceful
shutdown) or even after the DB handle closed:
1. Error-code notifier (runtime.go SetTelemetry wiring): review round 1 made
the notifier synchronous for FR-012 but kept the 24h diagnostics counter
write in an untracked goroutine — no better tracked than the pre-branch
`go notifier(code)` in the supervisor, and now able to race the marker
resolve. The counter write is now synchronous too: it completes inside the
supervisor's call stack, so Supervisor.Stop() (which joins its goroutines
before Close touches storage) is a hard barrier. Safe: sub-ms BBolt
Update, the callback never re-enters the supervisor (documented at
notifyErrorCode), and RecordLastErrorCode already writes synchronously
under identical locking. SetErrorCodeNotifier's contract now forbids
callback goroutines that outlive the call. The untracked-goroutine
exposure itself pre-dates the branch; the marker invariant it violates is
new here, and the specific goroutine was introduced in round 1 (1ec8109).
2. ActivityService (activity records, retention pruning, usage-snapshot
flushes, async sensitive-data detection — all BBolt writes): Close() only
context-cancelled it and never awaited it, so the flush-on-shutdown
(persistUsage) and any in-flight worker could land after the marker
resolve or be lost at DB close. Pre-existing on main (Stop() existed but
was never called from Close); the Spec 080 marker invariant makes it
observable, so it is fixed on this branch. ActivityService.Stop() is now
idempotent, returns immediately if Start never ran, and waits for the
event loop's final flush plus a WaitGroup over the retention loop, the
usage-flush loop, and per-event detection goroutines. Close() calls it
after appCancel (so the final flush is captured) and BEFORE
StopAsync/ResolveCleanShutdown/storage Close.
Tests: TestRuntimeCloseWaitsForActivityWritersBeforeMarkerResolve proves the
shutdown flush lands before the DB closes AND the marker still resolves to
clean; TestActivityServiceStopSafeWhenNeverStartedAndIdempotent covers the
never-started and repeated-Stop paths (every existing prechurn test now
exercises the never-started branch through Close).
* fix(telemetry): close startup-vs-shutdown races in marker write barriers (Spec 080, review round 5)
* fix(telemetry): join heartbeat loop before shutdown-marker resolve (Spec 080, review round 6)
* chore(ci): align codeql-action init/analyze with autobuild at v4.36.3
#811 bumped only autobuild to 4.36.3, leaving init/analyze at 4.36.2;
CodeQL rejects the cross-version configuration ('Loaded a configuration
file for version 4.36.2, but running version 4.36.3'), failing Analyze (go)
on every PR.1 parent e5aa21b commit 321ff89
34 files changed
Lines changed: 3855 additions & 138 deletions
File tree
- docs/features
- internal
- httpapi
- runtime
- supervisor
- server
- storage
- telemetry
- oas
- web
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
4 | 4 | | |
5 | 5 | | |
6 | 6 | | |
7 | | - | |
| 7 | + | |
8 | 8 | | |
9 | 9 | | |
10 | 10 | | |
| |||
25 | 25 | | |
26 | 26 | | |
27 | 27 | | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
28 | 35 | | |
29 | 36 | | |
30 | 37 | | |
| |||
65 | 72 | | |
66 | 73 | | |
67 | 74 | | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
68 | 113 | | |
69 | 114 | | |
70 | 115 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
76 | 76 | | |
77 | 77 | | |
78 | 78 | | |
79 | | - | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
80 | 86 | | |
81 | 87 | | |
82 | 88 | | |
| |||
151 | 157 | | |
152 | 158 | | |
153 | 159 | | |
154 | | - | |
| 160 | + | |
| 161 | + | |
155 | 162 | | |
156 | 163 | | |
157 | 164 | | |
| |||
225 | 232 | | |
226 | 233 | | |
227 | 234 | | |
228 | | - | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
229 | 241 | | |
230 | | - | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
| 271 | + | |
| 272 | + | |
| 273 | + | |
| 274 | + | |
| 275 | + | |
| 276 | + | |
| 277 | + | |
| 278 | + | |
| 279 | + | |
| 280 | + | |
| 281 | + | |
| 282 | + | |
| 283 | + | |
| 284 | + | |
| 285 | + | |
| 286 | + | |
| 287 | + | |
| 288 | + | |
| 289 | + | |
| 290 | + | |
| 291 | + | |
| 292 | + | |
| 293 | + | |
| 294 | + | |
| 295 | + | |
| 296 | + | |
| 297 | + | |
| 298 | + | |
| 299 | + | |
| 300 | + | |
| 301 | + | |
| 302 | + | |
231 | 303 | | |
0 commit comments