Skip to content

Commit cbf4b60

Browse files
authored
Expand runtime telemetry coverage (#366)
1 parent 51d16a7 commit cbf4b60

33 files changed

Lines changed: 697 additions & 11 deletions

.github/workflows/publish.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ jobs:
4646
- name: Patch install telemetry into publishable manifests
4747
run: node libs/telemetry/scripts/apply-install-telemetry.mjs dist/libs/chat dist/libs/langgraph dist/libs/ag-ui dist/libs/render dist/libs/a2ui dist/libs/licensing
4848

49+
- name: Verify install telemetry in publishable manifests
50+
run: node libs/telemetry/scripts/verify-install-telemetry.mjs dist/libs/chat dist/libs/langgraph dist/libs/ag-ui dist/libs/render dist/libs/a2ui dist/libs/licensing dist/libs/telemetry
51+
4952
- name: Verify atomic release versions
5053
run: node scripts/verify-release-versions.mjs --tag "$RELEASE_TAG"
5154
env:
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
3+
const capture = vi.fn();
4+
const shutdown = vi.fn();
5+
6+
vi.mock('posthog-node', () => ({
7+
PostHog: vi.fn(function PostHog() {
8+
return { capture, shutdown };
9+
}),
10+
}));
11+
12+
import { POST } from './route';
13+
14+
describe('/api/ingest', () => {
15+
beforeEach(() => {
16+
vi.clearAllMocks();
17+
process.env.NEXT_PUBLIC_POSTHOG_TOKEN = 'phc_server';
18+
delete process.env.NEXT_PUBLIC_POSTHOG_HOST;
19+
});
20+
21+
it('accepts neutral browser telemetry payloads without requiring the public ingest key', async () => {
22+
shutdown.mockResolvedValue(undefined);
23+
const response = await POST(new Request('https://cacheplane.ai/api/ingest', {
24+
method: 'POST',
25+
body: JSON.stringify({
26+
event: 'ngaf:browser_chat_init',
27+
distinctId: 'browser:test',
28+
properties: { surface: 'canonical_demo' },
29+
}),
30+
}) as never);
31+
32+
expect(response.status).toBe(202);
33+
expect(capture).toHaveBeenCalledWith({
34+
distinctId: 'browser:test',
35+
event: 'ngaf:browser_chat_init',
36+
properties: {
37+
surface: 'canonical_demo',
38+
$ip: null,
39+
$process_person_profile: false,
40+
},
41+
});
42+
});
43+
});

apps/website/src/app/api/ingest/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ function readPayload(value: unknown): {
3232
} | null {
3333
if (!isRecord(value)) return null;
3434
const payload = value as TelemetryIngestPayload;
35-
if (payload.key !== PUBLIC_INGEST_KEY) return null;
35+
if (payload.key !== undefined && payload.key !== PUBLIC_INGEST_KEY) return null;
3636

3737
const distinctId = toSafeAnalyticsString(payload.distinctId, 200);
3838
const event = toSafeAnalyticsString(payload.event, 100);

docs/gtm/taxonomy.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ sessions by design.
7575
|--------------------------------------|--------------------------------------------|-----------------|--------------|
7676
| `ngaf:postinstall` | Dependency/global install of a published `@ngaf/*` package | Node (script) | **Opt-out** |
7777
| `ngaf:runtime_instance_created` | Runtime adapter init | Node / Browser | **Opt-out** on Node, **Opt-in** in Browser |
78+
| `ngaf:runtime_request_created` | Runtime adapter request created | Node / Browser | **Opt-out** on Node, **Opt-in** in Browser |
7879
| `ngaf:stream_started` | Stream begins | Node / Browser | **Opt-out** on Node, **Opt-in** in Browser |
7980
| `ngaf:stream_ended` | Stream ends normally | Node / Browser | **Opt-out** on Node, **Opt-in** in Browser |
8081
| `ngaf:stream_errored` | Stream errors | Node / Browser | **Opt-out** on Node, **Opt-in** in Browser |
@@ -102,6 +103,19 @@ Browser events never fire unless the consumer explicitly opts in. See `libs/tele
102103
| `global_install` | bool | Whether npm reports a global install. |
103104
| `sample_weight` | number | Inverse sample rate for weighted counts. |
104105

106+
### Runtime telemetry properties
107+
108+
| Property | Type | Notes |
109+
|---------------|--------|--------------------------------------------|
110+
| `transport` | string | Runtime transport, e.g. `langgraph`, `ag-ui`, or `custom`. |
111+
| `surface` | string | Adapter surface emitting the event. |
112+
| `requestType` | string | Request shape, e.g. `submit`, `resubmit`, `regenerate`, `enqueue`, or `join`. |
113+
| `provider` | string | Model provider when known. |
114+
| `model` | string | Model name when known. |
115+
| `durationMs` | number | Stream duration for end/error events. |
116+
| `errorClass` | string | Error class only. Never send error messages. |
117+
| `sample_weight` | number | Inverse sample rate for weighted counts. |
118+
105119
## Shared properties
106120

107121
| Property | Type | Notes |

libs/ag-ui/src/lib/provide-ag-ui-agent.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// SPDX-License-Identifier: MIT
22
import { InjectionToken, inject, type Provider } from '@angular/core';
33
import { HttpAgent } from '@ag-ui/client';
4-
import type { Agent } from '@ngaf/chat';
4+
import type { Agent, AgentRuntimeTelemetrySink } from '@ngaf/chat';
55
import { toAgent } from './to-agent';
66

77
/**
@@ -17,6 +17,8 @@ export interface AgUiAgentConfig {
1717
agentId?: string;
1818
threadId?: string;
1919
headers?: Record<string, string>;
20+
/** Optional app-owned telemetry sink. No telemetry is emitted unless this is provided. */
21+
telemetry?: AgentRuntimeTelemetrySink | false;
2022
}
2123

2224
export const AG_UI_AGENT = new InjectionToken<Agent>('AG_UI_AGENT');
@@ -38,7 +40,7 @@ export function provideAgUiAgent(config: AgUiAgentConfig): Provider[] {
3840
...(config.threadId !== undefined ? { threadId: config.threadId } : {}),
3941
...(config.headers !== undefined ? { headers: config.headers } : {}),
4042
});
41-
return toAgent(source);
43+
return toAgent(source, { telemetry: config.telemetry });
4244
},
4345
},
4446
];

libs/ag-ui/src/lib/to-agent.spec.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { describe, it, expect, vi } from 'vitest';
33
import { Observable, Subject } from 'rxjs';
44
import type { AbstractAgent, BaseEvent } from '@ag-ui/client';
55
import type { RunAgentInput } from '@ag-ui/core';
6+
import type { AgentRuntimeTelemetryPayload } from '@ngaf/chat';
67
import { toAgent } from './to-agent';
78

89
/**
@@ -113,6 +114,51 @@ describe('toAgent', () => {
113114
expect(stub.runAgent).toHaveBeenCalledOnce();
114115
});
115116

117+
it('emits opt-in telemetry around completed AG-UI runs', async () => {
118+
const stub = new StubAgent();
119+
const seen: AgentRuntimeTelemetryPayload[] = [];
120+
const a = toAgent(stub as unknown as AbstractAgent, {
121+
telemetry: (payload) => seen.push(payload),
122+
});
123+
124+
await a.submit({ message: 'hi' });
125+
126+
expect(seen.map((payload) => payload.event)).toEqual([
127+
'ngaf:runtime_instance_created',
128+
'ngaf:runtime_request_created',
129+
'ngaf:stream_started',
130+
'ngaf:stream_ended',
131+
]);
132+
expect(seen[0].properties).toEqual({ transport: 'ag-ui', surface: 'to_agent' });
133+
expect(seen[1].properties).toEqual({ transport: 'ag-ui', surface: 'to_agent', requestType: 'submit' });
134+
expect(seen[2].properties).toEqual({ transport: 'ag-ui', surface: 'to_agent' });
135+
expect(seen[3].properties).toEqual({
136+
transport: 'ag-ui',
137+
surface: 'to_agent',
138+
durationMs: expect.any(Number),
139+
});
140+
});
141+
142+
it('emits opt-in telemetry for AG-UI failures without error messages', async () => {
143+
const stub = new StubAgent();
144+
const seen: AgentRuntimeTelemetryPayload[] = [];
145+
const a = toAgent(stub as unknown as AbstractAgent, {
146+
telemetry: (payload) => seen.push(payload),
147+
});
148+
stub.runAgent.mockRejectedValueOnce(new SyntaxError('private app state'));
149+
150+
await a.submit({ message: 'hi' });
151+
152+
const errored = seen.find((payload) => payload.event === 'ngaf:stream_errored');
153+
expect(errored?.properties).toEqual({
154+
transport: 'ag-ui',
155+
surface: 'to_agent',
156+
durationMs: expect.any(Number),
157+
errorClass: 'SyntaxError',
158+
});
159+
expect(JSON.stringify(seen)).not.toContain('private app state');
160+
});
161+
116162
it('stop() calls source.abortRun()', async () => {
117163
const stub = new StubAgent();
118164
const a = toAgent(stub as unknown as AbstractAgent);

libs/ag-ui/src/lib/to-agent.ts

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,45 @@ import { Subject } from 'rxjs';
44
import type { AbstractAgent } from '@ag-ui/client';
55
import type {
66
Agent, Message, AgentStatus, ToolCall, AgentEvent,
7+
AgentRuntimeTelemetryEvent,
8+
AgentRuntimeTelemetryProperties,
9+
AgentRuntimeTelemetrySink,
710
AgentSubmitInput, AgentSubmitOptions,
811
} from '@ngaf/chat';
912
import { reduceEvent, type ReducerStore } from './reducer';
1013

14+
export interface ToAgentOptions {
15+
/** Optional app-owned telemetry sink. No telemetry is emitted unless this is provided. */
16+
telemetry?: AgentRuntimeTelemetrySink | false;
17+
}
18+
19+
function captureAgentRuntimeTelemetry(
20+
sink: AgentRuntimeTelemetrySink | false | undefined,
21+
event: AgentRuntimeTelemetryEvent,
22+
properties: AgentRuntimeTelemetryProperties,
23+
): void {
24+
if (!sink) return;
25+
try {
26+
void Promise.resolve(sink({ event, properties })).catch(() => undefined);
27+
} catch {
28+
// Keep telemetry side effects isolated from adapter control flow.
29+
}
30+
}
31+
32+
function agentRuntimeTelemetryErrorClass(error: unknown): string {
33+
if (error instanceof Error) return error.name || error.constructor.name || 'Error';
34+
if (
35+
error
36+
&& typeof error === 'object'
37+
&& 'name' in error
38+
&& typeof error.name === 'string'
39+
&& error.name.length > 0
40+
) {
41+
return error.name;
42+
}
43+
return 'UnknownError';
44+
}
45+
1146
/**
1247
* Wraps an AG-UI AbstractAgent into the runtime-neutral Agent contract.
1348
*
@@ -22,7 +57,7 @@ import { reduceEvent, type ReducerStore } from './reducer';
2257
* agent instance they constructed. The subscriber registered via
2358
* source.subscribe() will fire for the lifetime of source.
2459
*/
25-
export function toAgent(source: AbstractAgent): Agent {
60+
export function toAgent(source: AbstractAgent, options: ToAgentOptions = {}): Agent {
2661
const store: ReducerStore = {
2762
messages: signal<Message[]>([]),
2863
status: signal<AgentStatus>('idle'),
@@ -32,6 +67,45 @@ export function toAgent(source: AbstractAgent): Agent {
3267
state: signal<Record<string, unknown>>({}),
3368
events$: new Subject<AgentEvent>(),
3469
};
70+
const telemetryProperties = { transport: 'ag-ui' as const, surface: 'to_agent' };
71+
let activeRun: { startedAt: number; errored: boolean } | null = null;
72+
73+
captureAgentRuntimeTelemetry(
74+
options.telemetry,
75+
'ngaf:runtime_instance_created',
76+
telemetryProperties,
77+
);
78+
79+
function startRunTelemetry(requestType: string): { startedAt: number; errored: boolean } {
80+
const run = { startedAt: Date.now(), errored: false };
81+
activeRun = run;
82+
captureAgentRuntimeTelemetry(options.telemetry, 'ngaf:runtime_request_created', {
83+
...telemetryProperties,
84+
requestType,
85+
});
86+
captureAgentRuntimeTelemetry(options.telemetry, 'ngaf:stream_started', telemetryProperties);
87+
return run;
88+
}
89+
90+
function finishRunTelemetry(run: { startedAt: number; errored: boolean }): void {
91+
if (run.errored) return;
92+
captureAgentRuntimeTelemetry(options.telemetry, 'ngaf:stream_ended', {
93+
...telemetryProperties,
94+
durationMs: Date.now() - run.startedAt,
95+
});
96+
if (activeRun === run) activeRun = null;
97+
}
98+
99+
function failRunTelemetry(error: unknown, run = activeRun): void {
100+
if (!run || run.errored) return;
101+
run.errored = true;
102+
captureAgentRuntimeTelemetry(options.telemetry, 'ngaf:stream_errored', {
103+
...telemetryProperties,
104+
durationMs: Date.now() - run.startedAt,
105+
errorClass: agentRuntimeTelemetryErrorClass(error),
106+
});
107+
if (activeRun === run) activeRun = null;
108+
}
35109

36110
// Tap all events from the source agent via the AgentSubscriber API.
37111
// This subscription lives for the lifetime of `source`.
@@ -43,6 +117,7 @@ export function toAgent(source: AbstractAgent): Agent {
43117
store.status.set('error');
44118
store.isLoading.set(false);
45119
store.error.set(error);
120+
failRunTelemetry(error);
46121
},
47122
});
48123

@@ -65,14 +140,17 @@ export function toAgent(source: AbstractAgent): Agent {
65140
source.addMessage(userMsg as Parameters<typeof source.addMessage>[0]);
66141
}
67142

143+
const run = startRunTelemetry('submit');
68144
try {
69145
await source.runAgent();
146+
finishRunTelemetry(run);
70147
} catch (err) {
71148
// If the run was aborted via stop(), abortRun() resolves the promise
72149
// rather than rejecting — but catch any unexpected errors here.
73150
store.status.set('error');
74151
store.isLoading.set(false);
75152
store.error.set(err);
153+
failRunTelemetry(err, run);
76154
}
77155
},
78156

@@ -113,12 +191,15 @@ export function toAgent(source: AbstractAgent): Agent {
113191
// message in `trimmed` becomes the active prompt for the next run.
114192
source.setMessages(trimmed as Parameters<typeof source.setMessages>[0]);
115193

194+
const run = startRunTelemetry('regenerate');
116195
try {
117196
await source.runAgent();
197+
finishRunTelemetry(run);
118198
} catch (err) {
119199
store.status.set('error');
120200
store.isLoading.set(false);
121201
store.error.set(err);
202+
failRunTelemetry(err, run);
122203
}
123204
},
124205
};

libs/ag-ui/src/public-api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// SPDX-License-Identifier: MIT
22
export { toAgent } from './lib/to-agent';
3+
export type { ToAgentOptions } from './lib/to-agent';
34
export { provideAgUiAgent, AG_UI_AGENT, injectAgUiAgent } from './lib/provide-ag-ui-agent';
45
export type { AgUiAgentConfig } from './lib/provide-ag-ui-agent';
56
export { FakeAgent } from './lib/testing/fake-agent';

libs/chat/src/lib/agent/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,9 @@ export type {
1616
} from './agent-event';
1717
export type { AgentCheckpoint } from './agent-checkpoint';
1818
export type { AgentWithHistory } from './agent-with-history';
19+
export type {
20+
AgentRuntimeTelemetryEvent,
21+
AgentRuntimeTelemetryPayload,
22+
AgentRuntimeTelemetryProperties,
23+
AgentRuntimeTelemetrySink,
24+
} from './runtime-telemetry';
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// SPDX-License-Identifier: MIT
2+
3+
export type AgentRuntimeTelemetryEvent =
4+
| 'ngaf:runtime_instance_created'
5+
| 'ngaf:runtime_request_created'
6+
| 'ngaf:stream_started'
7+
| 'ngaf:stream_ended'
8+
| 'ngaf:stream_errored';
9+
10+
export interface AgentRuntimeTelemetryProperties {
11+
transport: 'langgraph' | 'ag-ui' | 'custom' | string;
12+
surface?: string;
13+
requestType?: string;
14+
provider?: string;
15+
model?: string;
16+
durationMs?: number;
17+
errorClass?: string;
18+
}
19+
20+
export interface AgentRuntimeTelemetryPayload {
21+
event: AgentRuntimeTelemetryEvent;
22+
properties: AgentRuntimeTelemetryProperties;
23+
}
24+
25+
export type AgentRuntimeTelemetrySink = (payload: AgentRuntimeTelemetryPayload) => void | Promise<void>;

0 commit comments

Comments
 (0)