diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 08e0082cf..4dcc1eca5 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -10,10 +10,6 @@ on: description: 'Dry run (no actual publish)' type: boolean default: true - projects: - description: 'Optional comma-separated project list to publish instead of the full release group' - type: string - default: '' jobs: publish: @@ -62,29 +58,10 @@ jobs: - name: Publish to npm if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.dry-run == false) - run: | - if [ -n "${NPM_PUBLISH_PROJECTS}" ]; then - IFS=',' read -ra projects <<< "${NPM_PUBLISH_PROJECTS}" - for project in "${projects[@]}"; do - npx nx run "${project}:nx-release-publish" - done - else - npx nx release publish --groups=publishable - fi + run: npx nx release publish --groups=publishable env: NPM_CONFIG_PROVENANCE: 'true' - NPM_PUBLISH_PROJECTS: ${{ github.event_name == 'workflow_dispatch' && inputs.projects || '' }} - name: Publish to npm (dry run) if: github.event_name == 'workflow_dispatch' && inputs.dry-run == true - run: | - if [ -n "${NPM_PUBLISH_PROJECTS}" ]; then - IFS=',' read -ra projects <<< "${NPM_PUBLISH_PROJECTS}" - for project in "${projects[@]}"; do - npx nx run "${project}:nx-release-publish" --dryRun - done - else - npx nx release publish --groups=publishable --dry-run - fi - env: - NPM_PUBLISH_PROJECTS: ${{ inputs.projects }} + run: npx nx release publish --groups=publishable --dry-run diff --git a/docs/gtm/taxonomy.md b/docs/gtm/taxonomy.md index e6ff2e83d..7c23b9945 100644 --- a/docs/gtm/taxonomy.md +++ b/docs/gtm/taxonomy.md @@ -74,6 +74,25 @@ The standard PostHog `$pageview` event is used as-is across all three surfaces. Browser events never fire unless the consumer explicitly opts in. See `libs/telemetry/README.md` for the trust contract. +### `ngaf:postinstall` properties + +| Property | Type | Notes | +|----------------------------------|--------|--------------------------------------------| +| `pkg` | string | Published `@ngaf/*` package name. | +| `version` | string | Published package version. | +| `node` | string | Current `process.version`; kept for existing dashboards. | +| `node_version` | string | Current `process.version`. | +| `os` | string | Current `process.platform`. | +| `arch` | string | Current `process.arch`. | +| `package_manager` | string | Parsed from npm's package-manager user agent when available. | +| `package_manager_version` | string | Parsed from npm's package-manager user agent when available. | +| `package_manager_node_version` | string | Installer-reported Node version when available. | +| `package_manager_os` | string | Installer-reported OS token when available. | +| `package_manager_arch` | string | Installer-reported architecture token when available. | +| `package_manager_workspaces` | bool | Installer-reported workspace flag when available. | +| `global_install` | bool | Whether npm reports a global install. | +| `sample_weight` | number | Inverse sample rate for weighted counts. | + ## Shared properties | Property | Type | Notes | diff --git a/libs/telemetry/README.md b/libs/telemetry/README.md index b248ea87d..ecec5a2cd 100644 --- a/libs/telemetry/README.md +++ b/libs/telemetry/README.md @@ -29,7 +29,7 @@ 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 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:postinstall` — fires once per dependency/global install of a published `@ngaf/*` package. Properties: package name, package version, Node version, OS, CPU architecture, package manager name/version, installer-reported Node/OS/architecture, workspace/global install flags when npm exposes them, sample weight. It uses a per-process anonymous id. No project path, no raw 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. diff --git a/libs/telemetry/scripts/assemble-dist.mjs b/libs/telemetry/scripts/assemble-dist.mjs index 40e36d758..10975b7d7 100644 --- a/libs/telemetry/scripts/assemble-dist.mjs +++ b/libs/telemetry/scripts/assemble-dist.mjs @@ -14,9 +14,8 @@ * * Idempotent — re-running on an assembled dist is a no-op. */ -import { readFile, writeFile, rm, rename, mkdir, access, readdir, stat } from 'node:fs/promises'; -import { existsSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; +import { readFile, writeFile, rm, rename, mkdir, access, readdir } from 'node:fs/promises'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import { dirname, join, relative } from 'node:path'; const HERE = dirname(fileURLToPath(import.meta.url)); @@ -84,13 +83,12 @@ async function writeBrowserIndexReexport() { console.log('[assemble-dist] wrote browser/index.d.ts re-exporting from fesm2022 types'); } -async function writeCanonicalPackageJson() { - const srcPkg = JSON.parse(await readFile(join(LIB_ROOT, 'package.json'), 'utf8')); - // Strip dev fields, lock to a clean publishable shape. +export function createCanonicalPackageJson(srcPkg) { const out = { name: srcPkg.name, version: srcPkg.version, license: srcPkg.license, + publishConfig: srcPkg.publishConfig, repository: srcPkg.repository, homepage: srcPkg.homepage, bugs: srcPkg.bugs, @@ -129,6 +127,13 @@ async function writeCanonicalPackageJson() { }; // Strip undefined. for (const k of Object.keys(out)) if (out[k] === undefined) delete out[k]; + return out; +} + +async function writeCanonicalPackageJson() { + const srcPkg = JSON.parse(await readFile(join(LIB_ROOT, 'package.json'), 'utf8')); + // Strip dev fields, lock to a clean publishable shape. + const out = createCanonicalPackageJson(srcPkg); await writeFile(join(DIST, 'package.json'), JSON.stringify(out, null, 2) + '\n', 'utf8'); console.log('[assemble-dist] wrote canonical dist/libs/telemetry/package.json with corrected exports map'); } @@ -163,7 +168,9 @@ async function main() { await verifyExports(); } -main().catch((err) => { - console.error('[assemble-dist] failed:', err); - process.exit(1); -}); +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((err) => { + console.error('[assemble-dist] failed:', err); + process.exit(1); + }); +} diff --git a/libs/telemetry/scripts/assemble-dist.spec.mjs b/libs/telemetry/scripts/assemble-dist.spec.mjs new file mode 100644 index 000000000..64a447515 --- /dev/null +++ b/libs/telemetry/scripts/assemble-dist.spec.mjs @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; + +import { createCanonicalPackageJson } from './assemble-dist.mjs'; + +describe('assemble-dist', () => { + it('preserves publishConfig in the canonical dist manifest', () => { + const manifest = createCanonicalPackageJson({ + name: '@ngaf/telemetry', + version: '0.0.30', + license: 'MIT', + publishConfig: { access: 'public' }, + bin: { 'ngaf-telemetry-postinstall': './node/postinstall.js' }, + }); + + expect(manifest.publishConfig).toEqual({ access: 'public' }); + }); +}); diff --git a/libs/telemetry/src/node/client.spec.ts b/libs/telemetry/src/node/client.spec.ts index 432403a65..d77783e00 100644 --- a/libs/telemetry/src/node/client.spec.ts +++ b/libs/telemetry/src/node/client.spec.ts @@ -23,6 +23,8 @@ describe('node client', () => { delete process.env.CIRCLECI; delete process.env.NGAF_TELEMETRY_SAMPLE_RATE; delete process.env.npm_config_user_agent; + delete process.env.npm_config_global; + delete process.env.npm_config_location; process.env.NGAF_TELEMETRY_INGEST_URL = 'https://test.example/api/ingest'; }); @@ -78,6 +80,28 @@ describe('node client', () => { })); }); + test('capturePostinstall includes runtime and installer context without paths', async () => { + process.env.npm_config_user_agent = 'npm/10.9.2 node/v22.14.0 darwin arm64 workspaces/true'; + process.env.npm_config_global = 'true'; + await capturePostinstall({ pkg: 'x', version: '1' }); + const body = JSON.parse(String(fetchMock.mock.calls[0][1].body)); + expect(body.properties).toEqual(expect.objectContaining({ + node: process.version, + node_version: process.version, + os: process.platform, + arch: process.arch, + package_manager: 'npm', + package_manager_version: '10.9.2', + package_manager_node_version: '22.14.0', + package_manager_os: 'darwin', + package_manager_arch: 'arm64', + package_manager_workspaces: true, + global_install: true, + })); + expect(body.properties).not.toHaveProperty('cwd'); + expect(body.properties).not.toHaveProperty('init_cwd'); + }); + test('capturePostinstall awaits fetch before resolving', async () => { let fetchResolved = false; fetchMock.mockImplementationOnce(async () => { diff --git a/libs/telemetry/src/node/client.ts b/libs/telemetry/src/node/client.ts index f379695b0..bbc8944a2 100644 --- a/libs/telemetry/src/node/client.ts +++ b/libs/telemetry/src/node/client.ts @@ -25,15 +25,41 @@ function getSampleRate(env: NodeJS.ProcessEnv = process.env): number { return Math.max(0, Math.min(1, parsed)); } -function getPackageManager(env: NodeJS.ProcessEnv = process.env): Record { +function readBooleanToken(value: string | undefined): boolean | undefined { + if (value === undefined) return undefined; + if (/^(1|true|yes)$/i.test(value)) return true; + if (/^(0|false|no)$/i.test(value)) return false; + return undefined; +} + +function getPackageManager(env: NodeJS.ProcessEnv = process.env): Record { const userAgent = env.npm_config_user_agent; - const firstToken = userAgent?.split(/\s+/)[0]; + const tokens = userAgent?.split(/\s+/).filter(Boolean) ?? []; + const firstToken = tokens[0]; const match = firstToken?.match(/^([^/\s]+)\/([^/\s]+)$/); if (!match) return {}; - return { + + const out: Record = { package_manager: match[1], package_manager_version: match[2], }; + + const nodeTokenIndex = tokens.findIndex((token) => token.startsWith('node/')); + const nodeToken = nodeTokenIndex >= 0 ? tokens[nodeTokenIndex] : undefined; + const nodeVersion = nodeToken?.match(/^node\/([^/\s]+)$/)?.[1]; + if (nodeVersion) out.package_manager_node_version = nodeVersion.replace(/^v/, ''); + + if (nodeTokenIndex >= 0) { + const platformTokens = tokens.slice(nodeTokenIndex + 1).filter((token) => !token.includes('/')); + if (platformTokens[0]) out.package_manager_os = platformTokens[0]; + if (platformTokens[1]) out.package_manager_arch = platformTokens[1]; + } + + const workspacesValue = tokens.find((token) => token.startsWith('workspaces/'))?.split('/')[1]; + const workspaces = readBooleanToken(workspacesValue); + if (workspaces !== undefined) out.package_manager_workspaces = workspaces; + + return out; } // @internal @@ -45,7 +71,11 @@ export function createPostinstallProperties( pkg: input.pkg, version: input.version, node: process.version, + node_version: process.version, os: process.platform, + arch: process.arch, + global_install: + readBooleanToken(env.npm_config_global) === true || env.npm_config_location === 'global', ...getPackageManager(env), }; } diff --git a/tools/posthog/dashboards/package-telemetry.json b/tools/posthog/dashboards/package-telemetry.json new file mode 100644 index 000000000..0a2e0a347 --- /dev/null +++ b/tools/posthog/dashboards/package-telemetry.json @@ -0,0 +1,25 @@ +{ + "slug": "package-telemetry", + "posthog_id": null, + "name": "GTM · Package telemetry", + "description": "Published @ngaf/* install telemetry from ngaf:postinstall.", + "tags": [ + "gtm", + "package-telemetry", + "phase-1" + ], + "tiles": [ + { + "insight": "package-installs-by-package" + }, + { + "insight": "package-installs-by-package-manager" + }, + { + "insight": "package-installs-by-installer-os" + }, + { + "insight": "package-installs-by-workspace-flag" + } + ] +} diff --git a/tools/posthog/insights/package-installs-by-installer-os.json b/tools/posthog/insights/package-installs-by-installer-os.json new file mode 100644 index 000000000..c68c79a78 --- /dev/null +++ b/tools/posthog/insights/package-installs-by-installer-os.json @@ -0,0 +1,16 @@ +{ + "slug": "package-installs-by-installer-os", + "posthog_id": null, + "kind": "trends", + "name": "Package installs by installer OS", + "events": [ + { + "event": "ngaf:postinstall", + "math": "total" + } + ], + "breakdown": "package_manager_os", + "breakdown_limit": 10, + "interval": "day", + "date_from": "-30d" +} diff --git a/tools/posthog/insights/package-installs-by-package-manager.json b/tools/posthog/insights/package-installs-by-package-manager.json new file mode 100644 index 000000000..544c58ad3 --- /dev/null +++ b/tools/posthog/insights/package-installs-by-package-manager.json @@ -0,0 +1,16 @@ +{ + "slug": "package-installs-by-package-manager", + "posthog_id": null, + "kind": "trends", + "name": "Package installs by package manager", + "events": [ + { + "event": "ngaf:postinstall", + "math": "total" + } + ], + "breakdown": "package_manager", + "breakdown_limit": 10, + "interval": "day", + "date_from": "-30d" +} diff --git a/tools/posthog/insights/package-installs-by-package.json b/tools/posthog/insights/package-installs-by-package.json new file mode 100644 index 000000000..e0b4cf353 --- /dev/null +++ b/tools/posthog/insights/package-installs-by-package.json @@ -0,0 +1,16 @@ +{ + "slug": "package-installs-by-package", + "posthog_id": null, + "kind": "trends", + "name": "Package installs by package", + "events": [ + { + "event": "ngaf:postinstall", + "math": "total" + } + ], + "breakdown": "pkg", + "breakdown_limit": 20, + "interval": "day", + "date_from": "-30d" +} diff --git a/tools/posthog/insights/package-installs-by-workspace-flag.json b/tools/posthog/insights/package-installs-by-workspace-flag.json new file mode 100644 index 000000000..ef8da6e29 --- /dev/null +++ b/tools/posthog/insights/package-installs-by-workspace-flag.json @@ -0,0 +1,16 @@ +{ + "slug": "package-installs-by-workspace-flag", + "posthog_id": null, + "kind": "trends", + "name": "Package installs by workspace flag", + "events": [ + { + "event": "ngaf:postinstall", + "math": "total" + } + ], + "breakdown": "package_manager_workspaces", + "breakdown_limit": 5, + "interval": "day", + "date_from": "-30d" +}