Skip to content
Open
36 changes: 23 additions & 13 deletions .agents/skills/agent-core-dev/telemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ Telemetry is a **layer-1 root** domain (alongside `log`): the facade lives at `A
- `src/app/telemetry/telemetryService.ts`: `TelemetryService` impl + `registerScopedService(LifecycleScope.App, …)`.
- `src/app/telemetry/agentTelemetryContext.ts` + `agentTelemetryContextService.ts`: `IAgentTelemetryContextService` — Agent-scoped mutable request context (`mode` / `provider_type` / `protocol` / `turn_id` / `trace_id`) snapshot into turn telemetry at launch. Agent identity (`agent_id`) is not part of it — identity is bound by the Agent-scoped `ITelemetryService` view.
- `src/app/telemetry/consoleAppender.ts`: `ConsoleAppender` — echoes events to a log function (dev / debug).
- `src/app/telemetry/cloudAppender.ts`: `CloudAppender` — sanitizes + PII-cleans properties, batches + enriches + posts to the telemetry endpoint.
- `src/app/telemetry/cloudTransport.ts`: `CloudTransport` — HTTP transport behind `CloudAppender`.
- `src/app/telemetry/cloudAppender.ts`: `CloudAppender` — sanitizes + PII-cleans properties, owns batching / replay / shutdown, and posts to the telemetry endpoint.
- `src/app/telemetry/cloudTransport.ts`: `CloudTransport` — cancellable HTTP transport behind `CloudAppender`.
- `src/app/telemetry/telemetrySpoolStore.ts`: `TelemetrySpoolStore` — bounded durable storage and recovery for unsent v2 batches under the isolated `telemetry-v2` namespace.
- `src/app/telemetry/privacy.ts`: outbound PII redaction (`cleanTelemetryProperties`) — URLs, emails, tokens, and absolute file paths become `<REDACTED: ...>` labels; `node_modules/` tails are kept.

## Emitting events (business services)
Expand Down Expand Up @@ -48,18 +49,20 @@ An appender is the destination an event is fanned out to. It is **not a DI Servi

```ts
export interface ITelemetryAppender {
start?(): void;
recover?(): Promise<void> | void;
track(event: string, properties?: TelemetryProperties): void;
withContext?(patch: TelemetryContextPatch): ITelemetryAppender;
setContext?(patch: TelemetryContextPatch): void;
flush?(): Promise<void> | void;
shutdown?(): Promise<void> | void;
shutdown?(options?: TelemetryShutdownOptions): Promise<void> | void;
}
```

Built-in appenders:

- `ConsoleAppender` — `[telemetry] <event> <json>` to a log function (default `console.log`); options `prefix` / `pretty` / `log`.
- `CloudAppender` — batches events, enriches with common context (`app_name` / `version` / `platform` / …), and posts to `https://telemetry-logs.kimi.com/v1/event` through `CloudTransport` (Bearer auth, retry, on-disk fallback). Options: `homeDir` / `deviceId` / `sessionId?` / `appName` / `version` / `uiMode?` / `model?` / `getAccessToken?` / `endpoint?` / `flushThreshold?` / `flushIntervalMs?`.
- `CloudAppender` — batches events, enriches with common context (`app_name` / `version` / `platform` / …), and posts to `https://telemetry-logs.kimi.com/v1/event` through `CloudTransport` (Bearer auth + cancellable retry). `createCloudAppender(accessor, hostOptions)` supplies bootstrap facts and the v2 durable spool; host options include `deviceId` / `sessionId?` / `appName` / `uiMode?` / `model?` / `buildSha?` / `getAccessToken?`.

### Registering appenders (bootstrap)

Expand All @@ -70,28 +73,35 @@ const app = createAppScope();
const telemetry = app.accessor.get(ITelemetryService);

