Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 2 additions & 25 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
19 changes: 19 additions & 0 deletions docs/gtm/taxonomy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion libs/telemetry/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
27 changes: 17 additions & 10 deletions libs/telemetry/scripts/assemble-dist.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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');
}
Expand Down Expand Up @@ -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);
});
}
17 changes: 17 additions & 0 deletions libs/telemetry/scripts/assemble-dist.spec.mjs
Original file line number Diff line number Diff line change
@@ -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' });
});
});
24 changes: 24 additions & 0 deletions libs/telemetry/src/node/client.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
});

Expand Down Expand Up @@ -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 () => {
Expand Down
36 changes: 33 additions & 3 deletions libs/telemetry/src/node/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> {
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<string, unknown> {
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<string, unknown> = {
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
Expand All @@ -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),
};
}
Expand Down
25 changes: 25 additions & 0 deletions tools/posthog/dashboards/package-telemetry.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
16 changes: 16 additions & 0 deletions tools/posthog/insights/package-installs-by-installer-os.json
Original file line number Diff line number Diff line change
@@ -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"
}
16 changes: 16 additions & 0 deletions tools/posthog/insights/package-installs-by-package-manager.json
Original file line number Diff line number Diff line change
@@ -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"
}
16 changes: 16 additions & 0 deletions tools/posthog/insights/package-installs-by-package.json
Original file line number Diff line number Diff line change
@@ -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"
}
16 changes: 16 additions & 0 deletions tools/posthog/insights/package-installs-by-workspace-flag.json
Original file line number Diff line number Diff line change
@@ -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"
}
Loading