Skip to content

Commit 1ae1a3e

Browse files
feat(telemetry): emit session_end for exec and surface on cli_start (#1197)
* feat(telemetry): emit session_end for exec and surface on cli_start * fix(telemetry): close exec funnel gaps without counting usage errors Pre-try setup failures and unconfigured exec now emit a failed session_end so every cli_start(surface exec) is paired. Missing-prompt usage errors stay emission-free: exit 2 with no run is not a session.
1 parent 1eb829c commit 1ae1a3e

5 files changed

Lines changed: 728 additions & 10 deletions

File tree

‎src/exec/runner.ts‎

Lines changed: 82 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ import {
103103
loadState,
104104
saveState,
105105
type ConnectedMcpServer,
106+
type RunState,
106107
} from "../session/state.js";
107108
import { resolveExecRunStatus, type RunSink } from "../session/run-sink.js";
108109
import { createRunSummary } from "../session/hooks.js";
@@ -371,6 +372,33 @@ export interface ExecResult {
371372
model?: string;
372373
}
373374

375+
/**
376+
* Builds the `session_end` payload for exec runs, mirroring the TUI exit
377+
* reporter's status/exit_reason contract. Falls back to the live turn count
378+
* and wall clock when the run never produced a result.
379+
*/
380+
export function execSessionEndProperties(
381+
result: ExecResult | undefined,
382+
startedAt: number,
383+
sinkTurns: number,
384+
): {
385+
status: "done" | "failed" | "cancelled";
386+
turn_count: number;
387+
duration_ms: number;
388+
session_mode: "exec";
389+
exit_reason: "done" | "error" | "cancelled";
390+
} {
391+
const status = result?.status ?? "failed";
392+
return {
393+
status,
394+
turn_count: result?.turnsUsed ?? sinkTurns,
395+
duration_ms: result?.durationMs ?? Date.now() - startedAt,
396+
session_mode: "exec",
397+
exit_reason:
398+
status === "done" ? "done" : status === "failed" ? "error" : "cancelled",
399+
};
400+
}
401+
374402
export function createExecToolCallGate(
375403
isAdvertised: (name: string) => boolean,
376404
): (name: string) => boolean {
@@ -406,7 +434,7 @@ export async function runExec(config: Config): Promise<ExecResult> {
406434
const task = config.task.trim();
407435
if (task.length === 0) {
408436
stderr.write('Usage: corbits exec "<prompt>"\n');
409-
return {
437+
const result: ExecResult = {
410438
exitCode: 2,
411439
sessionId: config.sessionId,
412440
text: "",
@@ -425,18 +453,57 @@ export async function runExec(config: Config): Promise<ExecResult> {
425453
provider: config.providerName,
426454
model: config.model,
427455
};
456+
// Deliberate funnel gap: a missing prompt is a usage error (exit 2) and
457+
// no run ever started, so there is no session to close. Emitting a
458+
// failed session_end here would pollute failed counts with invocations
459+
// that never ran.
460+
return result;
428461
}
429462

430463
const sessionId =
431464
config.sessionId.length > 0 ? config.sessionId : generateSessionId();
432465
const startedAt = Date.now();
433466
const workdir = sessionContextDir(config.cwd, sessionId);
434-
await initSessionDir(config.cwd, sessionId);
435-
const prior =
436-
config.sessionId.length > 0
437-
? await loadState(config.cwd, config.sessionId)
438-
: undefined;
439-
const priorState = prior?.kind === "ok" ? prior.state : undefined;
467+
// Setup runs before the main try below: initSessionDir/loadState hit disk
468+
// before any session_end coverage exists, so an EACCES/EROFS here would
469+
// reject with zero session_end and orphan the cli_start funnel. Emit a
470+
// minimal failed session_end on this window instead of letting it throw.
471+
let priorState: RunState | undefined;
472+
try {
473+
await initSessionDir(config.cwd, sessionId);
474+
const prior =
475+
config.sessionId.length > 0
476+
? await loadState(config.cwd, config.sessionId)
477+
: undefined;
478+
priorState = prior?.kind === "ok" ? prior.state : undefined;
479+
} catch (err) {
480+
const message = formatCaughtError(err);
481+
logger.error("exec setup failed: {error}", { error: message });
482+
stderr.write(`Error: ${message}\n`);
483+
liveTelemetry.capture(
484+
"session_end",
485+
execSessionEndProperties(undefined, startedAt, 0),
486+
);
487+
return {
488+
exitCode: 1,
489+
sessionId,
490+
text: "",
491+
error: message,
492+
status: "failed",
493+
durationMs: Date.now() - startedAt,
494+
turnsUsed: 0,
495+
toolCallCount: 0,
496+
tokenUsage: {
497+
input: 0,
498+
output: 0,
499+
cacheRead: 0,
500+
cacheWrite: 0,
501+
thinking: 0,
502+
},
503+
provider: config.providerName,
504+
model: config.model,
505+
};
506+
}
440507

441508
let connectedMcp: ConnectedMcpServer[] = [];
442509
let agent: Agent | null = null;
@@ -1307,6 +1374,14 @@ export async function runExec(config: Config): Promise<ExecResult> {
13071374
result.error = `runtime dispose failed: ${message}`;
13081375
}
13091376
}
1377+
liveTelemetry.capture(
1378+
"session_end",
1379+
execSessionEndProperties(
1380+
result,
1381+
startedAt,
1382+
runSink?.getTurnCount() ?? turnsUsed,
1383+
),
1384+
);
13101385
clearActiveDisposeHost();
13111386
}
13121387
}

‎src/index.ts‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,9 @@ export async function mainWithRunners(
7979
if (settings?.telemetry?.noticeShown === true) {
8080
const telemetry = createTelemetry({ settings });
8181
setTelemetry(telemetry);
82-
telemetry.capture("cli_start");
82+
telemetry.capture("cli_start", {
83+
surface: config.command === "exec" ? "exec" : "tui",
84+
});
8385
}
8486
}
8587

@@ -92,6 +94,14 @@ export async function mainWithRunners(
9294
"No provider configured. Run `corbits` (interactive) once to complete setup, " +
9395
"or pass --provider / --model with credentials.\n",
9496
);
97+
// cli_start (surface exec) already emitted above while runExec never
98+
// runs on this branch — emit a minimal failed session_end so the
99+
// funnel stays paired instead of orphaning the start.
100+
const { execSessionEndProperties } = await import("./exec/runner.js");
101+
getTelemetry().capture(
102+
"session_end",
103+
execSessionEndProperties(undefined, Date.now(), 0),
104+
);
95105
exitCode = 2;
96106
} else {
97107
exitCode = await runners.runOnboarding(config);

‎src/telemetry/first-run.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ export async function activateHeldTelemetry(
6060
globalSettingsPath: string,
6161
confirmIntent: () => boolean = () => true,
6262
deps: FirstRunDeps = defaultDeps,
63+
surface: "tui" | "exec" = "tui",
6364
): Promise<void> {
6465
try {
6566
await deps.markTelemetryNoticeShown(globalSettingsPath);
@@ -89,5 +90,5 @@ export async function activateHeldTelemetry(
8990
if (!confirmIntent()) return;
9091
const telemetry = deps.createTelemetry({ settings });
9192
deps.setTelemetry(telemetry);
92-
telemetry.capture("cli_start");
93+
telemetry.capture("cli_start", { surface });
9394
}

‎src/telemetry/index.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ export function getSessionId(): string {
106106
// capture() appends ($app_version, service_version, os_type, os_arch,
107107
// schema_version, session_id), this bounds everything telemetry can ever contain.
108108
const EVENT_PROPERTY_ALLOWLIST: Record<TelemetryEvent, readonly string[]> = {
109-
cli_start: [],
109+
cli_start: ["surface"],
110110
session_end: [
111111
"status",
112112
"turn_count",

0 commit comments

Comments
 (0)