Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ jobs:
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3

- name: Initialize CodeQL
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
uses: github/codeql-action/init@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
Expand All @@ -51,6 +51,6 @@ jobs:
uses: github/codeql-action/autobuild@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3

- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
uses: github/codeql-action/analyze@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
with:
category: /language:${{ matrix.language }}
47 changes: 46 additions & 1 deletion docs/features/telemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ MCPProxy collects anonymous usage statistics to help improve the product. This p

## What is collected

MCPProxy sends a **daily heartbeat** containing only aggregate, non-identifying information. The current schema is **version 6** (`schema_version: 6` in the JSON payload); the schema is forward-compatible so older consumers simply ignore fields they don't recognize.
MCPProxy sends a **daily heartbeat** containing only aggregate, non-identifying information. The current schema is **version 7** (`schema_version: 7` in the JSON payload); the schema is forward-compatible so older consumers simply ignore fields they don't recognize.

| Field | Example | Purpose |
|-------|---------|---------|
Expand All @@ -25,6 +25,13 @@ MCPProxy sends a **daily heartbeat** containing only aggregate, non-identifying
| `server_docker_isolated_count` | `2` | How many configured servers the runtime actually wraps in Docker isolation (schema v3) |
| `feature_flags.docker_isolation_enabled` | `true` | Whether global Docker isolation is turned on (schema v5). Lets us tell "isolation on, 0 matching servers" apart from "isolation off" |
| `feature_flags.docker_cli_source` | `bundled` | How the `docker` CLI was located — fixed enum `path` / `bundled` / `login_shell` / `absent` (schema v5). The direct signal for "Docker installed but not on the spawn PATH" (issue #696). **Never** the path string itself |
| `wizard_shown` | `true` | Whether the onboarding wizard ever rendered for this install (schema v7). Makes "shown but ignored" measurable |
| `wizard_connect_step` | `completed_external` | Onboarding connect-step outcome — fixed enum, widened in v7 (see below) |
| `web_ui_opened` | `12` | Lifetime count of embedded Web UI entrypoint serves (schema v7) |
| `days_since_install` | `14` | Whole-day age of the install (schema v7). A day count, never a timestamp |
| `active_days_30d` | `5` | Distinct UTC days with process activity in the trailing 30 days (schema v7). Only the count — never the per-day breakdown |
| `previous_shutdown` | `clean` | How the previous process instance ended — fixed enum `clean` / `crash`, absent on first run (schema v7) |
| `last_error_code` | `MCPX_DOCKER_CLI_NOT_FOUND` | Most recent stable `MCPX_*` diagnostic code (schema v7). Enum code only, never error text |

The `server_protocol_counts` map uses a **fixed enum of keys** (`stdio`, `http`, `sse`, `streamable_http`, `auto`) — server names and URLs are never included. Unknown or misconfigured protocol values are bucketed into `auto`.

Expand Down Expand Up @@ -65,6 +72,44 @@ The `anonymous_id` above is a UUID persisted in the config file. In **ephemeral

`machine_id` respects the **same opt-out** as every other field: when telemetry is disabled (see below), the entire heartbeat — including `machine_id` — is never sent.

## Schema v7 — activation funnel & churn fields (Spec 080)

Schema v7 adds seven purely **additive** signals so we can measure whether installs come back after day one — and, when they don't, whether the last session ended cleanly or crashed. Every field keeps the established privacy posture: **booleans, non-negative integers, or documented fixed enums only** — no timestamps, no per-server identity, no free text. The anonymity scanner (`internal/telemetry/anonymity.go`) enforces these shapes on the serialized payload before every send, and all fields use `omitempty`, so a payload with none of them set is shape-identical to a v6 payload except for `schema_version`.

### Widened enum: `wizard_connect_step`

The onboarding connect-step status (a v4 field) gains a fourth value in v7:

| Value | Meaning |
|-------|---------|
| *(absent)* | Step never shown to this install |
| `completed` | User completed the connect step inside the wizard |
| `completed_external` | **New in v7.** User dismissed the wizard with the connect step untouched, but the install was already connected (via `mcpproxy connect`, the ConnectModal, or manual config). Previously miscounted as `skipped` |
| `skipped` | User dismissed the wizard with the connect step untouched and **no** connection evidence existed |

**Guidance for consumers**: this is a string enum that may widen again. Code that switches on `completed` / `skipped` must treat **unknown values as "other/engaged"**, never as a skip or an error. Statuses recorded before v7 are never rewritten — segment analyses by `schema_version`.

### New fields

| Field | Type | When it is set | Privacy rationale |
|-------|------|----------------|-------------------|
| `wizard_shown` | boolean | `true` once the onboarding wizard has rendered at least once for this install; omitted otherwise. Together with `wizard_engaged` it distinguishes "shown but ignored" from "never shown" | A single boolean about our own UI; carries no user data |
| `web_ui_opened` | non-negative integer | Lifetime count of serves of the embedded Web UI **entrypoint** (index document). Asset and API requests never increment it; it is independent of `surface_requests.webui`. Coarse by design — health checkers fetching `/` count too | A counter of our own page serves; no URLs, sessions, or timing |
| `days_since_install` | non-negative integer | Whole-day UTC age of the install, from a persisted first-install day stamp (independent of `anonymous_id`). `0` on install day; clamped at 0 on clock skew. Omitted when the local store isn't available (short-lived CLI commands) | Only a day **count** is transmitted — the install timestamp itself never leaves the machine |
| `active_days_30d` | non-negative integer (1–30) | Number of distinct UTC days with process activity in the trailing 30-day window. Old days age out | The per-day set is stored locally and **never transmitted** — counters, not timelines |
| `previous_shutdown` | fixed enum `clean` \| `crash` | How the **previous** process instance ended: `clean` = the graceful-shutdown path ran; `crash` = it didn't (SIGKILL, panic, power loss). Absent on a first-ever run — a fresh install is never reported as a crash. Stable across all heartbeats of the current instance | One enum value about our own process lifecycle; no stack traces, no session timing |
| `last_error_code` | fixed enum (`MCPX_*`) | The most recently observed stable diagnostic code (same fixed set as `diagnostics.error_code_counts_24h`), persisted across restarts so a post-crash heartbeat carries the pre-crash code. Absent when no error was ever recorded | Only the enum code is stored and sent — **never** error messages, server names, paths, or stack traces. The scanner rejects any value outside the fixed diagnostics catalog |

Why these exist: telemetry showed most installs connect successfully but never return after day one. `days_since_install` + `active_days_30d` make retention computable from a single heartbeat (no cross-heartbeat identity joins), and `previous_shutdown` + `last_error_code` let the final heartbeat before an install goes silent distinguish "crashed and never came back" from "exited cleanly and never returned".

All v7 fields ride the **same opt-out** as the rest of the heartbeat: when telemetry is disabled, nothing is transmitted. Local counters may still persist on disk (so re-enabling doesn't fabricate a fresh-install picture), but they never leave the machine.

You can inspect exactly what would be sent — including every v7 field — with:

```bash
mcpproxy telemetry show-payload
```

## One-time opt-out signal

When telemetry transitions from **enabled to disabled** (via the CLI, the config
Expand Down
80 changes: 76 additions & 4 deletions internal/httpapi/onboarding.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,13 @@ type OnboardingMarkRequest struct {
Engaged bool `json:"engaged"`

// ConnectStepStatus is one of: "", "completed", "skipped". Empty
// preserves the existing value.
// preserves the existing value. The stored enum is wider (Spec 080
// FR-001): a "skipped" request for a previously untouched connect step
// is upgraded server-side to "completed_external" when the install
// shows positive evidence of an external connection (Spec 080 FR-002).
// "completed_external" is NOT accepted from clients — it must never be
// persisted without that server-verified evidence (edge case: "never
// guess completed_external without positive evidence").
ConnectStepStatus string `json:"connect_step_status,omitempty"`

// ServerStepStatus is one of: "", "completed", "skipped". Empty
Expand Down Expand Up @@ -151,7 +157,8 @@ func (s *Server) handleMarkOnboardingState(w http.ResponseWriter, r *http.Reques
state.FirstShownAt = &t
}
if req.ConnectStepStatus != "" {
state.ConnectStepStatus = req.ConnectStepStatus
state.ConnectStepStatus = nextConnectStepStatus(
state.ConnectStepStatus, req.ConnectStepStatus, s.externalConnectionEvidence)
}
if req.ServerStepStatus != "" {
state.ServerStepStatus = req.ServerStepStatus
Expand Down Expand Up @@ -225,7 +232,72 @@ func (s *Server) computeOnboardingState() (*OnboardingStateResponse, error) {
return resp, nil
}

// validStepStatus returns true if v is an allowed step-status value.
// validStepStatus returns true if v is an allowed step-status REQUEST value.
// Note the request enum is deliberately narrower than the stored connect-step
// enum (Spec 080 FR-001): "completed_external" is server-derived only (see
// nextConnectStepStatus) and is rejected when a client sends it directly —
// no shipped client produces it, and accepting it would let a caller persist
// external-connection evidence that was never verified.
func validStepStatus(v string) bool {
return v == "" || v == "completed" || v == "skipped"
return v == "" || v == storage.StepStatusCompleted || v == storage.StepStatusSkipped
}

// nextConnectStepStatus decides what to persist for the wizard's connect
// step given its current value and the requested update (Spec 080 US1).
// requested has passed validStepStatus, so it is "", "completed", or
// "skipped" — "completed_external" can only ever be produced HERE, from the
// server-side evidence check, never accepted from a client.
//
// - "completed" and "completed_external" never regress to "skipped" once
// recorded, even if clients are later disconnected (FR-004). In-wizard
// completion may still upgrade "completed_external" to "completed".
// - A "skipped" request against a previously untouched step ("" current)
// is the wizard-dismissal case: if the install shows positive evidence
// of an external connection, record "completed_external" instead
// (FR-002). Without evidence, fall back to "skipped" (FR-002a).
// - An already-persisted "skipped" is never rewritten retroactively
// (FR-003): the evidence check only applies to the untouched step.
//
// hasExternalEvidence is evaluated lazily, only for the dismissal case.
func nextConnectStepStatus(current, requested string, hasExternalEvidence func() bool) string {
if requested == "" {
return current
}
switch current {
case storage.StepStatusCompleted:
// Terminal: strongest status, never changes (FR-004).
return storage.StepStatusCompleted
case storage.StepStatusCompletedExternal:
// Never regresses; in-wizard completion is an allowed upgrade.
if requested == storage.StepStatusCompleted {
return storage.StepStatusCompleted
}
return storage.StepStatusCompletedExternal
}
if requested == storage.StepStatusSkipped && current == "" && hasExternalEvidence() {
return storage.StepStatusCompletedExternal
}
return requested
}

// externalConnectionEvidence reports whether this install shows positive
// evidence of an MCP client connection made outside the wizard: at least one
// supported client currently connected, or an MCP client has ever completed
// an initialize handshake (Spec 080 FR-002).
//
// FR-002a: wizard dismissal must never be blocked or delayed by this check —
// any failure while gathering evidence downgrades to "no evidence" so the
// caller records "skipped" exactly as before Spec 080.
func (s *Server) externalConnectionEvidence() (evidence bool) {
defer func() {
if r := recover(); r != nil {
s.logger.Warnf("onboarding: external-connection evidence check failed, falling back to skipped: %v", r)
evidence = false
}
}()
if svc := s.getConnectService(); svc != nil && svc.GetConnectedCount() > 0 {
return true
}
firstEver, _ := s.controller.GetActivationFirstMCPClient()
return firstEver
}
Loading
Loading