diff --git a/.agents/skills/agent-core-dev/telemetry.md b/.agents/skills/agent-core-dev/telemetry.md index 0d04dc5da4e..e1bf621e21f 100644 --- a/.agents/skills/agent-core-dev/telemetry.md +++ b/.agents/skills/agent-core-dev/telemetry.md @@ -7,7 +7,8 @@ Telemetry is a **layer-1 root** domain (alongside `log`): the facade lives at `A ## Where things live - `src/app/telemetry/telemetry.ts`: contract — `ITelemetryService` (facade), `ITelemetryAppender` (destination), `TelemetryProperties`, `nullTelemetryAppender`, and `TelemetryServiceOptions`. -- `src/app/telemetry/events.ts`: event registry — `telemetryEventDefinitions` pairs every business event's property type with review metadata (owner / purpose / per-property comment); the single source of truth for `track2`. Agent-scope events register with `defineAgentTelemetryEvent

` and compose the ambient `AgentTelemetryEventContext` (`agent_id`) into their wire schema; all other events register with `defineTelemetryEvent

`. +- `src/app/telemetry/events.ts`: event registry — `telemetryEventDefinitions` pairs every business event's property type with review metadata (owner / domain / purpose / per-property comment); the single source of truth for `track2`. Agent-scope events register with `defineAgentTelemetryEvent

` and compose the ambient `AgentTelemetryEventContext` (`agent_id`) into their wire schema; all other events register with `defineTelemetryEvent

`. +- `src/app/telemetry/coverage.ts`: domain coverage map — `telemetryDomainExemptions` (domains that intentionally emit nothing, with reasons) and `telemetryDomainKnownGaps` (domains with zero coverage whose events are planned). `events.test.ts` walks `src/` and requires every domain to own an event, be exempted, or be listed as a known gap; adding a domain without a telemetry decision fails the test. - `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). @@ -27,7 +28,7 @@ constructor(@ITelemetryService private readonly telemetry: ITelemetryService) {} this.telemetry.track2('cron_fired', { task_id: taskId, coalesced_count: 0, stale: false, buffered: false, recurring: true }); ``` -`track2` is checked against the registry in `events.ts` at compile time: the event name must be a key of `telemetryEventDefinitions`, and the properties must match the registered interface exactly (extra or missing keys are compile errors). **New events must be registered first** — add a properties interface, then register it with `defineAgentTelemetryEvent

({ owner, comment, properties })` when every emission path goes through an Agent-scoped `ITelemetryService` view, or `defineTelemetryEvent

` otherwise (including events with any non-Agent emission path, e.g. `image_compress` from the kap-server prompt routes), documenting every property. For agent-scope events the registered interface is the business payload only: ambient `agent_id` is declared once in `AgentTelemetryEventContext` and composed into the wire schema, so it must not appear in the payload or at call sites. Naming: snake_case for events and properties, unit suffixes (`_ms` / `_count` / `_bytes`), no user content or file paths; `test/app/telemetry/events.test.ts` enforces the conventions. The low-level `track` remains for appender plumbing and tests only. +`track2` is checked against the registry in `events.ts` at compile time: the event name must be a key of `telemetryEventDefinitions`, and the properties must match the registered interface exactly (extra or missing keys are compile errors). **New events must be registered first** — add a properties interface, then register it with `defineAgentTelemetryEvent

({ owner, domain, comment, properties })` when every emission path goes through an Agent-scoped `ITelemetryService` view, or `defineTelemetryEvent

