Skip to content

Commit c609541

Browse files
committed
feat(telemetry): emit docker isolation flag, CLI source + docker failure codes (schema v5)
Make the #696 (docker CLI not on PATH) and image/interpreter-mismatch failure modes visible in the telemetry dashboard instead of guessing. Feature flags (schema bumped 4 -> 5, additive / forward-compatible): - docker_isolation_enabled (bool) from cfg.DockerIsolation in BuildFeatureFlagSnapshot — distinguishes "isolation on, 0 matching servers" from "isolation off". - docker_cli_source (enum path|bundled|login_shell|absent) — the direct #696 fleet signal. New shellwrap.ResolveDockerSource reports which resolution branch found docker (shares the docker-path cache); Runtime.GetDockerCLISource mirrors IsDockerAvailable. Coarse enum only — never the path string. Diagnostics (flow into error_code_counts_24h, MCPX-prefix gated/PII-safe): - MCPX_DOCKER_CLI_NOT_FOUND — isolation requested but docker binary unresolved. - MCPX_DOCKER_EXEC_NOT_FOUND — in-container interpreter missing (e.g. uvx absent in python:3.11); previously misclassified as MCPX_STDIO_SPAWN_ENOENT. - MCPX_DOCKER_OCI_RUNTIME — OCI runtime / exec-format failures. ClassifierHints gains DockerIsolated; the supervisor threads it from a side-effect-free mirror of IsolationManager.ShouldIsolate so containerized ENOENT routes to DOCKER codes. Catalog entries added for all three. Privacy: all new fields are coarse enums/bools; docker_cli_source canary test asserts only the 4 enum values ever reach the wire and ScanForPII stays green. Docs/contracts updated (docs/features/telemetry.md + v2/v3 contract notes). DASHBOARD HAND-OFF (telemetry.mcpproxy.app, separate service): add panels for feature_flags.docker_isolation_enabled, the 4-way docker_cli_source breakdown, and the 3 new MCPX_DOCKER_* rows in the error_code_counts_24h panel. Related #696
1 parent 6b1b346 commit c609541

19 files changed

Lines changed: 511 additions & 34 deletions

File tree

docs/features/telemetry.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ MCPProxy collects anonymous usage statistics to help improve the product. This p
44

55
## What is collected
66

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

99
| Field | Example | Purpose |
1010
|-------|---------|---------|
@@ -22,9 +22,15 @@ MCPProxy sends a **daily heartbeat** containing only aggregate, non-identifying
2222
| `feature_flags.docker_available` | `true` | Fraction of installs with a reachable Docker daemon (schema v3) |
2323
| `server_protocol_counts` | `{"stdio":3,"http":2,"sse":0,"streamable_http":1,"auto":0}` | Ratio of remote-HTTP vs local-stdio upstreams (schema v3) |
2424
| `server_docker_isolated_count` | `2` | How many configured servers the runtime actually wraps in Docker isolation (schema v3) |
25+
| `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" |
26+
| `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 |
2527

