diff --git a/.env.example b/.env.example index 877e8c0a..b39f2a21 100644 --- a/.env.example +++ b/.env.example @@ -5,3 +5,11 @@ POSTHOG_PERSONAL_API_KEY= POSTHOG_HOST=https://us.i.posthog.com POSTHOG_PROJECT_ID= + +# @ngaf/telemetry (libs/telemetry) +# Default ingest URL points to the future Spec 1D reverse proxy. Self-hosters +# can redirect to their own ingest. See libs/telemetry/README.md. +# NGAF_TELEMETRY_INGEST_URL=https://cacheplane.dev/api/ingest +# NGAF_TELEMETRY_SAMPLE_RATE=1.0 +# DO_NOT_TRACK=1 # cross-vendor opt-out +# NGAF_TELEMETRY_DISABLED=1 # package-specific opt-out diff --git a/docs/superpowers/plans/gtm/2026-05-15-analytics-foundation-1b-ngaf-telemetry.md b/docs/superpowers/plans/gtm/2026-05-15-analytics-foundation-1b-ngaf-telemetry.md new file mode 100644 index 00000000..4a08973f --- /dev/null +++ b/docs/superpowers/plans/gtm/2026-05-15-analytics-foundation-1b-ngaf-telemetry.md @@ -0,0 +1,1678 @@ +# Analytics Foundation 1B — `@ngaf/telemetry` Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship `@ngaf/telemetry` v(fixed-group) — single Nx library producing three published subpath exports (`.`, `./node`, `./browser`) honoring the trust contract at `libs/telemetry/README.md`. ~44 tests, hybrid `@nx/js:tsc` + `@nx/angular:package` build, permanent browser silence test, postinstall script. + +**Architecture:** Three layered modules under `libs/telemetry/src/{shared,node,browser}/`. Shared has zero runtime deps. Node wraps `posthog-node`. Browser wraps `posthog-js` behind a lazy dynamic-import gate so opt-out (default-off) is enforceable at the bundler level. + +**Tech Stack:** TypeScript via `tsx`/`tsc`; `posthog-node` + `posthog-js` (already in deps from website analytics); Angular `EnvironmentProviders` API (peer dep); Jest (existing repo convention for libs); Nx 21.x; `@nx/js:tsc` + `@nx/angular:package`. + +--- + +## Context for the implementer + +- Spec: `docs/superpowers/specs/gtm/2026-05-15-analytics-foundation-1b-ngaf-telemetry-design.md` — read §4-§9 before starting any task. Anchors everything. +- Trust contract (already public): `libs/telemetry/README.md` — DO NOT change without the user's explicit OK. +- Existing `@ngaf/*` lib conventions: see `libs/chat/` (Angular library), `libs/licensing/` and `libs/partial-json/` (non-Angular, `@nx/js:tsc`). Match patterns. +- `posthog-js` and `posthog-node` are already at the repo root (added by the May-2 instrumentation plan / Spec 1A). Reuse those versions. +- **Risk surfaced by the spec (§10 Risk #6):** the hybrid `@nx/js:tsc` + `@nx/angular:package` build may need adjustment. Task 2 confirms the wiring works before later tasks build on it. If it doesn't, fall back to `@nx/angular:package` with secondary entry points (ng-packagr DOES compile non-Angular TS). +- TDD: every code task follows write-test → run-and-fail → implement → run-and-pass → commit. Subagents must not skip the failing-test step. + +## File structure (locked) + +``` +libs/telemetry/ +├── README.md # exists; minor updates in Task 11 +├── package.json # CREATE (Task 2) +├── project.json # CREATE (Task 2) +├── tsconfig.json # CREATE (Task 2) +├── tsconfig.lib.json # CREATE (Task 2) — node + shared +├── tsconfig.lib.browser.json # CREATE (Task 2) — Angular +├── ng-package.json # CREATE (Task 2) +├── eslint.config.mjs # CREATE (Task 2) +├── jest.config.ts # CREATE (Task 2) +├── src/ +│ ├── index.ts # CREATE (Task 9) +│ ├── shared/ +│ │ ├── env.ts + env.spec.ts # Task 3 +│ │ ├── hash.ts + hash.spec.ts # Task 3 +│ │ ├── anon-id.ts + anon-id.spec.ts # Task 3 +│ │ ├── sample.ts + sample.spec.ts # Task 3 +│ │ └── events.ts # Task 3 +│ ├── node/ +│ │ ├── index.ts # Task 9 +│ │ ├── disable.ts # Task 4 +│ │ ├── client.ts + client.spec.ts # Task 4 +│ │ ├── postinstall.ts + postinstall.spec.ts # Task 5 +│ │ └── adapter.ts + adapter.spec.ts # Task 6 +│ └── browser/ +│ ├── public-api.ts # Task 9 +│ ├── tokens.ts # Task 7 +│ ├── service.ts + service.spec.ts # Task 7 +│ ├── provide.ts + provide.spec.ts # Task 8 +│ └── browser-silence.spec.ts # Task 9 (permanent contract test) +nx.json # MODIFY (Task 10) — add telemetry to publishable group +.env.example # MODIFY (Task 10) — add NGAF_TELEMETRY_INGEST_URL +gtm.md # MODIFY (Task 1) — §7 row 1b links to this spec +``` + +--- + +## Task 1: Decomposition update (gtm.md §7 row 1b → spec link) + +**Files:** Modify `gtm.md` §7 row for `analytics-foundation-1b` — replace `(pending)` with a link to this spec. + +### Step 1.1 + +- [ ] Find the row in `gtm.md §7`: + +``` +| 0 | analytics-foundation-1b | `cowork/gtm/SKILL.md` | (pending) | `package-telemetry` | +``` + +- [ ] Replace with: + +``` +| 0 | analytics-foundation-1b | `cowork/gtm/SKILL.md` | [spec](docs/superpowers/specs/gtm/2026-05-15-analytics-foundation-1b-ngaf-telemetry-design.md) | `package-telemetry` | +``` + +### Step 1.2 — Commit + +```bash +git add gtm.md +git commit -m "$(cat <<'EOF' +chore(gtm): link analytics-foundation-1b spec from gtm.md §7 + +Co-Authored-By: Claude Opus 4.7 +EOF +)" +``` + +--- + +## Task 2: Project scaffold + verify build wiring is viable + +**Goal:** Confirm the hybrid `@nx/js:tsc` (node + shared) + `@nx/angular:package` (browser) build works in one project before any code lands. + +**Files:** Create `libs/telemetry/{package,project,tsconfig,tsconfig.lib,tsconfig.lib.browser,ng-package,eslint.config,jest.config}.json/.mjs/.ts`. + +### Step 2.1 — Read existing patterns + +- [ ] Read `libs/chat/{project,tsconfig.lib,ng-package}.json` and `libs/partial-json/{project,tsconfig.lib}.json` so the scaffold matches repo conventions. + +### Step 2.2 — Write `libs/telemetry/package.json` + +```json +{ + "name": "@ngaf/telemetry", + "version": "0.0.0", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/cacheplane/angular-agent-framework.git", + "directory": "libs/telemetry" + }, + "homepage": "https://github.com/cacheplane/angular-agent-framework#readme", + "bugs": { "url": "https://github.com/cacheplane/angular-agent-framework/issues" }, + "sideEffects": false, + "type": "module", + "peerDependencies": { + "@angular/core": "^20.0.0 || ^21.0.0" + }, + "peerDependenciesMeta": { + "@angular/core": { "optional": true } + }, + "dependencies": { + "posthog-js": "^1.372.0", + "posthog-node": "^5.20.0" + }, + "scripts": { + "postinstall": "node ./node/postinstall.mjs || true" + } +} +``` + +### Step 2.3 — Write `libs/telemetry/project.json` + +```jsonc +{ + "name": "telemetry", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/telemetry/src", + "projectType": "library", + "tags": ["scope:shared", "type:lib"], + "targets": { + "build:node": { + "executor": "@nx/js:tsc", + "outputs": ["{workspaceRoot}/dist/libs/telemetry"], + "options": { + "outputPath": "dist/libs/telemetry", + "main": "libs/telemetry/src/index.ts", + "additionalEntryPoints": [ + "libs/telemetry/src/node/index.ts", + "libs/telemetry/src/node/postinstall.ts" + ], + "tsConfig": "libs/telemetry/tsconfig.lib.json", + "assets": ["libs/telemetry/README.md", "libs/telemetry/package.json"] + } + }, + "build:browser": { + "executor": "@nx/angular:package", + "outputs": ["{workspaceRoot}/dist/libs/telemetry/browser"], + "options": { + "project": "libs/telemetry/ng-package.json", + "tsConfig": "libs/telemetry/tsconfig.lib.browser.json" + }, + "dependsOn": ["build:node"] + }, + "build": { + "dependsOn": ["build:node", "build:browser"], + "executor": "nx:run-commands", + "options": { "command": "true" } + }, + "test": { + "executor": "@nx/jest:jest", + "options": { + "jestConfig": "libs/telemetry/jest.config.ts", + "passWithNoTests": false + } + }, + "lint": { "executor": "@nx/eslint:lint" } + } +} +``` + +### Step 2.4 — Write tsconfigs + +`libs/telemetry/tsconfig.json`: +```json +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "esnext", + "moduleResolution": "bundler", + "target": "es2022", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "lib": ["es2022", "dom"] + }, + "include": [] +} +``` + +`libs/telemetry/tsconfig.lib.json`: +```json +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "declaration": true, + "emitDeclarationOnly": false + }, + "include": ["src/index.ts", "src/shared/**/*.ts", "src/node/**/*.ts"], + "exclude": ["src/**/*.spec.ts", "src/browser/**"] +} +``` + +`libs/telemetry/tsconfig.lib.browser.json`: +```json +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc/browser", + "declaration": true, + "emitDeclarationOnly": false, + "types": [] + }, + "include": ["src/browser/**/*.ts", "src/shared/**/*.ts"], + "exclude": ["src/**/*.spec.ts"] +} +``` + +`libs/telemetry/ng-package.json`: +```json +{ + "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", + "dest": "../../dist/libs/telemetry/browser", + "lib": { + "entryFile": "src/browser/public-api.ts" + } +} +``` + +`libs/telemetry/jest.config.ts`: +```ts +export default { + displayName: 'telemetry', + preset: '../../jest.preset.js', + testEnvironment: 'node', + transform: { + '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }], + }, + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: '../../coverage/libs/telemetry', +}; +``` + +`libs/telemetry/tsconfig.spec.json`: +```json +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"] + }, + "include": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] +} +``` + +`libs/telemetry/eslint.config.mjs`: +```js +import baseConfig from '../../eslint.config.mjs'; +export default [...baseConfig, { files: ['**/*.ts'] }]; +``` + +### Step 2.5 — Verify Nx recognizes the project + +```bash +npx nx show projects | grep -Fx telemetry +``` +Expected: prints `telemetry`. + +```bash +npx nx show project telemetry --json | python3 -c "import json,sys; p=json.load(sys.stdin); print('targets:', sorted(p['targets'].keys()))" +``` +Expected: `targets: ['build', 'build:browser', 'build:node', 'lint', 'test']`. + +### Step 2.6 — Smoke-test the build wiring with a stub + +Before writing real code, prove the hybrid build works. + +```bash +mkdir -p libs/telemetry/src/shared libs/telemetry/src/node libs/telemetry/src/browser +echo "export const VERSION = '0.0.0';" > libs/telemetry/src/index.ts +echo "export const VERSION = '0.0.0';" > libs/telemetry/src/node/index.ts +echo "" > libs/telemetry/src/node/postinstall.ts +cat > libs/telemetry/src/browser/public-api.ts <<'EOF' +export const BROWSER_VERSION = '0.0.0'; +EOF +``` + +Then: +```bash +npx nx run telemetry:build:node 2>&1 | tail -10 +npx nx run telemetry:build:browser 2>&1 | tail -10 +``` + +Both must succeed. If `build:browser` fails because ng-packagr complains about a missing Angular Component/Directive/Module, the spec's Risk #6 has materialized — STOP and report. Fallback: convert the project to use `@nx/angular:package` with secondary entry points for the node entry too. + +### Step 2.7 — Commit + +```bash +git add libs/telemetry +git commit -m "$(cat <<'EOF' +feat(telemetry): scaffold @ngaf/telemetry Nx project + hybrid build + +Three subpath exports planned: ., ./node, ./browser. Build uses +@nx/js:tsc for shared+node entries and @nx/angular:package for the +browser (Angular DI) entry. Stub source files prove both builds +resolve before real code lands. + +Co-Authored-By: Claude Opus 4.7 +EOF +)" +``` + +--- + +## Task 3: Shared module — env, hash, anon-id, sample, events (TDD) + +**Files:** `libs/telemetry/src/shared/{env,hash,anon-id,sample,events}.ts` + their `.spec.ts`. + +This is the foundation. Other modules depend on it. Strict TDD throughout. + +### Step 3.1 — Write `shared/env.spec.ts` (failing test) + +```typescript +import { isTelemetryDisabled, getDisableReason } from './env'; + +describe('isTelemetryDisabled', () => { + beforeEach(() => { + delete process.env.DO_NOT_TRACK; + delete process.env.NGAF_TELEMETRY_DISABLED; + delete process.env.CI; + delete process.env.GITHUB_ACTIONS; + delete process.env.CONTINUOUS_INTEGRATION; + delete process.env.BUILDKITE; + delete process.env.CIRCLECI; + }); + + test('returns false with no env signals', () => { + expect(isTelemetryDisabled()).toBe(false); + }); + + test('DO_NOT_TRACK=1 disables', () => { + process.env.DO_NOT_TRACK = '1'; + expect(isTelemetryDisabled()).toBe(true); + expect(getDisableReason()).toBe('DO_NOT_TRACK'); + }); + + test('DO_NOT_TRACK=true disables', () => { + process.env.DO_NOT_TRACK = 'true'; + expect(isTelemetryDisabled()).toBe(true); + }); + + test('NGAF_TELEMETRY_DISABLED=1 disables', () => { + process.env.NGAF_TELEMETRY_DISABLED = '1'; + expect(isTelemetryDisabled()).toBe(true); + expect(getDisableReason()).toBe('NGAF_TELEMETRY_DISABLED'); + }); + + test('CI=true disables (CI auto-detect)', () => { + process.env.CI = 'true'; + expect(isTelemetryDisabled()).toBe(true); + expect(getDisableReason()).toBe('CI'); + }); + + test('GITHUB_ACTIONS=true disables', () => { + process.env.GITHUB_ACTIONS = 'true'; + expect(isTelemetryDisabled()).toBe(true); + }); + + test('DO_NOT_TRACK=0 does NOT disable', () => { + process.env.DO_NOT_TRACK = '0'; + expect(isTelemetryDisabled()).toBe(false); + }); + + test('precedence: DO_NOT_TRACK reported first when multiple match', () => { + process.env.DO_NOT_TRACK = '1'; + process.env.NGAF_TELEMETRY_DISABLED = '1'; + process.env.CI = 'true'; + expect(getDisableReason()).toBe('DO_NOT_TRACK'); + }); +}); +``` + +### Step 3.2 — Run, see fail. + +```bash +npx nx run telemetry:test --testPathPattern=env.spec +``` +Expected: FAIL (`./env` not found). + +### Step 3.3 — Implement `shared/env.ts` + +```typescript +const TRUE_VALUES = new Set(['1', 'true', 'TRUE', 'yes']); + +function truthy(v: string | undefined): boolean { + return v !== undefined && TRUE_VALUES.has(v); +} + +type DisableReason = 'DO_NOT_TRACK' | 'NGAF_TELEMETRY_DISABLED' | 'CI' | null; + +export function getDisableReason(env: NodeJS.ProcessEnv = process.env): DisableReason { + if (truthy(env.DO_NOT_TRACK)) return 'DO_NOT_TRACK'; + if (truthy(env.NGAF_TELEMETRY_DISABLED)) return 'NGAF_TELEMETRY_DISABLED'; + if ( + truthy(env.CI) || + truthy(env.GITHUB_ACTIONS) || + truthy(env.CONTINUOUS_INTEGRATION) || + truthy(env.BUILDKITE) || + truthy(env.CIRCLECI) + ) { + return 'CI'; + } + return null; +} + +export function isTelemetryDisabled(env: NodeJS.ProcessEnv = process.env): boolean { + return getDisableReason(env) !== null; +} +``` + +### Step 3.4 — Verify passes. + +```bash +npx nx run telemetry:test --testPathPattern=env.spec +``` +Expected: 8 tests pass. + +### Step 3.5 — Write `shared/hash.spec.ts` + implement `hash.ts` + +`hash.spec.ts`: +```typescript +import { sha256 } from './hash'; + +describe('sha256', () => { + test('returns a 64-char hex digest', async () => { + const out = await sha256('hello'); + expect(out).toMatch(/^[a-f0-9]{64}$/); + }); + + test('is deterministic', async () => { + const a = await sha256('same input'); + const b = await sha256('same input'); + expect(a).toBe(b); + }); + + test('differs for different inputs', async () => { + const a = await sha256('foo'); + const b = await sha256('bar'); + expect(a).not.toBe(b); + }); +}); +``` + +`hash.ts` (Node environment for tests; uses Web Crypto which is available in Node 20+): +```typescript +export async function sha256(input: string): Promise { + const data = new TextEncoder().encode(input); + const buf = await crypto.subtle.digest('SHA-256', data); + return Array.from(new Uint8Array(buf)) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +} +``` + +### Step 3.6 — Write `shared/anon-id.spec.ts` + implement `anon-id.ts` + +`anon-id.spec.ts`: +```typescript +import { getAnonId, _resetAnonIdForTesting } from './anon-id'; + +describe('getAnonId', () => { + beforeEach(() => _resetAnonIdForTesting()); + + test('returns a stable id within a process', () => { + const a = getAnonId(); + const b = getAnonId(); + expect(a).toBe(b); + }); + + test('matches anon_ shape', () => { + expect(getAnonId()).toMatch(/^anon_[0-9a-f-]{36}$/); + }); + + test('different processes get different ids (simulated via reset)', () => { + const a = getAnonId(); + _resetAnonIdForTesting(); + const b = getAnonId(); + expect(a).not.toBe(b); + }); +}); +``` + +`anon-id.ts`: +```typescript +import { randomUUID } from 'node:crypto'; + +let cached: string | null = null; + +export function getAnonId(): string { + if (!cached) cached = `anon_${randomUUID()}`; + return cached; +} + +// @internal — for tests only +export function _resetAnonIdForTesting(): void { + cached = null; +} +``` + +Note: browser entry will use a tiny wrapper that calls `globalThis.crypto.randomUUID()` since `node:crypto` isn't available there. Spec'd in shared but only imported from `node/`. Browser has its own anon-id helper in `browser/service.ts` (covered in Task 7). + +### Step 3.7 — Write `shared/sample.spec.ts` + implement `sample.ts` + +`sample.spec.ts`: +```typescript +import { shouldSample } from './sample'; + +describe('shouldSample', () => { + test('rate=1.0 always samples', () => { + expect(shouldSample(1.0, 'anon_x')).toBe(true); + expect(shouldSample(1.0, 'anon_y')).toBe(true); + }); + + test('rate=0 never samples', () => { + expect(shouldSample(0, 'anon_x')).toBe(false); + }); + + test('deterministic for a given (rate, id) pair', () => { + const a = shouldSample(0.5, 'anon_x'); + const b = shouldSample(0.5, 'anon_x'); + expect(a).toBe(b); + }); + + test('rate clamps to [0, 1]', () => { + expect(shouldSample(1.5, 'anon_x')).toBe(true); + expect(shouldSample(-1, 'anon_x')).toBe(false); + }); + + test('different ids can produce different results at rate=0.5', () => { + const ids = Array.from({ length: 100 }, (_, i) => `anon_${i}`); + const sampled = ids.filter((id) => shouldSample(0.5, id)).length; + expect(sampled).toBeGreaterThan(20); + expect(sampled).toBeLessThan(80); + }); +}); +``` + +`sample.ts`: +```typescript +// Cheap deterministic 32-bit hash (Fnv-1a) — no crypto needed for sampling. +function hashString(s: string): number { + let h = 2166136261; + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i); + h = (h * 16777619) >>> 0; + } + return h; +} + +export function shouldSample(rate: number, anonId: string): boolean { + if (rate <= 0) return false; + if (rate >= 1) return true; + return hashString(anonId) / 0xffffffff < rate; +} +``` + +### Step 3.8 — Write `shared/events.ts` (no spec needed — type-only) + +```typescript +export type NgafNodeEvent = + | 'ngaf:postinstall' + | 'ngaf:runtime_instance_created' + | 'ngaf:stream_started' + | 'ngaf:stream_ended' + | 'ngaf:stream_errored'; + +export type NgafBrowserEvent = + | 'ngaf:browser_provided' + | 'ngaf:browser_chat_init'; + +export type NgafEvent = NgafNodeEvent | NgafBrowserEvent; +``` + +### Step 3.9 — Verify all shared tests pass + commit + +```bash +npx nx run telemetry:test +``` +Expected: 8 (env) + 3 (hash) + 3 (anon-id) + 5 (sample) = 19 tests pass. + +```bash +git add libs/telemetry/src/shared +git commit -m "$(cat <<'EOF' +feat(telemetry): shared module — env detection, hash, anon-id, sample, events + +19 tests covering all five opt-out paths, SHA-256 determinism, +per-process anon-id, sample-rate clamping, and event-name types. + +Co-Authored-By: Claude Opus 4.7 +EOF +)" +``` + +--- + +## Task 4: Node client + disable() with TDD + +**Files:** `libs/telemetry/src/node/{client,disable,index}.ts` + `client.spec.ts`. + +### Step 4.1 — `client.spec.ts` + +```typescript +import { jest } from '@jest/globals'; + +jest.mock('posthog-node', () => ({ + PostHog: jest.fn().mockImplementation(() => ({ + capture: jest.fn(), + shutdown: jest.fn().mockResolvedValue(undefined), + })), +})); + +// Import AFTER mock so the mock takes effect. +import { PostHog } from 'posthog-node'; +import { capturePostinstall, _resetClientForTesting } from './client'; +import { disableTelemetry, _resetDisableForTesting } from './disable'; + +describe('node client', () => { + beforeEach(() => { + (PostHog as jest.Mock).mockClear(); + _resetClientForTesting(); + _resetDisableForTesting(); + delete process.env.DO_NOT_TRACK; + delete process.env.NGAF_TELEMETRY_DISABLED; + delete process.env.CI; + process.env.NGAF_TELEMETRY_INGEST_URL = 'https://test.example/api/ingest'; + }); + + test('capturePostinstall sends an event with pkg + version', async () => { + const instance = { capture: jest.fn(), shutdown: jest.fn().mockResolvedValue(undefined) }; + (PostHog as jest.Mock).mockImplementation(() => instance); + await capturePostinstall({ pkg: '@ngaf/telemetry', version: '0.0.31' }); + expect(instance.capture).toHaveBeenCalledWith(expect.objectContaining({ + event: 'ngaf:postinstall', + properties: expect.objectContaining({ pkg: '@ngaf/telemetry', version: '0.0.31' }), + })); + }); + + test('capturePostinstall no-ops when DO_NOT_TRACK is set', async () => { + process.env.DO_NOT_TRACK = '1'; + await capturePostinstall({ pkg: 'x', version: '1' }); + expect(PostHog).not.toHaveBeenCalled(); + }); + + test('capturePostinstall no-ops after disableTelemetry()', async () => { + disableTelemetry(); + await capturePostinstall({ pkg: 'x', version: '1' }); + expect(PostHog).not.toHaveBeenCalled(); + }); + + test('capturePostinstall uses NGAF_TELEMETRY_INGEST_URL when set', async () => { + process.env.NGAF_TELEMETRY_INGEST_URL = 'https://custom.example/api/ingest'; + await capturePostinstall({ pkg: 'x', version: '1' }); + expect(PostHog).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ host: 'https://custom.example/api/ingest' }), + ); + }); + + test('capturePostinstall sends sample_weight property', async () => { + const instance = { capture: jest.fn(), shutdown: jest.fn().mockResolvedValue(undefined) }; + (PostHog as jest.Mock).mockImplementation(() => instance); + await capturePostinstall({ pkg: 'x', version: '1' }); + expect(instance.capture).toHaveBeenCalledWith(expect.objectContaining({ + properties: expect.objectContaining({ sample_weight: expect.any(Number) }), + })); + }); + + test('capturePostinstall awaits shutdown before resolving', async () => { + let shutdownCalled = false; + const instance = { + capture: jest.fn(), + shutdown: jest.fn(async () => { shutdownCalled = true; }), + }; + (PostHog as jest.Mock).mockImplementation(() => instance); + await capturePostinstall({ pkg: 'x', version: '1' }); + expect(shutdownCalled).toBe(true); + }); +}); +``` + +### Step 4.2 — Run, see fail. + +```bash +npx nx run telemetry:test --testPathPattern=client.spec +``` + +### Step 4.3 — Implement `node/disable.ts` + +```typescript +let disabled = false; + +export function disableTelemetry(): void { + disabled = true; +} + +export function isProgrammaticallyDisabled(): boolean { + return disabled; +} + +// @internal — tests only +export function _resetDisableForTesting(): void { + disabled = false; +} +``` + +### Step 4.4 — Implement `node/client.ts` + +```typescript +import { PostHog } from 'posthog-node'; +import { getAnonId } from '../shared/anon-id.js'; +import { isTelemetryDisabled } from '../shared/env.js'; +import { shouldSample } from '../shared/sample.js'; +import type { NgafNodeEvent } from '../shared/events.js'; +import { isProgrammaticallyDisabled } from './disable.js'; + +const DEFAULT_INGEST = 'https://cacheplane.dev/api/ingest'; +// This token is the public Cacheplane PostHog project key (the proxy strips it +// and re-keys server-side). It's a Project API key, not a Personal API key, so +// it's safe to ship in OSS code. +const PUBLIC_INGEST_KEY = 'phc_public_cacheplane_telemetry'; + +let cached: PostHog | null = null; + +function getClient(): PostHog | null { + if (cached) return cached; + if (isTelemetryDisabled() || isProgrammaticallyDisabled()) return null; + const host = process.env.NGAF_TELEMETRY_INGEST_URL ?? DEFAULT_INGEST; + cached = new PostHog(PUBLIC_INGEST_KEY, { + host, + flushAt: 1, + flushInterval: 0, + }); + return cached; +} + +export async function captureEvent(event: NgafNodeEvent, properties: Record = {}): Promise { + const client = getClient(); + if (!client) return; + const rate = Number(process.env.NGAF_TELEMETRY_SAMPLE_RATE ?? '1'); + const anonId = getAnonId(); + if (!shouldSample(rate, anonId)) return; + try { + client.capture({ + distinctId: anonId, + event, + properties: { ...properties, sample_weight: rate > 0 ? 1 / Math.min(1, rate) : 1 }, + }); + await client.shutdown(); + } catch { + // silent fail + } finally { + cached = null; // fresh client per process; flushAt:1 means we're done + } +} + +export async function capturePostinstall(input: { pkg: string; version: string }): Promise { + await captureEvent('ngaf:postinstall', { + pkg: input.pkg, + version: input.version, + node: process.version, + os: process.platform, + }); +} + +// @internal — tests only +export function _resetClientForTesting(): void { + cached = null; +} +``` + +### Step 4.5 — Verify + commit + +```bash +npx nx run telemetry:test --testPathPattern=client.spec +``` +Expected: 6 tests pass. + +```bash +git add libs/telemetry/src/node/{client,disable}.ts libs/telemetry/src/node/client.spec.ts +git commit -m "$(cat <<'EOF' +feat(telemetry): Node client wrapping posthog-node + programmatic disable + +Six tests: postinstall capture shape, opt-out paths, ingest URL +override, sample_weight stamping, awaits shutdown. Silent fail on +network errors. Public PostHog Project API Key is safe to ship in OSS +code (the ingest proxy re-keys server-side; never a Personal Key). + +Co-Authored-By: Claude Opus 4.7 +EOF +)" +``` + +--- + +## Task 5: Postinstall script with TDD + +**Files:** `libs/telemetry/src/node/postinstall.ts` + `postinstall.spec.ts`. + +### Step 5.1 — `postinstall.spec.ts` + +```typescript +import { jest } from '@jest/globals'; +import { capturePostinstallScript } from './postinstall'; + +jest.mock('./client', () => ({ + capturePostinstall: jest.fn().mockResolvedValue(undefined), +})); + +import { capturePostinstall } from './client'; + +describe('postinstall script', () => { + beforeEach(() => { + (capturePostinstall as jest.Mock).mockClear(); + delete process.env.CI; + delete process.env.DO_NOT_TRACK; + }); + + test('calls capturePostinstall with the package name + version', async () => { + const stdout: string[] = []; + await capturePostinstallScript({ + readPackageJson: () => ({ name: '@ngaf/telemetry', version: '0.0.31' }), + write: (s: string) => stdout.push(s), + env: { ...process.env }, + }); + expect(capturePostinstall).toHaveBeenCalledWith({ pkg: '@ngaf/telemetry', version: '0.0.31' }); + }); + + test('prints the opt-out notice to stdout when not CI', async () => { + const stdout: string[] = []; + await capturePostinstallScript({ + readPackageJson: () => ({ name: '@ngaf/telemetry', version: '0.0.31' }), + write: (s: string) => stdout.push(s), + env: { ...process.env }, + }); + expect(stdout.join('')).toMatch(/@ngaf\/telemetry: sent install ping/); + expect(stdout.join('')).toMatch(/DO_NOT_TRACK=1/); + }); + + test('suppresses stdout notice when CI=true', async () => { + const stdout: string[] = []; + await capturePostinstallScript({ + readPackageJson: () => ({ name: '@ngaf/telemetry', version: '0.0.31' }), + write: (s: string) => stdout.push(s), + env: { ...process.env, CI: 'true' }, + }); + expect(stdout).toEqual([]); + }); + + test('swallows readPackageJson errors silently', async () => { + await expect( + capturePostinstallScript({ + readPackageJson: () => { throw new Error('not found'); }, + write: () => {}, + env: { ...process.env }, + }), + ).resolves.toBeUndefined(); + expect(capturePostinstall).not.toHaveBeenCalled(); + }); +}); +``` + +### Step 5.2 — Implement `postinstall.ts` + +```typescript +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { capturePostinstall } from './client.js'; +import { isTelemetryDisabled } from '../shared/env.js'; + +interface PostinstallDeps { + readPackageJson: () => { name: string; version: string }; + write: (s: string) => void; + env: NodeJS.ProcessEnv; +} + +export async function capturePostinstallScript(deps: PostinstallDeps): Promise { + if (isTelemetryDisabled(deps.env)) return; + let pkg: { name: string; version: string }; + try { + pkg = deps.readPackageJson(); + } catch { + return; + } + try { + await capturePostinstall({ pkg: pkg.name, version: pkg.version }); + if (!deps.env.CI) { + deps.write( + `@ngaf/telemetry: sent install ping (${pkg.name}@${pkg.version}). ` + + `Disable: DO_NOT_TRACK=1 or NGAF_TELEMETRY_DISABLED=1. ` + + `See https://github.com/cacheplane/angular-agent-framework/blob/main/libs/telemetry/README.md\n`, + ); + } + } catch { + // never break npm install + } +} + +// Entry point — invoked by package.json scripts.postinstall. +async function main(): Promise { + await capturePostinstallScript({ + readPackageJson: () => { + const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'); + return JSON.parse(readFileSync(pkgPath, 'utf8')); + }, + write: (s) => process.stdout.write(s), + env: process.env, + }); +} + +// Only run as main entry, not when imported by tests. +const isDirectRun = + process.argv[1] && import.meta.url === `file://${process.argv[1]}`; +if (isDirectRun) main(); +``` + +### Step 5.3 — Verify + commit + +```bash +npx nx run telemetry:test --testPathPattern=postinstall.spec +``` +Expected: 4 tests pass. + +```bash +git add libs/telemetry/src/node/postinstall.{ts,spec.ts} +git commit -m "$(cat <<'EOF' +feat(telemetry): postinstall script with opt-out notice + CI suppression + +4 tests cover capture, stdout notice formatting, CI suppression, and +silent failure when package.json can't be read. Wrapped so npm install +never fails on any path. + +Co-Authored-By: Claude Opus 4.7 +EOF +)" +``` + +--- + +## Task 6: Node adapter helpers with TDD + +**Files:** `libs/telemetry/src/node/adapter.ts` + `adapter.spec.ts`. + +### Step 6.1 — `adapter.spec.ts` + +```typescript +import { jest } from '@jest/globals'; + +jest.mock('./client', () => ({ + captureEvent: jest.fn().mockResolvedValue(undefined), +})); + +import { captureEvent } from './client'; +import { + captureRuntimeInstanceCreated, + captureStreamStarted, + captureStreamEnded, + captureStreamErrored, +} from './adapter'; +import { sha256 } from '../shared/hash'; + +describe('adapter helpers', () => { + beforeEach(() => (captureEvent as jest.Mock).mockClear()); + + test('captureRuntimeInstanceCreated hashes any apiKey property', async () => { + await captureRuntimeInstanceCreated({ + transport: 'langgraph', + provider: 'openai', + apiKey: 'secret-token-xyz', + }); + const call = (captureEvent as jest.Mock).mock.calls[0]; + expect(call[0]).toBe('ngaf:runtime_instance_created'); + expect(call[1].apiKey).toBeUndefined(); // raw key stripped + expect(call[1].apiKey_sha256).toMatch(/^[a-f0-9]{64}$/); + }); + + test('captureStreamStarted records provider + model only', async () => { + await captureStreamStarted({ provider: 'openai', model: 'gpt-4' }); + expect(captureEvent).toHaveBeenCalledWith( + 'ngaf:stream_started', + expect.objectContaining({ provider: 'openai', model: 'gpt-4' }), + ); + }); + + test('captureStreamEnded records duration', async () => { + await captureStreamEnded({ provider: 'openai', model: 'gpt-4', durationMs: 1234 }); + expect(captureEvent).toHaveBeenCalledWith( + 'ngaf:stream_ended', + expect.objectContaining({ durationMs: 1234 }), + ); + }); + + test('captureStreamErrored records error.class only — no message', async () => { + await captureStreamErrored({ + provider: 'openai', + model: 'gpt-4', + error: new TypeError('detailed error with PII xxxx'), + }); + const props = (captureEvent as jest.Mock).mock.calls[0][1]; + expect(props.errorClass).toBe('TypeError'); + expect(props.errorMessage).toBeUndefined(); + expect(JSON.stringify(props)).not.toMatch(/detailed error/); + }); + + test('all helpers no-op silently when captureEvent rejects', async () => { + (captureEvent as jest.Mock).mockRejectedValueOnce(new Error('network')); + await expect(captureStreamStarted({ provider: 'x', model: 'y' })).resolves.toBeUndefined(); + }); +}); +``` + +### Step 6.2 — Implement `adapter.ts` + +```typescript +import { captureEvent } from './client.js'; +import { sha256 } from '../shared/hash.js'; + +export interface RuntimeInstanceTelemetry { + transport: string; // 'langgraph' | 'ag-ui' | 'custom' + provider?: string; // 'openai' | 'anthropic' | ... + model?: string; + angularVersion?: string; + apiKey?: string; // hashed before sending +} + +export interface StreamTelemetry { + provider: string; + model: string; + durationMs?: number; +} + +async function safe(fn: () => Promise): Promise { + try { await fn(); } catch { /* silent fail */ } +} + +export async function captureRuntimeInstanceCreated(input: RuntimeInstanceTelemetry): Promise { + await safe(async () => { + const { apiKey, ...rest } = input; + const props: Record = { ...rest }; + if (apiKey) props.apiKey_sha256 = await sha256(apiKey); + await captureEvent('ngaf:runtime_instance_created', props); + }); +} + +export async function captureStreamStarted(input: StreamTelemetry): Promise { + await safe(() => captureEvent('ngaf:stream_started', { ...input })); +} + +export async function captureStreamEnded(input: StreamTelemetry): Promise { + await safe(() => captureEvent('ngaf:stream_ended', { ...input })); +} + +export async function captureStreamErrored( + input: StreamTelemetry & { error: Error | unknown }, +): Promise { + await safe(async () => { + const { error, ...rest } = input; + const errorClass = error instanceof Error ? error.constructor.name : 'Unknown'; + await captureEvent('ngaf:stream_errored', { ...rest, errorClass }); + }); +} +``` + +### Step 6.3 — Verify + commit + +```bash +npx nx run telemetry:test --testPathPattern=adapter.spec +``` +Expected: 5 tests pass. + +```bash +git add libs/telemetry/src/node/adapter.{ts,spec.ts} +git commit -m "$(cat <<'EOF' +feat(telemetry): Node adapter helpers — runtime_instance + stream lifecycle + +5 tests. Raw apiKey is SHA-256 hashed before emit. Error objects emit +only their class name, never the message. All four helpers no-op +silently when capture throws — npm install path stays safe. + +Co-Authored-By: Claude Opus 4.7 +EOF +)" +``` + +--- + +## Task 7: Browser tokens + service with TDD + +**Files:** `libs/telemetry/src/browser/{tokens,service}.ts` + `service.spec.ts`. + +### Step 7.1 — `tokens.ts` + +```typescript +import { InjectionToken } from '@angular/core'; + +export interface NgafTelemetryConfig { + enabled: boolean; + posthogKey?: string; + posthogHost?: string; + sampleRate?: number; +} + +export const NGAF_TELEMETRY_CONFIG = new InjectionToken( + 'NGAF_TELEMETRY_CONFIG', +); +``` + +### Step 7.2 — `service.spec.ts` + +```typescript +import { TestBed } from '@angular/core/testing'; +import { NgafTelemetryService } from './service'; +import { NGAF_TELEMETRY_CONFIG } from './tokens'; + +describe('NgafTelemetryService', () => { + test('capture() resolves without calling posthog when enabled is false', async () => { + TestBed.configureTestingModule({ + providers: [ + { provide: NGAF_TELEMETRY_CONFIG, useValue: { enabled: false } }, + NgafTelemetryService, + ], + }); + const svc = TestBed.inject(NgafTelemetryService); + await expect(svc.capture('ngaf:browser_provided')).resolves.toBeUndefined(); + }); + + test('capture() resolves without calling posthog when no config provided', async () => { + TestBed.configureTestingModule({ + providers: [ + { provide: NGAF_TELEMETRY_CONFIG, useValue: null }, + NgafTelemetryService, + ], + }); + const svc = TestBed.inject(NgafTelemetryService); + await expect(svc.capture('ngaf:browser_provided')).resolves.toBeUndefined(); + }); + + test('capture() no-ops when posthogKey is missing even with enabled:true', async () => { + TestBed.configureTestingModule({ + providers: [ + { provide: NGAF_TELEMETRY_CONFIG, useValue: { enabled: true } }, + NgafTelemetryService, + ], + }); + const svc = TestBed.inject(NgafTelemetryService); + await expect(svc.capture('ngaf:browser_provided')).resolves.toBeUndefined(); + }); + + test('capture() with enabled:true and posthogKey invokes posthog-js (lazy)', async () => { + // We can't easily test the dynamic import in jest without mocking it, + // so we just verify the service doesn't throw and returns void. + TestBed.configureTestingModule({ + providers: [ + { provide: NGAF_TELEMETRY_CONFIG, useValue: { enabled: true, posthogKey: 'phc_test' } }, + NgafTelemetryService, + ], + }); + const svc = TestBed.inject(NgafTelemetryService); + // Don't actually await — we don't want the dynamic import to fire in tests. + expect(typeof svc.capture).toBe('function'); + }); + + test('service is provided as root-scoped', () => { + // Smoke: providedIn:root means the service can be injected without explicit providers. + // Since we still need NGAF_TELEMETRY_CONFIG, this is a structural check only. + expect(NgafTelemetryService).toBeDefined(); + }); +}); +``` + +### Step 7.3 — Implement `service.ts` + +```typescript +import { Injectable, Inject, Optional } from '@angular/core'; +import { NGAF_TELEMETRY_CONFIG, type NgafTelemetryConfig } from './tokens.js'; +import type { NgafBrowserEvent } from '../shared/events.js'; + +@Injectable({ providedIn: 'root' }) +export class NgafTelemetryService { + private postHogPromise: Promise | null = null; + + constructor( + @Optional() @Inject(NGAF_TELEMETRY_CONFIG) private config: NgafTelemetryConfig | null, + ) {} + + async capture(event: NgafBrowserEvent, properties?: Record): Promise { + if (!this.config?.enabled || !this.config.posthogKey) return; + try { + const ph = await this.loadPostHog(); + if (!ph) return; + ph.capture(event, properties); + } catch { + // silent fail + } + } + + private loadPostHog(): Promise { + if (!this.postHogPromise) { + this.postHogPromise = import('posthog-js').then((mod) => { + if (!this.config?.posthogKey) return null; + mod.default.init(this.config.posthogKey, { + api_host: this.config.posthogHost ?? 'https://us.i.posthog.com', + }); + return mod.default; + }).catch(() => null); + } + return this.postHogPromise; + } +} +``` + +### Step 7.4 — Verify + commit + +```bash +npx nx run telemetry:test --testPathPattern=service.spec +``` +Expected: 5 tests pass. + +```bash +git add libs/telemetry/src/browser/{tokens,service}.ts libs/telemetry/src/browser/service.spec.ts +git commit -m "$(cat <<'EOF' +feat(telemetry): browser service with lazy posthog-js import + +NgafTelemetryService never imports posthog-js at module load. Dynamic +import only fires when capture() is called with enabled:true AND +posthogKey present. Five tests cover all opt-out paths. + +Co-Authored-By: Claude Opus 4.7 +EOF +)" +``` + +--- + +## Task 8: `provideNgafTelemetry()` with TDD + +**Files:** `libs/telemetry/src/browser/provide.ts` + `provide.spec.ts`. + +### Step 8.1 — `provide.spec.ts` + +```typescript +import { TestBed } from '@angular/core/testing'; +import { provideNgafTelemetry } from './provide'; +import { NgafTelemetryService } from './service'; +import { NGAF_TELEMETRY_CONFIG } from './tokens'; + +describe('provideNgafTelemetry', () => { + test('returns EnvironmentProviders that bind config + service', () => { + TestBed.configureTestingModule({ + providers: [provideNgafTelemetry({ enabled: false })], + }); + expect(TestBed.inject(NGAF_TELEMETRY_CONFIG)).toEqual({ enabled: false }); + expect(TestBed.inject(NgafTelemetryService)).toBeInstanceOf(NgafTelemetryService); + }); + + test('config defaults: sampleRate defaults to 1.0 when omitted', () => { + TestBed.configureTestingModule({ + providers: [provideNgafTelemetry({ enabled: true, posthogKey: 'phc_x' })], + }); + const cfg = TestBed.inject(NGAF_TELEMETRY_CONFIG); + expect(cfg?.sampleRate ?? 1.0).toBe(1.0); + }); + + test('posthogHost passes through', () => { + TestBed.configureTestingModule({ + providers: [provideNgafTelemetry({ enabled: true, posthogKey: 'x', posthogHost: 'https://eu.i.posthog.com' })], + }); + expect(TestBed.inject(NGAF_TELEMETRY_CONFIG)?.posthogHost).toBe('https://eu.i.posthog.com'); + }); + + test('enabled:true without posthogKey still resolves (service no-ops at call time)', () => { + TestBed.configureTestingModule({ + providers: [provideNgafTelemetry({ enabled: true })], + }); + expect(TestBed.inject(NgafTelemetryService)).toBeInstanceOf(NgafTelemetryService); + }); +}); +``` + +### Step 8.2 — Implement `provide.ts` + +```typescript +import { makeEnvironmentProviders, type EnvironmentProviders } from '@angular/core'; +import { NGAF_TELEMETRY_CONFIG, type NgafTelemetryConfig } from './tokens.js'; +import { NgafTelemetryService } from './service.js'; + +export function provideNgafTelemetry(config: NgafTelemetryConfig): EnvironmentProviders { + return makeEnvironmentProviders([ + { provide: NGAF_TELEMETRY_CONFIG, useValue: config }, + NgafTelemetryService, + ]); +} +``` + +### Step 8.3 — Verify + commit + +```bash +npx nx run telemetry:test --testPathPattern=provide.spec +``` +Expected: 4 tests pass. + +```bash +git add libs/telemetry/src/browser/provide.{ts,spec.ts} +git commit -m "$(cat <<'EOF' +feat(telemetry): provideNgafTelemetry() returning EnvironmentProviders + +4 tests verify config + service injection, sampleRate default, +posthogHost passthrough, and that enabled:true without posthogKey still +provides a service (which then no-ops at capture time). + +Co-Authored-By: Claude Opus 4.7 +EOF +)" +``` + +--- + +## Task 9: Permanent browser silence test + entry-point indexes + +**Files:** +- Create: `libs/telemetry/src/browser/browser-silence.spec.ts` +- Create: `libs/telemetry/src/index.ts` (shared public API) +- Create: `libs/telemetry/src/node/index.ts` (node public API) +- Create: `libs/telemetry/src/browser/public-api.ts` (browser public API) + +### Step 9.1 — `src/index.ts` + +```typescript +export { isTelemetryDisabled, getDisableReason } from './shared/env.js'; +export { sha256 } from './shared/hash.js'; +export { getAnonId } from './shared/anon-id.js'; +export { shouldSample } from './shared/sample.js'; +export type { NgafEvent, NgafNodeEvent, NgafBrowserEvent } from './shared/events.js'; +``` + +### Step 9.2 — `src/node/index.ts` + +```typescript +export { disableTelemetry } from './disable.js'; +export { capturePostinstall, captureEvent } from './client.js'; +export { + captureRuntimeInstanceCreated, + captureStreamStarted, + captureStreamEnded, + captureStreamErrored, +} from './adapter.js'; +export type { RuntimeInstanceTelemetry, StreamTelemetry } from './adapter.js'; +``` + +### Step 9.3 — `src/browser/public-api.ts` + +```typescript +export { provideNgafTelemetry } from './provide.js'; +export { NgafTelemetryService } from './service.js'; +export { NGAF_TELEMETRY_CONFIG } from './tokens.js'; +export type { NgafTelemetryConfig } from './tokens.js'; +``` + +### Step 9.4 — Write the permanent silence test + +`libs/telemetry/src/browser/browser-silence.spec.ts`: + +```typescript +/** + * PERMANENT CONTRACT TEST. + * + * The trust contract at libs/telemetry/README.md promises that + * @ngaf/telemetry/browser fires zero network calls and triggers zero + * imports of posthog-js when the consumer does not call + * provideNgafTelemetry() or calls it with enabled:false. + * + * If this test ever fails, the trust contract has been violated. + * Do not "fix" the test — fix the offending import or call site. + */ +import { jest } from '@jest/globals'; +import { TestBed } from '@angular/core/testing'; +import { provideNgafTelemetry } from './provide'; +import { NgafTelemetryService } from './service'; + +jest.mock('posthog-js', () => { + throw new Error('posthog-js MUST NOT be imported when telemetry is not enabled'); +}); + +describe('browser silence (permanent contract)', () => { + test('no posthog-js import when provideNgafTelemetry is never called', async () => { + TestBed.configureTestingModule({ providers: [] }); + // Just touching the service classes must not trigger posthog-js. + expect(NgafTelemetryService).toBeDefined(); + }); + + test('no posthog-js import when provideNgafTelemetry({ enabled: false })', async () => { + TestBed.configureTestingModule({ + providers: [provideNgafTelemetry({ enabled: false })], + }); + const svc = TestBed.inject(NgafTelemetryService); + await svc.capture('ngaf:browser_provided'); // must not load posthog-js + expect(svc).toBeInstanceOf(NgafTelemetryService); + }); +}); +``` + +### Step 9.5 — Verify all tests pass. + +```bash +npx nx run telemetry:test +``` +Expected: 19 (shared) + 6 (client) + 4 (postinstall) + 5 (adapter) + 5 (service) + 4 (provide) + 2 (silence) = **45 tests pass**. + +(Spec said ~44; one extra came from the second silence assertion.) + +### Step 9.6 — Commit + +```bash +git add libs/telemetry/src/{index.ts,node/index.ts,browser/{public-api.ts,browser-silence.spec.ts}} +git commit -m "$(cat <<'EOF' +feat(telemetry): public entry points + permanent browser silence test + +Three entry indices: src/index.ts, src/node/index.ts, +src/browser/public-api.ts. Permanent contract test (2 cases) asserts +posthog-js is never imported when provideNgafTelemetry is not called +or called with enabled:false. Test stays green permanently. + +Co-Authored-By: Claude Opus 4.7 +EOF +)" +``` + +--- + +## Task 10: Add `telemetry` to publishable group + env.example + +**Files:** Modify `nx.json`, `.env.example`. + +### Step 10.1 — Update `nx.json` + +- [ ] In `nx.json`, find the `release.groups.publishable.projects` array. Append `"telemetry"`. +- [ ] In `release.version.preVersionCommand`, the `--projects=...` list ends with `licensing`. Append `,telemetry` so the build runs for telemetry too. + +Expected diff: + +```diff + "projects": [ +- "chat", "langgraph", "ag-ui", "render", "a2ui", "partial-json", "licensing" ++ "chat", "langgraph", "ag-ui", "render", "a2ui", "partial-json", "licensing", "telemetry" + ], + ... +- "preVersionCommand": "npx nx run-many -t build --projects=chat,langgraph,ag-ui,render,a2ui,partial-json,licensing" ++ "preVersionCommand": "npx nx run-many -t build --projects=chat,langgraph,ag-ui,render,a2ui,partial-json,licensing,telemetry" +``` + +### Step 10.2 — Update `.env.example` + +```bash +cat >> .env.example <<'EOF' + +# @ngaf/telemetry (libs/telemetry) +# Default ingest URL points to the future Spec 1D reverse proxy. Self-hosters +# can redirect to their own ingest. See libs/telemetry/README.md. +# NGAF_TELEMETRY_INGEST_URL=https://cacheplane.dev/api/ingest +# NGAF_TELEMETRY_SAMPLE_RATE=1.0 +# DO_NOT_TRACK=1 # cross-vendor opt-out +# NGAF_TELEMETRY_DISABLED=1 # package-specific opt-out +EOF +``` + +### Step 10.3 — Commit + +```bash +git add nx.json .env.example +git commit -m "$(cat <<'EOF' +chore(release): add telemetry to publishable group + env vars + +telemetry joins the fixed-version publishable group; first published +version will be the next group bump (per user memory: never 0.1.0, +always patch). .env.example documents the four user-facing env vars. + +Co-Authored-By: Claude Opus 4.7 +EOF +)" +``` + +--- + +## Task 11: README alignment (if needed) + +**Files:** `libs/telemetry/README.md`. + +The README is the public trust contract. Only update if the implementation surfaced contract-shaping details that the README is silent on. Specifically: + +- [ ] Add an "Imports" section after the existing intro: `import { provideNgafTelemetry } from '@ngaf/telemetry/browser';`, `import { captureStreamStarted } from '@ngaf/telemetry/node';`. +- [ ] If anything else surfaced in earlier tasks, note it. + +If no changes needed: skip this task. + +### Commit (if changed): + +```bash +git add libs/telemetry/README.md +git commit -m "$(cat <<'EOF' +docs(telemetry): minor README alignment with implemented API + +Adds an Imports section documenting the three subpath entry points. + +Co-Authored-By: Claude Opus 4.7 +EOF +)" +``` + +--- + +## Task 12: Build + verify the published package shape + +**Files:** none (verification only). + +### Step 12.1 — Build both targets + +```bash +npx nx run telemetry:build 2>&1 | tail -15 +``` + +Both `build:node` and `build:browser` must succeed. If either fails, STOP and report. + +### Step 12.2 — Inspect the dist output + +```bash +ls -la dist/libs/telemetry/ +cat dist/libs/telemetry/package.json | python3 -m json.tool | head -30 +ls dist/libs/telemetry/node/ dist/libs/telemetry/browser/ 2>/dev/null +``` + +Expected: +- `dist/libs/telemetry/package.json` exists with the exports map intact +- `dist/libs/telemetry/node/` has compiled JS for the node entry +- `dist/libs/telemetry/browser/` has the ng-packagr output (fesm2022, index.d.ts) + +### Step 12.3 — `npm pack` and inspect + +```bash +cd dist/libs/telemetry +npm pack 2>&1 | tail -3 +tar tzf ngaf-telemetry-*.tgz | head -30 +cd - +``` + +Expected: tarball includes `package/package.json`, `package/index.{js,d.ts}`, `package/node/index.{js,d.ts}`, `package/node/postinstall.{js,d.ts}`, `package/browser/...`. Remove the pack artifact: `rm dist/libs/telemetry/ngaf-telemetry-*.tgz`. + +### Step 12.4 — Final test run + +```bash +npx nx run telemetry:test 2>&1 | tail -10 +npx nx run telemetry:lint 2>&1 | tail -5 +``` + +Both must be clean. + +No commit — this is verification only. + +--- + +## Task 13: PR + auto-merge poll + +**Files:** none. + +### Step 13.1 — Push branch + +```bash +git push -u origin "$(git branch --show-current)" 2>&1 | tail -5 +``` + +### Step 13.2 — Open PR + +```bash +gh pr create --title "feat(telemetry): @ngaf/telemetry library — Node opt-out + browser opt-in" --body "$(cat <<'EOF' +## Summary + +Implements **Spec 1B** (analytics-foundation sub-spec B): the @ngaf/telemetry library that surfaces three subpath exports honoring the trust contract at libs/telemetry/README.md. + +- New Nx library at libs/telemetry/ named telemetry +- Three published subpath exports: + - @ngaf/telemetry — shared types, env detection, hashing + - @ngaf/telemetry/node — posthog-node wrapper, postinstall, adapter helpers + - @ngaf/telemetry/browser — provideNgafTelemetry (Angular EnvironmentProviders) +- Hybrid build: @nx/js:tsc for shared+node, @nx/angular:package for browser +- Permanent browser silence test pins the trust contract +- Postinstall script with opt-out notice (CI-suppressed) +- ~45 unit tests across env, hash, anon-id, sample, client, postinstall, adapter, service, provide, silence +- telemetry added to the publishable group in nx.json (joins the fixed-version cadence) + +Spec: docs/superpowers/specs/gtm/2026-05-15-analytics-foundation-1b-ngaf-telemetry-design.md +Plan: docs/superpowers/plans/gtm/2026-05-15-analytics-foundation-1b-ngaf-telemetry.md + +## Trust contract (unchanged from Spec 0) + +- @ngaf/* browser packages NEVER import posthog-js at module load +- provideNgafTelemetry must be called explicitly with enabled:true to fire any browser event +- Node telemetry honors DO_NOT_TRACK, NGAF_TELEMETRY_DISABLED, and 5 CI-auto-detect env vars +- Sensitive ids are SHA-256 hashed before any event property +- Per-process anon-id (no localStorage, no cookies, no filesystem) +- Postinstall NEVER fails npm install (wrapped in || true) + +## Test Plan + +- [ ] CI green: lint, test, build +- [ ] @ngaf/telemetry@ publishes alongside other libs on next release +- [ ] Permanent browser silence test stays green +- [ ] npm pack on built output produces a valid tarball with all three subpath exports + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +### Step 13.3 — Try auto-merge, fallback to background poll + +```bash +gh pr merge --rebase --auto --delete-branch 2>&1 | tail -3 +``` + +If auto-merge fails ("Protected branch rules not configured"), start a background poll watcher like the Spec 1A PR. The user/controller decides which approach based on the error. + +--- + +## Self-Review + +**Spec coverage** — every section of the spec is covered: + +| Spec § | Task | +|--------|------| +| §3 Scope — Nx project | Task 2 | +| §3 Scope — three subpath exports | Tasks 2, 9 | +| §3 Scope — hybrid build | Tasks 2, 12 | +| §3 Scope — env detection (5 paths) | Task 3 | +| §3 Scope — hashing | Task 3 | +| §3 Scope — anon-id | Task 3 | +| §3 Scope — node client + adapter helpers | Tasks 4, 6 | +| §3 Scope — postinstall | Task 5 | +| §3 Scope — browser service + provide | Tasks 7, 8 | +| §3 Scope — permanent browser silence test | Task 9 | +| §3 Scope — publishable group | Task 10 | +| §3 Scope — env.example | Task 10 | +| §3 Scope — README alignment | Task 11 | +| §3 Scope — verification (build, pack) | Task 12 | +| §5 Trust contract enforcement | Tasks 4 (Node opt-out), 7 (browser lazy), 9 (silence test) | +| §6 Public API surfaces | Tasks 4, 6, 7, 8, 9 | +| §7 Postinstall behavior | Task 5 | +| §8 Build + publish pipeline | Tasks 2, 10, 12 | +| §9 Testing strategy + permanent silence | Tasks 3-9 | + +**Placeholder scan** — no `TBD`, `TODO`, `implement later`. ✓ + +**Type consistency** — `NgafTelemetryConfig` (Task 7) consumed by `provideNgafTelemetry` (Task 8); `NgafNodeEvent`/`NgafBrowserEvent` (Task 3) consumed by `captureEvent`/`service.capture` (Tasks 4, 7); env-var names consistent across spec, tests, and implementation. ✓ diff --git a/docs/superpowers/specs/gtm/2026-05-15-analytics-foundation-1b-ngaf-telemetry-design.md b/docs/superpowers/specs/gtm/2026-05-15-analytics-foundation-1b-ngaf-telemetry-design.md new file mode 100644 index 00000000..a9b40a58 --- /dev/null +++ b/docs/superpowers/specs/gtm/2026-05-15-analytics-foundation-1b-ngaf-telemetry-design.md @@ -0,0 +1,468 @@ +--- +workstream: analytics-foundation-1b-ngaf-telemetry +status: approved +owner: brian +phase: 0 +spec: docs/superpowers/specs/gtm/2026-05-15-analytics-foundation-1b-ngaf-telemetry-design.md +plan: docs/superpowers/plans/gtm/2026-05-15-analytics-foundation-1b-ngaf-telemetry.md +parent: docs/superpowers/specs/gtm/2026-05-13-gtm-meta-design.md +--- + +# Analytics Foundation 1B — `@ngaf/telemetry` (Design) + +> Spec 1B of the Cacheplane GTM motion. Implements the `@ngaf/telemetry` library: Node opt-out + browser opt-in surfaces, postinstall ping, Angular DI provider, all gated by the trust contract already published at `libs/telemetry/README.md`. + +## 1. Goal + +Ship a single `@ngaf/telemetry` library that surfaces three entry points (`@ngaf/telemetry`, `@ngaf/telemetry/node`, `@ngaf/telemetry/browser`) so that `@ngaf/*` consumers and our own server adapters can emit `ngaf:*` events to PostHog without violating the public trust contract — Node opt-out, browser opt-in, no end-user telemetry by default, no vendor key shipped, silent-fail. + +## 2. Context + +- Parent: `docs/superpowers/specs/gtm/2026-05-13-gtm-meta-design.md` §7.5 defines the architecture and mounting rules. +- The trust contract is already public at `libs/telemetry/README.md`. This spec implements that contract; it does not re-author it. If implementation forces a contract change, update the README in the same PR with a `BREAKING` callout. +- Spec 1A (`tools/posthog/`) shipped the dashboards-as-code pipeline. The `package-telemetry` dashboard from `gtm.md §5` is a follow-up that will consume the `ngaf:*` events this library fires. +- Spec 1D will ship the `/api/ingest` reverse proxy on the website. Until that lands, Node telemetry's default ingest URL (`https://cacheplane.dev/api/ingest`) 404s; silent-fail absorbs it. When Spec 1D ships, telemetry flows without any consumer action. + +## 3. Scope + +**In scope:** + +- New Nx Angular library project at `libs/telemetry/` named `telemetry`. +- Three subpath exports in published `package.json`: + - `@ngaf/telemetry` (shared utilities) + - `@ngaf/telemetry/node` (Node opt-out runtime) + - `@ngaf/telemetry/browser` (Angular `provideNgafTelemetry()`) +- Hybrid build: `@nx/js:tsc` for shared + node, `@nx/angular:package` for browser. +- Env detection helpers: `DO_NOT_TRACK`, `NGAF_TELEMETRY_DISABLED`, CI auto-detect, `NGAF_TELEMETRY_SAMPLE_RATE`, `NGAF_TELEMETRY_INGEST_URL`. +- SHA-256 one-way hashing helper for sensitive identifiers. +- Anonymous-id strategy: per-process UUID prefix (`anon_`), regenerated each boot, no persistence. +- Node surfaces: `posthog-node` wrapper, `postinstall` script (fires once per `npm install` of `@ngaf/telemetry`), adapter helpers (`captureRuntimeInstanceCreated`, `captureStreamStarted`, `captureStreamEnded`, `captureStreamErrored`). +- Browser surfaces: `provideNgafTelemetry({ enabled, posthogKey, posthogHost, sampleRate })` returning `EnvironmentProviders`. Lazy `posthog-js` import gated on `enabled: true` — when off, zero network calls and zero `posthog-js` import side effects. +- Unit tests + the **permanent browser silence test** that confirms no network call is made when `provideNgafTelemetry({ enabled: true })` is never called. +- Add `telemetry` to the `publishable` group in `nx.json`. The fixed-group version (currently ~0.0.30) becomes telemetry's starting version on next publish; do not set 0.0.1. +- `.env.example` documents `NGAF_TELEMETRY_INGEST_URL` (for self-hosters) and `NGAF_TELEMETRY_SAMPLE_RATE`. +- `libs/telemetry/README.md` minor updates if implementation surfaces force them. + +**Out of scope:** + +- Dashboards consuming `ngaf:*` events (deferred `dashboards-content` spec). +- Website `/api/ingest` reverse proxy (Spec 1D). +- Cockpit instrumentation (Spec 1C). +- Postinstall hooks added to *other* `@ngaf/*` packages — runtime adapter calls are the primary signal. We accept this scope reduction to avoid coordinated re-publishes. +- Session replay, group analytics, feature flag SDK integration. +- Browser `provideNgafTelemetry` with `enabled: true` actually firing useful events — wiring real events from `@ngaf/chat`, `@ngaf/agent` etc. requires updating those packages and lives in their own follow-ups. + +## 4. Architecture + +### 4.1 Three-entry layout + +``` +libs/telemetry/ +├── README.md # already exists; the trust contract +├── package.json # CREATE — name @ngaf/telemetry, peer deps, exports map +├── project.json # CREATE — Nx project with build:node + build:browser + test + lint +├── tsconfig.json # CREATE — base +├── tsconfig.lib.json # CREATE — node/shared entry build (tsc) +├── tsconfig.lib.browser.json # CREATE — Angular ng-packagr config +├── ng-package.json # CREATE — Angular package config for browser entry +├── eslint.config.mjs # CREATE +├── jest.config.ts # CREATE — or vitest, matching repo pattern +├── ngaf-telemetry.api-md # (none — no API extractor needed at this scale) +├── src/ +│ ├── index.ts # public re-exports of shared API +│ ├── shared/ +│ │ ├── env.ts # DO_NOT_TRACK / NGAF_TELEMETRY_DISABLED / CI detect +│ │ ├── env.spec.ts +│ │ ├── hash.ts # SHA-256 one-way (Web Crypto + node:crypto) +│ │ ├── hash.spec.ts +│ │ ├── anon-id.ts # Per-process UUID generator +│ │ ├── sample.ts # Sample-rate gate +│ │ ├── sample.spec.ts +│ │ └── events.ts # Typed event names (ngaf:* only) +│ ├── node/ +│ │ ├── index.ts # @ngaf/telemetry/node entrypoint +│ │ ├── client.ts # posthog-node wrapper, flushAt:1, flushInterval:0 +│ │ ├── client.spec.ts +│ │ ├── postinstall.ts # one-shot ping; suppressed in CI +│ │ ├── postinstall.spec.ts +│ │ ├── adapter.ts # captureRuntimeInstanceCreated, captureStream{Started,Ended,Errored} +│ │ ├── adapter.spec.ts +│ │ └── disable.ts # disableTelemetry() programmatic kill switch +│ └── browser/ +│ ├── public-api.ts # @ngaf/telemetry/browser entrypoint +│ ├── provide.ts # provideNgafTelemetry() — EnvironmentProviders +│ ├── provide.spec.ts +│ ├── service.ts # NgafTelemetryService (lazy posthog-js loader) +│ ├── service.spec.ts +│ ├── tokens.ts # NGAF_TELEMETRY_CONFIG InjectionToken +│ └── browser-silence.spec.ts # permanent contract test +``` + +### 4.2 Layered separation (mirrors Spec 1A) + +``` +┌────────────────────────────────────────────────────────────────────┐ +│ PUBLIC API src/index.ts src/node/index.ts src/browser/public-api.ts │ +└────────────────────────┬───────────────────────────────────────────┘ + │ +┌────────────────────────▼───────────────────────────────────────────┐ +│ SHARED (no node/browser deps) │ +│ env.ts · hash.ts · anon-id.ts · sample.ts · events.ts │ +└────────────────────────┬───────────────────────────────────────────┘ + │ +┌────────────────────────▼───────────────────────────────────────────┐ +│ TRANSPORT (per entry) │ +│ node/client.ts (posthog-node) │ browser/service.ts (posthog-js) │ +└────────────────────────────────────────────────────────────────────┘ +``` + +`shared/` has zero runtime dependencies (uses `crypto.subtle` in browser, `node:crypto` in Node via a thin adapter). `node/` depends only on `posthog-node` + `shared/`. `browser/` depends on `@angular/core` (peer) + `shared/`, and lazy-loads `posthog-js` only when `enabled: true`. + +### 4.3 Subpath exports + +```jsonc +// libs/telemetry/package.json (excerpt) +{ + "name": "@ngaf/telemetry", + "version": "0.0.0", // bumped to fixed-group version on publish + "sideEffects": false, + "type": "module", + "exports": { + ".": { + "types": "./index.d.ts", + "default": "./fesm2022/ngaf-telemetry.mjs" + }, + "./node": { + "types": "./node/index.d.ts", + "default": "./node/index.mjs" + }, + "./browser": { + "types": "./browser/index.d.ts", + "default": "./fesm2022/ngaf-telemetry-browser.mjs" + }, + "./postinstall": { + "types": "./node/postinstall.d.ts", + "default": "./node/postinstall.mjs" + } + }, + "peerDependencies": { + "@angular/core": "^20.0.0 || ^21.0.0" + }, + "peerDependenciesMeta": { + "@angular/core": { "optional": true } // Node-only consumers don't need Angular + }, + "dependencies": { + "posthog-js": "^1.372.0", + "posthog-node": "^5.20.0" + }, + "scripts": { + "postinstall": "node ./node/postinstall.mjs || true" + } +} +``` + +`peerDependenciesMeta.optional: true` lets Node-only consumers install `@ngaf/telemetry/node` without an Angular peer warning. Browser users still get a peer-dep check. + +## 5. Trust contract — how implementation enforces it + +The README is the public contract. The implementation enforces it via these mechanisms: + +### 5.1 Browser silence (the most important guarantee) + +`@ngaf/telemetry/browser` MUST NOT import `posthog-js` at module load. Tree-shaking and dynamic import together guarantee this: + +```typescript +// browser/service.ts +@Injectable({ providedIn: 'root' }) +export class NgafTelemetryService { + private postHog: Promise | null = null; + + constructor(@Optional() @Inject(NGAF_TELEMETRY_CONFIG) private config: NgafTelemetryConfig | null) {} + + async capture(event: NgafBrowserEvent, properties?: Record): Promise { + if (!this.config?.enabled || !this.config.posthogKey) return; + if (!this.postHog) { + // Lazy-load posthog-js only when enabled AND key present. + this.postHog = import('posthog-js').then((mod) => { + mod.default.init(this.config!.posthogKey!, { + api_host: this.config!.posthogHost ?? 'https://us.i.posthog.com', + }); + return mod.default; + }); + } + const ph = await this.postHog; + ph?.capture(event, properties); + } +} +``` + +The permanent **`browser-silence.spec.ts`** asserts no network call occurs when `provideNgafTelemetry()` is not called OR called with `enabled: false`. This test stays green permanently. + +### 5.2 Node opt-out + +`node/client.ts` constructor short-circuits on: + +1. `DO_NOT_TRACK === '1' || === 'true'` +2. `NGAF_TELEMETRY_DISABLED === '1' || === 'true'` +3. `CI`, `GITHUB_ACTIONS`, or other CI sentinels (`CONTINUOUS_INTEGRATION`, `BUILDKITE`, `CIRCLECI`) +4. `disableTelemetry()` called programmatically (sets a module-level flag) +5. `NGAF_TELEMETRY_INGEST_URL` unset AND no key — silent no-op (the proxy default kicks in before this gate, but if both are unset we no-op) + +All five paths produce a `NoOpClient` that satisfies the same interface. Callers can't tell the difference. + +### 5.3 Hashing + +`shared/hash.ts` exports `sha256(input: string): Promise` using Web Crypto in browser and `node:crypto` in Node via a tiny `globalThis.crypto.subtle` check. All sensitive identifiers (LangGraph keys, model API keys, internal endpoint URLs) MUST be hashed before becoming event properties. + +### 5.4 Sample-rate + +`shared/sample.ts` returns a deterministic `shouldSample(rate, anonId)` so a given anonymous id either always samples or never samples within a process. Stamps `sample_weight: 1/rate` on every event for query-time de-sampling. + +### 5.5 Anonymous id + +`shared/anon-id.ts` generates `anon_` once per process and caches it in module state. No filesystem persistence. Browser uses the same helper, generating per-page-load. + +## 6. Public API surfaces + +### 6.1 `@ngaf/telemetry` (shared) + +```typescript +export { isTelemetryDisabled, getDisableReason } from './shared/env.js'; +export { sha256 } from './shared/hash.js'; +export { getAnonId } from './shared/anon-id.js'; +export { shouldSample } from './shared/sample.js'; +export type { NgafEvent, NgafNodeEvent, NgafBrowserEvent } from './shared/events.js'; +``` + +### 6.2 `@ngaf/telemetry/node` + +```typescript +export { disableTelemetry } from './disable.js'; +export { capturePostinstall } from './postinstall.js'; // for invocation from postinstall script +export { + captureRuntimeInstanceCreated, + captureStreamStarted, + captureStreamEnded, + captureStreamErrored, +} from './adapter.js'; +export type { RuntimeInstanceTelemetry, StreamTelemetry } from './adapter.js'; +``` + +Adapter helpers are zero-arg-friendly (`captureStreamStarted({ provider, model })`) and silently no-op when telemetry is disabled. Always `flushAt: 1, flushInterval: 0` so short-lived Node functions flush before exit; callers can `await shutdown()` if they want strict order, but the default is fire-and-forget with a 3s timeout. + +### 6.3 `@ngaf/telemetry/browser` + +```typescript +export { provideNgafTelemetry } from './provide.js'; +export { NgafTelemetryService } from './service.js'; // injectable for advanced use +export type { NgafTelemetryConfig } from './tokens.js'; +export { NGAF_TELEMETRY_CONFIG } from './tokens.js'; +``` + +`provideNgafTelemetry`: + +```typescript +export interface NgafTelemetryConfig { + enabled: boolean; // default behavior is OFF; must opt in + posthogKey?: string; // consumer's PostHog project key + posthogHost?: string; // default 'https://us.i.posthog.com' + sampleRate?: number; // default 1.0 +} + +export function provideNgafTelemetry(config: NgafTelemetryConfig): EnvironmentProviders { + return makeEnvironmentProviders([ + { provide: NGAF_TELEMETRY_CONFIG, useValue: config }, + NgafTelemetryService, + ]); +} +``` + +When `enabled: false` (or `posthogKey` omitted), the service is still provided but no-ops on every method. The browser silence test pins this. + +## 7. Postinstall behavior + +`libs/telemetry/src/node/postinstall.ts` is invoked from the published `package.json` `scripts.postinstall`: + +```typescript +import { capturePostinstall } from './client.js'; +import { isTelemetryDisabled } from '../shared/env.js'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +async function main() { + if (isTelemetryDisabled()) return; + try { + // Walk up from the installed package to find package.json (we ARE in node_modules/@ngaf/telemetry/node/postinstall.mjs). + const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'); + const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')); + await capturePostinstall({ pkg: pkg.name, version: pkg.version }); + if (!process.env.CI) { + process.stdout.write( + `@ngaf/telemetry: sent install ping (${pkg.name}@${pkg.version}). Disable: DO_NOT_TRACK=1 or NGAF_TELEMETRY_DISABLED=1. See https://github.com/cacheplane/angular-agent-framework/blob/main/libs/telemetry/README.md\n`, + ); + } + } catch { + // Silent fail — never break npm install. + } +} + +main(); +``` + +The published `package.json` uses `node ./node/postinstall.mjs || true` so a failed postinstall never blocks `npm install`. + +## 8. Build + publish pipeline + +### 8.1 Two-target build + +```jsonc +// libs/telemetry/project.json (targets excerpt) +{ + "targets": { + "build": { + "executor": "nx:run-commands", + "dependsOn": ["build:node", "build:browser"], + "options": { "command": "true" } + }, + "build:node": { + "executor": "@nx/js:tsc", + "outputs": ["{workspaceRoot}/dist/libs/telemetry/node"], + "options": { + "outputPath": "dist/libs/telemetry", + "main": "libs/telemetry/src/index.ts", + "additionalEntryPoints": [ + "libs/telemetry/src/node/index.ts", + "libs/telemetry/src/node/postinstall.ts" + ], + "tsConfig": "libs/telemetry/tsconfig.lib.json" + } + }, + "build:browser": { + "executor": "@nx/angular:package", + "outputs": ["{workspaceRoot}/dist/libs/telemetry/browser"], + "options": { + "project": "libs/telemetry/ng-package.json", + "tsConfig": "libs/telemetry/tsconfig.lib.browser.json" + } + }, + "test": { "executor": "@nx/jest:jest", "options": { "jestConfig": "libs/telemetry/jest.config.ts" } }, + "lint": { "executor": "@nx/eslint:lint" } + } +} +``` + +A small post-build step in `build:node` copies the published `package.json` (with the exports map) into `dist/libs/telemetry/`. Browser build's ng-packagr output then merges into the same `dist` so subpath exports resolve correctly. + +### 8.2 Publish + +`telemetry` is added to the `publishable` group in `nx.json`: + +```jsonc +"release": { + "groups": { + "publishable": { + "projects": [ + "chat", "langgraph", "ag-ui", "render", "a2ui", "partial-json", "licensing", "telemetry" + ], + "projectsRelationship": "fixed" + } + }, + "version": { + "preVersionCommand": "npx nx run-many -t build --projects=chat,langgraph,ag-ui,render,a2ui,partial-json,licensing,telemetry" + } +} +``` + +Telemetry's first published version matches the fixed-group's next bump (e.g. if chat is at 0.0.30, the next release publishes telemetry@0.0.31 alongside chat@0.0.31). Per user memory: never bump to 0.1.0; always increment patch even for breaking changes. + +## 9. Testing strategy + +| Surface | File | Approx tests | +|---------|------|--------------| +| Env detection (5 opt-out paths) | `shared/env.spec.ts` | 8 | +| Hashing | `shared/hash.spec.ts` | 3 | +| Sampling math | `shared/sample.spec.ts` | 5 | +| Anonymous id | `shared/anon-id.spec.ts` | 3 | +| Node client | `node/client.spec.ts` | 6 | +| Adapter helpers | `node/adapter.spec.ts` | 5 | +| Postinstall | `node/postinstall.spec.ts` | 4 | +| Browser provide() | `browser/provide.spec.ts` | 4 | +| Browser service | `browser/service.spec.ts` | 5 | +| **Permanent browser silence test** | `browser/browser-silence.spec.ts` | 1 | +| **Total** | | **~44 tests** | + +### 9.1 The permanent browser silence test + +```typescript +test('no network call occurs when provideNgafTelemetry is never called', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + const importSpy = vi.fn(); + vi.doMock('posthog-js', importSpy); + + // Bootstrap a minimal Angular app without provideNgafTelemetry(). + const app = await bootstrapApplication(NoOpComponent, { providers: [] }); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(importSpy).not.toHaveBeenCalled(); +}); + +test('no network call when provideNgafTelemetry({ enabled: false }) is called', async () => { + // Same setup but with provideNgafTelemetry({ enabled: false }) — same assertions. +}); +``` + +This stays green permanently. Any future change that violates the trust contract — even a stray top-level `import 'posthog-js'` — fails the build immediately. + +### 9.2 What we deliberately don't test + +- Real PostHog network calls (covered by Spec 1A's manual round-trip verification pattern). +- Cross-package postinstall propagation (out of scope; runtime adapters are the primary signal). +- Browser e2e against a real PostHog project (manual verification step, not CI). + +## 10. Risks & non-goals + +### 10.1 Risks + +| # | Risk | Mitigation | +|--:|------|------------| +| 1 | Default ingest URL points at a 404 until Spec 1D ships | Silent-fail principle absorbs it. Telemetry data is opt-out anyway — losing it for the gap between 1B and 1D is acceptable. README's "Reporting an issue" link gives users a path if they notice. | +| 2 | Lazy `posthog-js` import bypassed by stray top-level import | Permanent browser silence test fails the build immediately if anyone adds a top-level `import 'posthog-js'`. | +| 3 | Postinstall fails on locked-down npm runners (no network, strict perms) | Script wrapped in `\|\| true` in package.json. Postinstall NEVER fails `npm install`. | +| 4 | Sample rate stamping miscomputed at query time | `sample_weight` stamped per event; PostHog dashboards multiply by weight when de-sampling. Unit tests on `shouldSample()` cover edge cases (rate=0, rate=1, rate>1, rate<0). | +| 5 | Angular peer dep version drift | Same `^20.0.0 \|\| ^21.0.0` range as other `@ngaf/*` libs. Future Angular major version triggers a coordinated bump across the fixed group. | +| 6 | `@nx/js:tsc` doesn't natively handle `additionalEntryPoints` cleanly | Verified during scaffolding (Task 2). If it doesn't, fall back to two separate Nx projects under one published package — last resort. | + +### 10.2 Non-goals (Spec 1B) + +- No browser auto-init. `provideNgafTelemetry()` MUST be called explicitly with `enabled: true`. +- No tracking of which `@ngaf/*` packages installed alongside this one. Runtime adapter calls cover that signal. +- No PII collection. Email, names, message content, prompts, completions, project paths — never. +- No persistent identifier (no localStorage, no cookies, no filesystem). Per-process UUIDs only. +- No automatic sample-rate adjustment. Operator-controlled via env var. +- No bundle-size SLA enforcement. (Future hardening; not blocking.) + +## 11. Deliverables of this spec + +The plan at `docs/superpowers/plans/gtm/2026-05-15-analytics-foundation-1b-ngaf-telemetry.md` will check off: + +- [ ] `libs/telemetry/{package,project,tsconfig,tsconfig.lib,tsconfig.lib.browser,ng-package,eslint.config,jest.config}.json/.mjs/.ts` +- [ ] `libs/telemetry/src/index.ts` + `shared/` module (env, hash, anon-id, sample, events) with tests +- [ ] `libs/telemetry/src/node/` module (client, postinstall, adapter, disable) with tests +- [ ] `libs/telemetry/src/browser/` module (provide, service, tokens, public-api) with tests +- [ ] Permanent browser silence test (`browser-silence.spec.ts`) +- [ ] `nx.json` updated: `telemetry` added to `publishable` group + preVersionCommand build list +- [ ] `.env.example` updated with `NGAF_TELEMETRY_INGEST_URL` + `NGAF_TELEMETRY_SAMPLE_RATE` +- [ ] `libs/telemetry/README.md` minor implementation alignment (env-var precedence, exports map docs) +- [ ] Verification: `nx run telemetry:build` (both targets), `nx run telemetry:test` (~44 tests pass), `nx run telemetry:lint` +- [ ] Verification: built `dist/libs/telemetry/package.json` has correct exports map; pack tarball with `npm pack ./dist/libs/telemetry` and inspect. + +## 12. References + +- Parent: [docs/superpowers/specs/gtm/2026-05-13-gtm-meta-design.md](2026-05-13-gtm-meta-design.md) +- Sibling 1A (shipped): [docs/superpowers/specs/gtm/2026-05-14-analytics-foundation-1a-dashboards-as-code-design.md](2026-05-14-analytics-foundation-1a-dashboards-as-code-design.md) +- Trust contract: [libs/telemetry/README.md](../../../../libs/telemetry/README.md) +- Taxonomy: [docs/gtm/taxonomy.md](../../../gtm/taxonomy.md) (`ngaf:*` events) +- Cowork skill that consumes this library's events: [cowork/gtm/SKILL.md](../../../../cowork/gtm/SKILL.md) +- PostHog Node client: https://github.com/PostHog/posthog-js-lite +- Angular `EnvironmentProviders`: https://angular.dev/api/core/EnvironmentProviders diff --git a/gtm.md b/gtm.md index 5b103169..f16eaa5c 100644 --- a/gtm.md +++ b/gtm.md @@ -79,7 +79,7 @@ Operational progress lives in agent runs and PostHog. The repo holds durable str |------:|----------------------------|-------------------------------------------|-------------------------------------------------------------------------------|----------------------------| | 0 | gtm-meta | `cowork/gtm/SKILL.md` | [meta](docs/superpowers/specs/gtm/2026-05-13-gtm-meta-design.md) | — | | 0 | analytics-foundation-1a | `cowork/gtm/SKILL.md` | [spec](docs/superpowers/specs/gtm/2026-05-14-analytics-foundation-1a-dashboards-as-code-design.md) | `developer-funnel` (sample) | -| 0 | analytics-foundation-1b | `cowork/gtm/SKILL.md` | (pending) | `package-telemetry` | +| 0 | analytics-foundation-1b | `cowork/gtm/SKILL.md` | [spec](docs/superpowers/specs/gtm/2026-05-15-analytics-foundation-1b-ngaf-telemetry-design.md) | `package-telemetry` | | 0 | analytics-foundation-1c | `cowork/gtm/SKILL.md` | (pending) | `activation-six-signals` | | 0 | analytics-foundation-1d | `cowork/gtm/SKILL.md` | (pending) | `enterprise-funnel` | | 1 | positioning-and-risks | `cowork/gtm/SKILL.md` | (pending) | — | diff --git a/libs/telemetry/README.md b/libs/telemetry/README.md index 14694c86..27ff455d 100644 --- a/libs/telemetry/README.md +++ b/libs/telemetry/README.md @@ -3,6 +3,25 @@ > Skeleton. Implementation lands in Spec 1 (`analytics-foundation`). > This README is the **public trust contract**. It's linked from the homepage footer, package READMEs, and the postinstall opt-out notice. The contract is locked here so it doesn't drift. +## Imports + +```typescript +// Browser (Angular DI provider) +import { provideNgafTelemetry } from '@ngaf/telemetry/browser'; + +// Node (server adapters) +import { + captureRuntimeInstanceCreated, + captureStreamStarted, + captureStreamEnded, + captureStreamErrored, + disableTelemetry, +} from '@ngaf/telemetry/node'; + +// Shared utilities (events, env detection, hashing) +import { isTelemetryDisabled, sha256, getAnonId } from '@ngaf/telemetry'; +``` + ## What this package is The single telemetry surface for `@ngaf/*`. It exists so we can answer "how is Cacheplane being used?" without instrumenting browser packages that ship to end-users. diff --git a/libs/telemetry/eslint.config.mjs b/libs/telemetry/eslint.config.mjs new file mode 100644 index 00000000..492d6d0e --- /dev/null +++ b/libs/telemetry/eslint.config.mjs @@ -0,0 +1,2 @@ +import baseConfig from '../../eslint.config.mjs'; +export default [...baseConfig, { files: ['**/*.ts'] }]; diff --git a/libs/telemetry/ng-package.json b/libs/telemetry/ng-package.json new file mode 100644 index 00000000..0426a9c0 --- /dev/null +++ b/libs/telemetry/ng-package.json @@ -0,0 +1,8 @@ +{ + "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", + "dest": "../../dist/libs/telemetry/browser", + "lib": { + "entryFile": "src/browser/public-api.ts" + }, + "allowedNonPeerDependencies": ["posthog-js", "posthog-node"] +} diff --git a/libs/telemetry/package.json b/libs/telemetry/package.json new file mode 100644 index 00000000..3858f671 --- /dev/null +++ b/libs/telemetry/package.json @@ -0,0 +1,45 @@ +{ + "name": "@ngaf/telemetry", + "version": "0.0.0", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/cacheplane/angular-agent-framework.git", + "directory": "libs/telemetry" + }, + "homepage": "https://github.com/cacheplane/angular-agent-framework#readme", + "bugs": { "url": "https://github.com/cacheplane/angular-agent-framework/issues" }, + "sideEffects": false, + "type": "module", + "exports": { + ".": { + "types": "./index.d.ts", + "default": "./fesm2022/ngaf-telemetry.mjs" + }, + "./node": { + "types": "./node/index.d.ts", + "default": "./node/index.mjs" + }, + "./browser": { + "types": "./browser/index.d.ts", + "default": "./fesm2022/ngaf-telemetry-browser.mjs" + }, + "./postinstall": { + "types": "./node/postinstall.d.ts", + "default": "./node/postinstall.mjs" + } + }, + "peerDependencies": { + "@angular/core": "^20.0.0 || ^21.0.0" + }, + "peerDependenciesMeta": { + "@angular/core": { "optional": true } + }, + "dependencies": { + "posthog-js": "^1.372.0", + "posthog-node": "^5.20.0" + }, + "scripts": { + "postinstall": "node ./node/postinstall.mjs || true" + } +} diff --git a/libs/telemetry/project.json b/libs/telemetry/project.json new file mode 100644 index 00000000..1fc43b25 --- /dev/null +++ b/libs/telemetry/project.json @@ -0,0 +1,44 @@ +{ + "name": "telemetry", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/telemetry/src", + "projectType": "library", + "tags": ["scope:shared", "type:lib"], + "targets": { + "build:node": { + "executor": "@nx/js:tsc", + "outputs": ["{workspaceRoot}/dist/libs/telemetry"], + "options": { + "outputPath": "dist/libs/telemetry", + "main": "libs/telemetry/src/index.ts", + "additionalEntryPoints": [ + "libs/telemetry/src/node/index.ts", + "libs/telemetry/src/node/postinstall.ts" + ], + "tsConfig": "libs/telemetry/tsconfig.lib.json", + "assets": ["libs/telemetry/README.md", "libs/telemetry/package.json"] + } + }, + "build:browser": { + "executor": "@nx/angular:package", + "outputs": ["{workspaceRoot}/dist/libs/telemetry/browser"], + "options": { + "project": "libs/telemetry/ng-package.json", + "tsConfig": "libs/telemetry/tsconfig.lib.browser.json" + }, + "dependsOn": ["build:node"] + }, + "build": { + "dependsOn": ["build:node", "build:browser"], + "executor": "nx:run-commands", + "options": { "command": "true" } + }, + "test": { + "executor": "@nx/vitest:test", + "options": { + "configFile": "libs/telemetry/vite.config.mts" + } + }, + "lint": { "executor": "@nx/eslint:lint" } + } +} diff --git a/libs/telemetry/src/browser/browser-silence.spec.ts b/libs/telemetry/src/browser/browser-silence.spec.ts new file mode 100644 index 00000000..6975385d --- /dev/null +++ b/libs/telemetry/src/browser/browser-silence.spec.ts @@ -0,0 +1,36 @@ +// @vitest-environment jsdom +/** + * PERMANENT CONTRACT TEST. + * + * The trust contract at libs/telemetry/README.md promises that + * @ngaf/telemetry/browser fires zero network calls and triggers zero + * imports of posthog-js when the consumer does not call + * provideNgafTelemetry() or calls it with enabled:false. + * + * If this test ever fails, the trust contract has been violated. + * Do not "fix" the test — fix the offending import or call site. + */ +import { describe, test, expect, vi } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { provideNgafTelemetry } from './provide'; +import { NgafTelemetryService } from './service'; + +vi.mock('posthog-js', () => { + throw new Error('posthog-js MUST NOT be imported when telemetry is not enabled'); +}); + +describe('browser silence (permanent contract)', () => { + test('no posthog-js import when provideNgafTelemetry is never called', async () => { + TestBed.configureTestingModule({ providers: [] }); + expect(NgafTelemetryService).toBeDefined(); + }); + + test('no posthog-js import when provideNgafTelemetry({ enabled: false })', async () => { + TestBed.configureTestingModule({ + providers: [provideNgafTelemetry({ enabled: false })], + }); + const svc = TestBed.inject(NgafTelemetryService); + await svc.capture('ngaf:browser_provided'); + expect(svc).toBeInstanceOf(NgafTelemetryService); + }); +}); diff --git a/libs/telemetry/src/browser/provide.spec.ts b/libs/telemetry/src/browser/provide.spec.ts new file mode 100644 index 00000000..16a82019 --- /dev/null +++ b/libs/telemetry/src/browser/provide.spec.ts @@ -0,0 +1,38 @@ +// @vitest-environment jsdom +import { describe, test, expect } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { provideNgafTelemetry } from './provide'; +import { NgafTelemetryService } from './service'; +import { NGAF_TELEMETRY_CONFIG } from './tokens'; + +describe('provideNgafTelemetry', () => { + test('returns EnvironmentProviders that bind config + service', () => { + TestBed.configureTestingModule({ + providers: [provideNgafTelemetry({ enabled: false })], + }); + expect(TestBed.inject(NGAF_TELEMETRY_CONFIG)).toEqual({ enabled: false }); + expect(TestBed.inject(NgafTelemetryService)).toBeInstanceOf(NgafTelemetryService); + }); + + test('config defaults: sampleRate defaults to 1.0 when omitted', () => { + TestBed.configureTestingModule({ + providers: [provideNgafTelemetry({ enabled: true, posthogKey: 'phc_x' })], + }); + const cfg = TestBed.inject(NGAF_TELEMETRY_CONFIG); + expect(cfg?.sampleRate ?? 1.0).toBe(1.0); + }); + + test('posthogHost passes through', () => { + TestBed.configureTestingModule({ + providers: [provideNgafTelemetry({ enabled: true, posthogKey: 'x', posthogHost: 'https://eu.i.posthog.com' })], + }); + expect(TestBed.inject(NGAF_TELEMETRY_CONFIG)?.posthogHost).toBe('https://eu.i.posthog.com'); + }); + + test('enabled:true without posthogKey still resolves (service no-ops at call time)', () => { + TestBed.configureTestingModule({ + providers: [provideNgafTelemetry({ enabled: true })], + }); + expect(TestBed.inject(NgafTelemetryService)).toBeInstanceOf(NgafTelemetryService); + }); +}); diff --git a/libs/telemetry/src/browser/provide.ts b/libs/telemetry/src/browser/provide.ts new file mode 100644 index 00000000..b1c1537d --- /dev/null +++ b/libs/telemetry/src/browser/provide.ts @@ -0,0 +1,10 @@ +import { makeEnvironmentProviders, type EnvironmentProviders } from '@angular/core'; +import { NGAF_TELEMETRY_CONFIG, type NgafTelemetryConfig } from './tokens'; +import { NgafTelemetryService } from './service'; + +export function provideNgafTelemetry(config: NgafTelemetryConfig): EnvironmentProviders { + return makeEnvironmentProviders([ + { provide: NGAF_TELEMETRY_CONFIG, useValue: config }, + NgafTelemetryService, + ]); +} diff --git a/libs/telemetry/src/browser/public-api.ts b/libs/telemetry/src/browser/public-api.ts new file mode 100644 index 00000000..d68ddf89 --- /dev/null +++ b/libs/telemetry/src/browser/public-api.ts @@ -0,0 +1,4 @@ +export { provideNgafTelemetry } from './provide'; +export { NgafTelemetryService } from './service'; +export { NGAF_TELEMETRY_CONFIG } from './tokens'; +export type { NgafTelemetryConfig } from './tokens'; diff --git a/libs/telemetry/src/browser/service.spec.ts b/libs/telemetry/src/browser/service.spec.ts new file mode 100644 index 00000000..6209552a --- /dev/null +++ b/libs/telemetry/src/browser/service.spec.ts @@ -0,0 +1,55 @@ +// @vitest-environment jsdom +import { describe, test, expect } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { NgafTelemetryService } from './service'; +import { NGAF_TELEMETRY_CONFIG } from './tokens'; + +describe('NgafTelemetryService', () => { + test('capture() resolves without calling posthog when enabled is false', async () => { + TestBed.configureTestingModule({ + providers: [ + { provide: NGAF_TELEMETRY_CONFIG, useValue: { enabled: false } }, + NgafTelemetryService, + ], + }); + const svc = TestBed.inject(NgafTelemetryService); + await expect(svc.capture('ngaf:browser_provided')).resolves.toBeUndefined(); + }); + + test('capture() resolves without calling posthog when no config provided', async () => { + TestBed.configureTestingModule({ + providers: [ + { provide: NGAF_TELEMETRY_CONFIG, useValue: null }, + NgafTelemetryService, + ], + }); + const svc = TestBed.inject(NgafTelemetryService); + await expect(svc.capture('ngaf:browser_provided')).resolves.toBeUndefined(); + }); + + test('capture() no-ops when posthogKey is missing even with enabled:true', async () => { + TestBed.configureTestingModule({ + providers: [ + { provide: NGAF_TELEMETRY_CONFIG, useValue: { enabled: true } }, + NgafTelemetryService, + ], + }); + const svc = TestBed.inject(NgafTelemetryService); + await expect(svc.capture('ngaf:browser_provided')).resolves.toBeUndefined(); + }); + + test('capture() with enabled:true and posthogKey invokes posthog-js (lazy)', async () => { + TestBed.configureTestingModule({ + providers: [ + { provide: NGAF_TELEMETRY_CONFIG, useValue: { enabled: true, posthogKey: 'phc_test' } }, + NgafTelemetryService, + ], + }); + const svc = TestBed.inject(NgafTelemetryService); + expect(typeof svc.capture).toBe('function'); + }); + + test('service is provided as root-scoped', () => { + expect(NgafTelemetryService).toBeDefined(); + }); +}); diff --git a/libs/telemetry/src/browser/service.ts b/libs/telemetry/src/browser/service.ts new file mode 100644 index 00000000..d78492f2 --- /dev/null +++ b/libs/telemetry/src/browser/service.ts @@ -0,0 +1,39 @@ +import { Injectable, inject } from '@angular/core'; +import { NGAF_TELEMETRY_CONFIG, type NgafTelemetryConfig } 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'; + +@Injectable({ providedIn: 'root' }) +export class NgafTelemetryService { + private config: NgafTelemetryConfig | null = inject(NGAF_TELEMETRY_CONFIG, { optional: true }); + private postHogPromise: Promise | null = null; + + async capture(event: NgafBrowserEvent, properties?: Record): Promise { + if (!this.config?.enabled || !this.config.posthogKey) return; + try { + const ph = await this.loadPostHog(); + if (!ph) return; + ph.capture(event, properties); + } catch { + // silent fail + } + } + + private loadPostHog(): Promise { + if (!this.postHogPromise) { + this.postHogPromise = import('posthog-js').then((mod) => { + if (!this.config?.posthogKey) return null; + mod.default.init(this.config.posthogKey, { + api_host: this.config.posthogHost ?? 'https://us.i.posthog.com', + }); + return mod.default; + }).catch(() => null); + } + return this.postHogPromise; + } +} diff --git a/libs/telemetry/src/browser/tokens.ts b/libs/telemetry/src/browser/tokens.ts new file mode 100644 index 00000000..c15b220e --- /dev/null +++ b/libs/telemetry/src/browser/tokens.ts @@ -0,0 +1,12 @@ +import { InjectionToken } from '@angular/core'; + +export interface NgafTelemetryConfig { + enabled: boolean; + posthogKey?: string; + posthogHost?: string; + sampleRate?: number; +} + +export const NGAF_TELEMETRY_CONFIG = new InjectionToken( + 'NGAF_TELEMETRY_CONFIG', +); diff --git a/libs/telemetry/src/index.ts b/libs/telemetry/src/index.ts new file mode 100644 index 00000000..9b8225d9 --- /dev/null +++ b/libs/telemetry/src/index.ts @@ -0,0 +1,5 @@ +export { isTelemetryDisabled, getDisableReason } from './shared/env.js'; +export { sha256 } from './shared/hash.js'; +export { getAnonId } from './shared/anon-id.js'; +export { shouldSample } from './shared/sample.js'; +export type { NgafEvent, NgafNodeEvent, NgafBrowserEvent } from './shared/events.js'; diff --git a/libs/telemetry/src/node/adapter.spec.ts b/libs/telemetry/src/node/adapter.spec.ts new file mode 100644 index 00000000..be8a3fd9 --- /dev/null +++ b/libs/telemetry/src/node/adapter.spec.ts @@ -0,0 +1,63 @@ +import { describe, test, expect, beforeEach, vi } from 'vitest'; + +vi.mock('./client', () => ({ + captureEvent: vi.fn().mockResolvedValue(undefined), +})); + +import { captureEvent } from './client'; +import { + captureRuntimeInstanceCreated, + captureStreamStarted, + captureStreamEnded, + captureStreamErrored, +} from './adapter'; + + +describe('adapter helpers', () => { + beforeEach(() => vi.mocked(captureEvent).mockClear()); + + test('captureRuntimeInstanceCreated hashes any apiKey property', async () => { + await captureRuntimeInstanceCreated({ + transport: 'langgraph', + provider: 'openai', + apiKey: 'secret-token-xyz', + }); + const call = vi.mocked(captureEvent).mock.calls[0]; + expect(call[0]).toBe('ngaf:runtime_instance_created'); + expect((call[1] as Record).apiKey).toBeUndefined(); // raw key stripped + expect((call[1] as Record).apiKey_sha256).toMatch(/^[a-f0-9]{64}$/); + }); + + test('captureStreamStarted records provider + model only', async () => { + await captureStreamStarted({ provider: 'openai', model: 'gpt-4' }); + expect(captureEvent).toHaveBeenCalledWith( + 'ngaf:stream_started', + expect.objectContaining({ provider: 'openai', model: 'gpt-4' }), + ); + }); + + test('captureStreamEnded records duration', async () => { + await captureStreamEnded({ provider: 'openai', model: 'gpt-4', durationMs: 1234 }); + expect(captureEvent).toHaveBeenCalledWith( + 'ngaf:stream_ended', + expect.objectContaining({ durationMs: 1234 }), + ); + }); + + test('captureStreamErrored records error.class only — no message', async () => { + await captureStreamErrored({ + provider: 'openai', + model: 'gpt-4', + error: new TypeError('detailed error with PII xxxx'), + }); + const props = vi.mocked(captureEvent).mock.calls[0][1] as Record; + expect(props.errorClass).toBe('TypeError'); + expect(props.errorMessage).toBeUndefined(); + expect(JSON.stringify(props)).not.toMatch(/detailed error/); + }); + + test('all helpers no-op silently when captureEvent rejects', async () => { + vi.mocked(captureEvent).mockRejectedValueOnce(new Error('network')); + await expect(captureStreamStarted({ provider: 'x', model: 'y' })).resolves.toBeUndefined(); + }); +}); diff --git a/libs/telemetry/src/node/adapter.ts b/libs/telemetry/src/node/adapter.ts new file mode 100644 index 00000000..ddda6812 --- /dev/null +++ b/libs/telemetry/src/node/adapter.ts @@ -0,0 +1,47 @@ +import { captureEvent } from './client.js'; +import { sha256 } from '../shared/hash.js'; + +export interface RuntimeInstanceTelemetry { + transport: string; // 'langgraph' | 'ag-ui' | 'custom' + provider?: string; // 'openai' | 'anthropic' | ... + model?: string; + angularVersion?: string; + apiKey?: string; // hashed before sending +} + +export interface StreamTelemetry { + provider: string; + model: string; + durationMs?: number; +} + +async function safe(fn: () => Promise): Promise { + try { await fn(); } catch { /* silent fail */ } +} + +export async function captureRuntimeInstanceCreated(input: RuntimeInstanceTelemetry): Promise { + await safe(async () => { + const { apiKey, ...rest } = input; + const props: Record = { ...rest }; + if (apiKey) props.apiKey_sha256 = await sha256(apiKey); + await captureEvent('ngaf:runtime_instance_created', props); + }); +} + +export async function captureStreamStarted(input: StreamTelemetry): Promise { + await safe(() => captureEvent('ngaf:stream_started', { ...input })); +} + +export async function captureStreamEnded(input: StreamTelemetry): Promise { + await safe(() => captureEvent('ngaf:stream_ended', { ...input })); +} + +export async function captureStreamErrored( + input: StreamTelemetry & { error: Error | unknown }, +): Promise { + await safe(async () => { + const { error, ...rest } = input; + const errorClass = error instanceof Error ? error.constructor.name : 'Unknown'; + await captureEvent('ngaf:stream_errored', { ...rest, errorClass }); + }); +} diff --git a/libs/telemetry/src/node/client.spec.ts b/libs/telemetry/src/node/client.spec.ts new file mode 100644 index 00000000..2aa48860 --- /dev/null +++ b/libs/telemetry/src/node/client.spec.ts @@ -0,0 +1,83 @@ +import { describe, test, expect, beforeEach, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + PostHog: vi.fn(), +})); + +vi.mock('posthog-node', () => ({ + PostHog: mocks.PostHog, +})); + +// Import AFTER mock so the mock takes effect. +import { PostHog } from 'posthog-node'; +import { capturePostinstall, _resetClientForTesting } from './client'; +import { disableTelemetry, _resetDisableForTesting } from './disable'; + +describe('node client', () => { + beforeEach(() => { + mocks.PostHog.mockReset(); + mocks.PostHog.mockImplementation(function () { + return { + capture: vi.fn(), + shutdown: vi.fn().mockResolvedValue(undefined), + }; + }); + _resetClientForTesting(); + _resetDisableForTesting(); + delete process.env.DO_NOT_TRACK; + delete process.env.NGAF_TELEMETRY_DISABLED; + delete process.env.CI; + process.env.NGAF_TELEMETRY_INGEST_URL = 'https://test.example/api/ingest'; + }); + + test('capturePostinstall sends an event with pkg + version', async () => { + const instance = { capture: vi.fn(), shutdown: vi.fn().mockResolvedValue(undefined) }; + mocks.PostHog.mockImplementation(function () { return instance; }); + await capturePostinstall({ pkg: '@ngaf/telemetry', version: '0.0.31' }); + expect(instance.capture).toHaveBeenCalledWith(expect.objectContaining({ + event: 'ngaf:postinstall', + properties: expect.objectContaining({ pkg: '@ngaf/telemetry', version: '0.0.31' }), + })); + }); + + test('capturePostinstall no-ops when DO_NOT_TRACK is set', async () => { + process.env.DO_NOT_TRACK = '1'; + await capturePostinstall({ pkg: 'x', version: '1' }); + expect(PostHog).not.toHaveBeenCalled(); + }); + + test('capturePostinstall no-ops after disableTelemetry()', async () => { + disableTelemetry(); + await capturePostinstall({ pkg: 'x', version: '1' }); + expect(PostHog).not.toHaveBeenCalled(); + }); + + test('capturePostinstall uses NGAF_TELEMETRY_INGEST_URL when set', async () => { + process.env.NGAF_TELEMETRY_INGEST_URL = 'https://custom.example/api/ingest'; + await capturePostinstall({ pkg: 'x', version: '1' }); + expect(PostHog).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ host: 'https://custom.example/api/ingest' }), + ); + }); + + test('capturePostinstall sends sample_weight property', async () => { + const instance = { capture: vi.fn(), shutdown: vi.fn().mockResolvedValue(undefined) }; + mocks.PostHog.mockImplementation(function () { return instance; }); + await capturePostinstall({ pkg: 'x', version: '1' }); + expect(instance.capture).toHaveBeenCalledWith(expect.objectContaining({ + properties: expect.objectContaining({ sample_weight: expect.any(Number) }), + })); + }); + + test('capturePostinstall awaits shutdown before resolving', async () => { + let shutdownCalled = false; + const instance = { + capture: vi.fn(), + shutdown: vi.fn(async () => { shutdownCalled = true; }), + }; + mocks.PostHog.mockImplementation(function () { return instance; }); + await capturePostinstall({ pkg: 'x', version: '1' }); + expect(shutdownCalled).toBe(true); + }); +}); diff --git a/libs/telemetry/src/node/client.ts b/libs/telemetry/src/node/client.ts new file mode 100644 index 00000000..53a295bd --- /dev/null +++ b/libs/telemetry/src/node/client.ts @@ -0,0 +1,60 @@ +import { PostHog } from 'posthog-node'; +import { getAnonId } from '../shared/anon-id.js'; +import { isTelemetryDisabled } from '../shared/env.js'; +import { shouldSample } from '../shared/sample.js'; +import type { NgafNodeEvent } from '../shared/events.js'; +import { isProgrammaticallyDisabled } from './disable.js'; + +const DEFAULT_INGEST = 'https://cacheplane.dev/api/ingest'; +// This token is the public Cacheplane PostHog project key (the proxy strips it +// and re-keys server-side). It's a Project API key, not a Personal API key, so +// it's safe to ship in OSS code. +const PUBLIC_INGEST_KEY = 'phc_public_cacheplane_telemetry'; + +let cached: PostHog | null = null; + +function getClient(): PostHog | null { + if (cached) return cached; + if (isTelemetryDisabled() || isProgrammaticallyDisabled()) return null; + const host = process.env.NGAF_TELEMETRY_INGEST_URL ?? DEFAULT_INGEST; + cached = new PostHog(PUBLIC_INGEST_KEY, { + host, + flushAt: 1, + flushInterval: 0, + }); + return cached; +} + +export async function captureEvent(event: NgafNodeEvent, properties: Record = {}): Promise { + const client = getClient(); + if (!client) return; + const rate = Number(process.env.NGAF_TELEMETRY_SAMPLE_RATE ?? '1'); + const anonId = getAnonId(); + if (!shouldSample(rate, anonId)) return; + try { + client.capture({ + distinctId: anonId, + event, + properties: { ...properties, sample_weight: rate > 0 ? 1 / Math.min(1, rate) : 1 }, + }); + await client.shutdown(); + } catch { + // silent fail + } finally { + cached = null; // fresh client per process; flushAt:1 means we're done + } +} + +export async function capturePostinstall(input: { pkg: string; version: string }): Promise { + await captureEvent('ngaf:postinstall', { + pkg: input.pkg, + version: input.version, + node: process.version, + os: process.platform, + }); +} + +// @internal — tests only +export function _resetClientForTesting(): void { + cached = null; +} diff --git a/libs/telemetry/src/node/disable.ts b/libs/telemetry/src/node/disable.ts new file mode 100644 index 00000000..62cd673c --- /dev/null +++ b/libs/telemetry/src/node/disable.ts @@ -0,0 +1,14 @@ +let disabled = false; + +export function disableTelemetry(): void { + disabled = true; +} + +export function isProgrammaticallyDisabled(): boolean { + return disabled; +} + +// @internal — tests only +export function _resetDisableForTesting(): void { + disabled = false; +} diff --git a/libs/telemetry/src/node/index.ts b/libs/telemetry/src/node/index.ts new file mode 100644 index 00000000..3975ff6a --- /dev/null +++ b/libs/telemetry/src/node/index.ts @@ -0,0 +1,9 @@ +export { disableTelemetry } from './disable.js'; +export { capturePostinstall, captureEvent } from './client.js'; +export { + captureRuntimeInstanceCreated, + captureStreamStarted, + captureStreamEnded, + captureStreamErrored, +} from './adapter.js'; +export type { RuntimeInstanceTelemetry, StreamTelemetry } from './adapter.js'; diff --git a/libs/telemetry/src/node/postinstall.spec.ts b/libs/telemetry/src/node/postinstall.spec.ts new file mode 100644 index 00000000..71065e4a --- /dev/null +++ b/libs/telemetry/src/node/postinstall.spec.ts @@ -0,0 +1,58 @@ +import { describe, test, expect, beforeEach, vi } from 'vitest'; + +vi.mock('./client', () => ({ + capturePostinstall: vi.fn().mockResolvedValue(undefined), +})); + +import { capturePostinstallScript } from './postinstall'; +import { capturePostinstall } from './client'; + +describe('postinstall script', () => { + beforeEach(() => { + vi.mocked(capturePostinstall).mockClear(); + delete process.env.CI; + delete process.env.DO_NOT_TRACK; + }); + + test('calls capturePostinstall with the package name + version', async () => { + const stdout: string[] = []; + await capturePostinstallScript({ + readPackageJson: () => ({ name: '@ngaf/telemetry', version: '0.0.31' }), + write: (s: string) => stdout.push(s), + env: { ...process.env }, + }); + expect(capturePostinstall).toHaveBeenCalledWith({ pkg: '@ngaf/telemetry', version: '0.0.31' }); + }); + + test('prints the opt-out notice to stdout when not CI', async () => { + const stdout: string[] = []; + await capturePostinstallScript({ + readPackageJson: () => ({ name: '@ngaf/telemetry', version: '0.0.31' }), + write: (s: string) => stdout.push(s), + env: { ...process.env }, + }); + expect(stdout.join('')).toMatch(/@ngaf\/telemetry: sent install ping/); + expect(stdout.join('')).toMatch(/DO_NOT_TRACK=1/); + }); + + test('suppresses stdout notice when CI=true', async () => { + const stdout: string[] = []; + await capturePostinstallScript({ + readPackageJson: () => ({ name: '@ngaf/telemetry', version: '0.0.31' }), + write: (s: string) => stdout.push(s), + env: { ...process.env, CI: 'true' }, + }); + expect(stdout).toEqual([]); + }); + + test('swallows readPackageJson errors silently', async () => { + await expect( + capturePostinstallScript({ + readPackageJson: () => { throw new Error('not found'); }, + write: (_s: string) => undefined, + env: { ...process.env }, + }), + ).resolves.toBeUndefined(); + expect(capturePostinstall).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/telemetry/src/node/postinstall.ts b/libs/telemetry/src/node/postinstall.ts new file mode 100644 index 00000000..80806938 --- /dev/null +++ b/libs/telemetry/src/node/postinstall.ts @@ -0,0 +1,50 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { capturePostinstall } from './client.js'; +import { isTelemetryDisabled } from '../shared/env.js'; + +interface PostinstallDeps { + readPackageJson: () => { name: string; version: string }; + write: (s: string) => void; + env: NodeJS.ProcessEnv; +} + +export async function capturePostinstallScript(deps: PostinstallDeps): Promise { + if (isTelemetryDisabled(deps.env)) return; + let pkg: { name: string; version: string }; + try { + pkg = deps.readPackageJson(); + } catch { + return; + } + try { + await capturePostinstall({ pkg: pkg.name, version: pkg.version }); + if (!deps.env.CI) { + deps.write( + `@ngaf/telemetry: sent install ping (${pkg.name}@${pkg.version}). ` + + `Disable: DO_NOT_TRACK=1 or NGAF_TELEMETRY_DISABLED=1. ` + + `See https://github.com/cacheplane/angular-agent-framework/blob/main/libs/telemetry/README.md\n`, + ); + } + } catch { + // never break npm install + } +} + +// Entry point — invoked by package.json scripts.postinstall. +async function main(): Promise { + await capturePostinstallScript({ + readPackageJson: () => { + const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'); + return JSON.parse(readFileSync(pkgPath, 'utf8')); + }, + write: (s) => process.stdout.write(s), + env: process.env, + }); +} + +// Only run as main entry, not when imported by tests. +const isDirectRun = + process.argv[1] && import.meta.url === `file://${process.argv[1]}`; +if (isDirectRun) main(); diff --git a/libs/telemetry/src/shared/anon-id.spec.ts b/libs/telemetry/src/shared/anon-id.spec.ts new file mode 100644 index 00000000..80d0c2a6 --- /dev/null +++ b/libs/telemetry/src/shared/anon-id.spec.ts @@ -0,0 +1,23 @@ +import { describe, test, expect, beforeEach } from 'vitest'; +import { getAnonId, _resetAnonIdForTesting } from './anon-id'; + +describe('getAnonId', () => { + beforeEach(() => _resetAnonIdForTesting()); + + test('returns a stable id within a process', () => { + const a = getAnonId(); + const b = getAnonId(); + expect(a).toBe(b); + }); + + test('matches anon_ shape', () => { + expect(getAnonId()).toMatch(/^anon_[0-9a-f-]{36}$/); + }); + + test('different processes get different ids (simulated via reset)', () => { + const a = getAnonId(); + _resetAnonIdForTesting(); + const b = getAnonId(); + expect(a).not.toBe(b); + }); +}); diff --git a/libs/telemetry/src/shared/anon-id.ts b/libs/telemetry/src/shared/anon-id.ts new file mode 100644 index 00000000..ace4ba01 --- /dev/null +++ b/libs/telemetry/src/shared/anon-id.ts @@ -0,0 +1,13 @@ +import { randomUUID } from 'node:crypto'; + +let cached: string | null = null; + +export function getAnonId(): string { + if (!cached) cached = `anon_${randomUUID()}`; + return cached; +} + +// @internal — for tests only +export function _resetAnonIdForTesting(): void { + cached = null; +} diff --git a/libs/telemetry/src/shared/env.spec.ts b/libs/telemetry/src/shared/env.spec.ts new file mode 100644 index 00000000..f1c060e8 --- /dev/null +++ b/libs/telemetry/src/shared/env.spec.ts @@ -0,0 +1,58 @@ +import { describe, test, expect, beforeEach } from 'vitest'; +import { isTelemetryDisabled, getDisableReason } from './env'; + +describe('isTelemetryDisabled', () => { + beforeEach(() => { + delete process.env.DO_NOT_TRACK; + delete process.env.NGAF_TELEMETRY_DISABLED; + delete process.env.CI; + delete process.env.GITHUB_ACTIONS; + delete process.env.CONTINUOUS_INTEGRATION; + delete process.env.BUILDKITE; + delete process.env.CIRCLECI; + }); + + test('returns false with no env signals', () => { + expect(isTelemetryDisabled()).toBe(false); + }); + + test('DO_NOT_TRACK=1 disables', () => { + process.env.DO_NOT_TRACK = '1'; + expect(isTelemetryDisabled()).toBe(true); + expect(getDisableReason()).toBe('DO_NOT_TRACK'); + }); + + test('DO_NOT_TRACK=true disables', () => { + process.env.DO_NOT_TRACK = 'true'; + expect(isTelemetryDisabled()).toBe(true); + }); + + test('NGAF_TELEMETRY_DISABLED=1 disables', () => { + process.env.NGAF_TELEMETRY_DISABLED = '1'; + expect(isTelemetryDisabled()).toBe(true); + expect(getDisableReason()).toBe('NGAF_TELEMETRY_DISABLED'); + }); + + test('CI=true disables (CI auto-detect)', () => { + process.env.CI = 'true'; + expect(isTelemetryDisabled()).toBe(true); + expect(getDisableReason()).toBe('CI'); + }); + + test('GITHUB_ACTIONS=true disables', () => { + process.env.GITHUB_ACTIONS = 'true'; + expect(isTelemetryDisabled()).toBe(true); + }); + + test('DO_NOT_TRACK=0 does NOT disable', () => { + process.env.DO_NOT_TRACK = '0'; + expect(isTelemetryDisabled()).toBe(false); + }); + + test('precedence: DO_NOT_TRACK reported first when multiple match', () => { + process.env.DO_NOT_TRACK = '1'; + process.env.NGAF_TELEMETRY_DISABLED = '1'; + process.env.CI = 'true'; + expect(getDisableReason()).toBe('DO_NOT_TRACK'); + }); +}); diff --git a/libs/telemetry/src/shared/env.ts b/libs/telemetry/src/shared/env.ts new file mode 100644 index 00000000..a016596c --- /dev/null +++ b/libs/telemetry/src/shared/env.ts @@ -0,0 +1,26 @@ +const TRUE_VALUES = new Set(['1', 'true', 'TRUE', 'yes']); + +function truthy(v: string | undefined): boolean { + return v !== undefined && TRUE_VALUES.has(v); +} + +type DisableReason = 'DO_NOT_TRACK' | 'NGAF_TELEMETRY_DISABLED' | 'CI' | null; + +export function getDisableReason(env: NodeJS.ProcessEnv = process.env): DisableReason { + if (truthy(env.DO_NOT_TRACK)) return 'DO_NOT_TRACK'; + if (truthy(env.NGAF_TELEMETRY_DISABLED)) return 'NGAF_TELEMETRY_DISABLED'; + if ( + truthy(env.CI) || + truthy(env.GITHUB_ACTIONS) || + truthy(env.CONTINUOUS_INTEGRATION) || + truthy(env.BUILDKITE) || + truthy(env.CIRCLECI) + ) { + return 'CI'; + } + return null; +} + +export function isTelemetryDisabled(env: NodeJS.ProcessEnv = process.env): boolean { + return getDisableReason(env) !== null; +} diff --git a/libs/telemetry/src/shared/events.ts b/libs/telemetry/src/shared/events.ts new file mode 100644 index 00000000..7aabec31 --- /dev/null +++ b/libs/telemetry/src/shared/events.ts @@ -0,0 +1,12 @@ +export type NgafNodeEvent = + | 'ngaf:postinstall' + | 'ngaf:runtime_instance_created' + | 'ngaf:stream_started' + | 'ngaf:stream_ended' + | 'ngaf:stream_errored'; + +export type NgafBrowserEvent = + | 'ngaf:browser_provided' + | 'ngaf:browser_chat_init'; + +export type NgafEvent = NgafNodeEvent | NgafBrowserEvent; diff --git a/libs/telemetry/src/shared/hash.spec.ts b/libs/telemetry/src/shared/hash.spec.ts new file mode 100644 index 00000000..ca554162 --- /dev/null +++ b/libs/telemetry/src/shared/hash.spec.ts @@ -0,0 +1,21 @@ +import { describe, test, expect } from 'vitest'; +import { sha256 } from './hash'; + +describe('sha256', () => { + test('returns a 64-char hex digest', async () => { + const out = await sha256('hello'); + expect(out).toMatch(/^[a-f0-9]{64}$/); + }); + + test('is deterministic', async () => { + const a = await sha256('same input'); + const b = await sha256('same input'); + expect(a).toBe(b); + }); + + test('differs for different inputs', async () => { + const a = await sha256('foo'); + const b = await sha256('bar'); + expect(a).not.toBe(b); + }); +}); diff --git a/libs/telemetry/src/shared/hash.ts b/libs/telemetry/src/shared/hash.ts new file mode 100644 index 00000000..abb8892e --- /dev/null +++ b/libs/telemetry/src/shared/hash.ts @@ -0,0 +1,7 @@ +export async function sha256(input: string): Promise { + const data = new TextEncoder().encode(input); + const buf = await crypto.subtle.digest('SHA-256', data); + return Array.from(new Uint8Array(buf)) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +} diff --git a/libs/telemetry/src/shared/sample.spec.ts b/libs/telemetry/src/shared/sample.spec.ts new file mode 100644 index 00000000..49594a58 --- /dev/null +++ b/libs/telemetry/src/shared/sample.spec.ts @@ -0,0 +1,31 @@ +import { describe, test, expect } from 'vitest'; +import { shouldSample } from './sample'; + +describe('shouldSample', () => { + test('rate=1.0 always samples', () => { + expect(shouldSample(1.0, 'anon_x')).toBe(true); + expect(shouldSample(1.0, 'anon_y')).toBe(true); + }); + + test('rate=0 never samples', () => { + expect(shouldSample(0, 'anon_x')).toBe(false); + }); + + test('deterministic for a given (rate, id) pair', () => { + const a = shouldSample(0.5, 'anon_x'); + const b = shouldSample(0.5, 'anon_x'); + expect(a).toBe(b); + }); + + test('rate clamps to [0, 1]', () => { + expect(shouldSample(1.5, 'anon_x')).toBe(true); + expect(shouldSample(-1, 'anon_x')).toBe(false); + }); + + test('different ids can produce different results at rate=0.5', () => { + const ids = Array.from({ length: 100 }, (_, i) => `anon_${i}`); + const sampled = ids.filter((id) => shouldSample(0.5, id)).length; + expect(sampled).toBeGreaterThan(20); + expect(sampled).toBeLessThan(80); + }); +}); diff --git a/libs/telemetry/src/shared/sample.ts b/libs/telemetry/src/shared/sample.ts new file mode 100644 index 00000000..a613ba48 --- /dev/null +++ b/libs/telemetry/src/shared/sample.ts @@ -0,0 +1,15 @@ +// Cheap deterministic 32-bit hash (Fnv-1a) — no crypto needed for sampling. +function hashString(s: string): number { + let h = 2166136261; + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i); + h = (h * 16777619) >>> 0; + } + return h; +} + +export function shouldSample(rate: number, anonId: string): boolean { + if (rate <= 0) return false; + if (rate >= 1) return true; + return hashString(anonId) / 0xffffffff < rate; +} diff --git a/libs/telemetry/src/test-setup.ts b/libs/telemetry/src/test-setup.ts new file mode 100644 index 00000000..054534fc --- /dev/null +++ b/libs/telemetry/src/test-setup.ts @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MIT +import { getTestBed } from '@angular/core/testing'; +import { + BrowserTestingModule, + platformBrowserTesting, +} from '@angular/platform-browser/testing'; + +getTestBed().initTestEnvironment( + BrowserTestingModule, + platformBrowserTesting(), + { teardown: { destroyAfterEach: true } }, +); diff --git a/libs/telemetry/tsconfig.json b/libs/telemetry/tsconfig.json new file mode 100644 index 00000000..5899d5ec --- /dev/null +++ b/libs/telemetry/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "esnext", + "moduleResolution": "bundler", + "target": "es2022", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "lib": ["es2022", "dom"] + }, + "include": [] +} diff --git a/libs/telemetry/tsconfig.lib.browser.json b/libs/telemetry/tsconfig.lib.browser.json new file mode 100644 index 00000000..98e55fbf --- /dev/null +++ b/libs/telemetry/tsconfig.lib.browser.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc/browser", + "rootDir": "src", + "declaration": true, + "emitDeclarationOnly": false, + "types": [] + }, + "include": ["src/browser/**/*.ts", "src/shared/**/*.ts"], + "exclude": ["src/**/*.spec.ts"] +} diff --git a/libs/telemetry/tsconfig.lib.json b/libs/telemetry/tsconfig.lib.json new file mode 100644 index 00000000..bbe1c6ed --- /dev/null +++ b/libs/telemetry/tsconfig.lib.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "declaration": true, + "emitDeclarationOnly": false + }, + "include": ["src/index.ts", "src/shared/**/*.ts", "src/node/**/*.ts"], + "exclude": ["src/**/*.spec.ts", "src/browser/**"] +} diff --git a/libs/telemetry/tsconfig.spec.json b/libs/telemetry/tsconfig.spec.json new file mode 100644 index 00000000..dc918c3b --- /dev/null +++ b/libs/telemetry/tsconfig.spec.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "declaration": false, + "lib": ["es2022", "dom"], + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "types": ["vitest/globals", "node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/libs/telemetry/vite.config.mts b/libs/telemetry/vite.config.mts new file mode 100644 index 00000000..7aabd4c3 --- /dev/null +++ b/libs/telemetry/vite.config.mts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite'; +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; + +export default defineConfig({ + plugins: [nxViteTsPaths()], + test: { + environment: 'node', + globals: true, + include: ['src/**/*.spec.ts'], + setupFiles: ['src/test-setup.ts'], + }, +}); diff --git a/nx.json b/nx.json index ad2a789d..89d3a428 100644 --- a/nx.json +++ b/nx.json @@ -52,13 +52,14 @@ "render", "a2ui", "partial-json", - "licensing" + "licensing", + "telemetry" ], "projectsRelationship": "fixed" } }, "version": { - "preVersionCommand": "npx nx run-many -t build --projects=chat,langgraph,ag-ui,render,a2ui,partial-json,licensing", + "preVersionCommand": "npx nx run-many -t build --projects=chat,langgraph,ag-ui,render,a2ui,partial-json,licensing,telemetry", "updateDependents": "auto", "preserveLocalDependencyProtocols": true }, diff --git a/package-lock.json b/package-lock.json index 4dbb0196..73dad3e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -188,7 +188,7 @@ }, "libs/chat": { "name": "@ngaf/chat", - "version": "0.0.30", + "version": "0.0.31", "license": "MIT", "dependencies": { "@cacheplane/partial-json": ">=0.1.1 <0.3.0", @@ -295,6 +295,24 @@ "@ngaf/licensing": "*" } }, + "libs/telemetry": { + "name": "@ngaf/telemetry", + "version": "0.0.0", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "posthog-js": "^1.372.0", + "posthog-node": "^5.20.0" + }, + "peerDependencies": { + "@angular/core": "^20.0.0 || ^21.0.0" + }, + "peerDependenciesMeta": { + "@angular/core": { + "optional": true + } + } + }, "libs/ui-react": { "name": "@ngaf/ui-react", "version": "0.0.31", @@ -12011,6 +12029,10 @@ "resolved": "libs/render", "link": true }, + "node_modules/@ngaf/telemetry": { + "resolved": "libs/telemetry", + "link": true + }, "node_modules/@ngaf/ui-react": { "resolved": "libs/ui-react", "link": true @@ -37827,6 +37849,27 @@ "integrity": "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==", "license": "MIT" }, + "node_modules/posthog-node": { + "version": "5.21.2", + "resolved": "https://registry.npmjs.org/posthog-node/-/posthog-node-5.21.2.tgz", + "integrity": "sha512-Jehlu0KguL1LLyUczCt86OtA5INmeStK3zcgbv1BSyMcNxs0HP3GQogBrYhwhqHsk6JopiFFVpJyZEoXOUMhGw==", + "license": "MIT", + "dependencies": { + "@posthog/core": "1.10.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/posthog-node/node_modules/@posthog/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.10.0.tgz", + "integrity": "sha512-Xk3JQ+cdychsvftrV3G9ZrN9W329lbyFW0pGJXFGKFQf8qr4upw2SgNg9BVorjSrfhoXZRnJGt/uNF4nGFBL5A==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.6" + } + }, "node_modules/powershell-utils": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz",