Skip to content

Commit 67d53b5

Browse files
committed
feat(telemetry): add auth_success, mcp_connect, and mcp_oauth events
1 parent 4a03fb5 commit 67d53b5

5 files changed

Lines changed: 402 additions & 14 deletions

File tree

‎src/mcp/client.ts‎

Lines changed: 47 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,14 @@ import type { McpToolAnnotations } from "./tool-permissions.js";
1515
import { buildStdioMcpProcessEnv } from "./stdio-env.js";
1616
import { isHttpServer } from "./is-http-server.js";
1717
import { MCP_CLIENT_NAME } from "../branding.js";
18+
import {
19+
captureMcpConnect,
20+
captureMcpOauth,
21+
classifyMcpConnectOutcome,
22+
classifyMcpOauthOutcome,
23+
NOOP_TELEMETRY,
24+
type Telemetry,
25+
} from "../telemetry/index.js";
1826

1927
export interface MCPTool {
2028
name: string;
@@ -94,6 +102,9 @@ export interface MCPConnectOptions {
94102
// learns the transport died under a live client. Never fired for
95103
// intentional teardown — close() disarms it before closing the transport.
96104
onDisconnect?: () => void;
105+
// Optional telemetry handle for enum-only connect/oauth outcome events. No
106+
// globals: callers that care pass their instance; otherwise nothing emits.
107+
telemetry?: Telemetry;
97108
}
98109

99110
export function unwrapToolContent(content: unknown): string {
@@ -137,6 +148,7 @@ interface HTTPAuthContext {
137148
interactive: boolean;
138149
serverName: string;
139150
onAuthorized?: (serverName: string) => void;
151+
telemetry: Telemetry;
140152
}
141153

142154
interface BrowserAuthFlow {
@@ -441,16 +453,28 @@ async function driveRecovery(
441453
}
442454

443455
const browserFlow = coordinator.browserFlow;
444-
await browserFlow.promptEmitted;
445-
const code = await waitForBrowserAuthCode(context);
446-
await new StreamableHTTPClientTransport(
447-
context.url,
448-
streamableHTTPTransportOptions(
449-
context.authProvider,
450-
coordinator.lifecycle.signal,
451-
),
452-
).finishAuth(code);
453-
await coordinator.probe();
456+
// One enum-only outcome per browser attempt: prompt, wait, token exchange,
457+
// and probe either complete together or map the failure to exactly one of
458+
// cancelled (explicit abort) / timeout (expiry, cap, denial, teardown).
459+
try {
460+
await browserFlow.promptEmitted;
461+
const code = await waitForBrowserAuthCode(context);
462+
await new StreamableHTTPClientTransport(
463+
context.url,
464+
streamableHTTPTransportOptions(
465+
context.authProvider,
466+
coordinator.lifecycle.signal,
467+
),
468+
).finishAuth(code);
469+
await coordinator.probe();
470+
} catch (error) {
471+
captureMcpOauth(
472+
context.telemetry,
473+
classifyMcpOauthOutcome(error, coordinator.lifecycle.signal.aborted),
474+
);
475+
throw error;
476+
}
477+
captureMcpOauth(context.telemetry, "completed");
454478
}
455479

456480
function completeVerifiedRecovery(
@@ -763,6 +787,7 @@ async function connectHttp(
763787
...(options.onAuthorized !== undefined
764788
? { onAuthorized: options.onAuthorized }
765789
: {}),
790+
telemetry: options.telemetry ?? NOOP_TELEMETRY,
766791
};
767792
gateRedirectToAuthorization(authContext);
768793
}
@@ -835,16 +860,27 @@ export async function connectMCPServers(
835860
onWarning: (message: string) => void,
836861
options: MCPConnectOptions = {},
837862
): Promise<MCPClient[]> {
863+
const telemetry = options.telemetry ?? NOOP_TELEMETRY;
838864
const results = await Promise.all(
839865
configs.map((c) => connectMCPServer(c, options)),
840866
);
841867
const clients: MCPClient[] = [];
842-
for (const result of results) {
868+
for (let index = 0; index < results.length; index += 1) {
869+
const result = results[index];
870+
if (result === undefined) continue;
843871
if (result.ok) clients.push(result.client);
844872
else
845873
onWarning(
846874
`[mcp] Warning: failed to connect to MCP server "${result.serverName}": ${result.error}`,
847875
);
876+
// One enum-only event per server: the transport that was dialed and the
877+
// outcome enum. Server name, URL, and error text never leave the process.
878+
const config = configs[index];
879+
captureMcpConnect(telemetry, {
880+
transport:
881+
config !== undefined && isHttpServer(config) ? "http" : "stdio",
882+
result: classifyMcpConnectOutcome(result),
883+
});
848884
}
849885
return clients;
850886
}

‎src/telemetry/index.ts‎

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,9 @@ export type TelemetryEvent =
6363
| "summarizer_failure"
6464
| "crash"
6565
| "auth_failure"
66+
| "auth_success"
67+
| "mcp_connect"
68+
| "mcp_oauth"
6669
// PostHog Surveys event name (space included). Intentional operator feedback
6770
// from /feedback — can ship when ambient product telemetry is off; still
6871
// blocked by env kill switches. See captureIntentional.
@@ -199,6 +202,15 @@ const EVENT_PROPERTY_ALLOWLIST: Record<TelemetryEvent, readonly string[]> = {
199202
// Which provider rejected the credentials, not why — the rejection detail is
200203
// provider-authored text and error_class means a JS constructor name.
201204
auth_failure: ["auth_provider"],
205+
// Which provider completed sign-in. Same enum as auth_failure, no message:
206+
// a success has no rejection detail to report.
207+
auth_success: ["auth_provider"],
208+
// Per-server connect outcome. transport is the stdio/http connect path and
209+
// result is the outcome enum — never the server name, URL, or error text.
210+
mcp_connect: ["transport", "result"],
211+
// Per-attempt browser-OAuth outcome. result only — never the
212+
// authorization URL, state, server name, or error text.
213+
mcp_oauth: ["result"],
202214
// Intentional /feedback survey response (PostHog custom survey capture shape).
203215
// Free text is only sent because the operator typed it for that purpose.
204216
// turn_trace_id links to the last $ai_generation in this session when known.
@@ -226,7 +238,8 @@ export function aiSpansEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
226238

227239
/**
228240
* Sample rate for successful `$ai_generation` events (0–1). Default 1.0 (no
229-
* drop). Errors (`$ai_is_error: true`), `crash`, and `auth_failure` always ship.
241+
* drop). Errors (`$ai_is_error: true`), `crash`, `auth_failure`,
242+
* `auth_success`, `mcp_connect`, and `mcp_oauth` always ship.
230243
*/
231244
export function generationSampleRate(
232245
env: NodeJS.ProcessEnv = process.env,
@@ -529,3 +542,77 @@ export function createTelemetry(options: CreateTelemetryOptions): Telemetry {
529542
discard,
530543
};
531544
}
545+
546+
// ---------------------------------------------------------------------------
547+
// Enum-only product events (CL-9017). These helpers live beside the schema
548+
// rather than the call sites so the values they can send stay visible next
549+
// to the allowlists that bound them: fixed enums in, nothing else out.
550+
// Sampling gates only `$ai_generation`, so every event here always ships.
551+
// ---------------------------------------------------------------------------
552+
553+
export type McpTransport = "stdio" | "http";
554+
555+
export type McpConnectOutcome = "ok" | "auth" | "timeout" | "fail";
556+
557+
export interface McpConnectReport {
558+
readonly transport: McpTransport;
559+
readonly result: McpConnectOutcome;
560+
}
561+
562+
// Structural subset of the connect result: the report carries the outcome
563+
// enum, never the server name or the error text it was classified from.
564+
export interface McpConnectClassificationInput {
565+
readonly ok: boolean;
566+
readonly serverName?: string;
567+
readonly error?: string;
568+
readonly authPending?: boolean;
569+
}
570+
571+
const MCP_CONNECT_TIMEOUT_MESSAGE = /abort|timeout|timed out|expir/i;
572+
573+
export function classifyMcpConnectOutcome(
574+
result: McpConnectClassificationInput,
575+
): McpConnectOutcome {
576+
if (result.ok) return "ok";
577+
if (result.authPending === true) return "auth";
578+
// Substring match, not word match: AbortError surfaces as "The operation
579+
// was aborted", and only the timeout-vs-fail bucket rides on this — the
580+
// error text itself never ships.
581+
if (
582+
result.error !== undefined &&
583+
MCP_CONNECT_TIMEOUT_MESSAGE.test(result.error)
584+
)
585+
return "timeout";
586+
return "fail";
587+
}
588+
589+
export function captureMcpConnect(
590+
telemetry: Telemetry,
591+
report: McpConnectReport,
592+
): void {
593+
telemetry.capture("mcp_connect", {
594+
transport: report.transport,
595+
result: report.result,
596+
});
597+
}
598+
599+
export type McpOauthOutcome = "completed" | "cancelled" | "timeout";
600+
601+
// Only an explicit abort (aborted signal, AbortError) is "cancelled". Every
602+
// other browser-wait end without a code — wait expiry, attempt cap, denial,
603+
// teardown — is "timeout", so each attempt maps to exactly one outcome.
604+
export function classifyMcpOauthOutcome(
605+
error: unknown,
606+
aborted: boolean,
607+
): McpOauthOutcome {
608+
if (aborted) return "cancelled";
609+
if (error instanceof Error && error.name === "AbortError") return "cancelled";
610+
return "timeout";
611+
}
612+
613+
export function captureMcpOauth(
614+
telemetry: Telemetry,
615+
result: McpOauthOutcome,
616+
): void {
617+
telemetry.capture("mcp_oauth", { result });
618+
}

‎src/tui/chrome-state.ts‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -854,6 +854,18 @@ export type SendFailureKind = "abort" | "auth" | "error";
854854
/** First-party auth_provider values only — never free-text provider labels. */
855855
export type AuthProviderId = "codex" | "xai" | "anthropic" | "other";
856856

857+
/**
858+
* Map a setup/connect provider id to its telemetry enum. Exact first-party
859+
* matches only — every other catalog id (openai, ollama, custom, …) is
860+
* "other", and free-text display names must never reach this function.
861+
*/
862+
export function authProviderFromConnectId(providerId: string): AuthProviderId {
863+
if (providerId === "codex") return "codex";
864+
if (providerId === "xai") return "xai";
865+
if (providerId === "anthropic") return "anthropic";
866+
return "other";
867+
}
868+
857869
export interface ClassifiedSendFailure {
858870
readonly kind: SendFailureKind;
859871
readonly authProvider: AuthProviderId | null;
@@ -908,6 +920,23 @@ export function captureAuthFailure(
908920
telemetry.capture("auth_failure", { auth_provider: failure.authProvider });
909921
}
910922

923+
// Mirrors ClassifiedSendFailure without the rejection detail: a success has
924+
// no message to classify, only the provider that completed sign-in. Stays
925+
// silent unless the sign-in actually completed, so failed attempts are never
926+
// counted as successes.
927+
export interface AuthSuccessOutcome {
928+
readonly connected: boolean;
929+
readonly authProvider: AuthProviderId | null;
930+
}
931+
932+
export function captureAuthSuccess(
933+
telemetry: Telemetry,
934+
outcome: AuthSuccessOutcome,
935+
): void {
936+
if (!outcome.connected || outcome.authProvider === null) return;
937+
telemetry.capture("auth_success", { auth_provider: outcome.authProvider });
938+
}
939+
911940
/** Same classification as `classifyAgentSendFailure`, from the message alone. */
912941
export function classifySendFailureMessage(
913942
message: string,

‎src/tui/provider/connect.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@
66
*/
77

88
import type { Settings } from "../../config/settings.js";
9+
import { getTelemetry } from "../../telemetry/singleton.js";
10+
import {
11+
authProviderFromConnectId,
12+
captureAuthSuccess,
13+
} from "../chrome-state.js";
914
import { runProviderSetup } from "./setup.js";
1015
import type { ProviderSetupConfig } from "./types.js";
1116
import {
@@ -84,5 +89,13 @@ export async function connectProviderInline(
8489
});
8590

8691
if (!submitted) return { connected: false };
92+
// Login-completion site: exactly one enum-only event per completed /connect
93+
// sign-in. Cancelled flows return above and failed submits never set
94+
// connected, so neither can emit. The setup choice id maps to the enum with
95+
// other-fallback — free-text names never leave the process.
96+
captureAuthSuccess(getTelemetry(), {
97+
connected: result.connected,
98+
authProvider: authProviderFromConnectId(input.providerId),
99+
});
87100
return result;
88101
}

0 commit comments

Comments
 (0)