2628
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`.
2729

30+
The `docker_cli_source` field is likewise a **fixed enum** (`path`, `bundled`, `login_shell`, `absent`); the resolved path is never transmitted.
31+
32+
Docker isolation failures surface in `error_code_counts_24h` via three stable diagnostic codes (schema v5): `MCPX_DOCKER_CLI_NOT_FOUND` (isolation requested but the `docker` binary is unresolved — issue #696), `MCPX_DOCKER_EXEC_NOT_FOUND` (the image lacks the interpreter the server needs, e.g. `uvx` missing in `python:3.11`), and `MCPX_DOCKER_OCI_RUNTIME` (OCI runtime / architecture-mismatch failures).
33+
2834
## What is NOT collected
2935

3036
The following is **never** collected:

internal/diagnostics/classifier.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,20 @@ func classifyDocker(err error, _ ClassifierHints) Code {
119119
strings.Contains(msg, "docker") && strings.Contains(msg, "image") && strings.Contains(msg, "pull") && strings.Contains(msg, "fail"),
120120
strings.Contains(msg, "manifest unknown"):
121121
return DockerImagePullFailed
122+
// docker CLI unresolved (#696). These shapes are unambiguous about the
123+
// docker BINARY being missing, so they classify even without the
124+
// DockerIsolated hint (e.g. shellwrap's resolution-failure error).
125+
case strings.Contains(msg, "docker not found in path"),
126+
strings.Contains(msg, "docker not found in login shell"),
127+
strings.Contains(msg, "docker: command not found"),
128+
strings.Contains(msg, "docker: not found"),
129+
strings.Contains(msg, `"docker": executable file not found`):
130+
return DockerCLINotFound
131+
// OCI runtime / architecture-mismatch failures from `docker run`.
132+
case strings.Contains(msg, "oci runtime"),
133+
strings.Contains(msg, "exec format error"),
134+
strings.Contains(msg, "runc"):
135+
return DockerOCIRuntime
122136
}
123137
return ""
124138
}
@@ -155,8 +169,62 @@ func classifyQuarantine(err error, _ ClassifierHints) Code {
155169
return ""
156170
}
157171

172+
// classifyDockerIsolatedSpawn maps a spawn/exec failure on a Docker-isolated
173+
// server to a specific DOCKER code. Returns "" when the error is not a
174+
// recognised docker-isolation failure (caller falls through to generic stdio
175+
// handling).
176+
//
177+
// Case order is load-bearing:
178+
// 1. The docker BINARY itself is missing (#696) — must win even though its
179+
// message also contains "command not found" / "executable file not found".
180+
// 2. The in-container interpreter is missing — real docker output nests this
181+
// inside an "OCI runtime create failed: … exec: \"x\": executable file not
182+
// found" string, so it must be checked BEFORE the generic OCI case below.
183+
// 3. Any other OCI runtime failure (exec format error / runc).
184+
func classifyDockerIsolatedSpawn(err error) Code {
185+
// Host couldn't even start the docker binary (direct exec path).
186+
var execErr *exec.Error
187+
if errors.As(err, &execErr) && errors.Is(execErr.Err, syscall.ENOENT) &&
188+
strings.Contains(strings.ToLower(execErr.Name), "docker") {
189+
return DockerCLINotFound
190+
}
191+
192+
msg := strings.ToLower(err.Error())
193+
switch {
194+
// (1) docker binary unresolved: shellwrap resolution failure, or the shell
195+
// / Go exec layer reporting `docker` itself missing.
196+
case strings.Contains(msg, `"docker": executable file not found`),
197+
strings.Contains(msg, "docker: command not found"),
198+
strings.Contains(msg, "docker: not found"),
199+
strings.Contains(msg, "docker not found in path"),
200+
strings.Contains(msg, "docker not found in login shell"):
201+
return DockerCLINotFound
202+
// (2) in-container interpreter missing (image lacks uvx/node/python/…).
203+
case strings.Contains(msg, "executable file not found"),
204+
strings.Contains(msg, "no such file or directory"),
205+
strings.Contains(msg, "command not found"):
206+
return DockerExecNotFound
207+
// (3) other OCI runtime failures (arch mismatch, runc start failure).
208+
case strings.Contains(msg, "oci runtime"),
209+
strings.Contains(msg, "exec format error"),
210+
strings.Contains(msg, "runc"):
211+
return DockerOCIRuntime
212+
}
213+
return ""
214+
}
215+
158216
// classifyStdio handles os/exec spawn errors and handshake failures.
159217
func classifyStdio(err error, hints ClassifierHints) Code {
218+
// Docker-isolated servers run `docker run …` over the stdio transport, so
219+
// ENOENT-class failures here are docker-specific (#696 CLI missing, or an
220+
// image/interpreter mismatch) rather than a plain host-binary miss. Resolve
221+
// those to DOCKER codes before the generic stdio matching below.
222+
if hints.DockerIsolated {
223+
if c := classifyDockerIsolatedSpawn(err); c != "" {
224+
return c
225+
}
226+
}
227+
160228
var execErr *exec.Error
161229
if errors.As(err, &execErr) {
162230
// exec.Error wraps os.PathError which wraps syscall.Errno; ENOENT/EACCES

internal/diagnostics/classifier_domains_test.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,64 @@ func TestClassify_Docker_SnapAppArmor(t *testing.T) {
7878
}
7979
}
8080

81+
// TestClassify_Docker_IsolationSpawn exercises the #696 / image-mismatch
82+
// routing: when the docker-isolation hint is set, ENOENT-class failures on the
83+
// stdio transport must resolve to DOCKER codes rather than a plain
84+
// MCPX_STDIO_SPAWN_ENOENT, so the telemetry dashboard sees the real cause.
85+
func TestClassify_Docker_IsolationSpawn(t *testing.T) {
86+
cases := []struct {
87+
name string
88+
err error
89+
hint ClassifierHints
90+
want Code
91+
}{
92+
{
93+
// #696: docker CLI not on the spawn PATH; the login-shell wrap
94+
// reports `docker: command not found`.
95+
name: "cli_not_found_shell",
96+
err: errors.New("stdio transport (command=\"/bin/zsh\", docker_isolation=true): recent stderr: docker: command not found"),
97+
hint: ClassifierHints{Transport: "stdio", DockerIsolated: true},
98+
want: DockerCLINotFound,
99+
},
100+
{
101+
// shellwrap resolution failure surfaces this even without the hint.
102+
name: "cli_not_found_resolve",
103+
err: errors.New("docker not found in PATH or well-known locations"),
104+
hint: ClassifierHints{Transport: "stdio", DockerIsolated: true},
105+
want: DockerCLINotFound,
106+
},
107+
{
108+
// In-container interpreter missing (e.g. uvx absent in python:3.11).
109+
name: "exec_not_found",
110+
err: errors.New("docker: Error response from daemon: failed to create task: OCI runtime create failed: runc create failed: exec: \"uvx\": executable file not found in $PATH: unknown"),
111+
hint: ClassifierHints{Transport: "stdio", DockerIsolated: true},
112+
want: DockerExecNotFound,
113+
},
114+
{
115+
// OCI runtime / arch mismatch with no interpreter-missing detail.
116+
name: "oci_runtime",
117+
err: errors.New("docker: Error response from daemon: failed to create shim task: OCI runtime create failed: exec format error: unknown"),
118+
hint: ClassifierHints{Transport: "stdio", DockerIsolated: true},
119+
want: DockerOCIRuntime,
120+
},
121+
{
122+
// Same ENOENT string WITHOUT the isolation hint stays a plain stdio
123+
// spawn failure — no false DOCKER attribution for host stdio servers.
124+
name: "non_containerized_enoent",
125+
err: errors.New("failed to spawn: executable file not found in $PATH"),
126+
hint: ClassifierHints{Transport: "stdio"},
127+
want: STDIOSpawnENOENT,
128+
},
129+
}
130+
for _, tc := range cases {
131+
t.Run(tc.name, func(t *testing.T) {
132+
if got := Classify(tc.err, tc.hint); got != tc.want {
133+
t.Errorf("Classify(%q) = %q, want %q", tc.err, got, tc.want)
134+
}
135+
})
136+
}
137+
}
138+
81139
// --- CONFIG -----------------------------------------------------------------
82140

83141
func TestClassify_Config_ParseError(t *testing.T) {

internal/diagnostics/codes.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,17 @@ const (
5151
DockerImagePullFailed Code = "MCPX_DOCKER_IMAGE_PULL_FAILED"
5252
DockerNoPermission Code = "MCPX_DOCKER_NO_PERMISSION"
5353
DockerSnapAppArmor Code = "MCPX_DOCKER_SNAP_APPARMOR"
54+
// DockerCLINotFound: isolation was requested but the `docker` binary could
55+
// not be resolved on the spawn PATH (issue #696 — Docker Desktop installed
56+
// without the admin-gated CLI shim, or a LaunchAgent's minimal PATH).
57+
DockerCLINotFound Code = "MCPX_DOCKER_CLI_NOT_FOUND"
58+
// DockerExecNotFound: the container started but its entrypoint interpreter
59+
// is missing from the image (e.g. `uvx` absent in `python:3.11`). Distinct
60+
// from a HOST stdio ENOENT, which is MCPX_STDIO_SPAWN_ENOENT.
61+
DockerExecNotFound Code = "MCPX_DOCKER_EXEC_NOT_FOUND"
62+
// DockerOCIRuntime: the OCI runtime (runc) failed to start the container —
63+
// e.g. an `exec format error` (image/host architecture mismatch).
64+
DockerOCIRuntime Code = "MCPX_DOCKER_OCI_RUNTIME"
5465
)
5566

5667
// CONFIG domain — configuration parsing and validation failures.

internal/diagnostics/registry.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,34 @@ func seedDOCKER() {
292292
},
293293
DocsURL: docsURL(DockerSnapAppArmor),
294294
})
295+
register(CatalogEntry{
296+
Code: DockerCLINotFound,
297+
Severity: SeverityError,
298+
UserMessage: "Docker isolation is enabled but the `docker` command could not be found. Install Docker, or add its CLI to your PATH.",
299+
FixSteps: []FixStep{
300+
{Type: FixStepCommand, Label: "Check docker is on PATH", Command: "docker --version"},
301+
{Type: FixStepLink, Label: "Install Docker / enable the CLI", URL: docsURL(DockerCLINotFound)},
302+
},
303+
DocsURL: docsURL(DockerCLINotFound),
304+
})
305+
register(CatalogEntry{
306+
Code: DockerExecNotFound,
307+
Severity: SeverityError,
308+
UserMessage: "The Docker image is missing the interpreter this server needs (e.g. the image has no `uvx`/`node`). Pick an image that includes it.",
309+
FixSteps: []FixStep{
310+
{Type: FixStepLink, Label: "Choosing a Docker isolation image", URL: docsURL(DockerExecNotFound)},
311+
},
312+
DocsURL: docsURL(DockerExecNotFound),
313+
})
314+
register(CatalogEntry{
315+
Code: DockerOCIRuntime,
316+
Severity: SeverityError,
317+
UserMessage: "The Docker container failed to start (OCI runtime error). This is often an image/CPU architecture mismatch.",
318+
FixSteps: []FixStep{
319+
{Type: FixStepLink, Label: "Troubleshooting OCI runtime errors", URL: docsURL(DockerOCIRuntime)},
320+
},
321+
DocsURL: docsURL(DockerOCIRuntime),
322+
})
295323
}
296324

297325
// --- CONFIG --------------------------------------------------------------

internal/diagnostics/types.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,12 @@ type DiagnosticError struct {
6767
type ClassifierHints struct {
6868
Transport string // "stdio", "http", "sse", "docker", etc.
6969
ServerID string
70+
// DockerIsolated is true when the failing server is launched through Docker
71+
// isolation (`docker run …` over the stdio transport). It lets the
72+
// classifier route ENOENT-class spawn failures to DOCKER codes (CLI missing
73+
// per #696, in-container interpreter missing) instead of a generic
74+
// MCPX_STDIO_SPAWN_ENOENT. See classifyDockerIsolatedSpawn.
75+
DockerIsolated bool
7076
}
7177

7278
// FixRequest is the input to a registered fixer.

internal/runtime/runtime.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2596,6 +2596,16 @@ func (r *Runtime) IsDockerAvailable() bool {
25962596
return r.dockerProbeResult
25972597
}
25982598

2599+
// GetDockerCLISource returns the coarse, fixed-enum branch that resolved the
2600+
// docker CLI — "path" | "bundled" | "login_shell" | "absent" (implements
2601+
// telemetry.RuntimeStats, schema v5 / MCP-2745). This is the direct #696 fleet
2602+
// signal (docker installed but not on the spawn PATH). It delegates to
2603+
// shellwrap.ResolveDockerSource, which shares the process-wide docker-path
2604+
// cache, so this is cheap on the heartbeat path. NEVER returns the path itself.
2605+
func (r *Runtime) GetDockerCLISource() string {
2606+
return shellwrap.ResolveDockerSource(r.logger)
2607+
}
2608+
25992609
// GetDockerIsolatedServerCount returns how many currently-configured servers
26002610
// the runtime actually wraps in a Docker container (implements
26012611
// telemetry.RuntimeStats, schema v3).

internal/runtime/supervisor/supervisor.go

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package supervisor
33
import (
44
"context"
55
"fmt"
6+
"path/filepath"
67
"sync"
78
"sync/atomic"
89
"time"
@@ -22,14 +23,15 @@ import (
2223
// classifyAndAttach converts a raw connection error into a DiagnosticError and
2324
// stores it on the server status. Called from the supervisor's reconcile and
2425
// event paths. Spec 044.
25-
func classifyAndAttach(status *stateview.ServerStatus, err error, transport string) {
26+
func classifyAndAttach(status *stateview.ServerStatus, err error, transport string, dockerIsolated bool) {
2627
if err == nil {
2728
status.Diagnostic = nil
2829
return
2930
}
3031
code := diagnostics.Classify(err, diagnostics.ClassifierHints{
31-
Transport: transport,
32-
ServerID: status.Name,
32+
Transport: transport,
33+
ServerID: status.Name,
34+
DockerIsolated: dockerIsolated,
3335
})
3436
if code == "" {
3537
// Classify always returns at least UnknownUnclassified for non-nil err,
@@ -52,6 +54,34 @@ func classifyAndAttach(status *stateview.ServerStatus, err error, transport stri
5254
}
5355
}
5456

57+
// usesDockerIsolation reports whether the given server would be launched
58+
// through Docker isolation, so the classifier can attribute spawn/exec failures
59+
// to DOCKER codes (#696 CLI missing, in-container interpreter missing) rather
60+
// than a generic stdio ENOENT. This is a side-effect-free mirror of
61+
// core.IsolationManager.ShouldIsolate (internal/upstream/core/isolation.go) —
62+
// it is only a classifier hint, so faithfulness matters more than sharing the
63+
// (logging) implementation.
64+
func (s *Supervisor) usesDockerIsolation(srv *config.ServerConfig) bool {
65+
if srv == nil || srv.Command == "" {
66+
return false
67+
}
68+
snap := s.configSvc.Current()
69+
if snap == nil || snap.Config == nil || snap.Config.DockerIsolation == nil ||
70+
!snap.Config.DockerIsolation.Enabled {
71+
return false
72+
}
73+
// Per-server explicit opt-out wins over the global enable.
74+
if srv.Isolation != nil && srv.Isolation.Enabled != nil && !*srv.Isolation.Enabled {
75+
return false
76+
}
77+
// Servers already running docker themselves are not double-isolated.
78+
cmdBase := filepath.Base(srv.Command)
79+
if cmdBase == "docker" || strings.Contains(srv.Command, "docker") {
80+
return false
81+
}
82+
return true
83+
}
84+
5585
// Supervisor manages the desired vs actual state reconciliation for upstream servers.
5686
// It subscribes to config changes and emits events when server states change.
5787
type Supervisor struct {
@@ -680,7 +710,7 @@ func (s *Supervisor) updateStateView(name string, state *ServerState) {
680710
if state.Config != nil {
681711
transport = transportpkg.DetermineTransportType(state.Config)
682712
}
683-
classifyAndAttach(status, state.ConnectionInfo.LastError, transport)
713+
classifyAndAttach(status, state.ConnectionInfo.LastError, transport, s.usesDockerIsolation(state.Config))
684714
// Spec 044 Phase H: notify telemetry counter store.
685715
if status.Diagnostic != nil {
686716
s.callbackMu.RLock()
@@ -978,7 +1008,7 @@ func (s *Supervisor) updateSnapshotFromEvent(event Event) {
9781008
if status.Config != nil {
9791009
transport = transportpkg.DetermineTransportType(status.Config)
9801010
}
981-
classifyAndAttach(status, connInfo.LastError, transport)
1011+
classifyAndAttach(status, connInfo.LastError, transport, s.usesDockerIsolation(status.Config))
9821012
// Spec 044 Phase H: notify telemetry counter store.
9831013
if status.Diagnostic != nil {
9841014
s.callbackMu.RLock()

0 commit comments

Comments
 (0)