From 3aae044332cf8761743f55bba471e89ef804d411 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 15 May 2026 18:33:45 -0700 Subject: [PATCH] feat(telemetry): dogfood browser runtime events --- docs/gtm/taxonomy.md | 8 +- examples/chat/angular/src/app/app.config.ts | 3 + .../src/app/shell/demo-shell.component.ts | 61 ++++++++-- .../environments/environment.development.ts | 4 + .../angular/src/environments/environment.ts | 5 + libs/telemetry/README.md | 28 +++-- libs/telemetry/src/browser/public-api.ts | 13 +- libs/telemetry/src/browser/service.spec.ts | 108 ++++++++++++++++- libs/telemetry/src/browser/service.ts | 111 ++++++++++++++++-- libs/telemetry/src/browser/tokens.ts | 27 +++++ scripts/demo-middleware.ts | 4 + scripts/langgraph-proxy.spec.ts | 40 +++++++ scripts/langgraph-proxy.ts | 51 ++++++-- tsconfig.base.json | 5 +- 14 files changed, 424 insertions(+), 44 deletions(-) diff --git a/docs/gtm/taxonomy.md b/docs/gtm/taxonomy.md index 7c23b9945..9ba8222cd 100644 --- a/docs/gtm/taxonomy.md +++ b/docs/gtm/taxonomy.md @@ -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** | diff --git a/examples/chat/angular/src/app/app.config.ts b/examples/chat/angular/src/app/app.config.ts index 3b510a089..aad34b5e9 100644 --- a/examples/chat/angular/src/app/app.config.ts +++ b/examples/chat/angular/src/app/app.config.ts @@ -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), ], }; diff --git a/examples/chat/angular/src/app/shell/demo-shell.component.ts b/examples/chat/angular/src/app/shell/demo-shell.component.ts index 805a666ad..134ca0ca8 100644 --- a/examples/chat/angular/src/app/shell/demo-shell.component.ts +++ b/examples/chat/angular/src/app/shell/demo-shell.component.ts @@ -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, @@ -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, @@ -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 so the @@ -340,23 +347,51 @@ export class DemoShell { // resulting tools:-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[0], opts?: Parameters[1], - ) => - orig( - { - ...(input ?? {}), - state: { - ...((input as { state?: Record })?.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 })?.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; })(); diff --git a/examples/chat/angular/src/environments/environment.development.ts b/examples/chat/angular/src/environments/environment.development.ts index dd7a424e3..2fd86f541 100644 --- a/examples/chat/angular/src/environments/environment.development.ts +++ b/examples/chat/angular/src/environments/environment.development.ts @@ -9,4 +9,8 @@ export const environment = { production: false, langGraphApiUrl: 'http://localhost:2024', assistantId: 'chat', + telemetry: { + enabled: false, + sampleRate: 1, + }, }; diff --git a/examples/chat/angular/src/environments/environment.ts b/examples/chat/angular/src/environments/environment.ts index 548d3cf34..63d77b557 100644 --- a/examples/chat/angular/src/environments/environment.ts +++ b/examples/chat/angular/src/environments/environment.ts @@ -11,4 +11,9 @@ export const environment = { production: true, langGraphApiUrl: '/api', assistantId: 'chat', + telemetry: { + enabled: true, + endpoint: '/api/ingest', + sampleRate: 1, + }, }; diff --git a/libs/telemetry/README.md b/libs/telemetry/README.md index ecec5a2cd..6af942247 100644 --- a/libs/telemetry/README.md +++ b/libs/telemetry/README.md @@ -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). @@ -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'; @@ -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:", + "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 @@ -97,7 +107,7 @@ If you don't call `provideNgafTelemetry({ enabled: true })`, every telemetry hel - Per-process UUID (`anon_`), 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:`). It is not written to localStorage or cookies. ## Self-hosting diff --git a/libs/telemetry/src/browser/public-api.ts b/libs/telemetry/src/browser/public-api.ts index d68ddf893..944180a42 100644 --- a/libs/telemetry/src/browser/public-api.ts +++ b/libs/telemetry/src/browser/public-api.ts @@ -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'; diff --git a/libs/telemetry/src/browser/service.spec.ts b/libs/telemetry/src/browser/service.spec.ts index 6209552af..d9804d054 100644 --- a/libs/telemetry/src/browser/service.spec.ts +++ b/libs/telemetry/src/browser/service.spec.ts @@ -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: [ @@ -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[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: [ diff --git a/libs/telemetry/src/browser/service.ts b/libs/telemetry/src/browser/service.ts index d78492f25..f524b8924 100644 --- a/libs/telemetry/src/browser/service.ts +++ b/libs/telemetry/src/browser/service.ts @@ -1,29 +1,126 @@ import { Injectable, inject } from '@angular/core'; -import { NGAF_TELEMETRY_CONFIG, type NgafTelemetryConfig } from './tokens'; +import { + NGAF_TELEMETRY_CONFIG, + type NgafTelemetryConfig, + type NgafTelemetryEvent, +} from './tokens'; // Inlined from shared/events.ts: ng-packagr enforces rootDir at the entry-file // level (src/browser/), so the browser entry cannot import from ../shared/. // Keep this type in sync with shared/events.ts. -export type NgafBrowserEvent = - | 'ngaf:browser_provided' - | 'ngaf:browser_chat_init'; +export type NgafBrowserEvent = NgafTelemetryEvent; + +export interface NgafBrowserRuntimeTelemetry { + transport: string; + surface?: string; + provider?: string; + model?: string; +} + +export interface NgafBrowserStreamTelemetry extends NgafBrowserRuntimeTelemetry { + durationMs?: number; +} + +export interface NgafBrowserStreamErrorTelemetry extends NgafBrowserStreamTelemetry { + error?: unknown; +} + +function normalizeSampleRate(sampleRate: number | undefined): number { + if (sampleRate === undefined) return 1; + if (!Number.isFinite(sampleRate)) return 1; + if (sampleRate <= 0) return 0; + if (sampleRate >= 1) return 1; + return sampleRate; +} + +function errorClass(error: unknown): string { + if (error instanceof Error && error.name) return error.name; + if (error && typeof error === 'object' && 'name' in error && typeof error.name === 'string') { + return error.name; + } + return 'UnknownError'; +} @Injectable({ providedIn: 'root' }) export class NgafTelemetryService { private config: NgafTelemetryConfig | null = inject(NGAF_TELEMETRY_CONFIG, { optional: true }); private postHogPromise: Promise | null = null; + private distinctId: string | null = null; + + async capture(event: NgafTelemetryEvent, properties?: Record): Promise { + if (!this.config?.enabled) return; + const sampleRate = normalizeSampleRate(this.config.sampleRate); + if (sampleRate === 0) return; + if (sampleRate < 1 && Math.random() >= sampleRate) return; + + const enrichedProperties = { + ...(properties ?? {}), + sample_weight: properties?.['sample_weight'] ?? 1 / sampleRate, + }; - async capture(event: NgafBrowserEvent, properties?: Record): Promise { - if (!this.config?.enabled || !this.config.posthogKey) return; try { + if (this.config.sink) { + await this.config.sink({ event, properties: enrichedProperties }); + return; + } + if (this.config.endpoint) { + await this.captureEndpoint(event, enrichedProperties); + return; + } + if (!this.config.posthogKey) return; const ph = await this.loadPostHog(); if (!ph) return; - ph.capture(event, properties); + ph.capture(event, enrichedProperties); } catch { // silent fail } } + captureRuntimeInstanceCreated(input: NgafBrowserRuntimeTelemetry): Promise { + return this.capture('ngaf:runtime_instance_created', { ...input }); + } + + captureStreamStarted(input: NgafBrowserStreamTelemetry): Promise { + return this.capture('ngaf:stream_started', { ...input }); + } + + captureStreamEnded(input: NgafBrowserStreamTelemetry): Promise { + return this.capture('ngaf:stream_ended', { ...input }); + } + + captureStreamErrored(input: NgafBrowserStreamErrorTelemetry): Promise { + const { error, ...rest } = input; + return this.capture('ngaf:stream_errored', { + ...rest, + errorClass: errorClass(error), + }); + } + + private async captureEndpoint(event: NgafTelemetryEvent, properties: Record): Promise { + if (typeof fetch !== 'function' || !this.config?.endpoint) return; + await fetch(this.config.endpoint, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + keepalive: true, + body: JSON.stringify({ + event, + distinctId: this.getDistinctId(), + properties, + }), + }); + } + + private getDistinctId(): string { + if (!this.distinctId) { + const cryptoApi = globalThis.crypto as Crypto | undefined; + const value = typeof cryptoApi?.randomUUID === 'function' + ? cryptoApi.randomUUID() + : Math.random().toString(36).slice(2, 12); + this.distinctId = `browser:${value}`; + } + return this.distinctId; + } + private loadPostHog(): Promise { if (!this.postHogPromise) { this.postHogPromise = import('posthog-js').then((mod) => { diff --git a/libs/telemetry/src/browser/tokens.ts b/libs/telemetry/src/browser/tokens.ts index c15b220e4..4982f8b33 100644 --- a/libs/telemetry/src/browser/tokens.ts +++ b/libs/telemetry/src/browser/tokens.ts @@ -1,8 +1,35 @@ import { InjectionToken } from '@angular/core'; +export type NgafTelemetryEvent = + | 'ngaf:browser_provided' + | 'ngaf:browser_chat_init' + | 'ngaf:runtime_instance_created' + | 'ngaf:stream_started' + | 'ngaf:stream_ended' + | 'ngaf:stream_errored'; + +export interface NgafTelemetryEventPayload { + event: NgafTelemetryEvent; + properties?: Record; +} + +export type NgafTelemetrySink = (payload: NgafTelemetryEventPayload) => void | Promise; + export interface NgafTelemetryConfig { enabled: boolean; + /** + * Preferred app-owned delivery hook. Use this when the consuming app wants + * to forward events through its own analytics boundary. + */ + sink?: NgafTelemetrySink; + /** + * Preferred app-owned ingest URL. The browser service POSTs neutral event + * payloads here; the endpoint decides where they ultimately go. + */ + endpoint?: string; + /** @deprecated Prefer sink or endpoint so public app code is vendor-neutral. */ posthogKey?: string; + /** @deprecated Prefer sink or endpoint so public app code is vendor-neutral. */ posthogHost?: string; sampleRate?: number; } diff --git a/scripts/demo-middleware.ts b/scripts/demo-middleware.ts index 3cb59af4c..db794f431 100644 --- a/scripts/demo-middleware.ts +++ b/scripts/demo-middleware.ts @@ -16,6 +16,7 @@ import { checkRateLimit } from './rate-limit'; const DEFAULT_ALLOWED_ORIGINS = ['https://demo.cacheplane.ai']; const DEFAULT_MAX_BODY_BYTES = 8192; +const DEFAULT_TELEMETRY_INGEST_URL = 'https://cacheplane.ai/api/ingest'; const allowedOrigins = (() => { const raw = process.env['ALLOWED_ORIGINS']; @@ -30,8 +31,11 @@ const maxBodyBytes = (() => { return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_MAX_BODY_BYTES; })(); +const telemetryIngestUrl = process.env['TELEMETRY_INGEST_URL'] ?? DEFAULT_TELEMETRY_INGEST_URL; + module.exports = createProxyHandler({ checkRateLimit, allowedOrigins, maxBodyBytes, + telemetryIngestUrl, }); diff --git a/scripts/langgraph-proxy.spec.ts b/scripts/langgraph-proxy.spec.ts index 874238f64..8b56bd230 100644 --- a/scripts/langgraph-proxy.spec.ts +++ b/scripts/langgraph-proxy.spec.ts @@ -44,6 +44,46 @@ describe('createProxyHandler', () => { expect(res.json).toHaveBeenCalledWith({ error: 'LANGSMITH_API_KEY not configured' }); }); + it('forwards telemetry ingest without requiring LANGSMITH_API_KEY', async () => { + delete process.env['LANGSMITH_API_KEY']; + const fetchMock = vi.spyOn(global, 'fetch').mockResolvedValue( + new Response('{"ok":true}', { status: 202, headers: { 'content-type': 'application/json' } }), + ); + const handler = createProxyHandler({ + backendUrl: DEFAULT_BACKEND, + telemetryIngestUrl: 'https://cacheplane.ai/api/ingest', + }); + const res = makeRes(); + const body = { + event: 'ngaf:stream_started', + distinctId: 'browser:test', + properties: { surface: 'canonical_demo' }, + }; + + await handler({ + method: 'POST', + headers: { host: 'demo.cacheplane.ai', 'content-type': 'application/json' }, + body, + url: '/api/ingest', + query: {}, + } as never, res as never); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]!; + expect(url).toBe('https://cacheplane.ai/api/ingest'); + expect(init).toEqual(expect.objectContaining({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + })); + expect(JSON.parse(String((init as RequestInit).body))).toEqual({ + ...body, + key: 'phc_public_cacheplane_telemetry', + }); + expect(res._status).toBe(202); + expect(res.setHeader).toHaveBeenCalledWith('content-type', 'application/json'); + expect(res.send).toHaveBeenCalledWith('{"ok":true}'); + }); + it('responds 204 to OPTIONS preflight with CORS headers', async () => { const handler = createProxyHandler({ backendUrl: DEFAULT_BACKEND }); const res = makeRes(); diff --git a/scripts/langgraph-proxy.ts b/scripts/langgraph-proxy.ts index 81da01982..0e87cd5dd 100644 --- a/scripts/langgraph-proxy.ts +++ b/scripts/langgraph-proxy.ts @@ -52,6 +52,11 @@ export interface ProxyConfig { * behavior). Checked against Content-Length first, falls back to * JSON.stringify(req.body).length. */ readonly maxBodyBytes?: number; + /** Optional first-party ingest endpoint for browser telemetry. When set, + * POST /api/ingest is forwarded here instead of the LangGraph backend. + * This keeps the browser-facing telemetry API vendor-neutral while the + * deployment owns its server-side analytics boundary. */ + readonly telemetryIngestUrl?: string; } const DEFAULT_BACKEND_URL = 'https://cockpit-dev-219a15942c545a00a03a9a41905d7fc2.us.langgraph.app'; @@ -69,6 +74,12 @@ function extractIp(headers: Record): string { return `unknown:${Math.random().toString(36).slice(2, 10)}`; } +function objectBody(body: unknown): Record { + return body && typeof body === 'object' && !Array.isArray(body) + ? body as Record + : {}; +} + export function createProxyHandler(config: ProxyConfig = {}): (req: VercelRequest, res: VercelResponse) => Promise { const fallbackBackend = config.backendUrl ?? DEFAULT_BACKEND_URL; const resolveBackend = config.resolveBackend ?? ((_referer) => fallbackBackend); @@ -100,6 +111,38 @@ export function createProxyHandler(config: ProxyConfig = {}): (req: VercelReques return; } + // Build target URL metadata once — strip /api prefix from req.url, drop the + // Vercel catch-all query param, keep real query params. + const parsedUrl = new URL(req.url ?? '', `https://${req.headers.host ?? 'localhost'}`); + const apiPath = parsedUrl.pathname.replace(/^\/api/, '') || '/'; + parsedUrl.searchParams.delete('[...path]'); + parsedUrl.searchParams.delete('[[...path]]'); + const cleanSearch = parsedUrl.searchParams.toString() ? `?${parsedUrl.searchParams.toString()}` : ''; + + if (config.telemetryIngestUrl && apiPath === '/ingest') { + if (req.method !== 'POST') { + res.status(405).json({ error: 'method_not_allowed' }); + return; + } + try { + const response = await fetch(config.telemetryIngestUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + ...objectBody(req.body), + key: 'phc_public_cacheplane_telemetry', + }), + }); + const contentType = response.headers.get('content-type') ?? 'application/json'; + res.setHeader('content-type', contentType); + res.status(response.status); + res.send(await response.text()); + } catch (err) { + res.status(502).json({ error: 'Telemetry ingest error', message: (err as Error).message }); + } + return; + } + const apiKey = process.env['LANGSMITH_API_KEY']; if (!apiKey) { res.status(500).json({ error: 'LANGSMITH_API_KEY not configured' }); @@ -107,14 +150,6 @@ export function createProxyHandler(config: ProxyConfig = {}): (req: VercelReques } const backendUrl = resolveBackend(req.headers.referer); - - // Build target URL — strip /api prefix from req.url, drop the - // Vercel catch-all query param, keep real query params. - const parsedUrl = new URL(req.url ?? '', `https://${req.headers.host ?? 'localhost'}`); - const apiPath = parsedUrl.pathname.replace(/^\/api/, '') || '/'; - parsedUrl.searchParams.delete('[...path]'); - parsedUrl.searchParams.delete('[[...path]]'); - const cleanSearch = parsedUrl.searchParams.toString() ? `?${parsedUrl.searchParams.toString()}` : ''; const targetUrl = `${backendUrl}${apiPath}${cleanSearch}`; // Debug endpoint — confirms the proxy is wired without hitting the upstream. diff --git a/tsconfig.base.json b/tsconfig.base.json index 79183d553..470b9d414 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -33,7 +33,10 @@ "@ngaf/langgraph": ["libs/langgraph/src/public-api.ts"], "@ngaf/licensing": ["libs/licensing/src/index.ts"], "@ngaf/licensing/testing": ["libs/licensing/src/testing.ts"], - "@ngaf/render": ["libs/render/src/public-api.ts"] + "@ngaf/render": ["libs/render/src/public-api.ts"], + "@ngaf/telemetry": ["libs/telemetry/src/index.ts"], + "@ngaf/telemetry/browser": ["libs/telemetry/src/browser/public-api.ts"], + "@ngaf/telemetry/node": ["libs/telemetry/src/node/index.ts"] }, "skipLibCheck": true, "strict": true,