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
8 changes: 4 additions & 4 deletions docs/gtm/taxonomy.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,10 @@ The standard PostHog `$pageview` event is used as-is across all three surfaces.
| Event | When | Surface | Default |
|--------------------------------------|--------------------------------------------|-----------------|--------------|
| `ngaf:postinstall` | Dependency/global install of a published `@ngaf/*` package | Node (script) | **Opt-out** |
| `ngaf:runtime_instance_created` | Server adapter init | Node | **Opt-out** |
| `ngaf:stream_started` | Stream begins | Node | **Opt-out** |
| `ngaf:stream_ended` | Stream ends normally | Node | **Opt-out** |
| `ngaf:stream_errored` | Stream errors | Node | **Opt-out** |
| `ngaf:runtime_instance_created` | Runtime adapter init | Node / Browser | **Opt-out** on Node, **Opt-in** in Browser |
| `ngaf:stream_started` | Stream begins | Node / Browser | **Opt-out** on Node, **Opt-in** in Browser |
| `ngaf:stream_ended` | Stream ends normally | Node / Browser | **Opt-out** on Node, **Opt-in** in Browser |
| `ngaf:stream_errored` | Stream errors | Node / Browser | **Opt-out** on Node, **Opt-in** in Browser |
| `ngaf:browser_provided` | `provideNgafTelemetry({enabled:true})` | Browser | **Opt-in** |
| `ngaf:browser_chat_init` | Browser chat surface initialized | Browser | **Opt-in** |

Expand Down
3 changes: 3 additions & 0 deletions examples/chat/angular/src/app/app.config.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
// SPDX-License-Identifier: MIT
import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZonelessChangeDetection } from '@angular/core';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { provideNgafTelemetry } from '@ngaf/telemetry/browser';
import { routes } from './app.routes';
import { environment } from '../environments/environment';