telemetry.addAppender(new ConsoleAppender({ prefix: '[dev]' })); // dev echo
telemetry.addAppender(new CloudAppender({ // production
homeDir, deviceId, sessionId,
appName: 'kimi-code', version, uiMode: 'shell', model,
getAccessToken: () => auth.getCachedAccessToken(KIMI_CODE_PROVIDER_NAME),
}));
const registration = telemetry.addAppender(createCloudAppender( // production
app.accessor,
{
deviceId, sessionId,
appName: 'kimi-code', uiMode: 'shell', model,
getAccessToken: () => auth.getCachedAccessToken(KIMI_CODE_PROVIDER_NAME),
},
));
```

`addAppender` returns an `IDisposable` that removes the appender when disposed. `setAppender(appender)` resets to a single appender (mainly for tests). `removeAppender(appender)` drops one.
`addAppender` starts the appender and returns an `ITelemetryAppenderRegistration`. `registration.dispose()` begins best-effort retirement without waiting; use `await registration.shutdown(options)` when the host must wait until the appender is durably retired. `removeAppender(appender, options)` has the same awaited retirement contract.

> There is no production bootstrap wired yet — `TelemetryService` defaults to `[nullTelemetryAppender]`, so `track(...)` is a no-op until `addAppender` is called at startup.
`await setAppender(appender, options)` stages new events, durably retires every previous appender, then starts the replacement before releasing those events to it. `flush()` and `shutdown()` join that transition. If the replacement was already active, its optional `recover()` hook runs after retirement so it can pick up batches handed to the shared spool before staged events resume. Shutdown is terminal: a retired appender object cannot be registered again.

`TelemetryService` defaults to `[nullTelemetryAppender]`, so each host owns registration. Kap-server and the experimental v2 CLI register a `CloudAppender` when telemetry is enabled.

## Lifecycle

- `setEnabled(false)` drops `track` (service-level switch); `setEnabled(true)` resumes. `flush` / `shutdown` are unaffected by the switch.
- `flush()` / `shutdown()` fan out to all appenders concurrently; a single rejecting appender is swallowed. Await `shutdown()` before process exit so buffered events (e.g. in `CloudAppender`) are sent.
- `flush()` / `shutdown(options)` fan out to all appenders concurrently; a single rejecting appender is swallowed. Later shutdown calls may tighten an active lifecycle with an `AbortSignal` or earlier `deadlineMs`.
- `CloudAppender` owns one drain across threshold, periodic, explicit-flush, and shutdown triggers. Shutdown rejects new events, cancels network / retry waits at the lifecycle boundary, and does not complete until each unsent batch is delivered or handed to the v2 spool. Startup replay runs before newly buffered events.
- Await `registration.shutdown(options)` or `telemetry.shutdown(options)` before process exit when a buffering appender is registered.

## Red lines (this topic)

- Business services depend only on `ITelemetryService` — never import an appender class.
- Telemetry is layer-1 root: do not inject any business-domain service into it, and keep the facade at `App` scope (only the ambient context service binds at `Agent`).
- Appenders are plain `ITelemetryAppender` objects, not DI Services — register them with `addAppender`, never via `registerScopedService`.
- `track` is fire-and-forget and must not throw; appender `track` must be synchronous — buffer and send asynchronously via `flush` / `shutdown`.
- Await `telemetry.shutdown()` before process exit when a buffering appender is registered.
- Await the registration or service shutdown contract before process exit when a buffering appender is registered; do not rely on `dispose()` when durable handoff must complete.
- Keep event names stable; register every business event in `events.ts` and emit via `track2` — properties must be JSON-serializable primitives (non-primitives are dropped with a warning by `CloudAppender`).
- Agent identity is ambient: agent-scope events go through `defineAgentTelemetryEvent` and get `agent_id` from the scoped telemetry view — do not pass `agent_id` at business call sites (per-event identities such as `subagent_created` and the cron events are the exception).
8 changes: 6 additions & 2 deletions apps/kimi-code/src/cli/v2/run-v2-print.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,11 @@ export async function runV2Print(
await restorePermission();
} finally {
if (telemetryService !== undefined) {
await raceWithTimeout(telemetryService.shutdown(), CLI_SHUTDOWN_TIMEOUT_MS);
const deadlineMs = Date.now() + CLI_SHUTDOWN_TIMEOUT_MS;
await raceWithTimeout(
telemetryService.shutdown({ deadlineMs }),
PROMPT_CLEANUP_TIMEOUT_MS,
);
}
app.dispose();
}
Expand All @@ -189,7 +193,7 @@ export async function runV2Print(
// model is reconciled via setContext once resolved.
telemetryService = app.accessor.get(ITelemetryService);
if (telemetryEnabled) {
telemetryService.setAppender(
await telemetryService.setAppender(
createCloudAppender(app.accessor, {
deviceId,
appName: CLI_USER_AGENT_PRODUCT,
Expand Down
20 changes: 20 additions & 0 deletions apps/kimi-code/test/cli/v2-run-print.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,26 @@ describe('runV2Print', () => {
expect(app.dispose).toHaveBeenCalled();
});

it('forwards an absolute deadline into telemetry shutdown', async () => {
const stdout = writer();
const stderr = writer();
const { app, agent, appServices } = makeFakeHarness();
const telemetry = appServices.get(ITelemetryService) as {
shutdown: ReturnType<typeof vi.fn>;
};
const startedAt = Date.now();

mocks.bootstrap.mockReturnValue({ app });
mocks.ensureMainAgent.mockResolvedValue(agent);

await runV2Print(opts() as never, '1.2.3-test', { stdout, stderr });

expect(telemetry.shutdown).toHaveBeenCalledOnce();
const options = telemetry.shutdown.mock.calls[0]?.[0] as { deadlineMs?: number } | undefined;
expect(options?.deadlineMs).toBeGreaterThanOrEqual(startedAt);
expect(options?.deadlineMs).toBeLessThanOrEqual(Date.now() + 3_000);
});

it('seeds explicit skill dirs from --skillsDir into bootstrap', async () => {
const stdout = writer();
const stderr = writer();
Expand Down
Loading
Loading