diff --git a/apps/website/public/AGENTS.md b/apps/website/public/AGENTS.md index fa13afad2..267feb599 100644 --- a/apps/website/public/AGENTS.md +++ b/apps/website/public/AGENTS.md @@ -1,4 +1,4 @@ -# Angular Agent Framework v0.0.28 +# Angular Agent Framework v0.0.29 Agent UI primitives and LangGraph streaming adapters for Angular. diff --git a/apps/website/public/CLAUDE.md b/apps/website/public/CLAUDE.md index fa13afad2..267feb599 100644 --- a/apps/website/public/CLAUDE.md +++ b/apps/website/public/CLAUDE.md @@ -1,4 +1,4 @@ -# Angular Agent Framework v0.0.28 +# Angular Agent Framework v0.0.29 Agent UI primitives and LangGraph streaming adapters for Angular. diff --git a/apps/website/src/app/api/ingest/route.ts b/apps/website/src/app/api/ingest/route.ts new file mode 100644 index 000000000..dfc806c71 --- /dev/null +++ b/apps/website/src/app/api/ingest/route.ts @@ -0,0 +1,79 @@ +import { PostHog } from 'posthog-node'; +import { NextRequest, NextResponse } from 'next/server'; +import { normalizePostHogHost, toSafeAnalyticsString } from '../../../lib/analytics/properties'; + +const PUBLIC_INGEST_KEY = 'phc_public_cacheplane_telemetry'; + +interface TelemetryIngestPayload { + key?: unknown; + distinctId?: unknown; + event?: unknown; + properties?: unknown; +} + +function getPostHogClient(): PostHog | null { + const token = toSafeAnalyticsString(process.env.NEXT_PUBLIC_POSTHOG_TOKEN, 500); + if (!token) return null; + return new PostHog(token, { + host: normalizePostHogHost(process.env.NEXT_PUBLIC_POSTHOG_HOST), + flushAt: 1, + flushInterval: 0, + }); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function readPayload(value: unknown): { + distinctId: string; + event: string; + properties: Record; +} | null { + if (!isRecord(value)) return null; + const payload = value as TelemetryIngestPayload; + if (payload.key !== PUBLIC_INGEST_KEY) return null; + + const distinctId = toSafeAnalyticsString(payload.distinctId, 200); + const event = toSafeAnalyticsString(payload.event, 100); + if (!distinctId || !event?.startsWith('ngaf:')) return null; + + return { + distinctId, + event, + properties: isRecord(payload.properties) ? payload.properties : {}, + }; +} + +export async function POST(req: NextRequest) { + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); + } + + const payload = readPayload(body); + if (!payload) return NextResponse.json({ error: 'Invalid telemetry payload' }, { status: 400 }); + + const posthog = getPostHogClient(); + if (!posthog) return NextResponse.json({ error: 'Telemetry ingest is not configured' }, { status: 503 }); + + try { + posthog.capture({ + distinctId: payload.distinctId, + event: payload.event, + properties: { + ...payload.properties, + $ip: null, + $process_person_profile: false, + }, + }); + await posthog.shutdown(); + return NextResponse.json({ ok: true }, { status: 202 }); + } catch (err) { + console.error('[telemetry-ingest] capture failed:', err); + await posthog.shutdown().catch(() => undefined); + return NextResponse.json({ error: 'Telemetry ingest failed' }, { status: 502 }); + } +} diff --git a/apps/website/src/components/landing/FinalCTA.tsx b/apps/website/src/components/landing/FinalCTA.tsx index abb11d7fa..bd22e8a95 100644 --- a/apps/website/src/components/landing/FinalCTA.tsx +++ b/apps/website/src/components/landing/FinalCTA.tsx @@ -24,7 +24,7 @@ export function FinalCTA({ subtext = 'Install the framework, read the docs, and have a streaming chat in your app this afternoon.', primary = DEFAULT_PRIMARY, secondary = DEFAULT_SECONDARY, - caption = 'MIT · No signup required · No telemetry', + caption = 'MIT · No signup required · App telemetry off by default', }: FinalCTAProps = {}) { return (
diff --git a/apps/website/src/components/landing/Promises.tsx b/apps/website/src/components/landing/Promises.tsx index a4efdb6aa..83e1920f4 100644 --- a/apps/website/src/components/landing/Promises.tsx +++ b/apps/website/src/components/landing/Promises.tsx @@ -18,8 +18,8 @@ const PROMISES = [ body: 'Self-host LangGraph + your Angular app. Run it all in your VPC.', }, { - title: 'No telemetry', - body: 'We don’t collect anything from your app. Not at install, not at runtime.', + title: 'No app telemetry', + body: 'We don’t collect prompts, completions, tool data, or app runtime content by default. Package installs send a minimal opt-out ping.', }, { title: 'No model lock-in', diff --git a/docs/RELEASE.md b/docs/RELEASE.md index f4a2d981a..48285defe 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -15,7 +15,7 @@ npx nx release patch This runs Nx Release in interactive mode, which: -1. Builds all seven publishable projects (preVersionCommand). +1. Builds all seven publishable projects and patches install telemetry into the publishable package manifests (preVersionCommand). 2. Bumps every package.json version (e.g., `0.0.1` → `0.0.2`). 3. Generates `CHANGELOG.md` from commits since the last tag. 4. Creates a git commit `chore(release): publish v0.0.2`. @@ -50,8 +50,9 @@ npx nx release publish --groups=publishable The very first publish ships the version currently on disk (`0.0.1`) — no version bump. `--first-release` skips the "previous tag exists" check and the "package already on registry" check. ```bash -# 1. Build everything +# 1. Build everything and patch install telemetry into publishable manifests npx nx run-many -t build --projects=chat,langgraph,ag-ui,render,a2ui,licensing,telemetry +node libs/telemetry/scripts/apply-install-telemetry.mjs dist/libs/chat dist/libs/langgraph dist/libs/ag-ui dist/libs/render dist/libs/a2ui dist/libs/licensing # 2. Generate the initial CHANGELOG, commit, and tag v0.0.1 npx nx release changelog 0.0.1 --first-release diff --git a/docs/gtm/messaging.md b/docs/gtm/messaging.md index 0f2f2a1f9..89d133c7d 100644 --- a/docs/gtm/messaging.md +++ b/docs/gtm/messaging.md @@ -10,13 +10,13 @@ **H1:** Ship production agent UIs in Angular. -**Subhead:** Signal-native chat, threads, interrupts, tool progress, and generative UI for LangGraph, AG-UI, and A2UI. MIT-licensed, self-hostable, opt-in telemetry, no React rewrite. +**Subhead:** Signal-native chat, threads, interrupts, tool progress, and generative UI for LangGraph, AG-UI, and A2UI. MIT-licensed, self-hostable, app telemetry off by default, no React rewrite. **Primary CTA:** `Install @ngaf/chat` (copy-to-clipboard, fires `marketing:cta_click` with `cta_id=hero_install`, `track=developer`). **Secondary CTA:** `Talk to our engineers` (routes to `/contact?source=home_hero&track=enterprise`, fires `marketing:cta_click` with `cta_id=hero_talk_to_engineers`, `track=enterprise`). -**Proof row:** `MIT · Angular-native Signals · LangGraph + AG-UI · A2UI-compatible · Self-hostable · Opt-in telemetry` +**Proof row:** `MIT · Angular-native Signals · LangGraph + AG-UI · A2UI-compatible · Self-hostable · App telemetry off by default` **Subline under proof row:** *Not another backend agent runtime. Keep LangGraph, Genkit, Mastra, CrewAI, or your own service. Cacheplane solves the Angular UI layer.* @@ -27,12 +27,12 @@ Repeat across the site, comparison pages, and content. 1. **Angular-native, not React-translated.** Signals, DI, OnPush, standalone components, Angular testing patterns, design-system ownership. 2. **Complete agent UI, not just stream plumbing.** Messages, status, errors, tool progress, interrupts, branching/history, thread persistence, reload, fallbacks, tests. 3. **Generative UI that respects the enterprise design system.** Approved components from your design system; no arbitrary code shipping. -4. **Enterprise OSS posture.** MIT, no end-user telemetry, no required cloud, self-hosting, optional paid support/SLA. +4. **Enterprise OSS posture.** MIT, no app/runtime content telemetry by default, no required cloud, self-hosting, optional paid support/SLA. 5. **Production patterns, not demo candy.** Real auth, real backends, observability, error boundaries, fallback strategies, CI/CD, load/chaos patterns, runbooks. ## Risk-cleanup copy changes (Spec 2) -- "No telemetry" → "**Opt-in telemetry**" with link to `libs/telemetry/README.md`. +- "No telemetry" → "**App telemetry off by default**" with link to `libs/telemetry/README.md` for the minimal opt-out package install ping. - "All Angular versions" (pricing) → **real compatibility matrix** with supported/experimental/planned/unsupported. - "A2UI v1" → **"A2UI v0.9-compatible"** until v1 is verified. - "Angular Agent Framework" → **"Agent UI for Angular"** (category sweep, with care for substring overlap per existing memory note). diff --git a/docs/gtm/taxonomy.md b/docs/gtm/taxonomy.md index b8a253018..e6ff2e83d 100644 --- a/docs/gtm/taxonomy.md +++ b/docs/gtm/taxonomy.md @@ -64,9 +64,8 @@ The standard PostHog `$pageview` event is used as-is across all three surfaces. | Event | When | Surface | Default | |--------------------------------------|--------------------------------------------|-----------------|--------------| -| `ngaf:postinstall` | `npm install` of an `@ngaf/*` package | Node (script) | **Opt-out** | +| `ngaf:postinstall` | Dependency/global install of a published `@ngaf/*` package | Node (script) | **Opt-out** | | `ngaf:runtime_instance_created` | Server adapter init | Node | **Opt-out** | -| `ngaf:runtime_request_created` | Server adapter handles a request | Node | **Opt-out** | | `ngaf:stream_started` | Stream begins | Node | **Opt-out** | | `ngaf:stream_ended` | Stream ends normally | Node | **Opt-out** | | `ngaf:stream_errored` | Stream errors | Node | **Opt-out** | diff --git a/gtm.md b/gtm.md index f16eaa5cb..f2c4ac628 100644 --- a/gtm.md +++ b/gtm.md @@ -92,7 +92,7 @@ Operational progress lives in agent runs and PostHog. The repo holds durable str ## 8. Non-goals (current phase) - We do not compete as a general agent UI framework. We claim the Angular final mile. -- We do not ship telemetry from `@ngaf/*` browser packages by default. Opt-in only. Node-side telemetry honors `DO_NOT_TRACK` and `NGAF_TELEMETRY_DISABLED`; see [libs/telemetry/README.md](libs/telemetry/README.md) for the full contract. +- We do not ship telemetry from `@ngaf/*` browser packages by default. Opt-in only. Node-side package telemetry is minimal and honors `DO_NOT_TRACK`, `npm_config_do_not_track`, and `NGAF_TELEMETRY_DISABLED`; see [libs/telemetry/README.md](libs/telemetry/README.md) for the full contract. - We do not run paid acquisition until Phase 2 organic baselines exist. - We do not pursue stars as a vanity metric. - We do not run A/B positioning experiments in Phase 1. Ship one hero, measure, iterate. diff --git a/libs/ag-ui/project.json b/libs/ag-ui/project.json index 77ccde3a9..a32068835 100644 --- a/libs/ag-ui/project.json +++ b/libs/ag-ui/project.json @@ -4,15 +4,6 @@ "sourceRoot": "libs/ag-ui/src", "prefix": "lib", "projectType": "library", - "release": { - "version": { - "manifestRootsToUpdate": [ - "{projectRoot}" - ], - "currentVersionResolver": "git-tag", - "fallbackCurrentVersionResolver": "disk" - } - }, "tags": [], "targets": { "build": { diff --git a/libs/chat/project.json b/libs/chat/project.json index 0b884f1e5..1b322f07b 100644 --- a/libs/chat/project.json +++ b/libs/chat/project.json @@ -4,15 +4,6 @@ "sourceRoot": "libs/chat/src", "prefix": "chat", "projectType": "library", - "release": { - "version": { - "manifestRootsToUpdate": [ - "{projectRoot}" - ], - "currentVersionResolver": "git-tag", - "fallbackCurrentVersionResolver": "disk" - } - }, "tags": [], "targets": { "build": { diff --git a/libs/langgraph/project.json b/libs/langgraph/project.json index ae1f0ccc6..0911998d5 100644 --- a/libs/langgraph/project.json +++ b/libs/langgraph/project.json @@ -4,15 +4,6 @@ "sourceRoot": "libs/langgraph/src", "prefix": "lib", "projectType": "library", - "release": { - "version": { - "manifestRootsToUpdate": [ - "{projectRoot}" - ], - "currentVersionResolver": "git-tag", - "fallbackCurrentVersionResolver": "disk" - } - }, "tags": [], "targets": { "build": { diff --git a/libs/render/project.json b/libs/render/project.json index 08d36c39a..c76ea21e7 100644 --- a/libs/render/project.json +++ b/libs/render/project.json @@ -4,15 +4,6 @@ "sourceRoot": "libs/render/src", "prefix": "render", "projectType": "library", - "release": { - "version": { - "manifestRootsToUpdate": [ - "{projectRoot}" - ], - "currentVersionResolver": "git-tag", - "fallbackCurrentVersionResolver": "disk" - } - }, "tags": [], "targets": { "build": { diff --git a/libs/telemetry/README.md b/libs/telemetry/README.md index 27ff455d7..4939ad936 100644 --- a/libs/telemetry/README.md +++ b/libs/telemetry/README.md @@ -1,7 +1,7 @@ # @ngaf/telemetry -> 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. +This README is the public trust contract for `@ngaf/*` telemetry. It is linked +from package install notices and should stay aligned with implementation. ## Imports @@ -29,8 +29,8 @@ The single telemetry surface for `@ngaf/*`. It exists so we can answer "how is C ## What is and isn't telemetered **Telemetered by default (Node, opt-out):** -- `ngaf:postinstall` — fires once per `npm install` of an `@ngaf/*` package. Properties: package name, package version, Node version, OS. No identifiers, no project path, no environment variables. -- `ngaf:runtime_instance_created` — server adapters (LangGraph, AG-UI) call this when they spin up. Properties: which transport, which model provider (string), Angular peer version. **No API keys**, no endpoint hostnames, no user data. Sensitive identifiers are hashed (SHA-256, one-way). +- `ngaf:postinstall` — fires once per dependency/global install of a published `@ngaf/*` package. Properties: package name, package version, Node version, OS, package manager name/version when npm exposes it, sample weight. It uses a per-process anonymous id. No project path, no environment variables, no dependency tree, no installer IP address. +- `ngaf:runtime_instance_created` — server adapters (LangGraph, AG-UI) call this when they spin up. Properties: which transport, which model provider (string), Angular peer version. **No API keys**, no endpoint hostnames, no user data. - `ngaf:stream_started` / `ngaf:stream_ended` / `ngaf:stream_errored` — per-request lifecycle on server adapters. Properties: provider, model name, duration, error class. No prompts, no completions, no message content. **Telemetered only on explicit opt-in (Browser):** @@ -41,7 +41,6 @@ The single telemetry surface for `@ngaf/*`. It exists so we can answer "how is C - Message content (user prompts, model completions, tool call inputs/outputs). - Personally identifiable information beyond `email_domain` on explicit server conversion events on the website. - API keys, vendor credentials, project paths, environment variables. -- Whatever you've told the SDK to ignore via the redaction config. ## Opt-out @@ -50,17 +49,26 @@ Node telemetry is on by default. Three ways to opt out — any one turns it off. | Method | How | |--------|-----| | Cross-vendor env var | `DO_NOT_TRACK=1` or `DO_NOT_TRACK=true` | +| npm config env var | `npm_config_do_not_track=true` | | Package env var | `NGAF_TELEMETRY_DISABLED=1` or `NGAF_TELEMETRY_DISABLED=true` | | Programmatic | `import { disableTelemetry } from '@ngaf/telemetry/node'; disableTelemetry();` before any other `@ngaf/*` import | CI environments (`CI=true`, `GITHUB_ACTIONS=true`, etc.) are auto-detected and treated as opt-out by default. -The postinstall script prints a single line on stdout describing what was sent and how to disable. The line is suppressed in CI. +Local top-level installs are skipped by default. Dependency installs and global installs are eligible unless opted out. + +The postinstall script prints a single line on stdout only when the install ping was actually accepted by the ingest endpoint. The line is suppressed in CI. + +To inspect the install payload locally, run with `DEBUG=ngaf:telemetry`. ## Opt-in (browser) Browser telemetry is **off by default** and never fires from the library itself. To enable in your Angular app: +```bash +npm install posthog-js +``` + ```ts // app.config.ts (or wherever you bootstrap) import { provideNgafTelemetry } from '@ngaf/telemetry/browser'; @@ -82,7 +90,7 @@ If you don't call `provideNgafTelemetry({ enabled: true })`, every telemetry hel ## Sampling - Default sample rate: **1.0** (100%) at current scale. -- Configurable via `NGAF_TELEMETRY_SAMPLE_RATE` env var (Node) or the `sampleRate` option (Browser). +- Configurable via `NGAF_TELEMETRY_SAMPLE_RATE` env var (Node). - Every event carries a `sample_weight` property so future de-sampling at query time works correctly. ## Anonymous id strategy @@ -96,10 +104,10 @@ If you don't call `provideNgafTelemetry({ enabled: true })`, every telemetry hel Enterprise users can redirect Node telemetry to their own ingest: ```bash -NGAF_TELEMETRY_INGEST_URL=https://posthog.acme-internal.example.com +NGAF_TELEMETRY_INGEST_URL=https://telemetry.acme-internal.example.com/api/ingest ``` -Default ingest (when env var is unset) is a thin proxy on the Cacheplane website (`https://cacheplane.dev/api/ingest`) that forwards to our PostHog project. Source of the proxy lives in `apps/website/src/app/api/ingest/`. +Default ingest (when env var is unset) is a thin proxy on the Cacheplane website (`https://cacheplane.dev/api/ingest`) that accepts the `@ngaf/telemetry` JSON payload and forwards `ngaf:*` events to our PostHog project without forwarding installer IP addresses. Source of the proxy lives in `apps/website/src/app/api/ingest/`. ## Verifying telemetry is silent @@ -116,7 +124,7 @@ If this test ever fails, the trust contract has been violated and the build bloc - Session replay. (Not in Phase 0–1.) - Cross-session identity stitching. - Heuristic PII detection. (Redaction is explicit and config-driven only.) -- Default writes to anyone's PostHog instance — including ours — without explicit configuration. +- Default browser writes to anyone's PostHog instance — including ours — without explicit configuration. ## Reporting an issue diff --git a/libs/telemetry/ng-package.json b/libs/telemetry/ng-package.json index 0426a9c00..83effc031 100644 --- a/libs/telemetry/ng-package.json +++ b/libs/telemetry/ng-package.json @@ -3,6 +3,5 @@ "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 index 9490e8f51..8bbd6c520 100644 --- a/libs/telemetry/package.json +++ b/libs/telemetry/package.json @@ -11,6 +11,9 @@ "bugs": { "url": "https://github.com/cacheplane/angular-agent-framework/issues" }, "sideEffects": false, "type": "module", + "bin": { + "ngaf-telemetry-postinstall": "./node/postinstall.js" + }, "exports": { ".": { "types": "./index.d.ts", @@ -35,16 +38,11 @@ "./README.md": "./README.md" }, "peerDependencies": { - "@angular/core": "^20.0.0 || ^21.0.0" + "@angular/core": "^20.0.0 || ^21.0.0", + "posthog-js": "^1.372.0" }, "peerDependenciesMeta": { - "@angular/core": { "optional": true } - }, - "dependencies": { - "posthog-js": "^1.372.0", - "posthog-node": "^5.20.0" - }, - "scripts": { - "postinstall": "node ./node/postinstall.js || true" + "@angular/core": { "optional": true }, + "posthog-js": { "optional": true } } } diff --git a/libs/telemetry/scripts/apply-install-telemetry.mjs b/libs/telemetry/scripts/apply-install-telemetry.mjs new file mode 100644 index 000000000..25892c6ae --- /dev/null +++ b/libs/telemetry/scripts/apply-install-telemetry.mjs @@ -0,0 +1,31 @@ +#!/usr/bin/env node +import { readFile, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +const TELEMETRY_DEP = '@ngaf/telemetry'; +const POSTINSTALL = 'ngaf-telemetry-postinstall || true'; + +async function patchPackageManifest(packageRoot) { + const manifestPath = join(packageRoot, 'package.json'); + const pkg = JSON.parse(await readFile(manifestPath, 'utf8')); + if (pkg.name === TELEMETRY_DEP) return; + if (typeof pkg.name !== 'string' || !pkg.name.startsWith('@ngaf/')) { + throw new Error(`${manifestPath} is not an @ngaf package manifest`); + } + + pkg.dependencies = { ...(pkg.dependencies ?? {}), [TELEMETRY_DEP]: '*' }; + pkg.scripts = { ...(pkg.scripts ?? {}), postinstall: POSTINSTALL }; + + await writeFile(manifestPath, JSON.stringify(pkg, null, 2) + '\n', 'utf8'); + console.log(`[install-telemetry] patched ${manifestPath}`); +} + +const roots = process.argv.slice(2); +if (roots.length === 0) { + console.error('Usage: node libs/telemetry/scripts/apply-install-telemetry.mjs [...]'); + process.exit(1); +} + +for (const root of roots) { + await patchPackageManifest(root); +} diff --git a/libs/telemetry/scripts/assemble-dist.mjs b/libs/telemetry/scripts/assemble-dist.mjs index 9ff257f4a..40e36d758 100644 --- a/libs/telemetry/scripts/assemble-dist.mjs +++ b/libs/telemetry/scripts/assemble-dist.mjs @@ -96,6 +96,7 @@ async function writeCanonicalPackageJson() { bugs: srcPkg.bugs, sideEffects: false, type: 'module', + bin: srcPkg.bin, exports: { '.': { types: './index.d.ts', diff --git a/libs/telemetry/src/node/adapter.spec.ts b/libs/telemetry/src/node/adapter.spec.ts index be8a3fd98..cb300a36a 100644 --- a/libs/telemetry/src/node/adapter.spec.ts +++ b/libs/telemetry/src/node/adapter.spec.ts @@ -16,7 +16,7 @@ import { describe('adapter helpers', () => { beforeEach(() => vi.mocked(captureEvent).mockClear()); - test('captureRuntimeInstanceCreated hashes any apiKey property', async () => { + test('captureRuntimeInstanceCreated drops any apiKey property without sending a derivative', async () => { await captureRuntimeInstanceCreated({ transport: 'langgraph', provider: 'openai', @@ -25,7 +25,7 @@ describe('adapter helpers', () => { 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}$/); + expect((call[1] as Record).apiKey_sha256).toBeUndefined(); }); test('captureStreamStarted records provider + model only', async () => { diff --git a/libs/telemetry/src/node/adapter.ts b/libs/telemetry/src/node/adapter.ts index ddda68126..631a65d94 100644 --- a/libs/telemetry/src/node/adapter.ts +++ b/libs/telemetry/src/node/adapter.ts @@ -1,12 +1,11 @@ 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 + apiKey?: string; // stripped before sending } export interface StreamTelemetry { @@ -15,16 +14,15 @@ export interface StreamTelemetry { durationMs?: number; } -async function safe(fn: () => Promise): Promise { +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); + void apiKey; + await captureEvent('ngaf:runtime_instance_created', { ...rest }); }); } diff --git a/libs/telemetry/src/node/client.spec.ts b/libs/telemetry/src/node/client.spec.ts index 2aa48860f..3410043b5 100644 --- a/libs/telemetry/src/node/client.spec.ts +++ b/libs/telemetry/src/node/client.spec.ts @@ -1,83 +1,91 @@ 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 { capturePostinstall, captureEvent, _resetClientForTesting } from './client'; import { disableTelemetry, _resetDisableForTesting } from './disable'; describe('node client', () => { + const fetchMock = vi.fn(); + beforeEach(() => { - mocks.PostHog.mockReset(); - mocks.PostHog.mockImplementation(function () { - return { - capture: vi.fn(), - shutdown: vi.fn().mockResolvedValue(undefined), - }; - }); + fetchMock.mockReset(); + fetchMock.mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal('fetch', fetchMock); _resetClientForTesting(); _resetDisableForTesting(); delete process.env.DO_NOT_TRACK; delete process.env.NGAF_TELEMETRY_DISABLED; delete process.env.CI; + delete process.env.NGAF_TELEMETRY_SAMPLE_RATE; + delete process.env.npm_config_user_agent; 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({ + await expect(capturePostinstall({ pkg: '@ngaf/telemetry', version: '0.0.31' })) + .resolves.toEqual({ sent: true }); + const body = JSON.parse(String(fetchMock.mock.calls[0][1].body)); + expect(body).toMatchObject({ 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(); + await expect(capturePostinstall({ pkg: 'x', version: '1' })) + .resolves.toEqual({ sent: false, reason: 'disabled' }); + expect(fetchMock).not.toHaveBeenCalled(); }); test('capturePostinstall no-ops after disableTelemetry()', async () => { disableTelemetry(); - await capturePostinstall({ pkg: 'x', version: '1' }); - expect(PostHog).not.toHaveBeenCalled(); + await expect(capturePostinstall({ pkg: 'x', version: '1' })) + .resolves.toEqual({ sent: false, reason: 'disabled' }); + expect(fetchMock).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' }), - ); + expect(fetchMock.mock.calls[0][0]).toBe('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) }), + const body = JSON.parse(String(fetchMock.mock.calls[0][1].body)); + expect(body.properties).toEqual(expect.objectContaining({ sample_weight: expect.any(Number) })); + }); + + test('capturePostinstall includes npm package manager metadata when available', async () => { + process.env.npm_config_user_agent = 'npm/10.9.2 node/v22.14.0 darwin arm64 workspaces/false'; + await capturePostinstall({ pkg: 'x', version: '1' }); + const body = JSON.parse(String(fetchMock.mock.calls[0][1].body)); + expect(body.properties).toEqual(expect.objectContaining({ + package_manager: 'npm', + package_manager_version: '10.9.2', })); }); - 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; }); + test('capturePostinstall awaits fetch before resolving', async () => { + let fetchResolved = false; + fetchMock.mockImplementationOnce(async () => { + fetchResolved = true; + return { ok: true, status: 200 }; + }); await capturePostinstall({ pkg: 'x', version: '1' }); - expect(shutdownCalled).toBe(true); + expect(fetchResolved).toBe(true); + }); + + test('captureEvent reports failed sends instead of pretending success', async () => { + fetchMock.mockRejectedValueOnce(new Error('network')); + await expect(captureEvent('ngaf:postinstall', {})) + .resolves.toEqual({ sent: false, reason: 'failed' }); + }); + + test('invalid sample rate falls back to 1 instead of silently dropping telemetry', async () => { + process.env.NGAF_TELEMETRY_SAMPLE_RATE = 'not-a-number'; + await expect(capturePostinstall({ pkg: 'x', version: '1' })) + .resolves.toEqual({ sent: true }); + expect(fetchMock).toHaveBeenCalled(); }); }); diff --git a/libs/telemetry/src/node/client.ts b/libs/telemetry/src/node/client.ts index 53a295bd3..8db7530c8 100644 --- a/libs/telemetry/src/node/client.ts +++ b/libs/telemetry/src/node/client.ts @@ -1,4 +1,3 @@ -import { PostHog } from 'posthog-node'; import { getAnonId } from '../shared/anon-id.js'; import { isTelemetryDisabled } from '../shared/env.js'; import { shouldSample } from '../shared/sample.js'; @@ -6,55 +5,93 @@ 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 REQUEST_TIMEOUT_MS = 3_000; +// Public identifier accepted by the Cacheplane ingest proxy. The proxy re-keys +// server-side with the private PostHog token. const PUBLIC_INGEST_KEY = 'phc_public_cacheplane_telemetry'; -let cached: PostHog | null = null; +export type CaptureResult = + | { sent: true } + | { sent: false; reason: 'disabled' | 'sampled' | 'failed' }; -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 interface PostinstallInput { + pkg: string; + version: string; } -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'); +function getSampleRate(env: NodeJS.ProcessEnv = process.env): number { + const parsed = Number(env.NGAF_TELEMETRY_SAMPLE_RATE ?? '1'); + if (!Number.isFinite(parsed)) return 1; + return Math.max(0, Math.min(1, parsed)); +} + +function getPackageManager(env: NodeJS.ProcessEnv = process.env): Record { + const userAgent = env.npm_config_user_agent; + const firstToken = userAgent?.split(/\s+/)[0]; + const match = firstToken?.match(/^([^/\s]+)\/([^/\s]+)$/); + if (!match) return {}; + return { + package_manager: match[1], + package_manager_version: match[2], + }; +} + +// @internal +export function createPostinstallProperties( + input: PostinstallInput, + env: NodeJS.ProcessEnv = process.env, +): Record { + return { + pkg: input.pkg, + version: input.version, + node: process.version, + os: process.platform, + ...getPackageManager(env), + }; +} + +async function postJson(url: string, body: unknown): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + signal: controller.signal, + }); + if (!res.ok) throw new Error(`telemetry ingest failed: ${res.status}`); + } finally { + clearTimeout(timeout); + } +} + +export async function captureEvent( + event: NgafNodeEvent, + properties: Record = {}, +): Promise { + if (isTelemetryDisabled() || isProgrammaticallyDisabled()) return { sent: false, reason: 'disabled' }; + const rate = getSampleRate(); const anonId = getAnonId(); - if (!shouldSample(rate, anonId)) return; + if (!shouldSample(rate, anonId)) return { sent: false, reason: 'sampled' }; try { - client.capture({ + await postJson(process.env.NGAF_TELEMETRY_INGEST_URL ?? DEFAULT_INGEST, { + key: PUBLIC_INGEST_KEY, distinctId: anonId, event, properties: { ...properties, sample_weight: rate > 0 ? 1 / Math.min(1, rate) : 1 }, }); - await client.shutdown(); + return { sent: true }; } catch { - // silent fail - } finally { - cached = null; // fresh client per process; flushAt:1 means we're done + return { sent: false, reason: 'failed' }; } } -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, - }); +export async function capturePostinstall(input: PostinstallInput): Promise { + return captureEvent('ngaf:postinstall', createPostinstallProperties(input)); } // @internal — tests only export function _resetClientForTesting(): void { - cached = null; + // retained for older tests and downstream test helpers } diff --git a/libs/telemetry/src/node/index.ts b/libs/telemetry/src/node/index.ts index 3975ff6af..90427d9be 100644 --- a/libs/telemetry/src/node/index.ts +++ b/libs/telemetry/src/node/index.ts @@ -1,5 +1,6 @@ export { disableTelemetry } from './disable.js'; export { capturePostinstall, captureEvent } from './client.js'; +export type { CaptureResult } from './client.js'; export { captureRuntimeInstanceCreated, captureStreamStarted, diff --git a/libs/telemetry/src/node/postinstall.spec.ts b/libs/telemetry/src/node/postinstall.spec.ts index 171105ded..2eff4a9af 100644 --- a/libs/telemetry/src/node/postinstall.spec.ts +++ b/libs/telemetry/src/node/postinstall.spec.ts @@ -1,8 +1,15 @@ import { describe, test, expect, beforeEach, vi } from 'vitest'; +import { mkdtempSync, mkdirSync, symlinkSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; -vi.mock('./client', () => ({ - capturePostinstall: vi.fn().mockResolvedValue(undefined), -})); +vi.mock('./client', async () => { + const actual = await vi.importActual('./client'); + return { + ...actual, + capturePostinstall: vi.fn().mockResolvedValue({ sent: true }), + }; +}); import { capturePostinstallScript } from './postinstall'; import { capturePostinstall } from './client'; @@ -12,6 +19,7 @@ describe('postinstall script', () => { vi.mocked(capturePostinstall).mockClear(); delete process.env.CI; delete process.env.DO_NOT_TRACK; + delete process.env.DEBUG; }); test('calls capturePostinstall with the package name + version', async () => { @@ -20,6 +28,7 @@ describe('postinstall script', () => { readPackageJson: () => ({ name: '@ngaf/telemetry', version: '0.0.31' }), write: (s: string) => stdout.push(s), env: { ...process.env }, + cwd: () => '/tmp/project/node_modules/@ngaf/telemetry', }); expect(capturePostinstall).toHaveBeenCalledWith({ pkg: '@ngaf/telemetry', version: '0.0.31' }); }); @@ -30,8 +39,9 @@ describe('postinstall script', () => { readPackageJson: () => ({ name: '@ngaf/telemetry', version: '0.0.31' }), write: (s: string) => stdout.push(s), env: { ...process.env }, + cwd: () => '/tmp/project/node_modules/@ngaf/telemetry', }); - expect(stdout.join('')).toMatch(/@ngaf\/telemetry: sent install ping/); + expect(stdout.join('')).toMatch(/@ngaf\/telemetry: install telemetry sent/); expect(stdout.join('')).toMatch(/DO_NOT_TRACK=1/); }); @@ -41,6 +51,7 @@ describe('postinstall script', () => { readPackageJson: () => ({ name: '@ngaf/telemetry', version: '0.0.31' }), write: (s: string) => stdout.push(s), env: { ...process.env, CI: 'true' }, + cwd: () => '/tmp/project/node_modules/@ngaf/telemetry', }); expect(stdout).toEqual([]); expect(capturePostinstall).not.toHaveBeenCalled(); @@ -52,6 +63,7 @@ describe('postinstall script', () => { readPackageJson: () => ({ name: '@ngaf/telemetry', version: '0.0.31' }), write: (s: string) => stdout.push(s), env: { ...process.env, DO_NOT_TRACK: '1' }, + cwd: () => '/tmp/project/node_modules/@ngaf/telemetry', }); expect(stdout).toEqual([]); expect(capturePostinstall).not.toHaveBeenCalled(); @@ -63,8 +75,78 @@ describe('postinstall script', () => { readPackageJson: () => { throw new Error('not found'); }, write: (_s: string) => undefined, env: { ...process.env }, + cwd: () => '/tmp/project/node_modules/@ngaf/telemetry', }), ).resolves.toBeUndefined(); expect(capturePostinstall).not.toHaveBeenCalled(); }); + + test('does not print sent when capturePostinstall reports a failed send', async () => { + vi.mocked(capturePostinstall).mockResolvedValueOnce({ sent: false, reason: 'failed' }); + const stdout: string[] = []; + await capturePostinstallScript({ + readPackageJson: () => ({ name: '@ngaf/telemetry', version: '0.0.31' }), + write: (s: string) => stdout.push(s), + env: { ...process.env }, + cwd: () => '/tmp/project/node_modules/@ngaf/telemetry', + }); + expect(stdout.join('')).not.toMatch(/sent install ping|install telemetry sent/); + }); + + test('skips local top-level installs by default', async () => { + await capturePostinstallScript({ + readPackageJson: () => ({ name: '@ngaf/chat', version: '0.0.31' }), + write: (_s: string) => undefined, + env: { ...process.env, INIT_CWD: '/repo/libs/chat' }, + cwd: () => '/repo/libs/chat', + }); + expect(capturePostinstall).not.toHaveBeenCalled(); + }); + + test('skips local top-level installs when INIT_CWD and cwd differ only by symlink', async () => { + const root = mkdtempSync(join(tmpdir(), 'ngaf-postinstall-')); + const link = `${root}-link`; + try { + mkdirSync(join(root, 'pkg'), { recursive: true }); + symlinkSync(root, link, 'dir'); + await capturePostinstallScript({ + readPackageJson: () => ({ name: '@ngaf/chat', version: '0.0.31' }), + write: (_s: string) => undefined, + env: { ...process.env, INIT_CWD: join(link, 'pkg') }, + cwd: () => join(root, 'pkg'), + }); + expect(capturePostinstall).not.toHaveBeenCalled(); + } finally { + rmSync(link, { recursive: true, force: true }); + rmSync(root, { recursive: true, force: true }); + } + }); + + test('allows global installs even when INIT_CWD matches cwd', async () => { + await capturePostinstallScript({ + readPackageJson: () => ({ name: '@ngaf/chat', version: '0.0.31' }), + write: (_s: string) => undefined, + env: { ...process.env, INIT_CWD: '/repo/libs/chat', npm_config_global: 'true' }, + cwd: () => '/repo/libs/chat', + }); + expect(capturePostinstall).toHaveBeenCalledWith({ pkg: '@ngaf/chat', version: '0.0.31' }); + }); + + test('prints exact payload when DEBUG includes ngaf:telemetry', async () => { + const stdout: string[] = []; + await capturePostinstallScript({ + readPackageJson: () => ({ name: '@ngaf/chat', version: '0.0.31' }), + write: (s: string) => stdout.push(s), + env: { + ...process.env, + DEBUG: 'foo,ngaf:telemetry', + npm_config_user_agent: 'npm/10.9.2 node/v22.14.0 darwin arm64 workspaces/false', + }, + cwd: () => '/tmp/project/node_modules/@ngaf/chat', + }); + expect(stdout.join('')).toMatch(/"pkg":"@ngaf\/chat"/); + expect(stdout.join('')).toMatch(/"version":"0.0.31"/); + expect(stdout.join('')).toMatch(/"package_manager":"npm"/); + expect(stdout.join('')).toMatch(/"package_manager_version":"10.9.2"/); + }); }); diff --git a/libs/telemetry/src/node/postinstall.ts b/libs/telemetry/src/node/postinstall.ts index f83dffc36..98cceb39c 100644 --- a/libs/telemetry/src/node/postinstall.ts +++ b/libs/telemetry/src/node/postinstall.ts @@ -1,20 +1,65 @@ +#!/usr/bin/env node import { readFileSync, realpathSync } from 'node:fs'; -import { fileURLToPath, pathToFileURL } from 'node:url'; -import { dirname, join } from 'node:path'; -import { capturePostinstall } from './client.js'; +import { pathToFileURL } from 'node:url'; +import { join, resolve, sep } from 'node:path'; +import { capturePostinstall, createPostinstallProperties } from './client.js'; import { isTelemetryDisabled } from '../shared/env.js'; interface PostinstallDeps { readPackageJson: () => { name: string; version: string }; write: (s: string) => void; env: NodeJS.ProcessEnv; + cwd?: () => string; +} + +const TRUE_VALUES = new Set(['1', 'true', 'TRUE', 'yes']); + +function truthy(value: string | undefined): boolean { + return value !== undefined && TRUE_VALUES.has(value); +} + +function debugEnabled(env: NodeJS.ProcessEnv): boolean { + return (env.DEBUG ?? '') + .split(/[\s,]+/) + .some((part) => part === '*' || part === 'ngaf:*' || part === 'ngaf:telemetry'); +} + +function isGlobalInstall(env: NodeJS.ProcessEnv): boolean { + return truthy(env.npm_config_global) || env.npm_config_location === 'global'; +} + +function canonicalPath(path: string): string { + try { + return realpathSync(path); + } catch { + return resolve(path); + } +} + +function isInNodeModules(cwd: string): boolean { + return canonicalPath(cwd).split(sep).includes('node_modules'); +} + +function shouldSkipLocalTopLevelInstall(env: NodeJS.ProcessEnv, cwd: string): boolean { + if (isGlobalInstall(env)) return false; + const resolvedCwd = canonicalPath(cwd); + if (env.INIT_CWD && canonicalPath(env.INIT_CWD) === resolvedCwd) return true; + return !isInNodeModules(resolvedCwd); +} + +function readLifecyclePackageJson(env: NodeJS.ProcessEnv, cwd: string): { name: string; version: string } { + if (env.npm_package_name && env.npm_package_version) { + return { name: env.npm_package_name, version: env.npm_package_version }; + } + return JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf8')); } export async function capturePostinstallScript(deps: PostinstallDeps): Promise { - // Single opt-out gate. DO_NOT_TRACK, NGAF_TELEMETRY_DISABLED, and CI envs - // all funnel through isTelemetryDisabled and return early — no event sent, - // no stdout notice. Matches libs/telemetry/README.md trust contract. + // Single opt-out gate. DO_NOT_TRACK, npm_config_do_not_track, + // NGAF_TELEMETRY_DISABLED, and CI envs all return early: no event, no notice. if (isTelemetryDisabled(deps.env)) return; + const cwd = deps.cwd?.() ?? process.cwd(); + if (shouldSkipLocalTopLevelInstall(deps.env, cwd)) return; let pkg: { name: string; version: string }; try { pkg = deps.readPackageJson(); @@ -22,26 +67,34 @@ export async function capturePostinstallScript(deps: PostinstallDeps): Promise { return new Promise((resolve) => { if (process.stdout.writableNeedDrain) { process.stdout.once('drain', () => resolve()); } else { - // Yield one tick so any pending write callbacks run before exit. setImmediate(() => resolve()); } }); @@ -50,19 +103,15 @@ async function flushStdout(): Promise { // 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')); - }, + readPackageJson: () => readLifecyclePackageJson(process.env, process.cwd()), write: (s) => process.stdout.write(s), env: process.env, + cwd: () => process.cwd(), }); await flushStdout(); } // Only run as main entry, not when imported by tests. -// Resolves symlinks on both sides so `/tmp` vs `/private/tmp` on macOS, -// pnpm content-addressed stores, and similar setups all match correctly. function isDirectRun(): boolean { const entry = process.argv[1]; if (!entry) return false; diff --git a/libs/telemetry/src/shared/env.spec.ts b/libs/telemetry/src/shared/env.spec.ts index f1c060e8d..4ba9c0706 100644 --- a/libs/telemetry/src/shared/env.spec.ts +++ b/libs/telemetry/src/shared/env.spec.ts @@ -5,6 +5,8 @@ describe('isTelemetryDisabled', () => { beforeEach(() => { delete process.env.DO_NOT_TRACK; delete process.env.NGAF_TELEMETRY_DISABLED; + delete process.env.npm_config_do_not_track; + delete process.env.NPM_CONFIG_DO_NOT_TRACK; delete process.env.CI; delete process.env.GITHUB_ACTIONS; delete process.env.CONTINUOUS_INTEGRATION; @@ -33,6 +35,12 @@ describe('isTelemetryDisabled', () => { expect(getDisableReason()).toBe('NGAF_TELEMETRY_DISABLED'); }); + test('npm do-not-track config disables', () => { + process.env.npm_config_do_not_track = 'true'; + expect(isTelemetryDisabled()).toBe(true); + expect(getDisableReason()).toBe('DO_NOT_TRACK'); + }); + test('CI=true disables (CI auto-detect)', () => { process.env.CI = 'true'; expect(isTelemetryDisabled()).toBe(true); diff --git a/libs/telemetry/src/shared/env.ts b/libs/telemetry/src/shared/env.ts index a016596cd..5ff12efbc 100644 --- a/libs/telemetry/src/shared/env.ts +++ b/libs/telemetry/src/shared/env.ts @@ -7,7 +7,9 @@ function truthy(v: string | undefined): boolean { 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.DO_NOT_TRACK) || truthy(env.npm_config_do_not_track) || truthy(env.NPM_CONFIG_DO_NOT_TRACK)) { + return 'DO_NOT_TRACK'; + } if (truthy(env.NGAF_TELEMETRY_DISABLED)) return 'NGAF_TELEMETRY_DISABLED'; if ( truthy(env.CI) || diff --git a/nx.json b/nx.json index c3ae6fe34..c9844a011 100644 --- a/nx.json +++ b/nx.json @@ -58,9 +58,15 @@ } }, "version": { - "preVersionCommand": "npx nx run-many -t build --projects=chat,langgraph,ag-ui,render,a2ui,licensing,telemetry", + "preVersionCommand": "npx nx run-many -t build --projects=chat,langgraph,ag-ui,render,a2ui,licensing,telemetry && node libs/telemetry/scripts/apply-install-telemetry.mjs dist/libs/chat dist/libs/langgraph dist/libs/ag-ui dist/libs/render dist/libs/a2ui dist/libs/licensing", "updateDependents": "auto", - "preserveLocalDependencyProtocols": true + "preserveLocalDependencyProtocols": true, + "currentVersionResolver": "git-tag", + "fallbackCurrentVersionResolver": "disk", + "manifestRootsToUpdate": [ + "{projectRoot}", + "dist/{projectRoot}" + ] }, "changelog": { "workspaceChangelog": { diff --git a/package-lock.json b/package-lock.json index 5d76e8436..d479afbc8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -188,7 +188,7 @@ }, "libs/chat": { "name": "@ngaf/chat", - "version": "0.0.31", + "version": "0.0.32", "license": "MIT", "dependencies": { "@cacheplane/partial-json": ">=0.1.1 <0.3.0", @@ -292,18 +292,20 @@ "libs/telemetry": { "name": "@ngaf/telemetry", "version": "0.0.0", - "hasInstallScript": true, "license": "MIT", - "dependencies": { - "posthog-js": "^1.372.0", - "posthog-node": "^5.20.0" + "bin": { + "ngaf-telemetry-postinstall": "node/postinstall.js" }, "peerDependencies": { - "@angular/core": "^20.0.0 || ^21.0.0" + "@angular/core": "^20.0.0 || ^21.0.0", + "posthog-js": "^1.372.0" }, "peerDependenciesMeta": { "@angular/core": { "optional": true + }, + "posthog-js": { + "optional": true } } }, @@ -37839,27 +37841,6 @@ "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",