Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3,128 changes: 1,721 additions & 1,407 deletions package-lock.json

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -88,8 +88,11 @@
"@aws-sdk/credential-providers": "^3.893.0",
"@aws/agent-inspector": "0.1.0",
"@commander-js/extra-typings": "^14.0.0",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/api": "^1.9.1",
"@opentelemetry/exporter-metrics-otlp-http": "^0.214.0",
"@opentelemetry/otlp-transformer": "^0.213.0",
"@opentelemetry/resources": "^2.6.1",
"@opentelemetry/sdk-metrics": "^2.6.1",
"@smithy/shared-ini-file-loader": "^4.4.2",
"commander": "^14.0.2",
"dotenv": "^17.2.3",
Expand Down
2 changes: 1 addition & 1 deletion src/cli/commands/telemetry/actions.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { GLOBAL_CONFIG_DIR, GLOBAL_CONFIG_FILE, updateGlobalConfig } from '../../global-config.js';
import { resolveTelemetryPreference } from '../../telemetry/resolve.js';
import { resolveTelemetryPreference } from '../../telemetry/config.js';

export async function handleTelemetryDisable(
configDir = GLOBAL_CONFIG_DIR,
Expand Down
146 changes: 146 additions & 0 deletions src/cli/telemetry/__tests__/client.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
/* eslint-disable @typescript-eslint/require-await */
import { CANCELLED, TelemetryClient } from '../client';
import { InMemorySink } from '../sinks/in-memory-sink';
import { describe, expect, it } from 'vitest';

describe('TelemetryClient', () => {
describe('withCommandRun', () => {
it('records success with returned attrs', async () => {
const sink = new InMemorySink();
const client = new TelemetryClient(sink);

await client.withCommandRun('update', async () => ({ check_only: true }));

expect(sink.metrics).toHaveLength(1);
expect(sink.metrics[0]!.attrs).toMatchObject({
command_group: 'update',
command: 'update',
exit_reason: 'success',
check_only: 'true',
});
});

it('accepts sync callbacks', async () => {
const sink = new InMemorySink();
const client = new TelemetryClient(sink);

await client.withCommandRun('telemetry.disable', () => ({}));

expect(sink.metrics).toHaveLength(1);
expect(sink.metrics[0]!.attrs).toMatchObject({ exit_reason: 'success' });
});

it('records failure and re-throws on error', async () => {
const sink = new InMemorySink();
const client = new TelemetryClient(sink);

await expect(
client.withCommandRun('deploy', async () => {
throw new Error('boom');
})
).rejects.toThrow('boom');

expect(sink.metrics).toHaveLength(1);
expect(sink.metrics[0]!.attrs).toMatchObject({
command_group: 'deploy',
exit_reason: 'failure',
error_name: 'UnknownError',
});
});

it('classifies PackagingError subclasses', async () => {
const sink = new InMemorySink();
const client = new TelemetryClient(sink);

class MissingDependencyError extends Error {
constructor() {
super('missing dep');
this.name = 'MissingDependencyError';
}
}

await expect(
client.withCommandRun('deploy', async () => {
throw new MissingDependencyError();
})
).rejects.toThrow();

expect(sink.metrics[0]!.attrs).toMatchObject({
error_name: 'PackagingError',
is_user_error: 'false',
});
});

it('marks credential errors as user errors', async () => {
const sink = new InMemorySink();
const client = new TelemetryClient(sink);

class AwsCredentialsError extends Error {
constructor() {
super('creds expired');
this.name = 'AwsCredentialsError';
}
}

await expect(
client.withCommandRun('invoke', async () => {
throw new AwsCredentialsError();
})
).rejects.toThrow();

expect(sink.metrics[0]!.attrs).toMatchObject({
error_name: 'CredentialsError',
is_user_error: 'true',
});
});

it('records duration as a non-negative integer', async () => {
const sink = new InMemorySink();
const client = new TelemetryClient(sink);

await client.withCommandRun('telemetry.disable', async () => {
await new Promise(r => globalThis.setTimeout(r, 5));
return {};
});

expect(sink.metrics[0]!.value).toBeGreaterThanOrEqual(0);
expect(Number.isInteger(sink.metrics[0]!.value)).toBe(true);
});

it('converts boolean attrs to strings', async () => {
const sink = new InMemorySink();
const client = new TelemetryClient(sink);

await client.withCommandRun('update', async () => ({ check_only: true }));

expect(sink.metrics[0]!.attrs.check_only).toBe('true');
});

it('silently drops invalid success payloads', async () => {
const sink = new InMemorySink();
const client = new TelemetryClient(sink);

// Missing required attrs for 'create' — should silently drop
await client.withCommandRun(
'create',
// @ts-expect-error — intentionally incomplete
async () => ({ language: 'python' })
);

expect(sink.metrics).toHaveLength(0);
});

it('records cancel when callback returns CANCELLED', async () => {
const sink = new InMemorySink();
const client = new TelemetryClient(sink);

await client.withCommandRun('deploy', () => CANCELLED);

expect(sink.metrics).toHaveLength(1);
expect(sink.metrics[0]!.attrs).toMatchObject({
command_group: 'deploy',
exit_reason: 'cancel',
});
});
});
});
60 changes: 60 additions & 0 deletions src/cli/telemetry/__tests__/composite-sink.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
import { InMemorySink } from '../sinks/in-memory-sink';
import { CompositeSink, type MetricSink } from '../sinks/metric-sink';
import { describe, expect, it, vi } from 'vitest';

describe('CompositeSink', () => {
it('fans out records to all sinks', () => {
const a = new InMemorySink();
const b = new InMemorySink();
const composite = new CompositeSink([a, b]);

composite.record(100, { command: 'deploy' });

expect(a.metrics).toHaveLength(1);
expect(b.metrics).toHaveLength(1);
expect(a.metrics[0]!.attrs.command).toBe('deploy');
});

it('isolates errors — one sink throwing does not affect others', () => {
const bad: MetricSink = {
record: vi.fn(() => {
throw new Error('sink failed');
}),
flush: vi.fn().mockResolvedValue(undefined),
shutdown: vi.fn().mockResolvedValue(undefined),
};
const good = new InMemorySink();
const composite = new CompositeSink([bad, good]);

composite.record(100, { command: 'deploy' });

expect(good.metrics).toHaveLength(1);
});

it('flushes all sinks in parallel', async () => {
const a = new InMemorySink();
const b = new InMemorySink();
const flushA = vi.spyOn(a, 'flush');
const flushB = vi.spyOn(b, 'flush');
const composite = new CompositeSink([a, b]);

await composite.flush(5000);

expect(flushA).toHaveBeenCalledWith(5000);
expect(flushB).toHaveBeenCalledWith(5000);
});

it('flush settles even if one sink rejects', async () => {
const bad: MetricSink = {
record: vi.fn(),
flush: vi.fn().mockRejectedValue(new Error('flush failed')),
shutdown: vi.fn().mockResolvedValue(undefined),
};
const good = new InMemorySink();
const flushGood = vi.spyOn(good, 'flush');
const composite = new CompositeSink([bad, good]);

await expect(composite.flush()).resolves.toBeUndefined();
expect(flushGood).toHaveBeenCalled();
});
});
63 changes: 63 additions & 0 deletions src/cli/telemetry/__tests__/error-classification.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
import { classifyError, isUserError } from '../error-classification';
import { describe, expect, it } from 'vitest';

function errorWithName(name: string): Error {
const err = new Error('test');
err.name = name;
return err;
}

describe('classifyError', () => {
it.each([
['ConfigValidationError', 'ConfigError'],
['ConfigNotFoundError', 'ConfigError'],
['ConfigReadError', 'ConfigError'],
['ConfigWriteError', 'ConfigError'],
['ConfigParseError', 'ConfigError'],
['AwsCredentialsError', 'CredentialsError'],
['AccessDeniedException', 'CredentialsError'],
['ExpiredToken', 'CredentialsError'],
['PackagingError', 'PackagingError'],
['MissingDependencyError', 'PackagingError'],
['ArtifactSizeError', 'PackagingError'],
['NoProjectError', 'ProjectError'],
['AgentAlreadyExistsError', 'ProjectError'],
['ResourceNotFoundException', 'ServiceError'],
['ValidationException', 'ServiceError'],
['ConflictException', 'ServiceError'],
['ConnectionError', 'ConnectionError'],
['ServerError', 'ConnectionError'],
] as const)('%s → %s', (errorName, expected) => {
expect(classifyError(errorWithName(errorName))).toBe(expected);
});

it('returns UnknownError for unrecognized errors', () => {
expect(classifyError(new Error('something'))).toBe('UnknownError');
});

it('returns UnknownError for non-Error values', () => {
expect(classifyError('string')).toBe('UnknownError');
expect(classifyError(null)).toBe('UnknownError');
expect(classifyError(undefined)).toBe('UnknownError');
});

it('uses err.name when constructor.name is Error (SDK pattern)', () => {
// AWS SDK errors often: new Error(); err.name = 'ValidationException'
expect(classifyError(errorWithName('ValidationException'))).toBe('ServiceError');
});
});

describe('isUserError', () => {
it('returns true for user-fixable categories', () => {
expect(isUserError(errorWithName('ConfigValidationError'))).toBe(true);
expect(isUserError(errorWithName('AwsCredentialsError'))).toBe(true);
expect(isUserError(errorWithName('NoProjectError'))).toBe(true);
});

it('returns false for system categories', () => {
expect(isUserError(errorWithName('PackagingError'))).toBe(false);
expect(isUserError(errorWithName('ResourceNotFoundException'))).toBe(false);
expect(isUserError(errorWithName('ConnectionError'))).toBe(false);
expect(isUserError(new Error('unknown'))).toBe(false);
});
});
2 changes: 1 addition & 1 deletion src/cli/telemetry/__tests__/resolve.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { createTempConfig } from '../../__tests__/helpers/temp-config';
import { resolveTelemetryPreference } from '../resolve';
import { resolveTelemetryPreference } from '../config';
import { writeFile } from 'fs/promises';
import { join } from 'node:path';
import { afterAll, afterEach, beforeEach, describe, expect, it } from 'vitest';
Expand Down
50 changes: 50 additions & 0 deletions src/cli/telemetry/__tests__/resource-resolver.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
import { resolveResourceAttributes } from '../config';
import { ResourceAttributesSchema } from '../schemas/common-attributes';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';

const ORIGINAL_ENV = process.env.AGENTCORE_CONFIG_DIR;

describe('resolveResourceAttributes', () => {
beforeEach(() => {
process.env.AGENTCORE_CONFIG_DIR = '/tmp/telemetry-test-' + Date.now();
});

afterEach(() => {
if (ORIGINAL_ENV === undefined) {
delete process.env.AGENTCORE_CONFIG_DIR;
} else {
process.env.AGENTCORE_CONFIG_DIR = ORIGINAL_ENV;
}
});

it('returns attributes that pass schema validation', async () => {
const attrs = await resolveResourceAttributes('cli');
expect(() => ResourceAttributesSchema.parse(attrs)).not.toThrow();
});

it('sets service.name to agentcore-cli', async () => {
const attrs = await resolveResourceAttributes('cli');
expect(attrs['service.name']).toBe('agentcore-cli');
});

it('generates unique session_id per call', async () => {
const a = await resolveResourceAttributes('cli');
const b = await resolveResourceAttributes('cli');
expect(a['agentcore-cli.session_id']).not.toBe(b['agentcore-cli.session_id']);
});

it('reflects the mode parameter', async () => {
const cli = await resolveResourceAttributes('cli');
const tui = await resolveResourceAttributes('tui');
expect(cli['agentcore-cli.mode']).toBe('cli');
expect(tui['agentcore-cli.mode']).toBe('tui');
});

it('populates os and node fields', async () => {
const attrs = await resolveResourceAttributes('cli');
expect(attrs['os.type']).toBeTruthy();
expect(attrs['os.version']).toBeTruthy();
expect(attrs['host.arch']).toBeTruthy();
expect(attrs['node.version']).toMatch(/^v\d+/);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3,128 changes: 1,721 additions & 1,407 deletions package-lock.json

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -88,8 +88,11 @@
"@aws-sdk/credential-providers": "^3.893.0",
"@aws/agent-inspector": "0.1.0",
"@commander-js/extra-typings": "^14.0.0",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/api": "^1.9.1",
"@opentelemetry/exporter-metrics-otlp-http": "^0.214.0",
"@opentelemetry/otlp-transformer": "^0.213.0",
"@opentelemetry/resources": "^2.6.1",
"@opentelemetry/sdk-metrics": "^2.6.1",
"@smithy/shared-ini-file-loader": "^4.4.2",
"commander": "^14.0.2",
"dotenv": "^17.2.3",
Expand Down
2 changes: 1 addition & 1 deletion src/cli/commands/telemetry/actions.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { GLOBAL_CONFIG_DIR, GLOBAL_CONFIG_FILE, updateGlobalConfig } from '../../global-config.js';
import { resolveTelemetryPreference } from '../../telemetry/resolve.js';
import { resolveTelemetryPreference } from '../../telemetry/config.js';

export async function handleTelemetryDisable(
configDir = GLOBAL_CONFIG_DIR,
Expand Down
146 changes: 146 additions & 0 deletions src/cli/telemetry/__tests__/client.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
/* eslint-disable @typescript-eslint/require-await */
import { CANCELLED, TelemetryClient } from '../client';
import { InMemorySink } from '../sinks/in-memory-sink';
import { describe, expect, it } from 'vitest';

describe('TelemetryClient', () => {
describe('withCommandRun', () => {
it('records success with returned attrs', async () => {
const sink = new InMemorySink();
const client = new TelemetryClient(sink);

await client.withCommandRun('update', async () => ({ check_only: true }));

expect(sink.metrics).toHaveLength(1);
expect(sink.metrics[0]!.attrs).toMatchObject({
command_group: 'update',
command: 'update',
exit_reason: 'success',
check_only: 'true',
});
});

it('accepts sync callbacks', async () => {
const sink = new InMemorySink();
const client = new TelemetryClient(sink);

await client.withCommandRun('telemetry.disable', () => ({}));

expect(sink.metrics).toHaveLength(1);
expect(sink.metrics[0]!.attrs).toMatchObject({ exit_reason: 'success' });
});

it('records failure and re-throws on error', async () => {
const sink = new InMemorySink();
const client = new TelemetryClient(sink);

await expect(
client.withCommandRun('deploy', async () => {
throw new Error('boom');
})
).rejects.toThrow('boom');

expect(sink.metrics).toHaveLength(1);
expect(sink.metrics[0]!.attrs).toMatchObject({
command_group: 'deploy',
exit_reason: 'failure',
error_name: 'UnknownError',
});
});

it('classifies PackagingError subclasses', async () => {
const sink = new InMemorySink();
const client = new TelemetryClient(sink);

class MissingDependencyError extends Error {
constructor() {
super('missing dep');
this.name = 'MissingDependencyError';
}
}

await expect(
client.withCommandRun('deploy', async () => {
throw new MissingDependencyError();
})
).rejects.toThrow();

expect(sink.metrics[0]!.attrs).toMatchObject({
error_name: 'PackagingError',
is_user_error: 'false',
});
});

it('marks credential errors as user errors', async () => {
const sink = new InMemorySink();
const client = new TelemetryClient(sink);

class AwsCredentialsError extends Error {
constructor() {
super('creds expired');
this.name = 'AwsCredentialsError';
}
}

await expect(
client.withCommandRun('invoke', async () => {
throw new AwsCredentialsError();
})
).rejects.toThrow();

expect(sink.metrics[0]!.attrs).toMatchObject({
error_name: 'CredentialsError',
is_user_error: 'true',
});
});

it('records duration as a non-negative integer', async () => {
const sink = new InMemorySink();
const client = new TelemetryClient(sink);

await client.withCommandRun('telemetry.disable', async () => {
await new Promise(r => globalThis.setTimeout(r, 5));
return {};
});

expect(sink.metrics[0]!.value).toBeGreaterThanOrEqual(0);
expect(Number.isInteger(sink.metrics[0]!.value)).toBe(true);
});

it('converts boolean attrs to strings', async () => {
const sink = new InMemorySink();
const client = new TelemetryClient(sink);

await client.withCommandRun('update', async () => ({ check_only: true }));

expect(sink.metrics[0]!.attrs.check_only).toBe('true');
});

it('silently drops invalid success payloads', async () => {
const sink = new InMemorySink();
const client = new TelemetryClient(sink);

// Missing required attrs for 'create' — should silently drop
await client.withCommandRun(
'create',
// @ts-expect-error — intentionally incomplete
async () => ({ language: 'python' })
);

expect(sink.metrics).toHaveLength(0);
});

it('records cancel when callback returns CANCELLED', async () => {
const sink = new InMemorySink();
const client = new TelemetryClient(sink);

await client.withCommandRun('deploy', () => CANCELLED);

expect(sink.metrics).toHaveLength(1);
expect(sink.metrics[0]!.attrs).toMatchObject({
command_group: 'deploy',
exit_reason: 'cancel',
});
});
});
});
60 changes: 60 additions & 0 deletions src/cli/telemetry/__tests__/composite-sink.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
import { InMemorySink } from '../sinks/in-memory-sink';
import { CompositeSink, type MetricSink } from '../sinks/metric-sink';
import { describe, expect, it, vi } from 'vitest';

describe('CompositeSink', () => {
it('fans out records to all sinks', () => {
const a = new InMemorySink();
const b = new InMemorySink();
const composite = new CompositeSink([a, b]);

composite.record(100, { command: 'deploy' });

expect(a.metrics).toHaveLength(1);
expect(b.metrics).toHaveLength(1);
expect(a.metrics[0]!.attrs.command).toBe('deploy');
});

it('isolates errors — one sink throwing does not affect others', () => {
const bad: MetricSink = {
record: vi.fn(() => {
throw new Error('sink failed');
}),
flush: vi.fn().mockResolvedValue(undefined),
shutdown: vi.fn().mockResolvedValue(undefined),
};
const good = new InMemorySink();
const composite = new CompositeSink([bad, good]);

composite.record(100, { command: 'deploy' });

expect(good.metrics).toHaveLength(1);
});

it('flushes all sinks in parallel', async () => {
const a = new InMemorySink();
const b = new InMemorySink();
const flushA = vi.spyOn(a, 'flush');
const flushB = vi.spyOn(b, 'flush');
const composite = new CompositeSink([a, b]);

await composite.flush(5000);

expect(flushA).toHaveBeenCalledWith(5000);
expect(flushB).toHaveBeenCalledWith(5000);
});

it('flush settles even if one sink rejects', async () => {
const bad: MetricSink = {
record: vi.fn(),
flush: vi.fn().mockRejectedValue(new Error('flush failed')),
shutdown: vi.fn().mockResolvedValue(undefined),
};
const good = new InMemorySink();
const flushGood = vi.spyOn(good, 'flush');
const composite = new CompositeSink([bad, good]);

await expect(composite.flush()).resolves.toBeUndefined();
expect(flushGood).toHaveBeenCalled();
});
});
63 changes: 63 additions & 0 deletions src/cli/telemetry/__tests__/error-classification.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
import { classifyError, isUserError } from '../error-classification';
import { describe, expect, it } from 'vitest';

function errorWithName(name: string): Error {
const err = new Error('test');
err.name = name;
return err;
}

describe('classifyError', () => {
it.each([
['ConfigValidationError', 'ConfigError'],
['ConfigNotFoundError', 'ConfigError'],
['ConfigReadError', 'ConfigError'],
['ConfigWriteError', 'ConfigError'],
['ConfigParseError', 'ConfigError'],
['AwsCredentialsError', 'CredentialsError'],
['AccessDeniedException', 'CredentialsError'],
['ExpiredToken', 'CredentialsError'],
['PackagingError', 'PackagingError'],
['MissingDependencyError', 'PackagingError'],
['ArtifactSizeError', 'PackagingError'],
['NoProjectError', 'ProjectError'],
['AgentAlreadyExistsError', 'ProjectError'],
['ResourceNotFoundException', 'ServiceError'],
['ValidationException', 'ServiceError'],
['ConflictException', 'ServiceError'],
['ConnectionError', 'ConnectionError'],
['ServerError', 'ConnectionError'],
] as const)('%s → %s', (errorName, expected) => {
expect(classifyError(errorWithName(errorName))).toBe(expected);
});

it('returns UnknownError for unrecognized errors', () => {
expect(classifyError(new Error('something'))).toBe('UnknownError');
});

it('returns UnknownError for non-Error values', () => {
expect(classifyError('string')).toBe('UnknownError');
expect(classifyError(null)).toBe('UnknownError');
expect(classifyError(undefined)).toBe('UnknownError');
});

it('uses err.name when constructor.name is Error (SDK pattern)', () => {
// AWS SDK errors often: new Error(); err.name = 'ValidationException'
expect(classifyError(errorWithName('ValidationException'))).toBe('ServiceError');
});
});

describe('isUserError', () => {
it('returns true for user-fixable categories', () => {
expect(isUserError(errorWithName('ConfigValidationError'))).toBe(true);
expect(isUserError(errorWithName('AwsCredentialsError'))).toBe(true);
expect(isUserError(errorWithName('NoProjectError'))).toBe(true);
});

it('returns false for system categories', () => {
expect(isUserError(errorWithName('PackagingError'))).toBe(false);
expect(isUserError(errorWithName('ResourceNotFoundException'))).toBe(false);
expect(isUserError(errorWithName('ConnectionError'))).toBe(false);
expect(isUserError(new Error('unknown'))).toBe(false);
});
});
2 changes: 1 addition & 1 deletion src/cli/telemetry/__tests__/resolve.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { createTempConfig } from '../../__tests__/helpers/temp-config';
import { resolveTelemetryPreference } from '../resolve';
import { resolveTelemetryPreference } from '../config';
import { writeFile } from 'fs/promises';
import { join } from 'node:path';
import { afterAll, afterEach, beforeEach, describe, expect, it } from 'vitest';
Expand Down
50 changes: 50 additions & 0 deletions src/cli/telemetry/__tests__/resource-resolver.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
import { resolveResourceAttributes } from '../config';
import { ResourceAttributesSchema } from '../schemas/common-attributes';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';

const ORIGINAL_ENV = process.env.AGENTCORE_CONFIG_DIR;

describe('resolveResourceAttributes', () => {
beforeEach(() => {
process.env.AGENTCORE_CONFIG_DIR = '/tmp/telemetry-test-' + Date.now();
});

afterEach(() => {
if (ORIGINAL_ENV === undefined) {
delete process.env.AGENTCORE_CONFIG_DIR;
} else {
process.env.AGENTCORE_CONFIG_DIR = ORIGINAL_ENV;
}
});

it('returns attributes that pass schema validation', async () => {
const attrs = await resolveResourceAttributes('cli');
expect(() => ResourceAttributesSchema.parse(attrs)).not.toThrow();
});

it('sets service.name to agentcore-cli', async () => {
const attrs = await resolveResourceAttributes('cli');
expect(attrs['service.name']).toBe('agentcore-cli');
});

it('generates unique session_id per call', async () => {
const a = await resolveResourceAttributes('cli');
const b = await resolveResourceAttributes('cli');
expect(a['agentcore-cli.session_id']).not.toBe(b['agentcore-cli.session_id']);
});

it('reflects the mode parameter', async () => {
const cli = await resolveResourceAttributes('cli');
const tui = await resolveResourceAttributes('tui');
expect(cli['agentcore-cli.mode']).toBe('cli');
expect(tui['agentcore-cli.mode']).toBe('tui');
});

it('populates os and node fields', async () => {
const attrs = await resolveResourceAttributes('cli');
expect(attrs['os.type']).toBeTruthy();
expect(attrs['os.version']).toBeTruthy();
expect(attrs['host.arch']).toBeTruthy();
expect(attrs['node.version']).toMatch(/^v\d+/);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3,128 changes: 1,721 additions & 1,407 deletions package-lock.json

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -88,8 +88,11 @@
"@aws-sdk/credential-providers": "^3.893.0",
"@aws/agent-inspector": "0.1.0",
"@commander-js/extra-typings": "^14.0.0",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/api": "^1.9.1",
"@opentelemetry/exporter-metrics-otlp-http": "^0.214.0",
"@opentelemetry/otlp-transformer": "^0.213.0",
"@opentelemetry/resources": "^2.6.1",
"@opentelemetry/sdk-metrics": "^2.6.1",
"@smithy/shared-ini-file-loader": "^4.4.2",
"commander": "^14.0.2",
"dotenv": "^17.2.3",
Expand Down
2 changes: 1 addition & 1 deletion src/cli/commands/telemetry/actions.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { GLOBAL_CONFIG_DIR, GLOBAL_CONFIG_FILE, updateGlobalConfig } from '../../global-config.js';
import { resolveTelemetryPreference } from '../../telemetry/resolve.js';
import { resolveTelemetryPreference } from '../../telemetry/config.js';

export async function handleTelemetryDisable(
configDir = GLOBAL_CONFIG_DIR,
Expand Down
146 changes: 146 additions & 0 deletions src/cli/telemetry/__tests__/client.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
/* eslint-disable @typescript-eslint/require-await */
import { CANCELLED, TelemetryClient } from '../client';
import { InMemorySink } from '../sinks/in-memory-sink';
import { describe, expect, it } from 'vitest';

describe('TelemetryClient', () => {
describe('withCommandRun', () => {
it('records success with returned attrs', async () => {
const sink = new InMemorySink();
const client = new TelemetryClient(sink);

await client.withCommandRun('update', async () => ({ check_only: true }));

expect(sink.metrics).toHaveLength(1);
expect(sink.metrics[0]!.attrs).toMatchObject({
command_group: 'update',
command: 'update',
exit_reason: 'success',
check_only: 'true',
});
});

it('accepts sync callbacks', async () => {
const sink = new InMemorySink();
const client = new TelemetryClient(sink);

await client.withCommandRun('telemetry.disable', () => ({}));

expect(sink.metrics).toHaveLength(1);
expect(sink.metrics[0]!.attrs).toMatchObject({ exit_reason: 'success' });
});

it('records failure and re-throws on error', async () => {
const sink = new InMemorySink();
const client = new TelemetryClient(sink);

await expect(
client.withCommandRun('deploy', async () => {
throw new Error('boom');
})
).rejects.toThrow('boom');

expect(sink.metrics).toHaveLength(1);
expect(sink.metrics[0]!.attrs).toMatchObject({
command_group: 'deploy',
exit_reason: 'failure',
error_name: 'UnknownError',
});
});

it('classifies PackagingError subclasses', async () => {
const sink = new InMemorySink();
const client = new TelemetryClient(sink);

class MissingDependencyError extends Error {
constructor() {
super('missing dep');
this.name = 'MissingDependencyError';
}
}

await expect(
client.withCommandRun('deploy', async () => {
throw new MissingDependencyError();
})
).rejects.toThrow();

expect(sink.metrics[0]!.attrs).toMatchObject({
error_name: 'PackagingError',
is_user_error: 'false',
});
});

it('marks credential errors as user errors', async () => {
const sink = new InMemorySink();
const client = new TelemetryClient(sink);

class AwsCredentialsError extends Error {
constructor() {
super('creds expired');
this.name = 'AwsCredentialsError';
}
}

await expect(
client.withCommandRun('invoke', async () => {
throw new AwsCredentialsError();
})
).rejects.toThrow();

expect(sink.metrics[0]!.attrs).toMatchObject({
error_name: 'CredentialsError',
is_user_error: 'true',
});
});

it('records duration as a non-negative integer', async () => {
const sink = new InMemorySink();
const client = new TelemetryClient(sink);

await client.withCommandRun('telemetry.disable', async () => {
await new Promise(r => globalThis.setTimeout(r, 5));
return {};
});

expect(sink.metrics[0]!.value).toBeGreaterThanOrEqual(0);
expect(Number.isInteger(sink.metrics[0]!.value)).toBe(true);
});

it('converts boolean attrs to strings', async () => {
const sink = new InMemorySink();
const client = new TelemetryClient(sink);

await client.withCommandRun('update', async () => ({ check_only: true }));

expect(sink.metrics[0]!.attrs.check_only).toBe('true');
});

it('silently drops invalid success payloads', async () => {
const sink = new InMemorySink();
const client = new TelemetryClient(sink);

// Missing required attrs for 'create' — should silently drop
await client.withCommandRun(
'create',
// @ts-expect-error — intentionally incomplete
async () => ({ language: 'python' })
);

expect(sink.metrics).toHaveLength(0);
});

it('records cancel when callback returns CANCELLED', async () => {
const sink = new InMemorySink();
const client = new TelemetryClient(sink);

await client.withCommandRun('deploy', () => CANCELLED);

expect(sink.metrics).toHaveLength(1);
expect(sink.metrics[0]!.attrs).toMatchObject({
command_group: 'deploy',
exit_reason: 'cancel',
});
});
});
});
60 changes: 60 additions & 0 deletions src/cli/telemetry/__tests__/composite-sink.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
import { InMemorySink } from '../sinks/in-memory-sink';
import { CompositeSink, type MetricSink } from '../sinks/metric-sink';
import { describe, expect, it, vi } from 'vitest';

describe('CompositeSink', () => {
it('fans out records to all sinks', () => {
const a = new InMemorySink();
const b = new InMemorySink();
const composite = new CompositeSink([a, b]);

composite.record(100, { command: 'deploy' });

expect(a.metrics).toHaveLength(1);
expect(b.metrics).toHaveLength(1);
expect(a.metrics[0]!.attrs.command).toBe('deploy');
});

it('isolates errors — one sink throwing does not affect others', () => {
const bad: MetricSink = {
record: vi.fn(() => {
throw new Error('sink failed');
}),
flush: vi.fn().mockResolvedValue(undefined),
shutdown: vi.fn().mockResolvedValue(undefined),
};
const good = new InMemorySink();
const composite = new CompositeSink([bad, good]);

composite.record(100, { command: 'deploy' });

expect(good.metrics).toHaveLength(1);
});

it('flushes all sinks in parallel', async () => {
const a = new InMemorySink();
const b = new InMemorySink();
const flushA = vi.spyOn(a, 'flush');
const flushB = vi.spyOn(b, 'flush');
const composite = new CompositeSink([a, b]);

await composite.flush(5000);

expect(flushA).toHaveBeenCalledWith(5000);
expect(flushB).toHaveBeenCalledWith(5000);
});

it('flush settles even if one sink rejects', async () => {
const bad: MetricSink = {
record: vi.fn(),
flush: vi.fn().mockRejectedValue(new Error('flush failed')),
shutdown: vi.fn().mockResolvedValue(undefined),
};
const good = new InMemorySink();
const flushGood = vi.spyOn(good, 'flush');
const composite = new CompositeSink([bad, good]);

await expect(composite.flush()).resolves.toBeUndefined();
expect(flushGood).toHaveBeenCalled();
});
});
63 changes: 63 additions & 0 deletions src/cli/telemetry/__tests__/error-classification.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
import { classifyError, isUserError } from '../error-classification';
import { describe, expect, it } from 'vitest';

function errorWithName(name: string): Error {
const err = new Error('test');
err.name = name;
return err;
}

describe('classifyError', () => {
it.each([
['ConfigValidationError', 'ConfigError'],
['ConfigNotFoundError', 'ConfigError'],
['ConfigReadError', 'ConfigError'],
['ConfigWriteError', 'ConfigError'],
['ConfigParseError', 'ConfigError'],
['AwsCredentialsError', 'CredentialsError'],
['AccessDeniedException', 'CredentialsError'],
['ExpiredToken', 'CredentialsError'],
['PackagingError', 'PackagingError'],
['MissingDependencyError', 'PackagingError'],
['ArtifactSizeError', 'PackagingError'],
['NoProjectError', 'ProjectError'],
['AgentAlreadyExistsError', 'ProjectError'],
['ResourceNotFoundException', 'ServiceError'],
['ValidationException', 'ServiceError'],
['ConflictException', 'ServiceError'],
['ConnectionError', 'ConnectionError'],
['ServerError', 'ConnectionError'],
] as const)('%s → %s', (errorName, expected) => {
expect(classifyError(errorWithName(errorName))).toBe(expected);
});

it('returns UnknownError for unrecognized errors', () => {
expect(classifyError(new Error('something'))).toBe('UnknownError');
});

it('returns UnknownError for non-Error values', () => {
expect(classifyError('string')).toBe('UnknownError');
expect(classifyError(null)).toBe('UnknownError');
expect(classifyError(undefined)).toBe('UnknownError');
});

it('uses err.name when constructor.name is Error (SDK pattern)', () => {
// AWS SDK errors often: new Error(); err.name = 'ValidationException'
expect(classifyError(errorWithName('ValidationException'))).toBe('ServiceError');
});
});

describe('isUserError', () => {
it('returns true for user-fixable categories', () => {
expect(isUserError(errorWithName('ConfigValidationError'))).toBe(true);
expect(isUserError(errorWithName('AwsCredentialsError'))).toBe(true);
expect(isUserError(errorWithName('NoProjectError'))).toBe(true);
});

it('returns false for system categories', () => {
expect(isUserError(errorWithName('PackagingError'))).toBe(false);
expect(isUserError(errorWithName('ResourceNotFoundException'))).toBe(false);
expect(isUserError(errorWithName('ConnectionError'))).toBe(false);
expect(isUserError(new Error('unknown'))).toBe(false);
});
});
2 changes: 1 addition & 1 deletion src/cli/telemetry/__tests__/resolve.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { createTempConfig } from '../../__tests__/helpers/temp-config';
import { resolveTelemetryPreference } from '../resolve';
import { resolveTelemetryPreference } from '../config';
import { writeFile } from 'fs/promises';
import { join } from 'node:path';
import { afterAll, afterEach, beforeEach, describe, expect, it } from 'vitest';
Expand Down
50 changes: 50 additions & 0 deletions src/cli/telemetry/__tests__/resource-resolver.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
import { resolveResourceAttributes } from '../config';
import { ResourceAttributesSchema } from '../schemas/common-attributes';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';

const ORIGINAL_ENV = process.env.AGENTCORE_CONFIG_DIR;

describe('resolveResourceAttributes', () => {
beforeEach(() => {
process.env.AGENTCORE_CONFIG_DIR = '/tmp/telemetry-test-' + Date.now();
});

afterEach(() => {
if (ORIGINAL_ENV === undefined) {
delete process.env.AGENTCORE_CONFIG_DIR;
} else {
process.env.AGENTCORE_CONFIG_DIR = ORIGINAL_ENV;
}
});

it('returns attributes that pass schema validation', async () => {
const attrs = await resolveResourceAttributes('cli');
expect(() => ResourceAttributesSchema.parse(attrs)).not.toThrow();
});

it('sets service.name to agentcore-cli', async () => {
const attrs = await resolveResourceAttributes('cli');
expect(attrs['service.name']).toBe('agentcore-cli');
});

it('generates unique session_id per call', async () => {
const a = await resolveResourceAttributes('cli');
const b = await resolveResourceAttributes('cli');
expect(a['agentcore-cli.session_id']).not.toBe(b['agentcore-cli.session_id']);
});

it('reflects the mode parameter', async () => {
const cli = await resolveResourceAttributes('cli');
const tui = await resolveResourceAttributes('tui');
expect(cli['agentcore-cli.mode']).toBe('cli');
expect(tui['agentcore-cli.mode']).toBe('tui');
});

it('populates os and node fields', async () => {
const attrs = await resolveResourceAttributes('cli');
expect(attrs['os.type']).toBeTruthy();
expect(attrs['os.version']).toBeTruthy();
expect(attrs['host.arch']).toBeTruthy();
expect(attrs['node.version']).toMatch(/^v\d+/);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3,128 changes: 1,721 additions & 1,407 deletions package-lock.json

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -88,8 +88,11 @@
"@aws-sdk/credential-providers": "^3.893.0",
"@aws/agent-inspector": "0.1.0",
"@commander-js/extra-typings": "^14.0.0",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/api": "^1.9.1",
"@opentelemetry/exporter-metrics-otlp-http": "^0.214.0",
"@opentelemetry/otlp-transformer": "^0.213.0",
"@opentelemetry/resources": "^2.6.1",
"@opentelemetry/sdk-metrics": "^2.6.1",
"@smithy/shared-ini-file-loader": "^4.4.2",
"commander": "^14.0.2",
"dotenv": "^17.2.3",
Expand Down
2 changes: 1 addition & 1 deletion src/cli/commands/telemetry/actions.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { GLOBAL_CONFIG_DIR, GLOBAL_CONFIG_FILE, updateGlobalConfig } from '../../global-config.js';
import { resolveTelemetryPreference } from '../../telemetry/resolve.js';
import { resolveTelemetryPreference } from '../../telemetry/config.js';

export async function handleTelemetryDisable(
configDir = GLOBAL_CONFIG_DIR,
Expand Down
146 changes: 146 additions & 0 deletions src/cli/telemetry/__tests__/client.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
/* eslint-disable @typescript-eslint/require-await */
import { CANCELLED, TelemetryClient } from '../client';
import { InMemorySink } from '../sinks/in-memory-sink';
import { describe, expect, it } from 'vitest';

describe('TelemetryClient', () => {
describe('withCommandRun', () => {
it('records success with returned attrs', async () => {
const sink = new InMemorySink();
const client = new TelemetryClient(sink);

await client.withCommandRun('update', async () => ({ check_only: true }));

expect(sink.metrics).toHaveLength(1);
expect(sink.metrics[0]!.attrs).toMatchObject({
command_group: 'update',
command: 'update',
exit_reason: 'success',
check_only: 'true',
});
});

it('accepts sync callbacks', async () => {
const sink = new InMemorySink();
const client = new TelemetryClient(sink);

await client.withCommandRun('telemetry.disable', () => ({}));

expect(sink.metrics).toHaveLength(1);
expect(sink.metrics[0]!.attrs).toMatchObject({ exit_reason: 'success' });
});

it('records failure and re-throws on error', async () => {
const sink = new InMemorySink();
const client = new TelemetryClient(sink);

await expect(
client.withCommandRun('deploy', async () => {
throw new Error('boom');
})
).rejects.toThrow('boom');

expect(sink.metrics).toHaveLength(1);
expect(sink.metrics[0]!.attrs).toMatchObject({
command_group: 'deploy',
exit_reason: 'failure',
error_name: 'UnknownError',
});
});

it('classifies PackagingError subclasses', async () => {
const sink = new InMemorySink();
const client = new TelemetryClient(sink);

class MissingDependencyError extends Error {
constructor() {
super('missing dep');
this.name = 'MissingDependencyError';
}
}

await expect(
client.withCommandRun('deploy', async () => {
throw new MissingDependencyError();
})
).rejects.toThrow();

expect(sink.metrics[0]!.attrs).toMatchObject({
error_name: 'PackagingError',
is_user_error: 'false',
});
});

it('marks credential errors as user errors', async () => {
const sink = new InMemorySink();
const client = new TelemetryClient(sink);

class AwsCredentialsError extends Error {
constructor() {
super('creds expired');
this.name = 'AwsCredentialsError';
}
}

await expect(
client.withCommandRun('invoke', async () => {
throw new AwsCredentialsError();
})
).rejects.toThrow();

expect(sink.metrics[0]!.attrs).toMatchObject({
error_name: 'CredentialsError',
is_user_error: 'true',
});
});

it('records duration as a non-negative integer', async () => {
const sink = new InMemorySink();
const client = new TelemetryClient(sink);

await client.withCommandRun('telemetry.disable', async () => {
await new Promise(r => globalThis.setTimeout(r, 5));
return {};
});

expect(sink.metrics[0]!.value).toBeGreaterThanOrEqual(0);
expect(Number.isInteger(sink.metrics[0]!.value)).toBe(true);
});

it('converts boolean attrs to strings', async () => {
const sink = new InMemorySink();
const client = new TelemetryClient(sink);

await client.withCommandRun('update', async () => ({ check_only: true }));

expect(sink.metrics[0]!.attrs.check_only).toBe('true');
});

it('silently drops invalid success payloads', async () => {
const sink = new InMemorySink();
const client = new TelemetryClient(sink);

// Missing required attrs for 'create' — should silently drop
await client.withCommandRun(
'create',
// @ts-expect-error — intentionally incomplete
async () => ({ language: 'python' })
);

expect(sink.metrics).toHaveLength(0);
});

it('records cancel when callback returns CANCELLED', async () => {
const sink = new InMemorySink();
const client = new TelemetryClient(sink);

await client.withCommandRun('deploy', () => CANCELLED);

expect(sink.metrics).toHaveLength(1);
expect(sink.metrics[0]!.attrs).toMatchObject({
command_group: 'deploy',
exit_reason: 'cancel',
});
});
});
});
60 changes: 60 additions & 0 deletions src/cli/telemetry/__tests__/composite-sink.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
import { InMemorySink } from '../sinks/in-memory-sink';
import { CompositeSink, type MetricSink } from '../sinks/metric-sink';
import { describe, expect, it, vi } from 'vitest';

describe('CompositeSink', () => {
it('fans out records to all sinks', () => {
const a = new InMemorySink();
const b = new InMemorySink();
const composite = new CompositeSink([a, b]);

composite.record(100, { command: 'deploy' });

expect(a.metrics).toHaveLength(1);
expect(b.metrics).toHaveLength(1);
expect(a.metrics[0]!.attrs.command).toBe('deploy');
});

it('isolates errors — one sink throwing does not affect others', () => {
const bad: MetricSink = {
record: vi.fn(() => {
throw new Error('sink failed');
}),
flush: vi.fn().mockResolvedValue(undefined),
shutdown: vi.fn().mockResolvedValue(undefined),
};
const good = new InMemorySink();
const composite = new CompositeSink([bad, good]);

composite.record(100, { command: 'deploy' });

expect(good.metrics).toHaveLength(1);
});

it('flushes all sinks in parallel', async () => {
const a = new InMemorySink();
const b = new InMemorySink();
const flushA = vi.spyOn(a, 'flush');
const flushB = vi.spyOn(b, 'flush');
const composite = new CompositeSink([a, b]);

await composite.flush(5000);

expect(flushA).toHaveBeenCalledWith(5000);
expect(flushB).toHaveBeenCalledWith(5000);
});

it('flush settles even if one sink rejects', async () => {
const bad: MetricSink = {
record: vi.fn(),
flush: vi.fn().mockRejectedValue(new Error('flush failed')),
shutdown: vi.fn().mockResolvedValue(undefined),
};
const good = new InMemorySink();
const flushGood = vi.spyOn(good, 'flush');
const composite = new CompositeSink([bad, good]);

await expect(composite.flush()).resolves.toBeUndefined();
expect(flushGood).toHaveBeenCalled();
});
});
63 changes: 63 additions & 0 deletions src/cli/telemetry/__tests__/error-classification.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
import { classifyError, isUserError } from '../error-classification';
import { describe, expect, it } from 'vitest';

function errorWithName(name: string): Error {
const err = new Error('test');
err.name = name;
return err;
}

describe('classifyError', () => {
it.each([
['ConfigValidationError', 'ConfigError'],
['ConfigNotFoundError', 'ConfigError'],
['ConfigReadError', 'ConfigError'],
['ConfigWriteError', 'ConfigError'],
['ConfigParseError', 'ConfigError'],
['AwsCredentialsError', 'CredentialsError'],
['AccessDeniedException', 'CredentialsError'],
['ExpiredToken', 'CredentialsError'],
['PackagingError', 'PackagingError'],
['MissingDependencyError', 'PackagingError'],
['ArtifactSizeError', 'PackagingError'],
['NoProjectError', 'ProjectError'],
['AgentAlreadyExistsError', 'ProjectError'],
['ResourceNotFoundException', 'ServiceError'],
['ValidationException', 'ServiceError'],
['ConflictException', 'ServiceError'],
['ConnectionError', 'ConnectionError'],
['ServerError', 'ConnectionError'],
] as const)('%s → %s', (errorName, expected) => {
expect(classifyError(errorWithName(errorName))).toBe(expected);
});

it('returns UnknownError for unrecognized errors', () => {
expect(classifyError(new Error('something'))).toBe('UnknownError');
});

it('returns UnknownError for non-Error values', () => {
expect(classifyError('string')).toBe('UnknownError');
expect(classifyError(null)).toBe('UnknownError');
expect(classifyError(undefined)).toBe('UnknownError');
});

it('uses err.name when constructor.name is Error (SDK pattern)', () => {
// AWS SDK errors often: new Error(); err.name = 'ValidationException'
expect(classifyError(errorWithName('ValidationException'))).toBe('ServiceError');
});
});

describe('isUserError', () => {
it('returns true for user-fixable categories', () => {
expect(isUserError(errorWithName('ConfigValidationError'))).toBe(true);
expect(isUserError(errorWithName('AwsCredentialsError'))).toBe(true);
expect(isUserError(errorWithName('NoProjectError'))).toBe(true);
});

it('returns false for system categories', () => {
expect(isUserError(errorWithName('PackagingError'))).toBe(false);
expect(isUserError(errorWithName('ResourceNotFoundException'))).toBe(false);
expect(isUserError(errorWithName('ConnectionError'))).toBe(false);
expect(isUserError(new Error('unknown'))).toBe(false);
});
});
2 changes: 1 addition & 1 deletion src/cli/telemetry/__tests__/resolve.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { createTempConfig } from '../../__tests__/helpers/temp-config';
import { resolveTelemetryPreference } from '../resolve';
import { resolveTelemetryPreference } from '../config';
import { writeFile } from 'fs/promises';
import { join } from 'node:path';
import { afterAll, afterEach, beforeEach, describe, expect, it } from 'vitest';
Expand Down
50 changes: 50 additions & 0 deletions src/cli/telemetry/__tests__/resource-resolver.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
import { resolveResourceAttributes } from '../config';
import { ResourceAttributesSchema } from '../schemas/common-attributes';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';

const ORIGINAL_ENV = process.env.AGENTCORE_CONFIG_DIR;

describe('resolveResourceAttributes', () => {
beforeEach(() => {
process.env.AGENTCORE_CONFIG_DIR = '/tmp/telemetry-test-' + Date.now();
});

afterEach(() => {
if (ORIGINAL_ENV === undefined) {
delete process.env.AGENTCORE_CONFIG_DIR;
} else {
process.env.AGENTCORE_CONFIG_DIR = ORIGINAL_ENV;
}
});

it('returns attributes that pass schema validation', async () => {
const attrs = await resolveResourceAttributes('cli');
expect(() => ResourceAttributesSchema.parse(attrs)).not.toThrow();
});

it('sets service.name to agentcore-cli', async () => {
const attrs = await resolveResourceAttributes('cli');
expect(attrs['service.name']).toBe('agentcore-cli');
});

it('generates unique session_id per call', async () => {
const a = await resolveResourceAttributes('cli');
const b = await resolveResourceAttributes('cli');
expect(a['agentcore-cli.session_id']).not.toBe(b['agentcore-cli.session_id']);
});

it('reflects the mode parameter', async () => {
const cli = await resolveResourceAttributes('cli');
const tui = await resolveResourceAttributes('tui');
expect(cli['agentcore-cli.mode']).toBe('cli');
expect(tui['agentcore-cli.mode']).toBe('tui');
});

it('populates os and node fields', async () => {
const attrs = await resolveResourceAttributes('cli');
expect(attrs['os.type']).toBeTruthy();
expect(attrs['os.version']).toBeTruthy();
expect(attrs['host.arch']).toBeTruthy();
expect(attrs['node.version']).toMatch(/^v\d+/);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3,128 changes: 1,721 additions & 1,407 deletions package-lock.json

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -88,8 +88,11 @@
"@aws-sdk/credential-providers": "^3.893.0",
"@aws/agent-inspector": "0.1.0",
"@commander-js/extra-typings": "^14.0.0",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/api": "^1.9.1",
"@opentelemetry/exporter-metrics-otlp-http": "^0.214.0",
"@opentelemetry/otlp-transformer": "^0.213.0",
"@opentelemetry/resources": "^2.6.1",
"@opentelemetry/sdk-metrics": "^2.6.1",
"@smithy/shared-ini-file-loader": "^4.4.2",
"commander": "^14.0.2",
"dotenv": "^17.2.3",
Expand Down
2 changes: 1 addition & 1 deletion src/cli/commands/telemetry/actions.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { GLOBAL_CONFIG_DIR, GLOBAL_CONFIG_FILE, updateGlobalConfig } from '../../global-config.js';
import { resolveTelemetryPreference } from '../../telemetry/resolve.js';
import { resolveTelemetryPreference } from '../../telemetry/config.js';

export async function handleTelemetryDisable(
configDir = GLOBAL_CONFIG_DIR,
Expand Down
146 changes: 146 additions & 0 deletions src/cli/telemetry/__tests__/client.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
/* eslint-disable @typescript-eslint/require-await */
import { CANCELLED, TelemetryClient } from '../client';
import { InMemorySink } from '../sinks/in-memory-sink';
import { describe, expect, it } from 'vitest';

describe('TelemetryClient', () => {
describe('withCommandRun', () => {
it('records success with returned attrs', async () => {
const sink = new InMemorySink();
const client = new TelemetryClient(sink);

await client.withCommandRun('update', async () => ({ check_only: true }));

expect(sink.metrics).toHaveLength(1);
expect(sink.metrics[0]!.attrs).toMatchObject({
command_group: 'update',
command: 'update',
exit_reason: 'success',
check_only: 'true',
});
});

it('accepts sync callbacks', async () => {
const sink = new InMemorySink();
const client = new TelemetryClient(sink);

await client.withCommandRun('telemetry.disable', () => ({}));

expect(sink.metrics).toHaveLength(1);
expect(sink.metrics[0]!.attrs).toMatchObject({ exit_reason: 'success' });
});

it('records failure and re-throws on error', async () => {
const sink = new InMemorySink();
const client = new TelemetryClient(sink);

await expect(
client.withCommandRun('deploy', async () => {
throw new Error('boom');
})
).rejects.toThrow('boom');

expect(sink.metrics).toHaveLength(1);
expect(sink.metrics[0]!.attrs).toMatchObject({
command_group: 'deploy',
exit_reason: 'failure',
error_name: 'UnknownError',
});
});

it('classifies PackagingError subclasses', async () => {
const sink = new InMemorySink();
const client = new TelemetryClient(sink);

class MissingDependencyError extends Error {
constructor() {
super('missing dep');
this.name = 'MissingDependencyError';
}
}

await expect(
client.withCommandRun('deploy', async () => {
throw new MissingDependencyError();
})
).rejects.toThrow();

expect(sink.metrics[0]!.attrs).toMatchObject({
error_name: 'PackagingError',
is_user_error: 'false',
});
});

it('marks credential errors as user errors', async () => {
const sink = new InMemorySink();
const client = new TelemetryClient(sink);

class AwsCredentialsError extends Error {
constructor() {
super('creds expired');
this.name = 'AwsCredentialsError';
}
}

await expect(
client.withCommandRun('invoke', async () => {
throw new AwsCredentialsError();
})
).rejects.toThrow();

expect(sink.metrics[0]!.attrs).toMatchObject({
error_name: 'CredentialsError',
is_user_error: 'true',
});
});

it('records duration as a non-negative integer', async () => {
const sink = new InMemorySink();
const client = new TelemetryClient(sink);

await client.withCommandRun('telemetry.disable', async () => {
await new Promise(r => globalThis.setTimeout(r, 5));
return {};
});

expect(sink.metrics[0]!.value).toBeGreaterThanOrEqual(0);
expect(Number.isInteger(sink.metrics[0]!.value)).toBe(true);
});

it('converts boolean attrs to strings', async () => {
const sink = new InMemorySink();
const client = new TelemetryClient(sink);

await client.withCommandRun('update', async () => ({ check_only: true }));

expect(sink.metrics[0]!.attrs.check_only).toBe('true');
});

it('silently drops invalid success payloads', async () => {
const sink = new InMemorySink();
const client = new TelemetryClient(sink);

// Missing required attrs for 'create' — should silently drop
await client.withCommandRun(
'create',
// @ts-expect-error — intentionally incomplete
async () => ({ language: 'python' })
);

expect(sink.metrics).toHaveLength(0);
});

it('records cancel when callback returns CANCELLED', async () => {
const sink = new InMemorySink();
const client = new TelemetryClient(sink);

await client.withCommandRun('deploy', () => CANCELLED);

expect(sink.metrics).toHaveLength(1);
expect(sink.metrics[0]!.attrs).toMatchObject({
command_group: 'deploy',
exit_reason: 'cancel',
});
});
});
});
60 changes: 60 additions & 0 deletions src/cli/telemetry/__tests__/composite-sink.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
import { InMemorySink } from '../sinks/in-memory-sink';
import { CompositeSink, type MetricSink } from '../sinks/metric-sink';
import { describe, expect, it, vi } from 'vitest';

describe('CompositeSink', () => {
it('fans out records to all sinks', () => {
const a = new InMemorySink();
const b = new InMemorySink();
const composite = new CompositeSink([a, b]);

composite.record(100, { command: 'deploy' });

expect(a.metrics).toHaveLength(1);
expect(b.metrics).toHaveLength(1);
expect(a.metrics[0]!.attrs.command).toBe('deploy');
});

it('isolates errors — one sink throwing does not affect others', () => {
const bad: MetricSink = {
record: vi.fn(() => {
throw new Error('sink failed');
}),
flush: vi.fn().mockResolvedValue(undefined),
shutdown: vi.fn().mockResolvedValue(undefined),
};
const good = new InMemorySink();
const composite = new CompositeSink([bad, good]);

composite.record(100, { command: 'deploy' });

expect(good.metrics).toHaveLength(1);
});

it('flushes all sinks in parallel', async () => {
const a = new InMemorySink();
const b = new InMemorySink();
const flushA = vi.spyOn(a, 'flush');
const flushB = vi.spyOn(b, 'flush');
const composite = new CompositeSink([a, b]);

await composite.flush(5000);

expect(flushA).toHaveBeenCalledWith(5000);
expect(flushB).toHaveBeenCalledWith(5000);
});

it('flush settles even if one sink rejects', async () => {
const bad: MetricSink = {
record: vi.fn(),
flush: vi.fn().mockRejectedValue(new Error('flush failed')),
shutdown: vi.fn().mockResolvedValue(undefined),
};
const good = new InMemorySink();
const flushGood = vi.spyOn(good, 'flush');
const composite = new CompositeSink([bad, good]);

await expect(composite.flush()).resolves.toBeUndefined();
expect(flushGood).toHaveBeenCalled();
});
});
63 changes: 63 additions & 0 deletions src/cli/telemetry/__tests__/error-classification.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
import { classifyError, isUserError } from '../error-classification';
import { describe, expect, it } from 'vitest';

function errorWithName(name: string): Error {
const err = new Error('test');
err.name = name;
return err;
}

describe('classifyError', () => {
it.each([
['ConfigValidationError', 'ConfigError'],
['ConfigNotFoundError', 'ConfigError'],
['ConfigReadError', 'ConfigError'],
['ConfigWriteError', 'ConfigError'],
['ConfigParseError', 'ConfigError'],
['AwsCredentialsError', 'CredentialsError'],
['AccessDeniedException', 'CredentialsError'],
['ExpiredToken', 'CredentialsError'],
['PackagingError', 'PackagingError'],
['MissingDependencyError', 'PackagingError'],
['ArtifactSizeError', 'PackagingError'],
['NoProjectError', 'ProjectError'],
['AgentAlreadyExistsError', 'ProjectError'],
['ResourceNotFoundException', 'ServiceError'],
['ValidationException', 'ServiceError'],
['ConflictException', 'ServiceError'],
['ConnectionError', 'ConnectionError'],
['ServerError', 'ConnectionError'],
] as const)('%s → %s', (errorName, expected) => {
expect(classifyError(errorWithName(errorName))).toBe(expected);
});

it('returns UnknownError for unrecognized errors', () => {
expect(classifyError(new Error('something'))).toBe('UnknownError');
});

it('returns UnknownError for non-Error values', () => {
expect(classifyError('string')).toBe('UnknownError');
expect(classifyError(null)).toBe('UnknownError');
expect(classifyError(undefined)).toBe('UnknownError');
});

it('uses err.name when constructor.name is Error (SDK pattern)', () => {
// AWS SDK errors often: new Error(); err.name = 'ValidationException'
expect(classifyError(errorWithName('ValidationException'))).toBe('ServiceError');
});
});

describe('isUserError', () => {
it('returns true for user-fixable categories', () => {
expect(isUserError(errorWithName('ConfigValidationError'))).toBe(true);
expect(isUserError(errorWithName('AwsCredentialsError'))).toBe(true);
expect(isUserError(errorWithName('NoProjectError'))).toBe(true);
});

it('returns false for system categories', () => {
expect(isUserError(errorWithName('PackagingError'))).toBe(false);
expect(isUserError(errorWithName('ResourceNotFoundException'))).toBe(false);
expect(isUserError(errorWithName('ConnectionError'))).toBe(false);
expect(isUserError(new Error('unknown'))).toBe(false);
});
});
2 changes: 1 addition & 1 deletion src/cli/telemetry/__tests__/resolve.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { createTempConfig } from '../../__tests__/helpers/temp-config';
import { resolveTelemetryPreference } from '../resolve';
import { resolveTelemetryPreference } from '../config';
import { writeFile } from 'fs/promises';
import { join } from 'node:path';
import { afterAll, afterEach, beforeEach, describe, expect, it } from 'vitest';
Expand Down
50 changes: 50 additions & 0 deletions src/cli/telemetry/__tests__/resource-resolver.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
import { resolveResourceAttributes } from '../config';
import { ResourceAttributesSchema } from '../schemas/common-attributes';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';

const ORIGINAL_ENV = process.env.AGENTCORE_CONFIG_DIR;

describe('resolveResourceAttributes', () => {
beforeEach(() => {
process.env.AGENTCORE_CONFIG_DIR = '/tmp/telemetry-test-' + Date.now();
});

afterEach(() => {
if (ORIGINAL_ENV === undefined) {
delete process.env.AGENTCORE_CONFIG_DIR;
} else {
process.env.AGENTCORE_CONFIG_DIR = ORIGINAL_ENV;
}
});

it('returns attributes that pass schema validation', async () => {
const attrs = await resolveResourceAttributes('cli');
expect(() => ResourceAttributesSchema.parse(attrs)).not.toThrow();
});

it('sets service.name to agentcore-cli', async () => {
const attrs = await resolveResourceAttributes('cli');
expect(attrs['service.name']).toBe('agentcore-cli');
});

it('generates unique session_id per call', async () => {
const a = await resolveResourceAttributes('cli');
const b = await resolveResourceAttributes('cli');
expect(a['agentcore-cli.session_id']).not.toBe(b['agentcore-cli.session_id']);
});

it('reflects the mode parameter', async () => {
const cli = await resolveResourceAttributes('cli');
const tui = await resolveResourceAttributes('tui');
expect(cli['agentcore-cli.mode']).toBe('cli');
expect(tui['agentcore-cli.mode']).toBe('tui');
});

it('populates os and node fields', async () => {
const attrs = await resolveResourceAttributes('cli');
expect(attrs['os.type']).toBeTruthy();
expect(attrs['os.version']).toBeTruthy();
expect(attrs['host.arch']).toBeTruthy();
expect(attrs['node.version']).toMatch(/^v\d+/);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3,128 changes: 1,721 additions & 1,407 deletions package-lock.json

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -88,8 +88,11 @@
"@aws-sdk/credential-providers": "^3.893.0",
"@aws/agent-inspector": "0.1.0",
"@commander-js/extra-typings": "^14.0.0",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/api": "^1.9.1",
"@opentelemetry/exporter-metrics-otlp-http": "^0.214.0",
"@opentelemetry/otlp-transformer": "^0.213.0",
"@opentelemetry/resources": "^2.6.1",
"@opentelemetry/sdk-metrics": "^2.6.1",
"@smithy/shared-ini-file-loader": "^4.4.2",
"commander": "^14.0.2",
"dotenv": "^17.2.3",
Expand Down
2 changes: 1 addition & 1 deletion src/cli/commands/telemetry/actions.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { GLOBAL_CONFIG_DIR, GLOBAL_CONFIG_FILE, updateGlobalConfig } from '../../global-config.js';
import { resolveTelemetryPreference } from '../../telemetry/resolve.js';
import { resolveTelemetryPreference } from '../../telemetry/config.js';

export async function handleTelemetryDisable(
configDir = GLOBAL_CONFIG_DIR,
Expand Down
146 changes: 146 additions & 0 deletions src/cli/telemetry/__tests__/client.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
/* eslint-disable @typescript-eslint/require-await */
import { CANCELLED, TelemetryClient } from '../client';
import { InMemorySink } from '../sinks/in-memory-sink';
import { describe, expect, it } from 'vitest';

describe('TelemetryClient', () => {
describe('withCommandRun', () => {
it('records success with returned attrs', async () => {
const sink = new InMemorySink();
const client = new TelemetryClient(sink);

await client.withCommandRun('update', async () => ({ check_only: true }));

expect(sink.metrics).toHaveLength(1);
expect(sink.metrics[0]!.attrs).toMatchObject({
command_group: 'update',
command: 'update',
exit_reason: 'success',
check_only: 'true',
});
});

it('accepts sync callbacks', async () => {
const sink = new InMemorySink();
const client = new TelemetryClient(sink);

await client.withCommandRun('telemetry.disable', () => ({}));

expect(sink.metrics).toHaveLength(1);
expect(sink.metrics[0]!.attrs).toMatchObject({ exit_reason: 'success' });
});

it('records failure and re-throws on error', async () => {
const sink = new InMemorySink();
const client = new TelemetryClient(sink);

await expect(
client.withCommandRun('deploy', async () => {
throw new Error('boom');
})
).rejects.toThrow('boom');

expect(sink.metrics).toHaveLength(1);
expect(sink.metrics[0]!.attrs).toMatchObject({
command_group: 'deploy',
exit_reason: 'failure',
error_name: 'UnknownError',
});
});

it('classifies PackagingError subclasses', async () => {
const sink = new InMemorySink();
const client = new TelemetryClient(sink);

class MissingDependencyError extends Error {
constructor() {
super('missing dep');
this.name = 'MissingDependencyError';
}
}

await expect(
client.withCommandRun('deploy', async () => {
throw new MissingDependencyError();
})
).rejects.toThrow();

expect(sink.metrics[0]!.attrs).toMatchObject({
error_name: 'PackagingError',
is_user_error: 'false',
});
});

it('marks credential errors as user errors', async () => {
const sink = new InMemorySink();
const client = new TelemetryClient(sink);

class AwsCredentialsError extends Error {
constructor() {
super('creds expired');
this.name = 'AwsCredentialsError';
}
}

await expect(
client.withCommandRun('invoke', async () => {
throw new AwsCredentialsError();
})
).rejects.toThrow();

expect(sink.metrics[0]!.attrs).toMatchObject({
error_name: 'CredentialsError',
is_user_error: 'true',
});
});

it('records duration as a non-negative integer', async () => {
const sink = new InMemorySink();
const client = new TelemetryClient(sink);

await client.withCommandRun('telemetry.disable', async () => {
await new Promise(r => globalThis.setTimeout(r, 5));
return {};
});

expect(sink.metrics[0]!.value).toBeGreaterThanOrEqual(0);
expect(Number.isInteger(sink.metrics[0]!.value)).toBe(true);
});

it('converts boolean attrs to strings', async () => {
const sink = new InMemorySink();
const client = new TelemetryClient(sink);

await client.withCommandRun('update', async () => ({ check_only: true }));

expect(sink.metrics[0]!.attrs.check_only).toBe('true');
});

it('silently drops invalid success payloads', async () => {
const sink = new InMemorySink();
const client = new TelemetryClient(sink);

// Missing required attrs for 'create' — should silently drop
await client.withCommandRun(
'create',
// @ts-expect-error — intentionally incomplete
async () => ({ language: 'python' })
);

expect(sink.metrics).toHaveLength(0);
});

it('records cancel when callback returns CANCELLED', async () => {
const sink = new InMemorySink();
const client = new TelemetryClient(sink);

await client.withCommandRun('deploy', () => CANCELLED);

expect(sink.metrics).toHaveLength(1);
expect(sink.metrics[0]!.attrs).toMatchObject({
command_group: 'deploy',
exit_reason: 'cancel',
});
});
});
});
60 changes: 60 additions & 0 deletions src/cli/telemetry/__tests__/composite-sink.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
import { InMemorySink } from '../sinks/in-memory-sink';
import { CompositeSink, type MetricSink } from '../sinks/metric-sink';
import { describe, expect, it, vi } from 'vitest';

describe('CompositeSink', () => {
it('fans out records to all sinks', () => {
const a = new InMemorySink();
const b = new InMemorySink();
const composite = new CompositeSink([a, b]);

composite.record(100, { command: 'deploy' });

expect(a.metrics).toHaveLength(1);
expect(b.metrics).toHaveLength(1);
expect(a.metrics[0]!.attrs.command).toBe('deploy');
});

it('isolates errors — one sink throwing does not affect others', () => {
const bad: MetricSink = {
record: vi.fn(() => {
throw new Error('sink failed');
}),
flush: vi.fn().mockResolvedValue(undefined),
shutdown: vi.fn().mockResolvedValue(undefined),
};
const good = new InMemorySink();
const composite = new CompositeSink([bad, good]);

composite.record(100, { command: 'deploy' });

expect(good.metrics).toHaveLength(1);
});

it('flushes all sinks in parallel', async () => {
const a = new InMemorySink();
const b = new InMemorySink();
const flushA = vi.spyOn(a, 'flush');
const flushB = vi.spyOn(b, 'flush');
const composite = new CompositeSink([a, b]);

await composite.flush(5000);

expect(flushA).toHaveBeenCalledWith(5000);
expect(flushB).toHaveBeenCalledWith(5000);
});

it('flush settles even if one sink rejects', async () => {
const bad: MetricSink = {
record: vi.fn(),
flush: vi.fn().mockRejectedValue(new Error('flush failed')),
shutdown: vi.fn().mockResolvedValue(undefined),
};
const good = new InMemorySink();
const flushGood = vi.spyOn(good, 'flush');
const composite = new CompositeSink([bad, good]);

await expect(composite.flush()).resolves.toBeUndefined();
expect(flushGood).toHaveBeenCalled();
});
});
63 changes: 63 additions & 0 deletions src/cli/telemetry/__tests__/error-classification.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
import { classifyError, isUserError } from '../error-classification';
import { describe, expect, it } from 'vitest';

function errorWithName(name: string): Error {
const err = new Error('test');
err.name = name;
return err;
}

describe('classifyError', () => {
it.each([
['ConfigValidationError', 'ConfigError'],
['ConfigNotFoundError', 'ConfigError'],
['ConfigReadError', 'ConfigError'],
['ConfigWriteError', 'ConfigError'],
['ConfigParseError', 'ConfigError'],
['AwsCredentialsError', 'CredentialsError'],
['AccessDeniedException', 'CredentialsError'],
['ExpiredToken', 'CredentialsError'],
['PackagingError', 'PackagingError'],
['MissingDependencyError', 'PackagingError'],
['ArtifactSizeError', 'PackagingError'],
['NoProjectError', 'ProjectError'],
['AgentAlreadyExistsError', 'ProjectError'],
['ResourceNotFoundException', 'ServiceError'],
['ValidationException', 'ServiceError'],
['ConflictException', 'ServiceError'],
['ConnectionError', 'ConnectionError'],
['ServerError', 'ConnectionError'],
] as const)('%s → %s', (errorName, expected) => {
expect(classifyError(errorWithName(errorName))).toBe(expected);
});

it('returns UnknownError for unrecognized errors', () => {
expect(classifyError(new Error('something'))).toBe('UnknownError');
});

it('returns UnknownError for non-Error values', () => {
expect(classifyError('string')).toBe('UnknownError');
expect(classifyError(null)).toBe('UnknownError');
expect(classifyError(undefined)).toBe('UnknownError');
});

it('uses err.name when constructor.name is Error (SDK pattern)', () => {
// AWS SDK errors often: new Error(); err.name = 'ValidationException'
expect(classifyError(errorWithName('ValidationException'))).toBe('ServiceError');
});
});

describe('isUserError', () => {
it('returns true for user-fixable categories', () => {
expect(isUserError(errorWithName('ConfigValidationError'))).toBe(true);
expect(isUserError(errorWithName('AwsCredentialsError'))).toBe(true);
expect(isUserError(errorWithName('NoProjectError'))).toBe(true);
});

it('returns false for system categories', () => {
expect(isUserError(errorWithName('PackagingError'))).toBe(false);
expect(isUserError(errorWithName('ResourceNotFoundException'))).toBe(false);
expect(isUserError(errorWithName('ConnectionError'))).toBe(false);
expect(isUserError(new Error('unknown'))).toBe(false);
});
});
2 changes: 1 addition & 1 deletion src/cli/telemetry/__tests__/resolve.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { createTempConfig } from '../../__tests__/helpers/temp-config';
import { resolveTelemetryPreference } from '../resolve';
import { resolveTelemetryPreference } from '../config';
import { writeFile } from 'fs/promises';
import { join } from 'node:path';
import { afterAll, afterEach, beforeEach, describe, expect, it } from 'vitest';
Expand Down
50 changes: 50 additions & 0 deletions src/cli/telemetry/__tests__/resource-resolver.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
import { resolveResourceAttributes } from '../config';
import { ResourceAttributesSchema } from '../schemas/common-attributes';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';

const ORIGINAL_ENV = process.env.AGENTCORE_CONFIG_DIR;

describe('resolveResourceAttributes', () => {
beforeEach(() => {
process.env.AGENTCORE_CONFIG_DIR = '/tmp/telemetry-test-' + Date.now();
});

afterEach(() => {
if (ORIGINAL_ENV === undefined) {
delete process.env.AGENTCORE_CONFIG_DIR;
} else {
process.env.AGENTCORE_CONFIG_DIR = ORIGINAL_ENV;
}
});

it('returns attributes that pass schema validation', async () => {
const attrs = await resolveResourceAttributes('cli');
expect(() => ResourceAttributesSchema.parse(attrs)).not.toThrow();
});

it('sets service.name to agentcore-cli', async () => {
const attrs = await resolveResourceAttributes('cli');
expect(attrs['service.name']).toBe('agentcore-cli');
});

it('generates unique session_id per call', async () => {
const a = await resolveResourceAttributes('cli');
const b = await resolveResourceAttributes('cli');
expect(a['agentcore-cli.session_id']).not.toBe(b['agentcore-cli.session_id']);
});

it('reflects the mode parameter', async () => {
const cli = await resolveResourceAttributes('cli');
const tui = await resolveResourceAttributes('tui');
expect(cli['agentcore-cli.mode']).toBe('cli');
expect(tui['agentcore-cli.mode']).toBe('tui');
});

it('populates os and node fields', async () => {
const attrs = await resolveResourceAttributes('cli');
expect(attrs['os.type']).toBeTruthy();
expect(attrs['os.version']).toBeTruthy();
expect(attrs['host.arch']).toBeTruthy();
expect(attrs['node.version']).toMatch(/^v\d+/);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3,128 changes: 1,721 additions & 1,407 deletions package-lock.json

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -88,8 +88,11 @@
"@aws-sdk/credential-providers": "^3.893.0",
"@aws/agent-inspector": "0.1.0",
"@commander-js/extra-typings": "^14.0.0",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/api": "^1.9.1",
"@opentelemetry/exporter-metrics-otlp-http": "^0.214.0",
"@opentelemetry/otlp-transformer": "^0.213.0",
"@opentelemetry/resources": "^2.6.1",
"@opentelemetry/sdk-metrics": "^2.6.1",
"@smithy/shared-ini-file-loader": "^4.4.2",
"commander": "^14.0.2",
"dotenv": "^17.2.3",
Expand Down
2 changes: 1 addition & 1 deletion src/cli/commands/telemetry/actions.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { GLOBAL_CONFIG_DIR, GLOBAL_CONFIG_FILE, updateGlobalConfig } from '../../global-config.js';
import { resolveTelemetryPreference } from '../../telemetry/resolve.js';
import { resolveTelemetryPreference } from '../../telemetry/config.js';

export async function handleTelemetryDisable(
configDir = GLOBAL_CONFIG_DIR,
Expand Down
146 changes: 146 additions & 0 deletions src/cli/telemetry/__tests__/client.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
/* eslint-disable @typescript-eslint/require-await */
import { CANCELLED, TelemetryClient } from '../client';
import { InMemorySink } from '../sinks/in-memory-sink';
import { describe, expect, it } from 'vitest';

describe('TelemetryClient', () => {
describe('withCommandRun', () => {
it('records success with returned attrs', async () => {
const sink = new InMemorySink();
const client = new TelemetryClient(sink);

await client.withCommandRun('update', async () => ({ check_only: true }));

expect(sink.metrics).toHaveLength(1);
expect(sink.metrics[0]!.attrs).toMatchObject({
command_group: 'update',
command: 'update',
exit_reason: 'success',
check_only: 'true',
});
});

it('accepts sync callbacks', async () => {
const sink = new InMemorySink();
const client = new TelemetryClient(sink);

await client.withCommandRun('telemetry.disable', () => ({}));

expect(sink.metrics).toHaveLength(1);
expect(sink.metrics[0]!.attrs).toMatchObject({ exit_reason: 'success' });
});

it('records failure and re-throws on error', async () => {
const sink = new InMemorySink();
const client = new TelemetryClient(sink);

await expect(
client.withCommandRun('deploy', async () => {
throw new Error('boom');
})
).rejects.toThrow('boom');

expect(sink.metrics).toHaveLength(1);
expect(sink.metrics[0]!.attrs).toMatchObject({
command_group: 'deploy',
exit_reason: 'failure',
error_name: 'UnknownError',
});
});

it('classifies PackagingError subclasses', async () => {
const sink = new InMemorySink();
const client = new TelemetryClient(sink);

class MissingDependencyError extends Error {
constructor() {
super('missing dep');
this.name = 'MissingDependencyError';
}
}

await expect(
client.withCommandRun('deploy', async () => {
throw new MissingDependencyError();
})
).rejects.toThrow();

expect(sink.metrics[0]!.attrs).toMatchObject({
error_name: 'PackagingError',
is_user_error: 'false',
});
});

it('marks credential errors as user errors', async () => {
const sink = new InMemorySink();
const client = new TelemetryClient(sink);

class AwsCredentialsError extends Error {
constructor() {
super('creds expired');
this.name = 'AwsCredentialsError';
}
}

await expect(
client.withCommandRun('invoke', async () => {
throw new AwsCredentialsError();
})
).rejects.toThrow();

expect(sink.metrics[0]!.attrs).toMatchObject({
error_name: 'CredentialsError',
is_user_error: 'true',
});
});

it('records duration as a non-negative integer', async () => {
const sink = new InMemorySink();
const client = new TelemetryClient(sink);

await client.withCommandRun('telemetry.disable', async () => {
await new Promise(r => globalThis.setTimeout(r, 5));
return {};
});

expect(sink.metrics[0]!.value).toBeGreaterThanOrEqual(0);
expect(Number.isInteger(sink.metrics[0]!.value)).toBe(true);
});

it('converts boolean attrs to strings', async () => {
const sink = new InMemorySink();
const client = new TelemetryClient(sink);

await client.withCommandRun('update', async () => ({ check_only: true }));

expect(sink.metrics[0]!.attrs.check_only).toBe('true');
});

it('silently drops invalid success payloads', async () => {
const sink = new InMemorySink();
const client = new TelemetryClient(sink);

// Missing required attrs for 'create' — should silently drop
await client.withCommandRun(
'create',
// @ts-expect-error — intentionally incomplete
async () => ({ language: 'python' })
);

expect(sink.metrics).toHaveLength(0);
});

it('records cancel when callback returns CANCELLED', async () => {
const sink = new InMemorySink();
const client = new TelemetryClient(sink);

await client.withCommandRun('deploy', () => CANCELLED);

expect(sink.metrics).toHaveLength(1);
expect(sink.metrics[0]!.attrs).toMatchObject({
command_group: 'deploy',
exit_reason: 'cancel',
});
});
});
});
60 changes: 60 additions & 0 deletions src/cli/telemetry/__tests__/composite-sink.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
import { InMemorySink } from '../sinks/in-memory-sink';
import { CompositeSink, type MetricSink } from '../sinks/metric-sink';
import { describe, expect, it, vi } from 'vitest';

describe('CompositeSink', () => {
it('fans out records to all sinks', () => {
const a = new InMemorySink();
const b = new InMemorySink();
const composite = new CompositeSink([a, b]);

composite.record(100, { command: 'deploy' });

expect(a.metrics).toHaveLength(1);
expect(b.metrics).toHaveLength(1);
expect(a.metrics[0]!.attrs.command).toBe('deploy');
});

it('isolates errors — one sink throwing does not affect others', () => {
const bad: MetricSink = {
record: vi.fn(() => {
throw new Error('sink failed');
}),
flush: vi.fn().mockResolvedValue(undefined),
shutdown: vi.fn().mockResolvedValue(undefined),
};
const good = new InMemorySink();
const composite = new CompositeSink([bad, good]);

composite.record(100, { command: 'deploy' });

expect(good.metrics).toHaveLength(1);
});

it('flushes all sinks in parallel', async () => {
const a = new InMemorySink();
const b = new InMemorySink();
const flushA = vi.spyOn(a, 'flush');
const flushB = vi.spyOn(b, 'flush');
const composite = new CompositeSink([a, b]);

await composite.flush(5000);

expect(flushA).toHaveBeenCalledWith(5000);
expect(flushB).toHaveBeenCalledWith(5000);
});

it('flush settles even if one sink rejects', async () => {
const bad: MetricSink = {
record: vi.fn(),
flush: vi.fn().mockRejectedValue(new Error('flush failed')),
shutdown: vi.fn().mockResolvedValue(undefined),
};
const good = new InMemorySink();
const flushGood = vi.spyOn(good, 'flush');
const composite = new CompositeSink([bad, good]);

await expect(composite.flush()).resolves.toBeUndefined();
expect(flushGood).toHaveBeenCalled();
});
});
63 changes: 63 additions & 0 deletions src/cli/telemetry/__tests__/error-classification.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
import { classifyError, isUserError } from '../error-classification';
import { describe, expect, it } from 'vitest';

function errorWithName(name: string): Error {
const err = new Error('test');
err.name = name;
return err;
}

describe('classifyError', () => {
it.each([
['ConfigValidationError', 'ConfigError'],
['ConfigNotFoundError', 'ConfigError'],
['ConfigReadError', 'ConfigError'],
['ConfigWriteError', 'ConfigError'],
['ConfigParseError', 'ConfigError'],
['AwsCredentialsError', 'CredentialsError'],
['AccessDeniedException', 'CredentialsError'],
['ExpiredToken', 'CredentialsError'],
['PackagingError', 'PackagingError'],
['MissingDependencyError', 'PackagingError'],
['ArtifactSizeError', 'PackagingError'],
['NoProjectError', 'ProjectError'],
['AgentAlreadyExistsError', 'ProjectError'],
['ResourceNotFoundException', 'ServiceError'],
['ValidationException', 'ServiceError'],
['ConflictException', 'ServiceError'],
['ConnectionError', 'ConnectionError'],
['ServerError', 'ConnectionError'],
] as const)('%s → %s', (errorName, expected) => {
expect(classifyError(errorWithName(errorName))).toBe(expected);
});

it('returns UnknownError for unrecognized errors', () => {
expect(classifyError(new Error('something'))).toBe('UnknownError');
});

it('returns UnknownError for non-Error values', () => {
expect(classifyError('string')).toBe('UnknownError');
expect(classifyError(null)).toBe('UnknownError');
expect(classifyError(undefined)).toBe('UnknownError');
});

it('uses err.name when constructor.name is Error (SDK pattern)', () => {
// AWS SDK errors often: new Error(); err.name = 'ValidationException'
expect(classifyError(errorWithName('ValidationException'))).toBe('ServiceError');
});
});

describe('isUserError', () => {
it('returns true for user-fixable categories', () => {
expect(isUserError(errorWithName('ConfigValidationError'))).toBe(true);
expect(isUserError(errorWithName('AwsCredentialsError'))).toBe(true);
expect(isUserError(errorWithName('NoProjectError'))).toBe(true);
});

it('returns false for system categories', () => {
expect(isUserError(errorWithName('PackagingError'))).toBe(false);
expect(isUserError(errorWithName('ResourceNotFoundException'))).toBe(false);
expect(isUserError(errorWithName('ConnectionError'))).toBe(false);
expect(isUserError(new Error('unknown'))).toBe(false);
});
});
2 changes: 1 addition & 1 deletion src/cli/telemetry/__tests__/resolve.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { createTempConfig } from '../../__tests__/helpers/temp-config';
import { resolveTelemetryPreference } from '../resolve';
import { resolveTelemetryPreference } from '../config';
import { writeFile } from 'fs/promises';
import { join } from 'node:path';
import { afterAll, afterEach, beforeEach, describe, expect, it } from 'vitest';
Expand Down
50 changes: 50 additions & 0 deletions src/cli/telemetry/__tests__/resource-resolver.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
import { resolveResourceAttributes } from '../config';
import { ResourceAttributesSchema } from '../schemas/common-attributes';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';

const ORIGINAL_ENV = process.env.AGENTCORE_CONFIG_DIR;

describe('resolveResourceAttributes', () => {
beforeEach(() => {
process.env.AGENTCORE_CONFIG_DIR = '/tmp/telemetry-test-' + Date.now();
});

afterEach(() => {
if (ORIGINAL_ENV === undefined) {
delete process.env.AGENTCORE_CONFIG_DIR;
} else {
process.env.AGENTCORE_CONFIG_DIR = ORIGINAL_ENV;
}
});

it('returns attributes that pass schema validation', async () => {
const attrs = await resolveResourceAttributes('cli');
expect(() => ResourceAttributesSchema.parse(attrs)).not.toThrow();
});

it('sets service.name to agentcore-cli', async () => {
const attrs = await resolveResourceAttributes('cli');
expect(attrs['service.name']).toBe('agentcore-cli');
});

it('generates unique session_id per call', async () => {
const a = await resolveResourceAttributes('cli');
const b = await resolveResourceAttributes('cli');
expect(a['agentcore-cli.session_id']).not.toBe(b['agentcore-cli.session_id']);
});

it('reflects the mode parameter', async () => {
const cli = await resolveResourceAttributes('cli');
const tui = await resolveResourceAttributes('tui');
expect(cli['agentcore-cli.mode']).toBe('cli');
expect(tui['agentcore-cli.mode']).toBe('tui');
});

it('populates os and node fields', async () => {
const attrs = await resolveResourceAttributes('cli');
expect(attrs['os.type']).toBeTruthy();
expect(attrs['os.version']).toBeTruthy();
expect(attrs['host.arch']).toBeTruthy();
expect(attrs['node.version']).toMatch(/^v\d+/);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3,128 changes: 1,721 additions & 1,407 deletions package-lock.json

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -88,8 +88,11 @@
"@aws-sdk/credential-providers": "^3.893.0",
"@aws/agent-inspector": "0.1.0",
"@commander-js/extra-typings": "^14.0.0",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/api": "^1.9.1",
"@opentelemetry/exporter-metrics-otlp-http": "^0.214.0",
"@opentelemetry/otlp-transformer": "^0.213.0",
"@opentelemetry/resources": "^2.6.1",
"@opentelemetry/sdk-metrics": "^2.6.1",
"@smithy/shared-ini-file-loader": "^4.4.2",
"commander": "^14.0.2",
"dotenv": "^17.2.3",
Expand Down
2 changes: 1 addition & 1 deletion src/cli/commands/telemetry/actions.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { GLOBAL_CONFIG_DIR, GLOBAL_CONFIG_FILE, updateGlobalConfig } from '../../global-config.js';
import { resolveTelemetryPreference } from '../../telemetry/resolve.js';
import { resolveTelemetryPreference } from '../../telemetry/config.js';

export async function handleTelemetryDisable(
configDir = GLOBAL_CONFIG_DIR,
Expand Down
146 changes: 146 additions & 0 deletions src/cli/telemetry/__tests__/client.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
/* eslint-disable @typescript-eslint/require-await */
import { CANCELLED, TelemetryClient } from '../client';
import { InMemorySink } from '../sinks/in-memory-sink';
import { describe, expect, it } from 'vitest';

describe('TelemetryClient', () => {
describe('withCommandRun', () => {
it('records success with returned attrs', async () => {
const sink = new InMemorySink();
const client = new TelemetryClient(sink);

await client.withCommandRun('update', async () => ({ check_only: true }));

expect(sink.metrics).toHaveLength(1);
expect(sink.metrics[0]!.attrs).toMatchObject({
command_group: 'update',
command: 'update',
exit_reason: 'success',
check_only: 'true',
});
});

it('accepts sync callbacks', async () => {
const sink = new InMemorySink();
const client = new TelemetryClient(sink);

await client.withCommandRun('telemetry.disable', () => ({}));

expect(sink.metrics).toHaveLength(1);
expect(sink.metrics[0]!.attrs).toMatchObject({ exit_reason: 'success' });
});

it('records failure and re-throws on error', async () => {
const sink = new InMemorySink();
const client = new TelemetryClient(sink);

await expect(
client.withCommandRun('deploy', async () => {
throw new Error('boom');
})
).rejects.toThrow('boom');

expect(sink.metrics).toHaveLength(1);
expect(sink.metrics[0]!.attrs).toMatchObject({
command_group: 'deploy',
exit_reason: 'failure',
error_name: 'UnknownError',
});
});

it('classifies PackagingError subclasses', async () => {
const sink = new InMemorySink();
const client = new TelemetryClient(sink);

class MissingDependencyError extends Error {
constructor() {
super('missing dep');
this.name = 'MissingDependencyError';
}
}

await expect(
client.withCommandRun('deploy', async () => {
throw new MissingDependencyError();
})
).rejects.toThrow();

expect(sink.metrics[0]!.attrs).toMatchObject({
error_name: 'PackagingError',
is_user_error: 'false',
});
});

it('marks credential errors as user errors', async () => {
const sink = new InMemorySink();
const client = new TelemetryClient(sink);

class AwsCredentialsError extends Error {
constructor() {
super('creds expired');
this.name = 'AwsCredentialsError';
}
}

await expect(
client.withCommandRun('invoke', async () => {
throw new AwsCredentialsError();
})
).rejects.toThrow();

expect(sink.metrics[0]!.attrs).toMatchObject({
error_name: 'CredentialsError',
is_user_error: 'true',
});
});

it('records duration as a non-negative integer', async () => {
const sink = new InMemorySink();
const client = new TelemetryClient(sink);

await client.withCommandRun('telemetry.disable', async () => {
await new Promise(r => globalThis.setTimeout(r, 5));
return {};
});

expect(sink.metrics[0]!.value).toBeGreaterThanOrEqual(0);
expect(Number.isInteger(sink.metrics[0]!.value)).toBe(true);
});

it('converts boolean attrs to strings', async () => {
const sink = new InMemorySink();
const client = new TelemetryClient(sink);

await client.withCommandRun('update', async () => ({ check_only: true }));

expect(sink.metrics[0]!.attrs.check_only).toBe('true');
});

it('silently drops invalid success payloads', async () => {
const sink = new InMemorySink();
const client = new TelemetryClient(sink);

// Missing required attrs for 'create' — should silently drop
await client.withCommandRun(
'create',
// @ts-expect-error — intentionally incomplete
async () => ({ language: 'python' })
);

expect(sink.metrics).toHaveLength(0);
});

it('records cancel when callback returns CANCELLED', async () => {
const sink = new InMemorySink();
const client = new TelemetryClient(sink);

await client.withCommandRun('deploy', () => CANCELLED);

expect(sink.metrics).toHaveLength(1);
expect(sink.metrics[0]!.attrs).toMatchObject({
command_group: 'deploy',
exit_reason: 'cancel',
});
});
});
});
60 changes: 60 additions & 0 deletions src/cli/telemetry/__tests__/composite-sink.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
import { InMemorySink } from '../sinks/in-memory-sink';
import { CompositeSink, type MetricSink } from '../sinks/metric-sink';
import { describe, expect, it, vi } from 'vitest';

describe('CompositeSink', () => {
it('fans out records to all sinks', () => {
const a = new InMemorySink();
const b = new InMemorySink();
const composite = new CompositeSink([a, b]);

composite.record(100, { command: 'deploy' });

expect(a.metrics).toHaveLength(1);
expect(b.metrics).toHaveLength(1);
expect(a.metrics[0]!.attrs.command).toBe('deploy');
});

it('isolates errors — one sink throwing does not affect others', () => {
const bad: MetricSink = {
record: vi.fn(() => {
throw new Error('sink failed');
}),
flush: vi.fn().mockResolvedValue(undefined),
shutdown: vi.fn().mockResolvedValue(undefined),
};
const good = new InMemorySink();
const composite = new CompositeSink([bad, good]);

composite.record(100, { command: 'deploy' });

expect(good.metrics).toHaveLength(1);
});

it('flushes all sinks in parallel', async () => {
const a = new InMemorySink();
const b = new InMemorySink();
const flushA = vi.spyOn(a, 'flush');
const flushB = vi.spyOn(b, 'flush');
const composite = new CompositeSink([a, b]);

await composite.flush(5000);

expect(flushA).toHaveBeenCalledWith(5000);
expect(flushB).toHaveBeenCalledWith(5000);
});

it('flush settles even if one sink rejects', async () => {
const bad: MetricSink = {
record: vi.fn(),
flush: vi.fn().mockRejectedValue(new Error('flush failed')),
shutdown: vi.fn().mockResolvedValue(undefined),
};
const good = new InMemorySink();
const flushGood = vi.spyOn(good, 'flush');
const composite = new CompositeSink([bad, good]);

await expect(composite.flush()).resolves.toBeUndefined();
expect(flushGood).toHaveBeenCalled();
});
});
63 changes: 63 additions & 0 deletions src/cli/telemetry/__tests__/error-classification.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
import { classifyError, isUserError } from '../error-classification';
import { describe, expect, it } from 'vitest';

function errorWithName(name: string): Error {
const err = new Error('test');
err.name = name;
return err;
}

describe('classifyError', () => {
it.each([
['ConfigValidationError', 'ConfigError'],
['ConfigNotFoundError', 'ConfigError'],
['ConfigReadError', 'ConfigError'],
['ConfigWriteError', 'ConfigError'],
['ConfigParseError', 'ConfigError'],
['AwsCredentialsError', 'CredentialsError'],
['AccessDeniedException', 'CredentialsError'],
['ExpiredToken', 'CredentialsError'],
['PackagingError', 'PackagingError'],
['MissingDependencyError', 'PackagingError'],
['ArtifactSizeError', 'PackagingError'],
['NoProjectError', 'ProjectError'],
['AgentAlreadyExistsError', 'ProjectError'],
['ResourceNotFoundException', 'ServiceError'],
['ValidationException', 'ServiceError'],
['ConflictException', 'ServiceError'],
['ConnectionError', 'ConnectionError'],
['ServerError', 'ConnectionError'],
] as const)('%s → %s', (errorName, expected) => {
expect(classifyError(errorWithName(errorName))).toBe(expected);
});

it('returns UnknownError for unrecognized errors', () => {
expect(classifyError(new Error('something'))).toBe('UnknownError');
});

it('returns UnknownError for non-Error values', () => {
expect(classifyError('string')).toBe('UnknownError');
expect(classifyError(null)).toBe('UnknownError');
expect(classifyError(undefined)).toBe('UnknownError');
});

it('uses err.name when constructor.name is Error (SDK pattern)', () => {
// AWS SDK errors often: new Error(); err.name = 'ValidationException'
expect(classifyError(errorWithName('ValidationException'))).toBe('ServiceError');
});
});

describe('isUserError', () => {
it('returns true for user-fixable categories', () => {
expect(isUserError(errorWithName('ConfigValidationError'))).toBe(true);
expect(isUserError(errorWithName('AwsCredentialsError'))).toBe(true);
expect(isUserError(errorWithName('NoProjectError'))).toBe(true);
});

it('returns false for system categories', () => {
expect(isUserError(errorWithName('PackagingError'))).toBe(false);
expect(isUserError(errorWithName('ResourceNotFoundException'))).toBe(false);
expect(isUserError(errorWithName('ConnectionError'))).toBe(false);
expect(isUserError(new Error('unknown'))).toBe(false);
});
});
2 changes: 1 addition & 1 deletion src/cli/telemetry/__tests__/resolve.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { createTempConfig } from '../../__tests__/helpers/temp-config';
import { resolveTelemetryPreference } from '../resolve';
import { resolveTelemetryPreference } from '../config';
import { writeFile } from 'fs/promises';
import { join } from 'node:path';
import { afterAll, afterEach, beforeEach, describe, expect, it } from 'vitest';
Expand Down
50 changes: 50 additions & 0 deletions src/cli/telemetry/__tests__/resource-resolver.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
import { resolveResourceAttributes } from '../config';
import { ResourceAttributesSchema } from '../schemas/common-attributes';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';

const ORIGINAL_ENV = process.env.AGENTCORE_CONFIG_DIR;

describe('resolveResourceAttributes', () => {
beforeEach(() => {
process.env.AGENTCORE_CONFIG_DIR = '/tmp/telemetry-test-' + Date.now();
});

afterEach(() => {
if (ORIGINAL_ENV === undefined) {
delete process.env.AGENTCORE_CONFIG_DIR;
} else {
process.env.AGENTCORE_CONFIG_DIR = ORIGINAL_ENV;
}
});

it('returns attributes that pass schema validation', async () => {
const attrs = await resolveResourceAttributes('cli');
expect(() => ResourceAttributesSchema.parse(attrs)).not.toThrow();
});

it('sets service.name to agentcore-cli', async () => {
const attrs = await resolveResourceAttributes('cli');
expect(attrs['service.name']).toBe('agentcore-cli');
});

it('generates unique session_id per call', async () => {
const a = await resolveResourceAttributes('cli');
const b = await resolveResourceAttributes('cli');
expect(a['agentcore-cli.session_id']).not.toBe(b['agentcore-cli.session_id']);
});

it('reflects the mode parameter', async () => {
const cli = await resolveResourceAttributes('cli');
const tui = await resolveResourceAttributes('tui');
expect(cli['agentcore-cli.mode']).toBe('cli');
expect(tui['agentcore-cli.mode']).toBe('tui');
});

it('populates os and node fields', async () => {
const attrs = await resolveResourceAttributes('cli');
expect(attrs['os.type']).toBeTruthy();
expect(attrs['os.version']).toBeTruthy();
expect(attrs['host.arch']).toBeTruthy();
expect(attrs['node.version']).toMatch(/^v\d+/);
});
});
Loading
Loading