export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideZonelessChangeDetection(),
provideRouter(routes, withComponentInputBinding()),
provideNgafTelemetry(environment.telemetry),
],
};
61 changes: 48 additions & 13 deletions examples/chat/angular/src/app/shell/demo-shell.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { Router, RouterOutlet, NavigationEnd } from '@angular/router';
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
import { filter, map, startWith } from 'rxjs/operators';
import { agent } from '@ngaf/langgraph';
import { NgafTelemetryService } from '@ngaf/telemetry/browser';
import {
ChatDebugComponent,
ChatDebugControlsDirective,
Expand Down Expand Up @@ -40,12 +41,17 @@ import { environment } from '../../environments/environment';
export type DemoMode = 'embed' | 'popup' | 'sidebar';

const MODES: readonly DemoMode[] = ['embed', 'popup', 'sidebar'] as const;
const TELEMETRY_SURFACE = 'canonical_demo';

function modeFromUrl(url: string): DemoMode {
const seg = url.split('?')[0].split('/').filter(Boolean)[0];
return (MODES as readonly string[]).includes(seg) ? (seg as DemoMode) : 'embed';
}

function nowMs(): number {
return globalThis.performance?.now?.() ?? Date.now();
}

@Component({
selector: 'demo-shell',
standalone: true,
Expand Down Expand Up @@ -75,6 +81,7 @@ export class DemoShell {
private readonly document = inject(DOCUMENT);
protected readonly threadsSvc = inject(ThreadsService);
protected readonly projectsSvc = inject(ProjectsService);
private readonly telemetry = inject(NgafTelemetryService);

constructor() {
// Reflect the chosen theme onto <html data-theme="..."> so the
Expand Down Expand Up @@ -340,23 +347,51 @@ export class DemoShell {
// resulting tools:<id>-namespaced stream events.
subagentToolNames: ['research'],
});
void this.telemetry.capture('ngaf:browser_chat_init', { surface: TELEMETRY_SURFACE });
void this.telemetry.captureRuntimeInstanceCreated({
transport: 'langgraph',
surface: TELEMETRY_SURFACE,
model: this.model(),
});
const orig = a.submit.bind(a);
(a as { submit: typeof a.submit }).submit = ((
(a as { submit: typeof a.submit }).submit = (async (
input: Parameters<typeof a.submit>[0],
opts?: Parameters<typeof a.submit>[1],
) =>
orig(
{
...(input ?? {}),
state: {
...((input as { state?: Record<string, unknown> })?.state ?? {}),
model: this.model(),
reasoning_effort: this.effort(),
gen_ui_mode: this.genUiMode(),
) => {
const start = nowMs();
const baseTelemetry = {
transport: 'langgraph',
surface: TELEMETRY_SURFACE,
model: this.model(),
};
void this.telemetry.captureStreamStarted(baseTelemetry);
try {
const result = await orig(
{
...(input ?? {}),
state: {
...((input as { state?: Record<string, unknown> })?.state ?? {}),
model: this.model(),
reasoning_effort: this.effort(),
gen_ui_mode: this.genUiMode(),
},
},
},
opts,
)) as typeof a.submit;
opts,
);
void this.telemetry.captureStreamEnded({
...baseTelemetry,
durationMs: Math.round(nowMs() - start),
});
return result;
} catch (error) {
void this.telemetry.captureStreamErrored({
...baseTelemetry,
durationMs: Math.round(nowMs() - start),
error,
});
throw error;
}
}) as typeof a.submit;
return a;
})();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,8 @@ export const environment = {
production: false,
langGraphApiUrl: 'http://localhost:2024',
assistantId: 'chat',
telemetry: {
enabled: false,
sampleRate: 1,
},
};
5 changes: 5 additions & 0 deletions examples/chat/angular/src/environments/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,9 @@ export const environment = {
production: true,
langGraphApiUrl: '/api',
assistantId: 'chat',
telemetry: {
enabled: true,
endpoint: '/api/ingest',
sampleRate: 1,
},
};
28 changes: 19 additions & 9 deletions libs/telemetry/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ The single telemetry surface for `@ngaf/*`. It exists so we can answer "how is C
- `ngaf:stream_started` / `ngaf:stream_ended` / `ngaf:stream_errored` — per-request lifecycle on server adapters. Properties: provider, model name, duration, error class. No prompts, no completions, no message content.

**Telemetered only on explicit opt-in (Browser):**
- Nothing fires unless the consumer calls `provideNgafTelemetry({ enabled: true, posthogKey, posthogHost })` in their root providers.
- When opted in: `ngaf:browser_provided`, `ngaf:browser_chat_init`. Anonymous, no message content.
- Nothing fires unless the consumer calls `provideNgafTelemetry({ enabled: true, sink })` or `provideNgafTelemetry({ enabled: true, endpoint })` in their root providers.
- When opted in: `ngaf:browser_provided`, `ngaf:browser_chat_init`, and browser-side runtime lifecycle events explicitly captured by the app (`ngaf:runtime_instance_created`, `ngaf:stream_started`, `ngaf:stream_ended`, `ngaf:stream_errored`). Anonymous, no message content.

**Never telemetered (by anyone, at any time):**
- Message content (user prompts, model completions, tool call inputs/outputs).
Expand Down Expand Up @@ -65,10 +65,6 @@ To inspect the install payload locally, run with `DEBUG=ngaf:telemetry`.

Browser telemetry is **off by default** and never fires from the library itself. To enable in your Angular app:

```bash
npm install posthog-js
```

```ts
// app.config.ts (or wherever you bootstrap)
import { provideNgafTelemetry } from '@ngaf/telemetry/browser';
Expand All @@ -78,13 +74,27 @@ export const appConfig: ApplicationConfig = {
// ...
provideNgafTelemetry({
enabled: true,
posthogKey: 'phc_yourKey', // your PostHog project key, never ours
posthogHost: 'https://us.i.posthog.com',
endpoint: '/api/telemetry',
}),
],
};
```

The endpoint receives neutral JSON:

```json
{
"event": "ngaf:stream_started",
"distinctId": "browser:<ephemeral-id>",
"properties": {
"surface": "my_app",
"sample_weight": 1
}
}
```

You can also pass `sink: async ({ event, properties }) => { ... }` and route events through your own analytics client. Legacy `posthogKey` / `posthogHost` options still work for existing adopters, but new app code should prefer `sink` or `endpoint` so the public API is vendor-neutral.

If you don't call `provideNgafTelemetry({ enabled: true })`, every telemetry helper in `@ngaf/*` browser packages no-ops. No network calls, ever.

## Sampling
Expand All @@ -97,7 +107,7 @@ If you don't call `provideNgafTelemetry({ enabled: true })`, every telemetry hel

- Per-process UUID (`anon_<uuid>`), regenerated every Node process boot.
- No persistence across restarts. No persistent identifier.
- Browser opt-in uses the consumer's PostHog `distinct_id` per their own configuration — Cacheplane does not manage browser identity.
- Browser opt-in endpoint delivery uses an ephemeral per-service-instance id (`browser:<uuid>`). It is not written to localStorage or cookies.

## Self-hosting

Expand Down
13 changes: 12 additions & 1 deletion libs/telemetry/src/browser/public-api.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,15 @@
export { provideNgafTelemetry } from './provide';
export { NgafTelemetryService } from './service';
export { NGAF_TELEMETRY_CONFIG } from './tokens';
export type { NgafTelemetryConfig } from './tokens';
export type {
NgafTelemetryConfig,
NgafTelemetryEvent,
NgafTelemetryEventPayload,
NgafTelemetrySink,
} from './tokens';
export type {
NgafBrowserEvent,
NgafBrowserRuntimeTelemetry,
NgafBrowserStreamErrorTelemetry,
NgafBrowserStreamTelemetry,
} from './service';
108 changes: 107 additions & 1 deletion libs/telemetry/src/browser/service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
// @vitest-environment jsdom
import { describe, test, expect } from 'vitest';
import { beforeEach, describe, test, expect, vi } from 'vitest';
import { TestBed } from '@angular/core/testing';
import { NgafTelemetryService } from './service';
import { NGAF_TELEMETRY_CONFIG } from './tokens';

describe('NgafTelemetryService', () => {
beforeEach(() => {
vi.restoreAllMocks();
});

test('capture() resolves without calling posthog when enabled is false', async () => {
TestBed.configureTestingModule({
providers: [
Expand Down Expand Up @@ -38,6 +42,108 @@ describe('NgafTelemetryService', () => {
await expect(svc.capture('ngaf:browser_provided')).resolves.toBeUndefined();
});

test('capture() delivers events to a configured neutral sink', async () => {
const sink = vi.fn();
TestBed.configureTestingModule({
providers: [
{ provide: NGAF_TELEMETRY_CONFIG, useValue: { enabled: true, sink } },
NgafTelemetryService,
],
});
const svc = TestBed.inject(NgafTelemetryService);

await svc.captureRuntimeInstanceCreated({
transport: 'langgraph',
surface: 'canonical_demo',
model: 'gpt-5-mini',
});

expect(sink).toHaveBeenCalledWith({
event: 'ngaf:runtime_instance_created',
properties: {
transport: 'langgraph',
surface: 'canonical_demo',
model: 'gpt-5-mini',
sample_weight: 1,
},
});
});

test('capture() posts neutral event payloads to a configured endpoint', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('{}', { status: 202 }));
TestBed.configureTestingModule({
providers: [
{ provide: NGAF_TELEMETRY_CONFIG, useValue: { enabled: true, endpoint: '/api/ingest' } },
NgafTelemetryService,
],
});
const svc = TestBed.inject(NgafTelemetryService);

await svc.capture('ngaf:browser_chat_init', { surface: 'canonical_demo' });

expect(fetchMock).toHaveBeenCalledTimes(1);
const call = fetchMock.mock.calls.at(0);
expect(call).toBeDefined();
const [url, init] = call as [Parameters<typeof fetch>[0], RequestInit];
expect(url).toBe('/api/ingest');
expect(init).toEqual(expect.objectContaining({
method: 'POST',
keepalive: true,
headers: { 'content-type': 'application/json' },
}));
expect(JSON.parse(String((init as RequestInit).body))).toEqual(expect.objectContaining({
event: 'ngaf:browser_chat_init',
distinctId: expect.stringMatching(/^browser:/),
properties: {
surface: 'canonical_demo',
sample_weight: 1,
},
}));
});

test('captureStreamErrored() strips error messages from browser telemetry', async () => {
const sink = vi.fn();
TestBed.configureTestingModule({
providers: [
{ provide: NGAF_TELEMETRY_CONFIG, useValue: { enabled: true, sink } },
NgafTelemetryService,
],
});
const svc = TestBed.inject(NgafTelemetryService);

await svc.captureStreamErrored({
transport: 'langgraph',
surface: 'canonical_demo',
error: new Error('contains user prompt text'),
});

expect(sink).toHaveBeenCalledWith({
event: 'ngaf:stream_errored',
properties: {
transport: 'langgraph',
surface: 'canonical_demo',
errorClass: 'Error',
sample_weight: 1,
},
});
expect(JSON.stringify(sink.mock.calls[0])).not.toContain('contains user prompt text');
});

test('capture() respects sampleRate:0 before delivering to a sink', async () => {
const sink = vi.fn();
TestBed.configureTestingModule({
providers: [
{ provide: NGAF_TELEMETRY_CONFIG, useValue: { enabled: true, sink, sampleRate: 0 } },
NgafTelemetryService,
],
});
const svc = TestBed.inject(NgafTelemetryService);

await svc.capture('ngaf:browser_chat_init');

expect(sink).not.toHaveBeenCalled();
});

test('capture() with enabled:true and posthogKey invokes posthog-js (lazy)', async () => {
TestBed.configureTestingModule({
providers: [
Expand Down
Loading
Loading