Skip to content

Commit 7c37fa6

Browse files
authored
feat: add telemetry schemas and client (#941)
1 parent 7a4104d commit 7c37fa6

21 files changed

Lines changed: 2890 additions & 1441 deletions

package-lock.json

Lines changed: 1721 additions & 1407 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,11 @@
8989
"@aws-sdk/credential-providers": "^3.893.0",
9090
"@aws/agent-inspector": "0.2.1",
9191
"@commander-js/extra-typings": "^14.0.0",
92-
"@opentelemetry/api": "^1.9.0",
92+
"@opentelemetry/api": "^1.9.1",
93+
"@opentelemetry/exporter-metrics-otlp-http": "^0.214.0",
9394
"@opentelemetry/otlp-transformer": "^0.213.0",
95+
"@opentelemetry/resources": "^2.6.1",
96+
"@opentelemetry/sdk-metrics": "^2.6.1",
9497
"@smithy/shared-ini-file-loader": "^4.4.2",
9598
"commander": "^14.0.2",
9699
"dotenv": "^17.2.3",

src/cli/commands/telemetry/actions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { GLOBAL_CONFIG_DIR, GLOBAL_CONFIG_FILE, updateGlobalConfig } from '../../global-config.js';
2-
import { resolveTelemetryPreference } from '../../telemetry/resolve.js';
2+
import { resolveTelemetryPreference } from '../../telemetry/config.js';
33

44
export async function handleTelemetryDisable(
55
configDir = GLOBAL_CONFIG_DIR,
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
/* eslint-disable @typescript-eslint/require-await */
2+
import { CANCELLED, TelemetryClient } from '../client';
3+
import { InMemorySink } from '../sinks/in-memory-sink';
4+
import { describe, expect, it } from 'vitest';
5+
6+
describe('TelemetryClient', () => {
7+
describe('withCommandRun', () => {
8+
it('records success with returned attrs', async () => {
9+
const sink = new InMemorySink();
10+
const client = new TelemetryClient(sink);
11+
12+
await client.withCommandRun('update', async () => ({ check_only: true }));
13+
14+
expect(sink.metrics).toHaveLength(1);
15+
expect(sink.metrics[0]!.attrs).toMatchObject({
16+
command_group: 'update',
17+
command: 'update',
18+
exit_reason: 'success',
19+
check_only: 'true',
20+
});
21+
});
22+
23+
it('accepts sync callbacks', async () => {
24+
const sink = new InMemorySink();
25+
const client = new TelemetryClient(sink);
26+
27+
await client.withCommandRun('telemetry.disable', () => ({}));
28+
29+
expect(sink.metrics).toHaveLength(1);
30+
expect(sink.metrics[0]!.attrs).toMatchObject({ exit_reason: 'success' });
31+
});
32+
33+
it('records failure and re-throws on error', async () => {
34+
const sink = new InMemorySink();
35+
const client = new TelemetryClient(sink);
36+
37+
await expect(
38+
client.withCommandRun('deploy', async () => {
39+
throw new Error('boom');
40+
})
41+
).rejects.toThrow('boom');
42+
43+
expect(sink.metrics).toHaveLength(1);
44+
expect(sink.metrics[0]!.attrs).toMatchObject({
45+
command_group: 'deploy',
46+
exit_reason: 'failure',
47+
error_name: 'UnknownError',
48+
});
49+
});
50+
51+
it('classifies PackagingError subclasses', async () => {
52+
const sink = new InMemorySink();
53+
const client = new TelemetryClient(sink);
54+
55+
class MissingDependencyError extends Error {
56+
constructor() {
57+
super('missing dep');
58+
this.name = 'MissingDependencyError';
59+
}
60+
}
61+
62+
await expect(
63+
client.withCommandRun('deploy', async () => {
64+
throw new MissingDependencyError();
65+
})
66+
).rejects.toThrow();
67+
68+
expect(sink.metrics[0]!.attrs).toMatchObject({
69+
error_name: 'PackagingError',
70+
is_user_error: 'false',
71+
});
72+
});
73+
74+
it('marks credential errors as user errors', async () => {
75+
const sink = new InMemorySink();
76+
const client = new TelemetryClient(sink);
77+
78+
class AwsCredentialsError extends Error {
79+
constructor() {
80+
super('creds expired');
81+
this.name = 'AwsCredentialsError';
82+
}
83+
}
84+
85+
await expect(
86+
client.withCommandRun('invoke', async () => {
87+
throw new AwsCredentialsError();
88+
})
89+
).rejects.toThrow();
90+
91+
expect(sink.metrics[0]!.attrs).toMatchObject({
92+
error_name: 'CredentialsError',
93+
is_user_error: 'true',
94+
});
95+
});
96+
97+
it('records duration as a non-negative integer', async () => {
98+
const sink = new InMemorySink();
99+
const client = new TelemetryClient(sink);
100+
101+
await client.withCommandRun('telemetry.disable', async () => {
102+
await new Promise(r => globalThis.setTimeout(r, 5));
103+
return {};
104+
});
105+
106+
expect(sink.metrics[0]!.value).toBeGreaterThanOrEqual(0);
107+
expect(Number.isInteger(sink.metrics[0]!.value)).toBe(true);
108+
});
109+
110+
it('converts boolean attrs to strings', async () => {
111+
const sink = new InMemorySink();
112+
const client = new TelemetryClient(sink);
113+
114+
await client.withCommandRun('update', async () => ({ check_only: true }));
115+
116+
expect(sink.metrics[0]!.attrs.check_only).toBe('true');
117+
});
118+
119+
it('silently drops invalid success payloads', async () => {
120+
const sink = new InMemorySink();
121+
const client = new TelemetryClient(sink);
122+
123+
// Missing required attrs for 'create' — should silently drop
124+
await client.withCommandRun(
125+
'create',
126+
// @ts-expect-error — intentionally incomplete
127+
async () => ({ language: 'python' })
128+
);
129+
130+
expect(sink.metrics).toHaveLength(0);
131+
});
132+
133+
it('records cancel when callback returns CANCELLED', async () => {
134+
const sink = new InMemorySink();
135+
const client = new TelemetryClient(sink);
136+
137+
await client.withCommandRun('deploy', () => CANCELLED);
138+
139+
expect(sink.metrics).toHaveLength(1);
140+
expect(sink.metrics[0]!.attrs).toMatchObject({
141+
command_group: 'deploy',
142+
exit_reason: 'cancel',
143+
});
144+
});
145+
});
146+
});
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { InMemorySink } from '../sinks/in-memory-sink';
2+
import { CompositeSink, type MetricSink } from '../sinks/metric-sink';
3+
import { describe, expect, it, vi } from 'vitest';
4+
5+
describe('CompositeSink', () => {
6+
it('fans out records to all sinks', () => {
7+
const a = new InMemorySink();
8+
const b = new InMemorySink();
9+
const composite = new CompositeSink([a, b]);
10+
11+
composite.record(100, { command: 'deploy' });
12+
13+
expect(a.metrics).toHaveLength(1);
14+
expect(b.metrics).toHaveLength(1);
15+
expect(a.metrics[0]!.attrs.command).toBe('deploy');
16+
});
17+
18+
it('isolates errors — one sink throwing does not affect others', () => {
19+
const bad: MetricSink = {
20+
record: vi.fn(() => {
21+
throw new Error('sink failed');
22+
}),
23+
flush: vi.fn().mockResolvedValue(undefined),
24+
shutdown: vi.fn().mockResolvedValue(undefined),
25+
};
26+
const good = new InMemorySink();
27+
const composite = new CompositeSink([bad, good]);
28+
29+
composite.record(100, { command: 'deploy' });
30+
31+
expect(good.metrics).toHaveLength(1);
32+
});
33+
34+
it('flushes all sinks in parallel', async () => {
35+
const a = new InMemorySink();
36+
const b = new InMemorySink();
37+
const flushA = vi.spyOn(a, 'flush');
38+
const flushB = vi.spyOn(b, 'flush');
39+
const composite = new CompositeSink([a, b]);
40+
41+
await composite.flush(5000);
42+
43+
expect(flushA).toHaveBeenCalledWith(5000);
44+
expect(flushB).toHaveBeenCalledWith(5000);
45+
});
46+
47+
it('flush settles even if one sink rejects', async () => {
48+
const bad: MetricSink = {
49+
record: vi.fn(),
50+
flush: vi.fn().mockRejectedValue(new Error('flush failed')),
51+
shutdown: vi.fn().mockResolvedValue(undefined),
52+
};
53+
const good = new InMemorySink();
54+
const flushGood = vi.spyOn(good, 'flush');
55+
const composite = new CompositeSink([bad, good]);
56+
57+
await expect(composite.flush()).resolves.toBeUndefined();
58+
expect(flushGood).toHaveBeenCalled();
59+
});
60+
});
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { classifyError, isUserError } from '../error-classification';
2+
import { describe, expect, it } from 'vitest';
3+
4+
function errorWithName(name: string): Error {
5+
const err = new Error('test');
6+
err.name = name;
7+
return err;
8+
}
9+
10+
describe('classifyError', () => {
11+
it.each([
12+
['ConfigValidationError', 'ConfigError'],
13+
['ConfigNotFoundError', 'ConfigError'],
14+
['ConfigReadError', 'ConfigError'],
15+
['ConfigWriteError', 'ConfigError'],
16+
['ConfigParseError', 'ConfigError'],
17+
['AwsCredentialsError', 'CredentialsError'],
18+
['AccessDeniedException', 'CredentialsError'],
19+
['ExpiredToken', 'CredentialsError'],
20+
['PackagingError', 'PackagingError'],
21+
['MissingDependencyError', 'PackagingError'],
22+
['ArtifactSizeError', 'PackagingError'],
23+
['NoProjectError', 'ProjectError'],
24+
['AgentAlreadyExistsError', 'ProjectError'],
25+
['ResourceNotFoundException', 'ServiceError'],
26+
['ValidationException', 'ServiceError'],
27+
['ConflictException', 'ServiceError'],
28+
['ConnectionError', 'ConnectionError'],
29+
['ServerError', 'ConnectionError'],
30+
] as const)('%s → %s', (errorName, expected) => {
31+
expect(classifyError(errorWithName(errorName))).toBe(expected);
32+
});
33+
34+
it('returns UnknownError for unrecognized errors', () => {
35+
expect(classifyError(new Error('something'))).toBe('UnknownError');
36+
});
37+
38+
it('returns UnknownError for non-Error values', () => {
39+
expect(classifyError('string')).toBe('UnknownError');
40+
expect(classifyError(null)).toBe('UnknownError');
41+
expect(classifyError(undefined)).toBe('UnknownError');
42+
});
43+
44+
it('uses err.name when constructor.name is Error (SDK pattern)', () => {
45+
// AWS SDK errors often: new Error(); err.name = 'ValidationException'
46+
expect(classifyError(errorWithName('ValidationException'))).toBe('ServiceError');
47+
});
48+
});
49+
50+
describe('isUserError', () => {
51+
it('returns true for user-fixable categories', () => {
52+
expect(isUserError(errorWithName('ConfigValidationError'))).toBe(true);
53+
expect(isUserError(errorWithName('AwsCredentialsError'))).toBe(true);
54+
expect(isUserError(errorWithName('NoProjectError'))).toBe(true);
55+
});
56+
57+
it('returns false for system categories', () => {
58+
expect(isUserError(errorWithName('PackagingError'))).toBe(false);
59+
expect(isUserError(errorWithName('ResourceNotFoundException'))).toBe(false);
60+
expect(isUserError(errorWithName('ConnectionError'))).toBe(false);
61+
expect(isUserError(new Error('unknown'))).toBe(false);
62+
});
63+
});

src/cli/telemetry/__tests__/resolve.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { createTempConfig } from '../../__tests__/helpers/temp-config';
2-
import { resolveTelemetryPreference } from '../resolve';
2+
import { resolveTelemetryPreference } from '../config';
33
import { writeFile } from 'fs/promises';
44
import { join } from 'node:path';
55
import { afterAll, afterEach, beforeEach, describe, expect, it } from 'vitest';
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { resolveResourceAttributes } from '../config';
2+
import { ResourceAttributesSchema } from '../schemas/common-attributes';
3+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
4+
5+
const ORIGINAL_ENV = process.env.AGENTCORE_CONFIG_DIR;
6+
7+
describe('resolveResourceAttributes', () => {
8+
beforeEach(() => {
9+
process.env.AGENTCORE_CONFIG_DIR = '/tmp/telemetry-test-' + Date.now();
10+
});
11+
12+
afterEach(() => {
13+
if (ORIGINAL_ENV === undefined) {
14+
delete process.env.AGENTCORE_CONFIG_DIR;
15+
} else {
16+
process.env.AGENTCORE_CONFIG_DIR = ORIGINAL_ENV;
17+
}
18+
});
19+
20+
it('returns attributes that pass schema validation', async () => {
21+
const attrs = await resolveResourceAttributes('cli');
22+
expect(() => ResourceAttributesSchema.parse(attrs)).not.toThrow();
23+
});
24+
25+
it('sets service.name to agentcore-cli', async () => {
26+
const attrs = await resolveResourceAttributes('cli');
27+
expect(attrs['service.name']).toBe('agentcore-cli');
28+
});
29+
30+
it('generates unique session_id per call', async () => {
31+
const a = await resolveResourceAttributes('cli');
32+
const b = await resolveResourceAttributes('cli');
33+
expect(a['agentcore-cli.session_id']).not.toBe(b['agentcore-cli.session_id']);
34+
});
35+
36+
it('reflects the mode parameter', async () => {
37+
const cli = await resolveResourceAttributes('cli');
38+
const tui = await resolveResourceAttributes('tui');
39+
expect(cli['agentcore-cli.mode']).toBe('cli');
40+
expect(tui['agentcore-cli.mode']).toBe('tui');
41+
});
42+
43+
it('populates os and node fields', async () => {
44+
const attrs = await resolveResourceAttributes('cli');
45+
expect(attrs['os.type']).toBeTruthy();
46+
expect(attrs['os.version']).toBeTruthy();
47+
expect(attrs['host.arch']).toBeTruthy();
48+
expect(attrs['node.version']).toMatch(/^v\d+/);
49+
});
50+
});

0 commit comments

Comments
 (0)