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
2 changes: 2 additions & 0 deletions apps/website/content/docs/telemetry/guides/node.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ The postinstall entry point reads package name and version from npm lifecycle en

It skips local top-level installs by default. Dependency installs under `node_modules` and global installs can be eligible unless disabled.

Postinstall payloads include `install_context` with one of `ci`, `dependency`, `global`, or `workspace`. CI environments are disabled before sending; the `ci` value exists as a defensive classification if a CI install is intentionally forced through for diagnostics.

When `DEBUG` includes `ngaf:telemetry`, `ngaf:*`, or `*`, the script prints the payload shape it attempted to send. It prints the normal install telemetry notice only when the ingest endpoint accepted the event.

## Failure behavior
Expand Down
18 changes: 8 additions & 10 deletions apps/website/content/docs/telemetry/reference/events.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,13 @@ type NgafNodeEvent =
| 'ngaf:stream_errored';
```

| Event | Source | Properties from source |
|-------|--------|------------------------|
| `ngaf:postinstall` | package postinstall script | `pkg`, `version`, `node`, `node_version`, `os`, `arch`, `global_install`, package-manager fields when npm exposes them |
| `ngaf:runtime_instance_created` | Node adapter helper | `transport`, `provider`, `model`, `angularVersion`; `apiKey` is removed |
| `ngaf:stream_started` | Node adapter helper | `provider`, `model`, optional fields in the input object |
| `ngaf:stream_ended` | Node adapter helper | `provider`, `model`, `durationMs` when supplied |
| `ngaf:stream_errored` | Node adapter helper | stream properties plus `errorClass` |
| Event | Source | Properties from source |
| ------------------------------- | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `ngaf:postinstall` | package postinstall script | `pkg`, `version`, `node`, `node_version`, `os`, `arch`, `global_install`, `install_context`, package-manager fields when npm exposes them |
| `ngaf:runtime_instance_created` | Node adapter helper | `transport`, `provider`, `model`, `angularVersion`; `apiKey` is removed |
| `ngaf:stream_started` | Node adapter helper | `provider`, `model`, optional fields in the input object |
| `ngaf:stream_ended` | Node adapter helper | `provider`, `model`, `durationMs` when supplied |
| `ngaf:stream_errored` | Node adapter helper | stream properties plus `errorClass` |

`captureEvent()` also adds `sample_weight` to sent event properties.

Expand All @@ -32,9 +32,7 @@ type NgafNodeEvent =
The shared event file lists these browser-only events:

```ts
type NgafBrowserEvent =
| 'ngaf:browser_provided'
| 'ngaf:browser_chat_init';
type NgafBrowserEvent = 'ngaf:browser_provided' | 'ngaf:browser_chat_init';
```

The browser Angular token broadens the local service event type to:
Expand Down
210 changes: 109 additions & 101 deletions docs/gtm/taxonomy.md

Large diffs are not rendered by default.

109 changes: 76 additions & 33 deletions libs/telemetry/src/node/client.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { describe, test, expect, beforeEach, vi } from 'vitest';

import { capturePostinstall, captureEvent, _resetClientForTesting } from './client';
import {
capturePostinstall,
captureEvent,
createPostinstallProperties,
_resetClientForTesting,
} from './client';
import { disableTelemetry, _resetDisableForTesting } from './disable';

describe('node client', () => {
Expand Down Expand Up @@ -29,33 +34,41 @@ describe('node client', () => {
});

test('capturePostinstall sends an event with pkg + version', async () => {
await expect(capturePostinstall({ pkg: '@ngaf/telemetry', version: '0.0.31' }))
.resolves.toEqual({ sent: true });
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' }),
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 expect(capturePostinstall({ pkg: 'x', version: '1' }))
.resolves.toEqual({ sent: false, reason: 'disabled' });
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 expect(capturePostinstall({ pkg: 'x', version: '1' }))
.resolves.toEqual({ sent: false, reason: 'disabled' });
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(fetchMock.mock.calls[0][0]).toBe('https://custom.example/api/ingest');
expect(fetchMock.mock.calls[0][0]).toBe(
'https://custom.example/api/ingest'
);
});

test('capturePostinstall defaults to the live Cacheplane ingest proxy', async () => {
Expand All @@ -67,41 +80,68 @@ describe('node client', () => {
test('capturePostinstall sends sample_weight property', async () => {
await capturePostinstall({ pkg: 'x', version: '1' });
const body = JSON.parse(String(fetchMock.mock.calls[0][1].body));
expect(body.properties).toEqual(expect.objectContaining({ sample_weight: expect.any(Number) }));
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';
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',
}));
expect(body.properties).toEqual(
expect.objectContaining({
package_manager: 'npm',
package_manager_version: '10.9.2',
})
);
});

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_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).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('createPostinstallProperties classifies CI installs for dashboard filtering', () => {
expect(
createPostinstallProperties(
{ pkg: '@ngaf/chat', version: '0.0.31' },
{ CI: 'true' }
)
).toEqual(expect.objectContaining({ install_context: 'ci' }));

expect(
createPostinstallProperties(
{ pkg: '@ngaf/chat', version: '0.0.31' },
{
npm_config_user_agent:
'npm/10.9.2 node/v22.14.0 darwin arm64 workspaces/false',
}
)
).toEqual(expect.objectContaining({ install_context: 'dependency' }));
});

test('capturePostinstall awaits fetch before resolving', async () => {
let fetchResolved = false;
fetchMock.mockImplementationOnce(async () => {
Expand All @@ -114,14 +154,17 @@ describe('node client', () => {

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' });
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 });
await expect(
capturePostinstall({ pkg: 'x', version: '1' })
).resolves.toEqual({ sent: true });
expect(fetchMock).toHaveBeenCalled();
});
});
60 changes: 50 additions & 10 deletions libs/telemetry/src/node/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,19 @@ function readBooleanToken(value: string | undefined): boolean | undefined {
return undefined;
}

function getPackageManager(env: NodeJS.ProcessEnv = process.env): Record<string, unknown> {
function isCiEnv(env: NodeJS.ProcessEnv = process.env): boolean {
return [
env.CI,
env.GITHUB_ACTIONS,
env.CONTINUOUS_INTEGRATION,
env.BUILDKITE,
env.CIRCLECI,
].some((value) => readBooleanToken(value) === true);
}

function getPackageManager(
env: NodeJS.ProcessEnv = process.env
): Record<string, unknown> {
const userAgent = env.npm_config_user_agent;
const tokens = userAgent?.split(/\s+/).filter(Boolean) ?? [];
const firstToken = tokens[0];
Expand All @@ -47,25 +59,45 @@ function getPackageManager(env: NodeJS.ProcessEnv = process.env): Record<string,
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 (nodeVersion)
out.package_manager_node_version = nodeVersion.replace(/^v/, '');

if (nodeTokenIndex >= 0) {
const platformTokens = tokens.slice(nodeTokenIndex + 1).filter((token) => !token.includes('/'));
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 workspacesValue = tokens
.find((token) => token.startsWith('workspaces/'))
?.split('/')[1];
const workspaces = readBooleanToken(workspacesValue);
if (workspaces !== undefined) out.package_manager_workspaces = workspaces;

return out;
}

function getInstallContext(
env: NodeJS.ProcessEnv = process.env
): 'ci' | 'dependency' | 'global' | 'workspace' {
if (isCiEnv(env)) return 'ci';
if (
readBooleanToken(env.npm_config_global) === true ||
env.npm_config_location === 'global'
) {
return 'global';
}
const packageManager = getPackageManager(env);
if (packageManager.package_manager_workspaces === true) return 'workspace';
return 'dependency';
}

// @internal
export function createPostinstallProperties(
input: PostinstallInput,
env: NodeJS.ProcessEnv = process.env,
env: NodeJS.ProcessEnv = process.env
): Record<string, unknown> {
return {
pkg: input.pkg,
Expand All @@ -75,7 +107,9 @@ export function createPostinstallProperties(
os: process.platform,
arch: process.arch,
global_install:
readBooleanToken(env.npm_config_global) === true || env.npm_config_location === 'global',
readBooleanToken(env.npm_config_global) === true ||
env.npm_config_location === 'global',
install_context: getInstallContext(env),
...getPackageManager(env),
};
}
Expand All @@ -98,9 +132,10 @@ async function postJson(url: string, body: unknown): Promise<void> {

export async function captureEvent(
event: NgafNodeEvent,
properties: Record<string, unknown> = {},
properties: Record<string, unknown> = {}
): Promise<CaptureResult> {
if (isTelemetryDisabled() || isProgrammaticallyDisabled()) return { sent: false, reason: 'disabled' };
if (isTelemetryDisabled() || isProgrammaticallyDisabled())
return { sent: false, reason: 'disabled' };
const rate = getSampleRate();
const anonId = getAnonId();
if (!shouldSample(rate, anonId)) return { sent: false, reason: 'sampled' };
Expand All @@ -109,15 +144,20 @@ export async function captureEvent(
key: PUBLIC_INGEST_KEY,
distinctId: anonId,
event,
properties: { ...properties, sample_weight: rate > 0 ? 1 / Math.min(1, rate) : 1 },
properties: {
...properties,
sample_weight: rate > 0 ? 1 / Math.min(1, rate) : 1,
},
});
return { sent: true };
} catch {
return { sent: false, reason: 'failed' };
}
}

export async function capturePostinstall(input: PostinstallInput): Promise<CaptureResult> {
export async function capturePostinstall(
input: PostinstallInput
): Promise<CaptureResult> {
return captureEvent('ngaf:postinstall', createPostinstallProperties(input));
}

Expand Down
8 changes: 2 additions & 6 deletions tools/posthog/dashboards/package-telemetry.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,8 @@
"slug": "package-telemetry",
"posthog_id": 1590768,
"name": "GTM · Package telemetry",
"description": "Published @ngaf/* install telemetry from ngaf:postinstall.",
"tags": [
"gtm",
"package-telemetry",
"phase-1"
],
"description": "Published @ngaf/* install telemetry from ngaf:postinstall. Package install insights exclude CI install context.",
"tags": ["gtm", "package-telemetry", "phase-1"],
"tiles": [
{
"insight": "package-installs-by-package"
Expand Down
9 changes: 8 additions & 1 deletion tools/posthog/insights/package-installs-by-installer-os.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,14 @@
"events": [
{
"event": "ngaf:postinstall",
"math": "total"
"math": "total",
"properties": [
{
"key": "install_context",
"value": "ci",
"operator": "is_not"
}
]
}
],
"breakdown": "package_manager_os",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,14 @@
"events": [
{
"event": "ngaf:postinstall",
"math": "total"
"math": "total",
"properties": [
{
"key": "install_context",
"value": "ci",
"operator": "is_not"
}
]
}
],
"breakdown": "package_manager",
Expand Down
9 changes: 8 additions & 1 deletion tools/posthog/insights/package-installs-by-package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,14 @@
"events": [
{
"event": "ngaf:postinstall",
"math": "total"
"math": "total",
"properties": [
{
"key": "install_context",
"value": "ci",
"operator": "is_not"
}
]
}
],
"breakdown": "pkg",
Expand Down
Loading
Loading