` otherwise (including events with any non-Agent emission path, e.g. `image_compress` from the kap-server prompt routes), documenting every property. `domain` is the owning `src/` directory path (`agent/loop`, `wire`) or the pseudo-domain `host` for events the host app emits; `coverage.ts` plus `events.test.ts` require every source domain to own an event, be exempted, or be listed as a known gap. For agent-scope events the registered interface is the business payload only: ambient `agent_id` is declared once in `AgentTelemetryEventContext` and composed into the wire schema, so it must not appear in the payload or at call sites. Naming: snake_case for events and properties, unit suffixes (`_ms` / `_count` / `_bytes`), no user content or file paths; `test/app/telemetry/events.test.ts` enforces the conventions. The low-level `track` remains for appender plumbing and tests only. `TelemetryService.track` merges the bound context into the properties and fans the event out to every registered appender. A single throwing appender is isolated via `onUnexpectedError` and never blocks the rest. @@ -79,7 +80,7 @@ telemetry.addAppender(new CloudAppender({ // production `addAppender` returns an `IDisposable` that removes the appender when disposed. `setAppender(appender)` resets to a single appender (mainly for tests). `removeAppender(appender)` drops one. -> There is no production bootstrap wired yet — `TelemetryService` defaults to `[nullTelemetryAppender]`, so `track(...)` is a no-op until `addAppender` is called at startup. +> Production bootstrap exists in three places: kap-server (`packages/kap-server/src/services/telemetry.ts`), the v2 print runner (`apps/kimi-code/src/cli/v2/run-v2-print.ts`), and the node-sdk v2 client (`installEngineTelemetry` in `packages/node-sdk/src/sdk-rpc-client-v2.ts`) — each attaches a `CloudAppender` (or the host client) at startup. Without `addAppender`, `TelemetryService` defaults to `[nullTelemetryAppender]` and `track(...)` is a no-op. ## Lifecycle diff --git a/packages/agent-core-v2/AGENTS.md b/packages/agent-core-v2/AGENTS.md index eb8da514b25..076e13ad441 100644 --- a/packages/agent-core-v2/AGENTS.md +++ b/packages/agent-core-v2/AGENTS.md @@ -48,6 +48,7 @@ Business events go through `ITelemetryService.track2` — never the low-level `t - **Naming**: event names and property keys are snake_case (`tool_call`, `duration_ms`). Durations, counts, and sizes carry a unit suffix (`_ms` / `_count` / `_bytes`). Use specific names (`error_type`, not `error`). - **Privacy**: never register user content, prompts, or file paths as properties. `CloudAppender` redacts URLs, emails, tokens, and absolute paths from string values before events leave the process, but that is a safety net, not a license. - **Stability**: registered event names and property keys are wire data consumed by dashboards — treat renames as breaking changes. +- **Coverage**: every event declares its owning `domain` — a `src/` directory path (`agent/loop`, `wire`) or the pseudo-domain `host` for events the host app emits. `src/app/telemetry/coverage.ts` records the domains that intentionally emit nothing (`telemetryDomainExemptions`, with reasons) and the domains whose gaps are known and tracked (`telemetryDomainKnownGaps`, with the planned events). `events.test.ts` walks `src/` and fails unless every domain owns an event, is exempted, or is a known gap — adding a domain without a telemetry decision breaks the test, so make the decision explicit. - The registry is the single source of truth; `test/app/telemetry/events.test.ts` enforces the naming conventions. ## Persistence diff --git a/packages/agent-core-v2/src/app/telemetry/coverage.ts b/packages/agent-core-v2/src/app/telemetry/coverage.ts new file mode 100644 index 00000000000..e859b439c4b --- /dev/null +++ b/packages/agent-core-v2/src/app/telemetry/coverage.ts @@ -0,0 +1,146 @@ +export const telemetryCoverageTierRoots = ['app', 'session', 'agent', 'workspace', 'features'] as const; + +export const telemetryPseudoDomains = ['host'] as const; + +export const telemetryDomainExemptions: Readonly> = { + 'app/agentIdentity': 'Config-derived snapshot built once; no IO, state machine, or failure branches.', + 'app/agentProfileCatalog': + 'Type definitions, pure functions, and static built-in profile loading; no IO or user decisions.', + 'app/authLegacy': + 'Read-only aggregate facade over config and oauth status; authentication events belong to app/auth.', + 'app/bashParser': 'Stateless parse adapter over tree-sitter-bash; no IO or lifecycle.', + 'app/bootstrap': 'Pure value carriers (path derivation, env reads) and scope creation functions.', + 'app/edit': 'Thin file-edit adapter; failures return to the Edit tool and are covered by tool_call.', + 'app/event': 'Pure pub/sub plumbing with no business semantics.', + 'app/feature': 'Thin DI unit management shell; no user-perceivable behavior.', + 'app/flag': 'In-memory flag resolution (env > config > default); no IO or lifecycle.', + 'app/gateway': 'Thin delegate to prompt/loop services; turn events are emitted by the owning domains.', + 'app/hostFolderBrowser': 'Stateless readdir proxy; errors map to typed RPC errors.', + 'app/mcpRegistry': 'Read-only aggregation of config and plugin queries; no writes or failure branches.', + 'app/projectLocalConfig': 'Interface declaration only; implementation lives in persistence backends.', + 'app/remoteControl': 'Flag definition registration only; no runtime logic.', + 'app/sessionManager': + 'Resume failures funnel to session_load_failed via sessionLookup; lifecycle events are owned by workspace/sessionLifecycle.', + 'app/sessionLegacy': 'Read-only aggregate proxy; resume failures are covered by session_load_failed.', + 'app/state': 'StateRegistry subclass with no business logic.', + 'app/task': 'Generic task-handle primitive; background task events live in agent/task.', + 'app/telemetry': 'Telemetry infrastructure itself; it cannot instrument itself.', + 'app/workspaceAliases': 'Stateless resolution proxy over IWorkspaceService and the session index.', + 'app/workspaceSessions': 'Read-only aggregate facade over workspace aliases and the session index.', + 'session/approval': 'Thin delegate to features/interaction; resolution events are emitted by agent/toolApproval.', + 'session/mcp': 'Type seeds and a merged view; connection events are emitted by workspace/workspaceMcp.', + 'session/question': + 'Thin delegate; resolution events are emitted by the ask-user-question tool (agent/tools).', + 'session/sessionActivity': 'Pure in-memory fold of already-instrumented turn and activity events.', + 'session/sessionAgentProfileCatalog': + 'In-memory registry merge projection; diagnostics go through log and inspect surfaces.', + 'session/sessionContext': 'Pure data carrier (sessionId/workspaceId/cwd) plus seed factory.', + 'session/sessionInstructions': 'Interface declaration and DI seed helper only.', + 'session/sessionLog': + 'Thin adapter over FileLogWriter; it is the logging substrate telemetry itself relies on.', + 'session/sessionToolPolicy': 'Simple disabled-tools preference persistence; changes broadcast via onDidChange.', + 'session/sessionToolPolicyGate': 'No-op implementation with no behavior to observe.', + 'session/state': 'StateRegistry subclass with no business logic.', + 'session/tokenCounting': + 'Pure in-memory token estimation bookkeeping; size signals are covered by loop and compaction events.', + 'session/usage': + 'Pure in-memory usage accumulator; consumption signals are covered by llmRequester and loop events.', + 'session/workspaceInfo': 'Interface declaration and scope seed factory only.', + 'agent/activityView': + 'Read-only projection of loop, task, compaction, and approval events that are instrumented at the source.', + 'agent/agentContext': 'In-memory agent model lease registry; anomalies route to onUnexpectedError.', + 'agent/command': 'Thin dispatcher over command contributions; real work is instrumented by owning domains.', + 'agent/contextMemory': + 'In-memory history mutation API; lifecycle operations are instrumented by owner domains (undo, compaction, loop).', + 'agent/interruptionReminder': + 'Fixed-text reminder injection on user_cancelled; the interrupt itself is covered by turn_interrupted.', + 'agent/modeMutex': + 'Mode mutual-exclusion wiring over the event bus; mode transitions are owned by features/plan, features/swarm, and features/tower.', + 'agent/permissionPolicy': 'Stateless policy evaluation chain; decisions are recorded by permissionGate.', + 'agent/permissionRules': 'Thin state wrapper; approval persistence is recorded by permission_approval_result.', + 'agent/plugin': 'Reminder reconcile and render logic; plugin install/enable events belong to app/plugin.', + 'agent/replayBuilder': 'Pure type definitions.', + 'agent/scopeContext': 'Stateless plumbing (scope key and frozen context factory).', + 'agent/state': 'StateRegistry subclass for replayable key bookkeeping.', + 'agent/tokenCounting': 'Contracts and wire event definitions only; counting logic lives in session/tokenCounting.', + 'agent/toolActivation': 'Tool registration bookkeeping; policy-blocked calls surface via tool_call.', + 'agent/toolPolicy': 'Pure policy evaluation; guard interceptions are recorded via tool_call.', + 'agent/toolRegistry': 'In-memory Map registry with no IO or failure degradation.', + 'workspace/state': 'StateRegistry subclass with no business logic.', + 'workspace/workspaceContext': 'Pure data interface plus seed factory.', + 'workspace/workspaceDirs': 'Thin state holder over project-local config; failures propagate to session creation.', + 'workspace/workspaceGit': 'Pass-through proxy to IGitService; git subprocess telemetry belongs to app/git.', + 'workspace/workspaceInstructions': + 'AGENTS.md snapshot loader with fs watch; reload failures degrade to prior content with log.warn.', + 'workspace/workspaceMcpConfig': + 'Config aggregation with fingerprint diff; connection outcomes are covered by workspaceMcp events.', + 'features/dateChange': 'Date disclosure computation and reminder injection; no IO or decisions.', + 'features/debugEvents': 'Read-only introspection for the /api/v1/debug surface.', + 'features/tokenCounting': 'Pure feature assembly; counting logic lives in session/tokenCounting.', + 'features/usage': 'Pure feature assembly; usage logic lives in session/usage.', + _base: 'DI kernel and base utilities below the telemetry layer; activation failures surface as sticky Failed units.', + debug: 'Read-only introspection views for the /api/v1/debug surface.', + kosong: + 'LLM HTTP errors translate and bubble to api_error in agent/llmRequester; instrumenting here would double-count.', + os: 'Host capability implementations; failures bubble to caller domains (mcp_failed, tool_call, fs fallbacks).', + runtime: + 'Runtime registry and host shells; failures throw typed RuntimeError to callers and state changes publish via onDidChange.', + state: 'State definitions and the event dispatcher pipeline; restore failures are covered by session_load_failed.', + tool: 'Stateless tool utilities (path access, args validation, output accumulation); rejections surface via tool_call.', +}; + +export const telemetryDomainKnownGaps: Readonly> = { + 'app/auth': + 'Login funnel: oauth_login_finished (provider, status, duration_ms), oauth_models_refresh_finished, auth_ensure_ready_failed.', + 'app/capability': 'Install funnel: capability_install_started / capability_install_ended (outcome, duration_ms).', + 'app/config': 'Config health: config_load_failed, config_persist_blocked, config_migration_applied.', + 'app/file': 'Upload health: file_saved (outcome, size_bytes), file_blob_missing (index/blob divergence).', + 'app/git': 'Subprocess health: git_spawn_failed, git_command_timeout, git_command_duration.', + 'app/kosongConfig': 'Provider config: config_persist_failed, provider_models_refreshed, provider_catalog_import.', + 'app/mcpConfig': 'Credential store: mcp_oauth_store_read_failed (silent credential loss).', + 'app/mcpManagement': 'Server management: mcp_server_test, mcp_auth_flow_completed, mcp_server_config_mutated.', + 'app/plugin': 'Plugin lifecycle: plugin_install, plugin_reload, plugin_update_check.', + 'app/sessionExport': 'Export health: session_export (success, duration_ms, entries_count).', + 'app/sessionIndex': + 'Read model health: session_index_degraded, session_index_projected, session_index_mirror_give_up.', + 'app/web': 'Managed fetch fallback: web_fetch_fallback (silent local degradation).', + 'app/workspace': + 'Workspace lifecycle: workspace_created, workspace_deleted, workspace_catalog_rebuilt, workspace_root_invalid.', + 'session/agentLifecycle': 'Creation failure: agent_create_failed (stage, error_type).', + 'session/sessionMetadata': 'Metadata health: session_meta_load_failed, session_meta_migrated.', + 'session/sessionTitle': + 'Title generation: session_title_generated, session_title_generation_failed (experiment evaluation).', + 'session/terminal': 'Terminal lifecycle: terminal_spawn_failed, terminal_exited.', + 'session/workspaceContext': 'Security boundary: workspace_path_denied (path escape attempts).', + 'agent/blob': 'Media storage: blob_read_failed (silent media loss), blob_offloaded.', + 'agent/mcp': 'MCP tool calls: mcp_tool_reconnect, mcp_tool_name_collision.', + 'agent/pluginCommand': 'Adoption: plugin_command (plugin_id, command_name).', + 'agent/runtime': 'Runtime lifecycle: agent_runtime_failed (phase), agent_runtime_restored (duration_ms).', + 'agent/runtimeBinding': 'Binding decisions: agent_runtime_binding_changed, agent_runtime_binding_rejected.', + 'agent/shellCommand': + 'Execution bypasses tool_call: shell_command_finished (duration_ms, is_error, backgrounded).', + 'agent/stepRetry': 'Retry behavior: turn_step_retrying, turn_step_retry_exhausted.', + 'agent/toolResultTruncation': + 'Truncation: tool_result_truncated (size distribution), tool_result_spill_save_failed.', + 'agent/toolSelect': 'Dynamic tool loading: tool_select_load (to_load_count, unknown_count).', + 'agent/userTool': 'Adoption: user_tool_registered.', + 'workspace/workspaceAgentProfileLoader': 'Profile loading: agent_profile_load_failed (source, fatal).', + 'workspace/workspaceInstance': 'Materialization: workspace_materialized (duration_ms), workspace_materialize_failed.', + 'workspace/workspaceTrust': + 'Trust decisions: workspace_trust_changed, workspace_trust_read_failed (fail-closed silently).', + 'features/btw': 'Adoption: btw_started.', + 'features/externalHooks': + 'Hook execution: external_hook_executed (outcome), external_hook_blocked (security decisions).', + 'features/interaction': 'Orphan interactions: interaction_cancelled (kind, reason, pending_duration_ms).', + 'features/reminder': 'Injection health: reminder_provider_failed (silent context-injection failure).', + 'features/sessionInit': '/init run: session_init (outcome, duration_ms).', + 'features/staleGuard': 'Guard hits: stale_guard_blocked (reason).', + 'features/swarm': + 'Batch runs: agent_swarm_batch_finished (outcome distribution), agent_swarm_rate_limit_mode_entered.', + 'features/todo': 'Reminder strategy: todo_list_reminder_shown.', + 'features/tower': + 'Tower governance: tower_mode_entered, tower_spawn_denied, tower_rate_limit_paused, tower_worktree_escape_denied, tower_worktree_setup_warning.', + mcpCore: 'Runtime connection health: mcp_server_dropped, mcp_oauth_refresh_failed.', + persistence: 'Store health: query_store_rebuilt (silent corruption recovery).', + program: 'Generation failures: program_generation_failed (stage).', +}; diff --git a/packages/agent-core-v2/src/app/telemetry/events.ts b/packages/agent-core-v2/src/app/telemetry/events.ts index 4cc18048738..ac1dc186e65 100644 --- a/packages/agent-core-v2/src/app/telemetry/events.ts +++ b/packages/agent-core-v2/src/app/telemetry/events.ts @@ -2,6 +2,7 @@ import type { TelemetryPrimitive } from './telemetry'; export interface TelemetryEventMeta { readonly owner: string; + readonly domain: string; readonly comment: string; readonly properties: Readonly>; } @@ -477,6 +478,7 @@ export interface ExitEvent { export const telemetryEventDefinitions = { turn_started: defineAgentTelemetryEvent({ owner: 'kimi-code', + domain: 'agent/loop', comment: 'A turn starts running.', properties: { turn_id: 'Per-agent turn index (main or subagent); pair with agent_id to locate a turn within a session', @@ -488,6 +490,7 @@ export const telemetryEventDefinitions = { }), turn_interrupted: defineAgentTelemetryEvent({ owner: 'kimi-code', + domain: 'agent/loop', comment: 'A running turn is interrupted.', properties: { turn_id: 'Per-agent turn index (main or subagent); pair with agent_id to locate a turn within a session', @@ -503,6 +506,7 @@ export const telemetryEventDefinitions = { }), turn_ended: defineAgentTelemetryEvent({ owner: 'kimi-code', + domain: 'agent/loop', comment: 'A turn ends, unconditionally.', properties: { turn_id: 'Per-agent turn index (main or subagent); pair with agent_id to locate a turn within a session', @@ -518,6 +522,7 @@ export const telemetryEventDefinitions = { }), prompt_cache_probe: defineAgentTelemetryEvent({ owner: 'kimi-code', + domain: 'agent/usage', comment: 'An agent whose first request is expected to hit the prompt cache reports that request\'s cache usage.', properties: { @@ -533,6 +538,7 @@ export const telemetryEventDefinitions = { }), tool_call: defineAgentTelemetryEvent({ owner: 'kimi-code', + domain: 'agent/toolExecutor', comment: 'A tool call finishes execution.', properties: { turn_id: 'Per-agent turn index (main or subagent); pair with agent_id to locate a turn within a session', @@ -548,6 +554,7 @@ export const telemetryEventDefinitions = { }), api_error: defineAgentTelemetryEvent({ owner: 'kimi-code', + domain: 'agent/llmRequester', comment: 'An LLM API request fails.', properties: { error_type: 'Classified error category', @@ -568,6 +575,7 @@ export const telemetryEventDefinitions = { }), skill_invoked: defineAgentTelemetryEvent({ owner: 'kimi-code', + domain: 'features/skill', comment: 'A skill is invoked.', properties: { skill_name: 'Skill name', @@ -576,11 +584,13 @@ export const telemetryEventDefinitions = { }), flow_invoked: defineAgentTelemetryEvent({ owner: 'kimi-code', + domain: 'features/skill', comment: 'A flow-type skill is invoked.', properties: { flow_name: 'Flow name' }, }), input_steer: defineAgentTelemetryEvent({ owner: 'kimi-code', + domain: 'agent/prompt', comment: 'The user steers input while a turn is running.', properties: { parts: 'Number of input parts', @@ -588,6 +598,7 @@ export const telemetryEventDefinitions = { }), cancel: defineAgentTelemetryEvent({ owner: 'kimi-code', + domain: 'agent/loop', comment: 'The user cancels ongoing work.', properties: { from: 'What was running when cancelled', @@ -597,6 +608,7 @@ export const telemetryEventDefinitions = { }), conversation_undo: defineAgentTelemetryEvent({ owner: 'kimi-code', + domain: 'agent/undo', comment: 'The user undoes conversation entries.', properties: { count: 'Number of entries undone', @@ -604,16 +616,19 @@ export const telemetryEventDefinitions = { }), yolo_toggle: defineAgentTelemetryEvent({ owner: 'kimi-code', + domain: 'agent/permissionMode', comment: 'Yolo permission mode is toggled.', properties: { enabled: 'Whether yolo mode is now enabled' }, }), afk_toggle: defineAgentTelemetryEvent({ owner: 'kimi-code', + domain: 'agent/permissionMode', comment: 'AFK (auto) permission mode is toggled.', properties: { enabled: 'Whether auto mode is now enabled' }, }), permission_policy_decision: defineAgentTelemetryEvent({ owner: 'kimi-code', + domain: 'agent/permissionGate', comment: 'A permission policy evaluates a tool call.', properties: { turn_id: 'Per-agent turn index (main or subagent); pair with agent_id to locate a turn within a session', @@ -626,6 +641,7 @@ export const telemetryEventDefinitions = { }), permission_approval_result: defineAgentTelemetryEvent({ owner: 'kimi-code', + domain: 'agent/toolApproval', comment: 'A permission approval prompt resolves.', properties: { turn_id: 'Per-agent turn index (main or subagent); pair with agent_id to locate a turn within a session', @@ -644,6 +660,7 @@ export const telemetryEventDefinitions = { }), plan_submitted: defineAgentTelemetryEvent({ owner: 'kimi-code', + domain: 'features/plan', comment: 'A plan is submitted for review.', properties: { has_options: 'Whether the plan offered selectable options', @@ -651,6 +668,7 @@ export const telemetryEventDefinitions = { }), plan_resolved: defineAgentTelemetryEvent({ owner: 'kimi-code', + domain: 'features/plan', comment: 'A submitted plan is resolved.', properties: { outcome: 'How the plan was resolved', @@ -660,6 +678,7 @@ export const telemetryEventDefinitions = { }), plan_enter_resolved: defineAgentTelemetryEvent({ owner: 'kimi-code', + domain: 'features/plan', comment: 'A request to enter plan mode is resolved.', properties: { outcome: 'How the request was resolved', @@ -667,6 +686,7 @@ export const telemetryEventDefinitions = { }), compaction_finished: defineAgentTelemetryEvent({ owner: 'kimi-code', + domain: 'agent/fullCompaction', comment: 'Context compaction completes.', properties: { turn_id: 'Per-agent turn index when compaction ran inside a turn; omitted for manual compaction between turns', @@ -689,6 +709,7 @@ export const telemetryEventDefinitions = { }), compaction_failed: defineAgentTelemetryEvent({ owner: 'kimi-code', + domain: 'agent/fullCompaction', comment: 'Context compaction fails.', properties: { turn_id: 'Per-agent turn index when compaction ran inside a turn; omitted for manual compaction between turns', @@ -705,6 +726,7 @@ export const telemetryEventDefinitions = { }), context_projection_repaired: defineAgentTelemetryEvent({ owner: 'kimi-code', + domain: 'agent/contextProjector', comment: 'The context projector repairs the outgoing request to keep it wire-valid.', properties: { reordered: 'Tool results moved back next to their call', @@ -720,6 +742,7 @@ export const telemetryEventDefinitions = { }), background_task_created: defineAgentTelemetryEvent({ owner: 'kimi-code', + domain: 'agent/task', comment: 'A background task is created.', properties: { task_id: 'Background task id; joins background_task_created with background_task_completed', @@ -728,6 +751,7 @@ export const telemetryEventDefinitions = { }), background_task_completed: defineAgentTelemetryEvent({ owner: 'kimi-code', + domain: 'agent/task', comment: 'A background task reaches a terminal state.', properties: { task_id: 'Background task id; joins background_task_created with background_task_completed', @@ -738,6 +762,7 @@ export const telemetryEventDefinitions = { }), wait_for_completed: defineAgentTelemetryEvent({ owner: 'kimi-code', + domain: 'agent/tools', comment: 'A WaitFor tool call returns.', properties: { outcome: @@ -750,11 +775,13 @@ export const telemetryEventDefinitions = { }), model_switch: defineAgentTelemetryEvent({ owner: 'kimi-code', + domain: 'agent/profile', comment: 'The active model is bound or switched.', properties: { model: 'Model alias' }, }), thinking_toggle: defineAgentTelemetryEvent({ owner: 'kimi-code', + domain: 'agent/profile', comment: 'Thinking effort is toggled.', properties: { enabled: 'Whether thinking is now enabled', @@ -764,6 +791,7 @@ export const telemetryEventDefinitions = { }), question_dismissed: defineAgentTelemetryEvent({ owner: 'kimi-code', + domain: 'agent/tools', comment: 'A user question prompt is dismissed.', properties: { trace_id: @@ -772,6 +800,7 @@ export const telemetryEventDefinitions = { }), question_answered: defineAgentTelemetryEvent({ owner: 'kimi-code', + domain: 'agent/tools', comment: 'A user question prompt is answered.', properties: { answered: 'Number of questions answered', @@ -782,6 +811,7 @@ export const telemetryEventDefinitions = { }), goal_created: defineAgentTelemetryEvent({ owner: 'kimi-code', + domain: 'features/goal', comment: 'A goal is created.', properties: { actor: 'Who created the goal', @@ -790,6 +820,7 @@ export const telemetryEventDefinitions = { }), goal_budget_set: defineAgentTelemetryEvent({ owner: 'kimi-code', + domain: 'features/goal', comment: 'A goal budget is set.', properties: { actor: 'Who set the budget', @@ -800,16 +831,19 @@ export const telemetryEventDefinitions = { }), goal_continued: defineAgentTelemetryEvent({ owner: 'kimi-code', + domain: 'features/goal', comment: 'A goal continues into another turn.', properties: { turns_used: 'Turns consumed so far' }, }), goal_cleared: defineAgentTelemetryEvent({ owner: 'kimi-code', + domain: 'features/goal', comment: 'A goal is cleared.', properties: { actor: 'Who cleared the goal' }, }), goal_status_changed: defineAgentTelemetryEvent({ owner: 'kimi-code', + domain: 'features/goal', comment: 'A goal changes status.', properties: { actor: 'Who changed the status', @@ -824,6 +858,7 @@ export const telemetryEventDefinitions = { }), tool_call_dedup_detected: defineAgentTelemetryEvent({ owner: 'kimi-code', + domain: 'agent/toolDedupe', comment: 'A duplicate tool call is detected.', properties: { turn_id: 'Per-agent turn index (main or subagent); pair with agent_id to locate a turn within a session; omitted when no turn is active', @@ -838,6 +873,7 @@ export const telemetryEventDefinitions = { }), tool_call_repeat: defineAgentTelemetryEvent({ owner: 'kimi-code', + domain: 'agent/toolDedupe', comment: 'A repeated tool call streak is detected.', properties: { turn_id: 'Per-agent turn index (main or subagent); pair with agent_id to locate a turn within a session; omitted when no turn is active', @@ -850,6 +886,7 @@ export const telemetryEventDefinitions = { }), tool_call_turn_repeat: defineAgentTelemetryEvent({ owner: 'kimi-code', + domain: 'agent/toolDedupe', comment: 'A tool call reappears within the same turn.', properties: { turn_id: 'Per-agent turn index (main or subagent); pair with agent_id to locate a turn within a session; omitted when no turn is active', @@ -864,6 +901,7 @@ export const telemetryEventDefinitions = { }), agents_md_reminder_shown: defineAgentTelemetryEvent({ owner: 'kimi-code', + domain: 'agent/agentsMdReminder', comment: 'An AGENTS.md discovery reminder is appended to a tool result.', properties: { turn_id: 'Per-agent turn index (main or subagent); pair with agent_id to locate a turn within a session', @@ -875,6 +913,7 @@ export const telemetryEventDefinitions = { }), grep_tool_rg_fallback: defineAgentTelemetryEvent({ owner: 'kimi-code', + domain: 'agent/tools', comment: 'The grep tool falls back when resolving ripgrep.', properties: { source: 'Where ripgrep was resolved from', @@ -883,6 +922,7 @@ export const telemetryEventDefinitions = { }), glob_tool_rg_fallback: defineAgentTelemetryEvent({ owner: 'kimi-code', + domain: 'agent/tools', comment: 'The glob tool falls back when resolving ripgrep.', properties: { source: 'Where ripgrep was resolved from', @@ -891,16 +931,19 @@ export const telemetryEventDefinitions = { }), fs_grep_node_fallback: defineTelemetryEvent({ owner: 'kimi-code', + domain: 'workspace/workspaceFs', comment: 'The fs grep path falls back to the node implementation.', properties: { reason: 'Why the fallback was taken' }, }), fs_suggest_node_fallback: defineTelemetryEvent({ owner: 'kimi-code', + domain: 'workspace/workspaceFs', comment: 'The fs suggest path falls back to the node implementation.', properties: { reason: 'Why the fallback was taken' }, }), subagent_created: defineTelemetryEvent({ owner: 'kimi-code', + domain: 'session/subagent', comment: 'A subagent run is created.', properties: { subagent_name: 'Profile name of the subagent', @@ -916,6 +959,7 @@ export const telemetryEventDefinitions = { }), mcp_connected: defineTelemetryEvent({ owner: 'kimi-code', + domain: 'workspace/workspaceMcp', comment: 'MCP servers connect at session start.', properties: { server_count: 'Number of servers connected', @@ -924,6 +968,7 @@ export const telemetryEventDefinitions = { }), mcp_failed: defineTelemetryEvent({ owner: 'kimi-code', + domain: 'workspace/workspaceMcp', comment: 'MCP servers fail to connect at session start.', properties: { failed_count: 'Number of servers that failed', @@ -932,11 +977,13 @@ export const telemetryEventDefinitions = { }), cron_missed: defineTelemetryEvent({ owner: 'kimi-code', + domain: 'features/cron', comment: 'Cron tasks fire late after being slept through.', properties: { count: 'Number of tasks that missed their fire time' }, }), cron_scheduled: defineTelemetryEvent({ owner: 'kimi-code', + domain: 'features/cron', comment: 'A cron task is scheduled.', properties: { recurring: 'Whether the task repeats', @@ -945,6 +992,7 @@ export const telemetryEventDefinitions = { }), cron_deleted: defineTelemetryEvent({ owner: 'kimi-code', + domain: 'features/cron', comment: 'A cron task is deleted.', properties: { task_id: 'Cron task id', @@ -953,6 +1001,7 @@ export const telemetryEventDefinitions = { }), cron_fired: defineTelemetryEvent({ owner: 'kimi-code', + domain: 'features/cron', comment: 'A cron task fires.', properties: { recurring: 'Whether the task repeats', @@ -963,6 +1012,7 @@ export const telemetryEventDefinitions = { }), image_compress: defineTelemetryEvent({ owner: 'kimi-code', + domain: 'agent/media', comment: 'An image is compressed before being sent to the model.', properties: { source: 'Where the image came from', @@ -981,6 +1031,7 @@ export const telemetryEventDefinitions = { }), image_crop: defineTelemetryEvent({ owner: 'kimi-code', + domain: 'agent/media', comment: 'An image is cropped to a region before being sent to the model.', properties: { source: 'Where the image came from', @@ -996,6 +1047,7 @@ export const telemetryEventDefinitions = { }), video_upload: defineAgentTelemetryEvent({ owner: 'kimi-code', + domain: 'agent/media', comment: 'A video is uploaded for the model.', properties: { model: 'Model the video is uploaded for', @@ -1010,16 +1062,19 @@ export const telemetryEventDefinitions = { }), session_started: defineTelemetryEvent({ owner: 'kimi-code', + domain: 'workspace/sessionLifecycle', comment: 'A session becomes active (created, forked, or resumed).', properties: { resumed: 'Whether the session was resumed from disk' }, }), session_load_failed: defineTelemetryEvent({ owner: 'kimi-code', + domain: 'workspace/sessionLifecycle', comment: 'A session resume fails.', properties: { reason: 'Error code, error name, or unknown' }, }), wire_repair: defineTelemetryEvent({ owner: 'kimi-code', + domain: 'wire', comment: 'A corrupted wire journal is truncated to its valid prefix and healed on disk.', properties: { kind: 'Corruption kind: unparseable middle line or torn final line', @@ -1030,11 +1085,13 @@ export const telemetryEventDefinitions = { }), first_launch: defineTelemetryEvent({ owner: 'kimi-code', + domain: 'host', comment: 'The CLI runs for the first time on this device.', properties: {}, }), exit: defineTelemetryEvent({ owner: 'kimi-code', + domain: 'host', comment: 'A CLI run exits.', properties: { duration_ms: 'Run wall-clock time in milliseconds' }, }), diff --git a/packages/agent-core-v2/test/app/telemetry/events.test.ts b/packages/agent-core-v2/test/app/telemetry/events.test.ts index 5c0191ead84..2a39eb9b450 100644 --- a/packages/agent-core-v2/test/app/telemetry/events.test.ts +++ b/packages/agent-core-v2/test/app/telemetry/events.test.ts @@ -1,5 +1,15 @@ +import { readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + import { describe, expect, expectTypeOf, it } from 'vitest'; +import { + telemetryCoverageTierRoots, + telemetryDomainExemptions, + telemetryDomainKnownGaps, + telemetryPseudoDomains, +} from '#/app/telemetry/coverage'; import { agentTelemetryContextProperties, telemetryEventDefinitions, @@ -48,3 +58,63 @@ describe('telemetry event registry', () => { }>(); }); }); + +describe('telemetry domain coverage', () => { + const srcDir = join(fileURLToPath(new URL('.', import.meta.url)), '..', '..', '..', 'src'); + const tierRoots = new Set(telemetryCoverageTierRoots); + + const domains: string[] = []; + for (const entry of readdirSync(srcDir, { withFileTypes: true })) { + if (!entry.isDirectory()) { + continue; + } + if (!tierRoots.has(entry.name)) { + domains.push(entry.name); + continue; + } + for (const sub of readdirSync(join(srcDir, entry.name), { withFileTypes: true })) { + if (sub.isDirectory()) { + domains.push(`${entry.name}/${sub.name}`); + } + } + } + const domainSet = new Set(domains); + const validDomains = new Set([...domains, ...telemetryPseudoDomains]); + const eventDomains = new Set( + Object.values(telemetryEventDefinitions).map((definition) => definition.meta.domain), + ); + + it('accounts for every source domain with an event, an exemption, or a known gap', () => { + for (const domain of domains) { + const covered = eventDomains.has(domain); + const exempt = domain in telemetryDomainExemptions; + const gap = domain in telemetryDomainKnownGaps; + expect( + covered || exempt || gap, + `${domain}: register an event with this domain, or list it in src/app/telemetry/coverage.ts`, + ).toBe(true); + expect( + [covered, exempt, gap].filter(Boolean).length, + `${domain}: a domain with events must not also appear in exemptions or known gaps`, + ).toBe(1); + } + }); + + it('references only existing domains, with non-empty reasons', () => { + for (const domain of eventDomains) { + expect(validDomains.has(domain), `event domain "${domain}"`).toBe(true); + } + for (const [domain, reason] of Object.entries(telemetryDomainExemptions)) { + expect(domainSet.has(domain), `exemption "${domain}"`).toBe(true); + expect(reason.length, `exemption "${domain}" reason`).toBeGreaterThan(0); + } + for (const [domain, reason] of Object.entries(telemetryDomainKnownGaps)) { + expect(domainSet.has(domain), `known gap "${domain}"`).toBe(true); + expect(reason.length, `known gap "${domain}" reason`).toBeGreaterThan(0); + } + const overlap = Object.keys(telemetryDomainExemptions).filter( + (domain) => domain in telemetryDomainKnownGaps, + ); + expect(overlap, 'exemptions and known gaps must be disjoint').toEqual([]); + }); +});