Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
5da7e4b
refactor: decouple TelemetryClient from command_run logic
Hweinstock May 14, 2026
ffbe31f
refactor: make withCommandRun internal, migrate consumers
Hweinstock May 14, 2026
992ad6d
feat: add typed metric registry, MetricName enforced at compile time
Hweinstock May 14, 2026
7a1ded1
docs: update telemetry README with new metric and new command guides
Hweinstock May 14, 2026
50e781d
fix: restore try/catch in recordCommandRun to prevent telemetry crashes
Hweinstock May 14, 2026
328e823
fix: tighten emit() type safety, use MetricName in sinks, rename mode…
Hweinstock May 14, 2026
6468ed7
simplify: derive registry types from COMMAND_SCHEMAS, remove manual C…
Hweinstock May 14, 2026
201185c
simplify: remove dead exports, Command z.enum, and fix deploy/utils d…
Hweinstock May 14, 2026
ab570dd
fix: remove dead CancelResult schema — no code path produces cancel e…
Hweinstock May 14, 2026
1021dc3
fix: rename stale mode→deploy_mode in useDeployFlow diff path
Hweinstock May 14, 2026
36f3c36
test: add coverage for callback-throws path in withCommandRunTelemetry
Hweinstock May 14, 2026
bed69e4
docs: rewrite telemetry README to match implementation
Hweinstock May 14, 2026
4f4c48f
fix: wrap no-client case in try-catch for consistency with other case
Hweinstock May 15, 2026
7702fce
docs: update outdated docstring
Hweinstock May 15, 2026
10d9e96
chore: rebase onto mainline
Hweinstock May 15, 2026
4a2b6ea
feat(telemetry): add MetricRegistry type with descriptions
Hweinstock May 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/cli/commands/deploy/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ describe('computeDeployAttrs', () => {
gateway_target_count: 3,
policy_engine_count: 2,
policy_count: 3,
mode: 'diff',
deploy_mode: 'diff',
});
});

Expand All @@ -39,7 +39,7 @@ describe('computeDeployAttrs', () => {
gateway_target_count: 0,
policy_engine_count: 0,
policy_count: 0,
mode: 'deploy',
deploy_mode: 'deploy',
});
});

Expand All @@ -49,6 +49,6 @@ describe('computeDeployAttrs', () => {

expect(attrs.runtime_count).toBe(1);
expect(attrs.memory_count).toBe(0);
expect(attrs.mode).toBe('dry-run');
expect(attrs.deploy_mode).toBe('dry-run');
});
});
7 changes: 3 additions & 4 deletions src/cli/commands/deploy/utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type { AgentCoreProjectSpec } from '../../../schema';

export type DeployMode = 'deploy' | 'dry-run' | 'diff';
import type { DeployMode } from '../../telemetry/schemas/common-shapes';

