Skip to content
Merged
2 changes: 1 addition & 1 deletion src/errors/errors.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ describe("AgentCoreCLIError", () => {
])("fromError SDK %s → expected source", (_label, err, expectedSource) => {
const result = AgentCoreCLIError.fromError(err as Error);
expect(result.json()).toMatchObject({
name: "AgentCoreCLIError",
name: err.name,
source: expectedSource,
});
});
Expand Down
5 changes: 4 additions & 1 deletion src/errors/errors.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ export interface AgentCoreCLIErrorOptions extends ErrorOptions {
meta?: Record<string, unknown>;
/** Describes the exitCode for the CLI when this error hits the root handler */
exitCode?: number;
/** Describes the name of the underlying error, defaults to AgentCoreCLIError */
name?: string;
}

/** Base error for CLI failures, including their source, metadata, and process exit code. */
Expand All @@ -18,7 +20,7 @@ export class AgentCoreCLIError extends Error {

constructor(message?: string, options?: AgentCoreCLIErrorOptions) {
super(message, options);
this.name = new.target.name;
this.name = options?.name ?? new.target.name;
this.source = options?.source ?? ERROR_SOURCE.INTERNAL;
this.meta = options?.meta ?? {};
this.exitCode = options?.exitCode ?? 1;
Expand Down Expand Up @@ -48,6 +50,7 @@ export class AgentCoreCLIError extends Error {
return new AgentCoreCLIError(error.message, {
cause: error,
source,
name: error.name,
meta: { ...error.$metadata },
});
}
Expand Down
23 changes: 15 additions & 8 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ import { FsReadWriteJson } from "./io";
import { createFileLogger, LOG_LEVEL } from "./logging";
import { runWithExitCode } from "./runnable";
import { DefaultGlobalConfigAccessor } from "./globalConfig";
import { DefaultTelemetryClient, TelemetryAttributesRecorder } from "./telemetry";
import { DefaultTelemetryClient } from "./telemetry";
import { AgentCoreCLIError } from "./errors";
import { CommandRunMetricEventKey, ValueContext } from "./router";

process.exit(
await runWithExitCode(async (argv: string[]) => {
Expand Down Expand Up @@ -49,7 +50,7 @@ process.exit(
globalConfigAccessor,
});

const commandRunTelemetryRecorder = new TelemetryAttributesRecorder("cli.command_run", {
const commandRunMetricEvent = telemetryClient.createMetricEvent("cli.command_run", {
exit_reason: "success",
});

Expand All @@ -74,19 +75,25 @@ process.exit(
globalConfigAccessor,
});

const context = ValueContext.EmptyContext().withValue(
CommandRunMetricEventKey,
commandRunMetricEvent,
);

// Handle the request
await rootHandler.route(argv);
await rootHandler.route(argv, context);
} catch (e) {
const error = AgentCoreCLIError.fromError(e);
rootLogger.child({ error: error.json() }).error();
// TODO: add error details to telemetry recorder;
commandRunTelemetryRecorder.record({ exit_reason: "failure" });

commandRunMetricEvent.setAttributes({
exit_reason: "failure",
error_name: error.name,
error_source: error.source,
});
throw error;
} finally {
try {
const attributes = commandRunTelemetryRecorder.getAttributes();
await telemetryClient.emit("cli.command_run", Date.now() - startTime, attributes);
await commandRunMetricEvent.emit(Date.now() - startTime);
} catch (e) {
const error = AgentCoreCLIError.fromError(e);
rootLogger.child({ error: error.json() }).warn("failed to emit telemetry");
Expand Down
1 change: 1 addition & 0 deletions src/router/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export {
PathKey,
LoggerKey,
GlobalConfigAccessorKey,
CommandRunMetricEventKey,
ProjectKey,
type DefaultHandle,
type DefaultHandlerProvider,
Expand Down
63 changes: 63 additions & 0 deletions src/router/router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import z from "zod";

import {
Router,
CommandRunMetricEventKey,
ValueContext,
argument,
compile,
Expand All @@ -16,6 +17,8 @@ import {
type Middleware,
} from "./index";
import { InputValidationError } from "../errors";
import { DefaultTelemetryClient } from "../telemetry";
import { createSilentLogger, TestGlobalConfigAccessor } from "../testing";

// --- helpers ---------------------------------------------------------------

Expand Down Expand Up @@ -668,3 +671,63 @@ test("commands without long-form flag help have no Parameter details section", a
expect(out).toContain("--id");
expect(out).not.toContain("Parameter details:");
});

// --- telemetry: command path recording -------------------------------------

test.each([
{ scenario: "flag validation succeeds", idFlag: "abc", shouldThrow: false },
{ scenario: "flag validation fails", idFlag: "toolong", shouldThrow: true },
])("records command_path on the metric event when $scenario", async ({ idFlag, shouldThrow }) => {
// we use a real command name here so that telemetry schemas accept the path produced below
const get = createHandler({
name: "config",
description: "",
flags: [flag("id", "id", z.string().max(3))],
handle: async () => {},
});

const recordedMetrics: { metricName: string; value: number; attributes: Record<string, any> }[] =
[];
const inMemorySink = {
getName: () => "InMemorySink",
send: (metricName: string, value: number, attributes: Record<string, any>) => {
recordedMetrics.push({ metricName, value, attributes });
},
shutdown: async () => {},
};

const telemetryClient = new DefaultTelemetryClient({
logger: createSilentLogger(),
globalConfigAccessor: new TestGlobalConfigAccessor(),
sessionId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
metricSinks: [inMemorySink],
});

const root = new Router("agentcore");
root.handler(get);

const commandRunMetricEvent = telemetryClient.createMetricEvent("cli.command_run");
const ctx = ValueContext.EmptyContext().withValue(
CommandRunMetricEventKey,
commandRunMetricEvent,
);
const cmd = compile(root, ctx);

if (shouldThrow) {
await expect(cmd.parseAsync(["node", "agentcore", "config", "--id", idFlag])).rejects.toThrow();
commandRunMetricEvent.setAttributes({ exit_reason: "failure" });
} else {
await cmd.parseAsync(["node", "agentcore", "config", "--id", idFlag]);
commandRunMetricEvent.setAttributes({ exit_reason: "success" });
}

await commandRunMetricEvent.emit(100);

expect(recordedMetrics).toHaveLength(1);
expect(recordedMetrics[0]!.metricName).toBe("cli.command_run");
expect(recordedMetrics[0]!.value).toBe(100);
expect(recordedMetrics[0]!.attributes).toMatchObject({
command_path: "/agentcore/config",
exit_reason: shouldThrow ? "failure" : "success",
});
});
19 changes: 18 additions & 1 deletion src/router/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Command } from "commander";
import type { Logger } from "../logging";
import type { GlobalConfigAccessor } from "../globalConfig";
import type { Project } from "../handlers/project/types";
import { type MetricEvent } from "../telemetry";

// CommandKey exposes the Commander Command for the executing leaf via context.
export const CommandKey: ContextKey<Command> = contextKey<Command>("commander.command");
Expand All @@ -16,6 +17,9 @@ export const PathKey: ContextKey<string> = contextKey<string>("path");

export const LoggerKey = contextKey<Logger>("logger");

export const CommandRunMetricEventKey =
contextKey<MetricEvent<"cli.command_run">>("commandRunMetricEvent");

export const GlobalConfigAccessorKey: ContextKey<GlobalConfigAccessor> =
contextKey<GlobalConfigAccessor>("globalConfigAccessor");
export const ProjectKey = contextKey<Project>("project");
Expand Down Expand Up @@ -72,6 +76,8 @@ function attachAction(
const command = actionArgs[actionArgs.length - 1] as Command;
const merged = command.optsWithGlobals();

recordCommandPath(ctx);

// Inherited group/global flags -> context (typed, read via ctx.value(key)).
let leafCtx = ctx.withValue(CommandKey, command);
leafCtx = applyGlobalFlags(globals, merged, leafCtx);
Expand All @@ -90,6 +96,11 @@ function globalFlagsOf(node: Handler): GlobalFlag[] {
return node.flags().filter((f): f is GlobalFlag => "id" in f);
}

/** Add the command path to active command run metric **/
function recordCommandPath(ctx: Context): void {
ctx.value(CommandRunMetricEventKey)?.setAttributes({ command_path: ctx.value(PathKey) });
}

// compile walks the Handler tree into a Commander Command tree.
//
// `stack` is the accumulated middleware declared by ancestors. A node's own
Expand All @@ -108,7 +119,7 @@ export function compile(
stack: Middleware[] = [],
inheritedGlobals: GlobalFlag[] = [],
): Command {
const c = new Command(node.name()).exitOverride();
const c = new Command(node.name());
c.description(node.description());

const ownFlags = node.flags();
Expand All @@ -129,6 +140,12 @@ export function compile(
const newPath = `${path}/${node.name()}`;
ctx = ctx.withValue(PathKey, newPath);

// commander may fail on invalid flags before we are able to record on the happy path, so we must record here as well
c.exitOverride((e) => {
recordCommandPath(ctx);
throw e;
});

const children = node.children();
if (children.length > 0) {
// attaching both children and subcommands leads to ambiguity.
Expand Down
71 changes: 55 additions & 16 deletions src/telemetry/client.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { join } from "node:path";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import os, { tmpdir } from "node:os";
import { DefaultTelemetryClient } from "./client";
import { TelemetryAttributesRecorder } from "./recorder";
import { createFileLogger, type Logger } from "../logging";
import { LOG_LEVEL } from "../logging";
import { assertLogsMatch, TestGlobalConfigAccessor } from "../testing";
Expand Down Expand Up @@ -41,12 +40,20 @@ describe("DefaultTelemetryClient", () => {
metricSinks: [fileSystemSink],
});

const recorder = new TelemetryAttributesRecorder("cli.command_run", { exit_reason: "success" });
const metricEvent = client.createMetricEvent("cli.command_run", {
exit_reason: "success",
command_path: "/agentcore",
});

await metricEvent.emit(123);

await client.emit("cli.command_run", 123, recorder.getAttributes());
// create a second event with failure
const metricEvent2 = client.createMetricEvent("cli.command_run", {
exit_reason: "failure",
command_path: "/agentcore",
});

recorder.record({ exit_reason: "failure" });
await client.emit("cli.command_run", 456, recorder.getAttributes());
await metricEvent2.emit(456);
await client.shutdown();

expect(fileSystemSink.getName()).toBe("FileSystemSink");
Expand Down Expand Up @@ -75,12 +82,22 @@ describe("DefaultTelemetryClient", () => {
{
metricName: "cli.command_run",
value: 123,
attrs: { ...resourceAttributes, exit_reason: "success" },
attrs: {
...resourceAttributes,
exit_reason: "success",
command_path: "/agentcore",
is_tui: false,
},
},
{
metricName: "cli.command_run",
value: 456,
attrs: { ...resourceAttributes, exit_reason: "failure" },
attrs: {
...resourceAttributes,
exit_reason: "failure",
command_path: "/agentcore",
is_tui: false,
},
},
]);
});
Expand Down Expand Up @@ -117,8 +134,20 @@ describe("DefaultTelemetryClient", () => {
auditFilePath,
});

await enabledClient.emit("cli.command_run", 123, { exit_reason: "success" });
await disabledClient.emit("cli.command_run", 456, { exit_reason: "failure" });
const enabledEvent = enabledClient.createMetricEvent("cli.command_run", {
exit_reason: "success",
command_path: "/agentcore",
is_tui: true,
});
await enabledEvent.emit(123);

const disabledEvent = disabledClient.createMetricEvent("cli.command_run", {
exit_reason: "failure",
command_path: "/agentcore",
is_tui: false,
});
await disabledEvent.emit(123);

await Promise.all([enabledClient.shutdown(), disabledClient.shutdown()]);

const auditLines = (await readFile(auditFilePath, "utf8")).trimEnd().split("\n");
Expand All @@ -136,21 +165,23 @@ describe("DefaultTelemetryClient", () => {
"host.arch": os.arch(),
"node.version": process.version,
exit_reason: "success",
command_path: "/agentcore",
is_tui: true,
},
});
});

test("throws when recorder has incomplete attributes", async () => {
test("throws when metric event has incomplete attributes", async () => {
const client = new DefaultTelemetryClient({
logger,
sessionId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
globalConfigAccessor: new TestGlobalConfigAccessor(),
metricSinks: [],
});

const recorder = new TelemetryAttributesRecorder("cli.command_run");
const metricEvent = client.createMetricEvent("cli.command_run");

expect(() => client.emit("cli.command_run", 100, recorder.getAttributes())).toThrow();
await expect(metricEvent.emit(100)).rejects.toThrow();
await client.shutdown();
});

Expand All @@ -167,8 +198,11 @@ describe("DefaultTelemetryClient", () => {
globalConfigAccessor: new TestGlobalConfigAccessor(),
metricSinks: [sink],
});

await client.emit("cli.command_run", 1, { exit_reason: "success" });
const metricEvent = client.createMetricEvent("cli.command_run", {
exit_reason: "success",
command_path: "/agentcore",
});
await metricEvent.emit(1);
await client.shutdown();

await assertLogsMatch(tempDir, [
Expand Down Expand Up @@ -208,8 +242,13 @@ describe("DefaultTelemetryClient", () => {
metricSinks: [badSink, goodSink],
});

// emit should not throw even though the sink's record() throws
await client.emit("cli.command_run", 100, { exit_reason: "success" });
const metricEvent = client.createMetricEvent("cli.command_run", {
exit_reason: "success",
command_path: "/agentcore",
});

// end should not throw even though the sink's send() throws
await metricEvent.emit(100);
// shutdown should not throw even though the sink's shutdown() rejects
await client.shutdown();

Expand Down
Loading
Loading