Skip to content

Commit 721c2a9

Browse files
authored
feat: expose ability to publish otel metrics to remote endpoint (#1244)
* feat: expose ability to publish otel metrics to remote endpoint * fix: detect /v1/metrics suffix in endpoint to avoid double-appending
1 parent 5e1a24f commit 721c2a9

5 files changed

Lines changed: 196 additions & 33 deletions

File tree

src/cli/commands/telemetry/actions.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import { GLOBAL_CONFIG_DIR, GLOBAL_CONFIG_FILE, updateGlobalConfig } from '../../../lib/schemas/io/global-config.js';
1+
import {
2+
GLOBAL_CONFIG_DIR,
3+
GLOBAL_CONFIG_FILE,
4+
readGlobalConfig,
5+
updateGlobalConfig,
6+
} from '../../../lib/schemas/io/global-config.js';
27
import { resolveTelemetryPreference } from '../../telemetry/config.js';
38

49
export async function handleTelemetryDisable(
@@ -20,7 +25,8 @@ export async function handleTelemetryEnable(
2025
}
2126

2227
export async function handleTelemetryStatus(configFile = GLOBAL_CONFIG_FILE): Promise<void> {
23-
const pref = await resolveTelemetryPreference(configFile);
28+
const globalConfig = await readGlobalConfig(configFile);
29+
const pref = await resolveTelemetryPreference(globalConfig);
2430

2531
const status = pref.enabled ? 'Enabled' : 'Disabled';
2632
const sourceLabel =
Lines changed: 112 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,29 @@
1-
import { createTempConfig } from '../../__tests__/helpers/temp-config';
2-
import { resolveTelemetryPreference } from '../config';
3-
import { writeFile } from 'fs/promises';
4-
import { join } from 'node:path';
5-
import { afterAll, afterEach, beforeEach, describe, expect, it } from 'vitest';
6-
7-
const tmp = createTempConfig('resolve');
1+
import {
2+
resolveAuditEnabled,
3+
resolveTelemetryEndpoint,
4+
resolveTelemetryPreference,
5+
validateEndpointUrl,
6+
} from '../config';
7+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
88

99
describe('resolveTelemetryPreference', () => {
1010
const originalEnv = process.env;
1111

12-
beforeEach(async () => {
12+
beforeEach(() => {
1313
process.env = { ...originalEnv };
1414
delete process.env.AGENTCORE_TELEMETRY_DISABLED;
15-
await tmp.setup();
1615
});
1716

1817
afterEach(() => {
1918
process.env = originalEnv;
2019
});
2120

22-
afterAll(() => tmp.cleanup());
23-
2421
describe('AGENTCORE_TELEMETRY_DISABLED env var', () => {
2522
it('disables telemetry for any non-false/non-0 value', async () => {
2623
for (const val of ['true', 'TRUE', '1', 'yes']) {
2724
process.env.AGENTCORE_TELEMETRY_DISABLED = val;
2825

29-
const result = await resolveTelemetryPreference(tmp.configFile);
26+
const result = await resolveTelemetryPreference();
3027

3128
expect(result).toMatchObject({ enabled: false, source: 'environment' });
3229
expect(result.envVar).toEqual({ name: 'AGENTCORE_TELEMETRY_DISABLED', value: val });
@@ -37,7 +34,7 @@ describe('resolveTelemetryPreference', () => {
3734
for (const val of ['false', '0']) {
3835
process.env.AGENTCORE_TELEMETRY_DISABLED = val;
3936

40-
const result = await resolveTelemetryPreference(tmp.configFile);
37+
const result = await resolveTelemetryPreference();
4138

4239
expect(result).toMatchObject({ enabled: true, source: 'environment' });
4340
expect(result.envVar).toEqual({ name: 'AGENTCORE_TELEMETRY_DISABLED', value: val });
@@ -46,28 +43,122 @@ describe('resolveTelemetryPreference', () => {
4643
});
4744

4845
describe('global config', () => {
49-
it('uses config file when no env vars set', async () => {
50-
await writeFile(tmp.configFile, JSON.stringify({ telemetry: { enabled: false } }));
51-
52-
const result = await resolveTelemetryPreference(tmp.configFile);
46+
it('uses config when telemetry.enabled is false', async () => {
47+
const result = await resolveTelemetryPreference({ telemetry: { enabled: false } });
5348

5449
expect(result).toEqual({ enabled: false, source: 'global-config' });
5550
});
5651

5752
it('ignores non-boolean enabled values in config', async () => {
58-
await writeFile(tmp.configFile, JSON.stringify({ telemetry: { enabled: 'false' } }));
59-
60-
const result = await resolveTelemetryPreference(tmp.configFile);
53+
// @ts-expect-error — intentionally invalid
54+
const result = await resolveTelemetryPreference({ telemetry: { enabled: 'false' } });
6155

6256
expect(result).toEqual({ enabled: true, source: 'default' });
6357
});
6458
});
6559

6660
describe('default', () => {
6761
it('defaults to enabled when no env vars or config', async () => {
68-
const result = await resolveTelemetryPreference(join(tmp.testDir, 'nonexistent.json'));
62+
const result = await resolveTelemetryPreference({});
6963

7064
expect(result).toEqual({ enabled: true, source: 'default' });
7165
});
7266
});
7367
});
68+
69+
describe('validateEndpointUrl', () => {
70+
it('returns success with normalized URL for valid https endpoint', () => {
71+
const result = validateEndpointUrl('https://telemetry.example.com/v1/');
72+
expect(result).toEqual({ success: true, url: 'https://telemetry.example.com/v1' });
73+
});
74+
75+
it('returns success for http endpoint', () => {
76+
const result = validateEndpointUrl('http://localhost:4318');
77+
expect(result).toEqual({ success: true, url: 'http://localhost:4318' });
78+
});
79+
80+
it('strips trailing slashes', () => {
81+
const result = validateEndpointUrl('https://example.com/');
82+
expect(result).toEqual({ success: true, url: 'https://example.com' });
83+
});
84+
85+
it('returns failure for non-http protocol', () => {
86+
const result = validateEndpointUrl('file:///etc/passwd');
87+
expect(result.success).toBe(false);
88+
expect(!result.success && result.error.message).toContain('Unsupported protocol');
89+
});
90+
91+
it('returns failure for malformed URL', () => {
92+
const result = validateEndpointUrl('not-a-url');
93+
expect(result.success).toBe(false);
94+
expect(!result.success && result.error.message).toContain('Invalid URL');
95+
});
96+
});
97+
98+
describe('resolveTelemetryEndpoint', () => {
99+
const originalEnv = process.env;
100+
101+
beforeEach(() => {
102+
process.env = { ...originalEnv };
103+
delete process.env.AGENTCORE_TELEMETRY_ENDPOINT;
104+
});
105+
106+
afterEach(() => {
107+
process.env = originalEnv;
108+
});
109+
110+
it('returns endpoint from env var', async () => {
111+
process.env.AGENTCORE_TELEMETRY_ENDPOINT = 'https://env.example.com';
112+
113+
const result = await resolveTelemetryEndpoint({});
114+
115+
expect(result).toEqual({ success: true, url: 'https://env.example.com' });
116+
});
117+
118+
it('falls back to config endpoint', async () => {
119+
const result = await resolveTelemetryEndpoint({ telemetry: { endpoint: 'https://config.example.com' } });
120+
121+
expect(result).toEqual({ success: true, url: 'https://config.example.com' });
122+
});
123+
124+
it('returns failure when no endpoint configured', async () => {
125+
const result = await resolveTelemetryEndpoint({});
126+
127+
expect(result.success).toBe(false);
128+
});
129+
130+
it('returns failure for invalid env endpoint', async () => {
131+
process.env.AGENTCORE_TELEMETRY_ENDPOINT = 'not-a-url';
132+
133+
const result = await resolveTelemetryEndpoint({});
134+
135+
expect(result.success).toBe(false);
136+
});
137+
});
138+
139+
describe('resolveAuditEnabled', () => {
140+
const originalEnv = process.env;
141+
142+
beforeEach(() => {
143+
process.env = { ...originalEnv };
144+
delete process.env.AGENTCORE_TELEMETRY_AUDIT;
145+
});
146+
147+
afterEach(() => {
148+
process.env = originalEnv;
149+
});
150+
151+
it('returns true when env var is "1"', async () => {
152+
process.env.AGENTCORE_TELEMETRY_AUDIT = '1';
153+
154+
expect(await resolveAuditEnabled({})).toBe(true);
155+
});
156+
157+
it('returns true when config audit is true', async () => {
158+
expect(await resolveAuditEnabled({ telemetry: { audit: true } })).toBe(true);
159+
});
160+
161+
it('returns false when neither env nor config enables audit', async () => {
162+
expect(await resolveAuditEnabled({})).toBe(false);
163+
});
164+
});

src/cli/telemetry/client-accessor.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
11
import { GLOBAL_CONFIG_DIR, readGlobalConfig } from '../../lib/schemas/io/global-config.js';
22
import { TelemetryClient } from './client.js';
3-
import { resolveAuditFilePath, resolveResourceAttributes } from './config.js';
3+
import {
4+
resolveAuditEnabled,
5+
resolveAuditFilePath,
6+
resolveResourceAttributes,
7+
resolveTelemetryEndpoint,
8+
resolveTelemetryPreference,
9+
} from './config.js';
410
import { FileSystemSink } from './sinks/filesystem-sink.js';
511
import { CompositeSink } from './sinks/metric-sink.js';
12+
import { OtelMetricSink } from './sinks/otel-metric-sink.js';
613
import { join } from 'path';
714

815
/**
@@ -24,17 +31,26 @@ export class TelemetryClientAccessor {
2431

2532
static async shutdown(): Promise<void> {
2633
if (this.clientPromise) {
27-
const client = await this.clientPromise;
28-
await client.shutdown();
34+
try {
35+
const client = await this.clientPromise;
36+
await client.shutdown();
37+
} catch {
38+
// Telemetry is best-effort — don't propagate init or shutdown failures
39+
}
2940
}
3041
}
3142
}
3243

3344
async function createClient(entrypoint: string, mode: 'cli' | 'tui' = 'cli'): Promise<TelemetryClient> {
3445
const [resource, config] = await Promise.all([resolveResourceAttributes(mode), readGlobalConfig()]);
3546

47+
const [{ enabled }, endpointResult, audit] = await Promise.all([
48+
resolveTelemetryPreference(config),
49+
resolveTelemetryEndpoint(config),
50+
resolveAuditEnabled(config),
51+
]);
52+
3653
const sinks = [];
37-
const audit = process.env.AGENTCORE_TELEMETRY_AUDIT === '1' || config.telemetry?.audit === true;
3854

3955
if (audit) {
4056
const filePath = resolveAuditFilePath(
@@ -45,5 +61,9 @@ async function createClient(entrypoint: string, mode: 'cli' | 'tui' = 'cli'): Pr
4561
sinks.push(new FileSystemSink({ filePath, resource }));
4662
}
4763

64+
if (endpointResult.success && enabled) {
65+
sinks.push(new OtelMetricSink({ endpoint: endpointResult.url, resource }));
66+
}
67+
4868
return new TelemetryClient(new CompositeSink(sinks));
4969
}

src/cli/telemetry/config.ts

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { getOrCreateInstallationId, readGlobalConfig } from '../../lib/schemas/io/global-config.js';
1+
import type { Result } from '../../lib/result.js';
2+
import { type GlobalConfig, getOrCreateInstallationId, readGlobalConfig } from '../../lib/schemas/io/global-config.js';
23
import { PACKAGE_VERSION } from '../constants.js';
34
import { type ResourceAttributes, ResourceAttributesSchema } from './schemas/common-attributes.js';
45
import { randomUUID } from 'crypto';
@@ -17,7 +18,7 @@ export interface TelemetryPreference {
1718

1819
const ENV_VAR_NAME = 'AGENTCORE_TELEMETRY_DISABLED';
1920

20-
export async function resolveTelemetryPreference(configFile?: string): Promise<TelemetryPreference> {
21+
export async function resolveTelemetryPreference(config?: GlobalConfig): Promise<TelemetryPreference> {
2122
const agentcoreEnv = process.env[ENV_VAR_NAME];
2223
if (agentcoreEnv !== undefined) {
2324
const normalized = agentcoreEnv.toLowerCase().trim();
@@ -29,9 +30,9 @@ export async function resolveTelemetryPreference(configFile?: string): Promise<T
2930
}
3031
}
3132

32-
const config = await readGlobalConfig(configFile);
33-
if (typeof config.telemetry?.enabled === 'boolean') {
34-
return { enabled: config.telemetry.enabled, source: 'global-config' };
33+
const resolved = config ?? (await readGlobalConfig());
34+
if (typeof resolved.telemetry?.enabled === 'boolean') {
35+
return { enabled: resolved.telemetry.enabled, source: 'global-config' };
3536
}
3637

3738
return { enabled: true, source: 'default' };
@@ -64,3 +65,46 @@ export async function resolveResourceAttributes(mode: 'cli' | 'tui'): Promise<Re
6465
export function resolveAuditFilePath(outputDir: string, entrypoint: string, sessionId: string): string {
6566
return join(outputDir, `${entrypoint}-${sessionId}.json`);
6667
}
68+
69+
/**
70+
* Determine whether telemetry audit mode is enabled.
71+
* Audit mode writes all telemetry entries to a local file for inspection.
72+
*/
73+
export async function resolveAuditEnabled(config?: GlobalConfig): Promise<boolean> {
74+
if (process.env.AGENTCORE_TELEMETRY_AUDIT === '1') return true;
75+
const resolved = config ?? (await readGlobalConfig());
76+
return resolved.telemetry?.audit === true;
77+
}
78+
79+
/**
80+
* Validate that a string is a well-formed HTTP(S) URL suitable for an OTLP endpoint.
81+
* Returns the normalized URL (trailing slashes stripped) on success.
82+
*/
83+
export function validateEndpointUrl(endpoint: string): Result<{ url: string }> {
84+
try {
85+
const parsed = new URL(endpoint);
86+
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
87+
return { success: false, error: new Error(`Unsupported protocol: ${parsed.protocol}`) };
88+
}
89+
return { success: true, url: parsed.origin + parsed.pathname.replace(/\/+$/, '') };
90+
} catch {
91+
return { success: false, error: new Error(`Invalid URL: ${endpoint}`) };
92+
}
93+
}
94+
95+
/**
96+
* Resolve the telemetry endpoint from env var or global config.
97+
* Returns a failure Result if no endpoint is configured or the value is invalid.
98+
*/
99+
export async function resolveTelemetryEndpoint(config?: GlobalConfig): Promise<Result<{ url: string }>> {
100+
const envEndpoint = process.env.AGENTCORE_TELEMETRY_ENDPOINT;
101+
if (envEndpoint) {
102+
return validateEndpointUrl(envEndpoint);
103+
}
104+
const resolved = config ?? (await readGlobalConfig());
105+
const configEndpoint = resolved.telemetry?.endpoint;
106+
if (configEndpoint) {
107+
return validateEndpointUrl(configEndpoint);
108+
}
109+
return { success: false, error: new Error('No telemetry endpoint found.') };
110+
}

src/cli/telemetry/sinks/otel-metric-sink.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,13 @@ export class OtelMetricSink implements MetricSink {
1717

1818
constructor(config: OtelMetricSinkConfig) {
1919
const resource = resourceFromAttributes(config.resource);
20+
const url = config.endpoint.endsWith('/v1/metrics') ? config.endpoint : `${config.endpoint}/v1/metrics`;
2021
const exporter = new OTLPMetricExporter({
21-
url: `${config.endpoint}/v1/metrics`,
22+
url,
2223
headers: { 'X-Installation-Id': config.resource['agentcore-cli.installation_id'] },
2324
temporalityPreference: AggregationTemporality.DELTA,
2425
});
26+
2527
this.meterProvider = new MeterProvider({
2628
resource,
2729
readers: [

0 commit comments

Comments
 (0)