export const DEFAULT_DEPLOY_ATTRS = {
runtime_count: 0,
Expand All @@ -12,7 +11,7 @@ export const DEFAULT_DEPLOY_ATTRS = {
gateway_target_count: 0,
policy_engine_count: 0,
policy_count: 0,
mode: 'deploy' as DeployMode,
deploy_mode: 'deploy' as DeployMode,
};

export function computeDeployAttrs(projectSpec: Partial<AgentCoreProjectSpec>, mode: DeployMode) {
Expand All @@ -28,6 +27,6 @@ export function computeDeployAttrs(projectSpec: Partial<AgentCoreProjectSpec>, m
gateway_target_count: gateways.reduce((sum, g) => sum + (g.targets ?? []).length, 0),
policy_engine_count: policyEngines.length,
policy_count: policyEngines.reduce((sum, pe) => sum + (pe.policies ?? []).length, 0),
mode,
deploy_mode: mode,
};
}
23 changes: 12 additions & 11 deletions src/cli/commands/dev/command.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -479,20 +479,21 @@ export const registerDev = (program: Command) => {
// Default: launch web UI in browser
// NOTE: Do not copy this pattern. runBrowserMode blocks forever (internal
// await new Promise(() => {})) so we cannot use withCommandRunTelemetry here.
// We emit telemetry eagerly before the blocking call. If startup fails, the
// error propagates to the outer catch. Prefer withCommandRunTelemetry for
// commands that return.
// We emit telemetry eagerly before the blocking call.
{
const client = await TelemetryClientAccessor.get().catch(() => undefined);
const devAttrs = {
action: 'server' as const,
ui_mode: 'browser' as const,
has_stream: false,
protocol: standardize(Protocol, (targetDevAgent?.protocol ?? 'http').toLowerCase()),
invoke_count: 0,
};
if (client) {
await client.withCommandRun('dev', () => devAttrs);
client.emit('cli.command_run', 0, {
command_group: 'dev',
command: 'dev',
exit_reason: 'success',
action: 'server',
ui_mode: 'browser',
has_stream: false,
protocol: standardize(Protocol, (targetDevAgent?.protocol ?? 'http').toLowerCase()),
invoke_count: 0,
});
await client.flush();
}
await runBrowserMode({
workingDir,
Expand Down
12 changes: 5 additions & 7 deletions src/cli/commands/help/command.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { TelemetryClientAccessor } from '../../telemetry/client-accessor.js';
import { withCommandRunTelemetry } from '../../telemetry/cli-command-run.js';
import type { Command } from '@commander-js/extra-typings';

const MODES_HELP = `
Expand Down Expand Up @@ -43,22 +43,20 @@ export const registerHelp = (program: Command) => {
.command('help')
.description('Display help topics')
.action(async () => {
const client = await TelemetryClientAccessor.get();
await client.withCommandRun('help', () => {
await withCommandRunTelemetry('help', {}, () => {
console.log('Available help topics: modes');
console.log('Run `agentcore help <topic>` for details.');
return {};
return { success: true as const };
});
});

helpCmd
.command('modes')
.description('Explain interactive vs non-interactive modes')
.action(async () => {
const client = await TelemetryClientAccessor.get();
await client.withCommandRun('help.modes', () => {
await withCommandRunTelemetry('help.modes', {}, () => {
console.log(MODES_HELP);
return {};
return { success: true as const };
});
});
};
132 changes: 54 additions & 78 deletions src/cli/telemetry/README.md
Original file line number Diff line number Diff line change
@@ -1,121 +1,97 @@
# Adding New Telemetry Metrics
# Telemetry

## Overview
## Adding a New Metric

Every CLI command emits a `command_run` metric with a command key, exit reason, and command-specific attributes. This
guide shows how to add telemetry to a new command.
### 1. Define attributes in `schemas/common-shapes.ts`

## Step 1: Register the command in `schemas/command-run.ts`

Add an entry to `COMMAND_SCHEMAS`:
Skip if reusing existing attributes.

```ts
// No attributes:
'remove.widget': NoAttrs,
export const ToolName = z.enum(['read_file', 'write_file', 'search']);
```

// With attributes:
'add.widget': safeSchema({
widget_type: WidgetType, // z.enum(), z.boolean(), z.number(), or z.literal() only
count: Count,
}),
Add to the `ATTRIBUTES` object using the field name as the key:

```ts
export const ATTRIBUTES = {
// ...existing
tool_name: ToolName,
} as const;
```

`safeSchema` enforces allowed field types at compile time. No `z.string()` fields.
### 2. Register the metric in `schemas/registry.ts`

## Step 2: Add enums to `schemas/common-shapes.ts`
Add an entry to `METRICS` with a description, and a corresponding `MetricAttrs` branch:

```ts
export const WidgetType = z.enum(['basic', 'advanced']);
export const METRICS = {
'cli.command_run': { description: 'CLI/TUI Command Execution' },
'cli.mcp_tool_call': { description: 'MCP tool invocation' },
} as const satisfies MetricRegistry;

export type MetricAttrs<M extends MetricName> = M extends 'cli.command_run'
? CommandRunAttrs
: M extends 'cli.mcp_tool_call'
? { tool_name: z.infer<typeof ATTRIBUTES.tool_name>; success: boolean }
: never;
```

Use `standardize()` to normalize input before recording:
### 3. Emit it

```ts
import { WidgetType, standardize } from '../telemetry/schemas/common-shapes.js';

const type = standardize(WidgetType, userInput);
client.emit('cli.mcp_tool_call', durationMs, { tool_name: 'read_file', success: true });
```

## Step 3: Instrument the command handler
Wrong metric name or missing attrs = compile error.

Use **`withCommandRunTelemetry`** — the primary helper for recording telemetry:
---

```ts
import { withCommandRunTelemetry } from '../telemetry/cli-command-run.js';
## Adding a New Command (to `cli.command_run`)

const result = await withCommandRunTelemetry('remove.gateway', {}, () => this.remove(name));
```

**Signature:**
### 1. Define the command's attribute schema in `schemas/command-run.ts`

```ts
async function withCommandRunTelemetry<C extends Command, R extends OperationResult>(
command: C,
attrs: CommandAttrs<C>,
fn: () => Promise<R>
): Promise<R>;
const AddWidgetAttrs = safeSchema({
widget_type: WidgetType,
count: Count,
});
```

- `command` — the registered command key (e.g. `'add.widget'`)
- `attrs` — attribute object matching the schema registered in Step 1
- `fn` — async callback returning `Result<T>` (from `src/lib/result.ts`)
Add to `COMMAND_SCHEMAS`:

**Behavior:**
```ts
'add.widget': AddWidgetAttrs,
```

- On success: records `attrs` with `exit_reason: 'success'`, returns the result.
- On failure/throw: records `attrs` with `exit_reason: 'failure'`, returns `{ success: false, error }`.
- If telemetry is unavailable: runs `fn()` untracked.
The `Command` type and optional fields in `MetricAttrs<'cli.command_run'>` are derived automatically from
`COMMAND_SCHEMAS`.

Since `attrs` are passed upfront, they are always recorded — even on failure.
### 2. Instrument the handler

**Example with attributes:**
Use `withCommandRunTelemetry`:

```ts
const result = await withCommandRunTelemetry(
'add.widget',
{ widget_type: standardize(WidgetType, config.type), count: config.items.length },
{ widget_type: standardize(WidgetType, input), count: items.length },
() => widgetPrimitive.add(config)
);

if (!result.success) {
console.error(result.error);
process.exit(1);
}
```

### `runCliCommand` (alternative for top-level CLI handlers)

For CLI handlers that own `process.exit`, use `runCliCommand` instead. The callback throws on failure and returns attrs
on success:
Or `runCliCommand` for top-level CLI handlers that own `process.exit`:

```ts
await runCliCommand('add.widget', !!options.json, async () => {
const result = await widgetPrimitive.add(options);
if (!result.success) throw new Error(result.error);
return { widget_type: standardize(WidgetType, options.type), count: options.items.length };
await runCliCommand('add.widget', !!opts.json, async () => {
await widgetPrimitive.add(opts);
return { widget_type: standardize(WidgetType, opts.type), count: opts.items.length };
});
```

To record attrs on failure, pass `knownAttrs` as the fourth argument:

```ts
const knownAttrs = { widget_type: standardize(WidgetType, options.type), count: options.items.length };
await runCliCommand(
'add.widget',
!!options.json,
async () => {
const result = await widgetPrimitive.add(options);
if (!result.success) throw new Error(result.error);
return knownAttrs;
},
knownAttrs
);
```
---

## Key Points
## Key Rules

- Telemetry never crashes the CLI — `standardize()` falls back gracefully, `resilientParse` defaults invalid fields to
`'unknown'`.
- Prefer `withCommandRunTelemetry` for new code — it returns the `Result` for the caller to handle output and control
flow.
- Use `runCliCommand` only when the handler owns `process.exit` and prints its own output.
- `safeSchema` only allows `z.enum()`, `z.boolean()`, `z.number()`, `z.literal()`. No `z.string()`.
- `standardize(schema, value)` lowercases and validates enum values. Invalid values fall through gracefully.
- `resilientParse` validates each field independently — one bad field defaults to `'unknown'`, never drops the metric.
- Telemetry never crashes the CLI.
Loading
Loading