From 0e753bbb4cf386a78144ac76ef72e2117005d832 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 18 May 2026 11:34:42 -0700 Subject: [PATCH] feat(telemetry): filter CI install dashboard context --- .../content/docs/telemetry/guides/node.mdx | 2 + .../docs/telemetry/reference/events.mdx | 18 +- docs/gtm/taxonomy.md | 210 ++++++++------- libs/telemetry/src/node/client.spec.ts | 109 +++++--- libs/telemetry/src/node/client.ts | 60 ++++- .../posthog/dashboards/package-telemetry.json | 8 +- .../package-installs-by-installer-os.json | 9 +- .../package-installs-by-package-manager.json | 9 +- .../insights/package-installs-by-package.json | 9 +- .../package-installs-by-workspace-flag.json | 9 +- tools/posthog/telemetry-contract.spec.ts | 146 ++++++++--- tools/posthog/telemetry-contract.ts | 248 ++++++++++-------- 12 files changed, 538 insertions(+), 299 deletions(-) diff --git a/apps/website/content/docs/telemetry/guides/node.mdx b/apps/website/content/docs/telemetry/guides/node.mdx index bf6463880..6d2a0a1f5 100644 --- a/apps/website/content/docs/telemetry/guides/node.mdx +++ b/apps/website/content/docs/telemetry/guides/node.mdx @@ -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 diff --git a/apps/website/content/docs/telemetry/reference/events.mdx b/apps/website/content/docs/telemetry/reference/events.mdx index 1ad63525e..0111aea4a 100644 --- a/apps/website/content/docs/telemetry/reference/events.mdx +++ b/apps/website/content/docs/telemetry/reference/events.mdx @@ -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. @@ -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: diff --git a/docs/gtm/taxonomy.md b/docs/gtm/taxonomy.md index 8aef28982..c22eb30de 100644 --- a/docs/gtm/taxonomy.md +++ b/docs/gtm/taxonomy.md @@ -6,12 +6,12 @@ Single PostHog project. Three event-name prefixes: -| Prefix | Source | Notes | -|--------------|---------------------------------|---------------------------------------------| -| `marketing:` | `apps/website` | Carried forward from May-2 instrumentation plan. | -| `cockpit:` | `apps/cockpit` | Activation surface. New in Spec 1. | -| `ngaf:` | `libs/telemetry` | Library telemetry. Opt-out node, opt-in browser. | -| `docs:` | `apps/website` (docs surface) | Subset of website events scoped to docs interactions. Distinguished by prefix for filtering convenience. | +| Prefix | Source | Notes | +| ------------ | ----------------------------- | -------------------------------------------------------------------------------------------------------- | +| `marketing:` | `apps/website` | Carried forward from May-2 instrumentation plan. | +| `cockpit:` | `apps/cockpit` | Activation surface. New in Spec 1. | +| `ngaf:` | `libs/telemetry` | Library telemetry. Opt-out node, opt-in browser. | +| `docs:` | `apps/website` (docs surface) | Subset of website events scoped to docs interactions. Distinguished by prefix for filtering convenience. | The standard PostHog `$pageview` event is used as-is across all three surfaces. @@ -24,44 +24,44 @@ The standard PostHog `$pageview` event is used as-is across all three surfaces. ## Marketing (website) -| Event | When | -|---------------------------------------------|---------------------------------------------------| -| `$pageview` | Automatic, every route | -| `marketing:cta_click` | Any tracked CTA | -| `marketing:external_link_click` | External outbound (github, npm, cockpit) | -| `marketing:whitepaper_download_click` | Direct PDF download click | -| `marketing:whitepaper_signup_submit` | Submit attempt | -| `marketing:whitepaper_signup_success` | Server 2xx | -| `marketing:whitepaper_signup_fail` | Server non-2xx or validation error | -| `marketing:lead_form_submit` | Submit attempt (any surface) | -| `marketing:lead_form_success` | Server 2xx | -| `marketing:lead_form_fail` | Server non-2xx | -| `marketing:lead_qualified` | Server-side enrichment passes (qualified-lead def) | -| `marketing:newsletter_signup_submit` | Submit attempt | -| `marketing:newsletter_signup_success` | Server 2xx | -| `marketing:newsletter_signup_fail` | Failure | -| `docs:search_submit` | Docs search invocation | -| `docs:search_result_click` | Result click | -| `docs:copy_prompt_click` | Prompt-copy button | -| `docs:copy_code_click` | Code-copy button | -| `blog:cta_click` | Tracked CTA inside a blog post body. Props: `surface: 'blog'`, `cta_id?`, `destination_url?`. | -| `blog:copy_code_click` | Copy-button click on a code block inside a blog post. Props: `surface: 'blog'`, `code_lang?`. | -| `docs:tab_select` | MDX tab change | -| `docs:sidebar_section_toggle` | Sidebar nav toggle | +| Event | When | +| ------------------------------------- | --------------------------------------------------------------------------------------------- | +| `$pageview` | Automatic, every route | +| `marketing:cta_click` | Any tracked CTA | +| `marketing:external_link_click` | External outbound (github, npm, cockpit) | +| `marketing:whitepaper_download_click` | Direct PDF download click | +| `marketing:whitepaper_signup_submit` | Submit attempt | +| `marketing:whitepaper_signup_success` | Server 2xx | +| `marketing:whitepaper_signup_fail` | Server non-2xx or validation error | +| `marketing:lead_form_submit` | Submit attempt (any surface) | +| `marketing:lead_form_success` | Server 2xx | +| `marketing:lead_form_fail` | Server non-2xx | +| `marketing:lead_qualified` | Server-side enrichment passes (qualified-lead def) | +| `marketing:newsletter_signup_submit` | Submit attempt | +| `marketing:newsletter_signup_success` | Server 2xx | +| `marketing:newsletter_signup_fail` | Failure | +| `docs:search_submit` | Docs search invocation | +| `docs:search_result_click` | Result click | +| `docs:copy_prompt_click` | Prompt-copy button | +| `docs:copy_code_click` | Code-copy button | +| `blog:cta_click` | Tracked CTA inside a blog post body. Props: `surface: 'blog'`, `cta_id?`, `destination_url?`. | +| `blog:copy_code_click` | Copy-button click on a code block inside a blog post. Props: `surface: 'blog'`, `code_lang?`. | +| `docs:tab_select` | MDX tab change | +| `docs:sidebar_section_toggle` | Sidebar nav toggle | ## Cockpit (activation surface) -| Event | When | -|------------------------------------------------|-----------------------------------------------------------------| -| `cockpit:recipe_opened` | Sidebar capability link clicked | -| `cockpit:mode_switched` | Run/Code/Docs/API tab change | -| `cockpit:code_copied` | Copy click in code mode, doc snippet, or agentic-prompt block | -| `cockpit:transport_connected` | LangGraph/AG-UI/custom adapter wired in iframe | -| `cockpit:chat_first_message` | First user message sent in cockpit chat | -| `cockpit:thread_persisted` | Thread saved (re-load demonstrated) | -| `cockpit:interrupt_handled` | Human-approval interrupt completed | -| `cockpit:generative_component_rendered` | One generative Angular component rendered | -| `cockpit:activation_complete` | All five activation signals fired within 30 min for one session | +| Event | When | +| --------------------------------------- | --------------------------------------------------------------- | +| `cockpit:recipe_opened` | Sidebar capability link clicked | +| `cockpit:mode_switched` | Run/Code/Docs/API tab change | +| `cockpit:code_copied` | Copy click in code mode, doc snippet, or agentic-prompt block | +| `cockpit:transport_connected` | LangGraph/AG-UI/custom adapter wired in iframe | +| `cockpit:chat_first_message` | First user message sent in cockpit chat | +| `cockpit:thread_persisted` | Thread saved (re-load demonstrated) | +| `cockpit:interrupt_handled` | Human-approval interrupt completed | +| `cockpit:generative_component_rendered` | One generative Angular component rendered | +| `cockpit:activation_complete` | All five activation signals fired within 30 min for one session | The five activation signals (whose union fires `cockpit:activation_complete`) are `transport_connected`, `chat_first_message`, `thread_persisted`, `interrupt_handled`, @@ -73,100 +73,107 @@ sessions by design. ## ngaf (library telemetry) -| Event | When | Surface | Default | -|--------------------------------------|--------------------------------------------|-----------------|--------------| -| `ngaf:postinstall` | Dependency/global install of a published `@ngaf/*` package | Node (script) | **Opt-out** | -| `ngaf:runtime_instance_created` | Runtime adapter init | Node / Browser | **Opt-out** on Node, **Opt-in** in Browser | -| `ngaf:runtime_request_created` | Runtime adapter request created | Node / Browser | **Opt-out** on Node, **Opt-in** in Browser | -| `ngaf:stream_started` | Stream begins | Node / Browser | **Opt-out** on Node, **Opt-in** in Browser | -| `ngaf:stream_ended` | Stream ends normally | Node / Browser | **Opt-out** on Node, **Opt-in** in Browser | -| `ngaf:stream_errored` | Stream errors | Node / Browser | **Opt-out** on Node, **Opt-in** in Browser | -| `ngaf:browser_provided` | `provideNgafTelemetry({enabled:true})` | Browser | **Opt-in** | -| `ngaf:browser_chat_init` | Browser chat surface initialized | Browser | **Opt-in** | +| Event | When | Surface | Default | +| ------------------------------- | ---------------------------------------------------------- | -------------- | ------------------------------------------ | +| `ngaf:postinstall` | Dependency/global install of a published `@ngaf/*` package | Node (script) | **Opt-out** | +| `ngaf:runtime_instance_created` | Runtime adapter init | Node / Browser | **Opt-out** on Node, **Opt-in** in Browser | +| `ngaf:runtime_request_created` | Runtime adapter request created | Node / Browser | **Opt-out** on Node, **Opt-in** in Browser | +| `ngaf:stream_started` | Stream begins | Node / Browser | **Opt-out** on Node, **Opt-in** in Browser | +| `ngaf:stream_ended` | Stream ends normally | Node / Browser | **Opt-out** on Node, **Opt-in** in Browser | +| `ngaf:stream_errored` | Stream errors | Node / Browser | **Opt-out** on Node, **Opt-in** in Browser | +| `ngaf:browser_provided` | `provideNgafTelemetry({enabled:true})` | Browser | **Opt-in** | +| `ngaf:browser_chat_init` | Browser chat surface initialized | Browser | **Opt-in** | 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. | +| 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`. | +| `install_context` | enum | `ci`, `dependency`, `global`, or `workspace`; dashboards exclude `ci` defensively. | +| `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. | ### Runtime telemetry properties -| Property | Type | Notes | -|---------------|--------|--------------------------------------------| -| `transport` | string | Runtime transport, e.g. `langgraph`, `ag-ui`, or `custom`. | -| `surface` | string | Adapter surface emitting the event. | -| `requestType` | string | Request shape, e.g. `submit`, `resubmit`, `regenerate`, `enqueue`, or `join`. | -| `provider` | string | Model provider when known. | -| `model` | string | Model name when known. | -| `durationMs` | number | Stream duration for end/error events. | -| `errorClass` | string | Error class only. Never send error messages. | -| `sample_weight` | number | Inverse sample rate for weighted counts. | +| Property | Type | Notes | +| --------------- | ------ | ----------------------------------------------------------------------------- | +| `transport` | string | Runtime transport, e.g. `langgraph`, `ag-ui`, or `custom`. | +| `surface` | string | Adapter surface emitting the event. | +| `requestType` | string | Request shape, e.g. `submit`, `resubmit`, `regenerate`, `enqueue`, or `join`. | +| `provider` | string | Model provider when known. | +| `model` | string | Model name when known. | +| `durationMs` | number | Stream duration for end/error events. | +| `errorClass` | string | Error class only. Never send error messages. | +| `sample_weight` | number | Inverse sample rate for weighted counts. | ## Shared properties -| Property | Type | Notes | -|------------------|--------|-------------------------------------------------------------| -| `source_page` | string | Stable pathname or surface id (`home`, `compare_langchain_angular`, `pricing`). | -| `source_section` | string | Stable section/component id where known. | -| `surface` | enum | `nav` `mobile_nav` `footer` `home` `pricing` `docs` `blog` `library_landing` `solution` `toast` `cockpit` `contact` | -| `destination_url`| string | Clicked URL where applicable. | -| `cta_id` | string | Stable CTA id (see below). | -| `cta_text` | string | Visible label where stable. | -| `track` | enum | `developer` `enterprise` `pricing` `pilot` | -| `paper` | enum | `overview` `angular` `render` `chat` | -| `library` | enum | `agent` `render` `chat` `unknown` | -| `email_domain` | string | Extracted server-side. Never raw email in client events. | -| `company` | string | Server-side only, on conversion events, if approved. | -| `is_success` | bool | Generic success flag for wrapper events. | -| `failure_reason` | string | Short stable code on failure events. | -| `referrer_host` | string | Sanitized host of HTTP referrer. | -| `message_length` | int | Lead form / whitepaper message length (never the content). | -| `message_empty` | bool | Whether the free-text body was empty. | +| Property | Type | Notes | +| ----------------- | ------ | ------------------------------------------------------------------------------------------------------------------- | +| `source_page` | string | Stable pathname or surface id (`home`, `compare_langchain_angular`, `pricing`). | +| `source_section` | string | Stable section/component id where known. | +| `surface` | enum | `nav` `mobile_nav` `footer` `home` `pricing` `docs` `blog` `library_landing` `solution` `toast` `cockpit` `contact` | +| `destination_url` | string | Clicked URL where applicable. | +| `cta_id` | string | Stable CTA id (see below). | +| `cta_text` | string | Visible label where stable. | +| `track` | enum | `developer` `enterprise` `pricing` `pilot` | +| `paper` | enum | `overview` `angular` `render` `chat` | +| `library` | enum | `agent` `render` `chat` `unknown` | +| `email_domain` | string | Extracted server-side. Never raw email in client events. | +| `company` | string | Server-side only, on conversion events, if approved. | +| `is_success` | bool | Generic success flag for wrapper events. | +| `failure_reason` | string | Short stable code on failure events. | +| `referrer_host` | string | Sanitized host of HTTP referrer. | +| `message_length` | int | Lead form / whitepaper message length (never the content). | +| `message_empty` | bool | Whether the free-text body was empty. | ## CTA ids (stable, lowercase snake_case) **Hero** + - `hero_install` — primary, copy-to-clipboard - `hero_talk_to_engineers` — secondary, → `/contact` **Nav** + - `nav_get_started` `nav_docs` `nav_pricing` `nav_github` `nav_npm` `nav_cockpit` **Footer** + - `footer_github` `footer_npm` `footer_cockpit` `footer_pricing` `footer_pilot_to_prod` `footer_contact` **Comparison pages** (`/compare/`) + - `compare__install` — primary CTA on each comparison page - `compare__talk_to_engineers` — secondary CTA on each comparison page - `compare__view_demo` — link to cockpit recipe **Pricing & Pilot** + - `pricing_enterprise_lead` — pricing page form submit - `pilot_book_call` — `/pilot-to-prod` form submit **Contact** + - `contact_send` — `/contact` form submit ## Privacy & redaction rules - **Never send** raw lead form `message`, raw docs search query, copied code content, or any free-form customer text. -- **Always send** message *length* and *is_empty* booleans instead. +- **Always send** message _length_ and _is_empty_ booleans instead. - **Email domains only** in client-side events; raw emails never leave the form. - **Company** is server-side only, on conversion events. - **Free-form lead body** is never forwarded to PostHog under any property name. @@ -176,9 +183,10 @@ Browser events never fire unless the consumer explicitly opts in. See `libs/tele This file is human-edited. When events are added/renamed/removed, update the affected event-constant files in the same PR. CI guards `posthog:sync` will warn if a dashboard JSON references an event not listed here. -| Date | Change | -|------------|--------| -| 2026-05-13 | Initial draft per Spec 0. | -| 2026-05-15 | Drop cockpit:install_command_copied, rename cockpit:six_signals_complete → cockpit:activation_complete (Spec 1C). | +| Date | Change | +| ---------- | ------------------------------------------------------------------------------------------------------------------------------ | +| 2026-05-13 | Initial draft per Spec 0. | +| 2026-05-15 | Drop cockpit:install_command_copied, rename cockpit:six_signals_complete → cockpit:activation_complete (Spec 1C). | | 2026-05-15 | Cockpit shell events: rename `recipe_start` → `recipe_opened`; add `mode_switched` and `code_copied` (Spec 1C implementation). | -| 2026-05-17 | Add `blog:cta_click` + `blog:copy_code_click` events; add `'blog'` to `AnalyticsSurface` (Spec 5). | +| 2026-05-17 | Add `blog:cta_click` + `blog:copy_code_click` events; add `'blog'` to `AnalyticsSurface` (Spec 5). | +| 2026-05-18 | Add `install_context` to `ngaf:postinstall` and filter package telemetry dashboard insights away from `ci` context. | diff --git a/libs/telemetry/src/node/client.spec.ts b/libs/telemetry/src/node/client.spec.ts index d77783e00..22683a9d4 100644 --- a/libs/telemetry/src/node/client.spec.ts +++ b/libs/telemetry/src/node/client.spec.ts @@ -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', () => { @@ -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 () => { @@ -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 () => { @@ -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(); }); }); diff --git a/libs/telemetry/src/node/client.ts b/libs/telemetry/src/node/client.ts index bbc8944a2..1ef4d6107 100644 --- a/libs/telemetry/src/node/client.ts +++ b/libs/telemetry/src/node/client.ts @@ -32,7 +32,19 @@ function readBooleanToken(value: string | undefined): boolean | undefined { return undefined; } -function getPackageManager(env: NodeJS.ProcessEnv = process.env): Record { +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 { const userAgent = env.npm_config_user_agent; const tokens = userAgent?.split(/\s+/).filter(Boolean) ?? []; const firstToken = tokens[0]; @@ -47,25 +59,45 @@ function getPackageManager(env: NodeJS.ProcessEnv = process.env): Record 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 { return { pkg: input.pkg, @@ -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), }; } @@ -98,9 +132,10 @@ async function postJson(url: string, body: unknown): Promise { export async function captureEvent( event: NgafNodeEvent, - properties: Record = {}, + properties: Record = {} ): Promise { - 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' }; @@ -109,7 +144,10 @@ 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 { @@ -117,7 +155,9 @@ export async function captureEvent( } } -export async function capturePostinstall(input: PostinstallInput): Promise { +export async function capturePostinstall( + input: PostinstallInput +): Promise { return captureEvent('ngaf:postinstall', createPostinstallProperties(input)); } diff --git a/tools/posthog/dashboards/package-telemetry.json b/tools/posthog/dashboards/package-telemetry.json index f6d8116ce..fc61a37b3 100644 --- a/tools/posthog/dashboards/package-telemetry.json +++ b/tools/posthog/dashboards/package-telemetry.json @@ -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" diff --git a/tools/posthog/insights/package-installs-by-installer-os.json b/tools/posthog/insights/package-installs-by-installer-os.json index 1ed8022ec..93d1bcfb1 100644 --- a/tools/posthog/insights/package-installs-by-installer-os.json +++ b/tools/posthog/insights/package-installs-by-installer-os.json @@ -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", diff --git a/tools/posthog/insights/package-installs-by-package-manager.json b/tools/posthog/insights/package-installs-by-package-manager.json index 005e57a86..6a3902e49 100644 --- a/tools/posthog/insights/package-installs-by-package-manager.json +++ b/tools/posthog/insights/package-installs-by-package-manager.json @@ -6,7 +6,14 @@ "events": [ { "event": "ngaf:postinstall", - "math": "total" + "math": "total", + "properties": [ + { + "key": "install_context", + "value": "ci", + "operator": "is_not" + } + ] } ], "breakdown": "package_manager", diff --git a/tools/posthog/insights/package-installs-by-package.json b/tools/posthog/insights/package-installs-by-package.json index d367f6ae2..fef9cf2b2 100644 --- a/tools/posthog/insights/package-installs-by-package.json +++ b/tools/posthog/insights/package-installs-by-package.json @@ -6,7 +6,14 @@ "events": [ { "event": "ngaf:postinstall", - "math": "total" + "math": "total", + "properties": [ + { + "key": "install_context", + "value": "ci", + "operator": "is_not" + } + ] } ], "breakdown": "pkg", diff --git a/tools/posthog/insights/package-installs-by-workspace-flag.json b/tools/posthog/insights/package-installs-by-workspace-flag.json index ccf645c4d..31487605a 100644 --- a/tools/posthog/insights/package-installs-by-workspace-flag.json +++ b/tools/posthog/insights/package-installs-by-workspace-flag.json @@ -6,7 +6,14 @@ "events": [ { "event": "ngaf:postinstall", - "math": "total" + "math": "total", + "properties": [ + { + "key": "install_context", + "value": "ci", + "operator": "is_not" + } + ] } ], "breakdown": "package_manager_workspaces", diff --git a/tools/posthog/telemetry-contract.spec.ts b/tools/posthog/telemetry-contract.spec.ts index b40113100..f861253f2 100644 --- a/tools/posthog/telemetry-contract.spec.ts +++ b/tools/posthog/telemetry-contract.spec.ts @@ -18,41 +18,52 @@ async function readJson(path: string): Promise { } async function insightFiles(): Promise { - return (await readdir(INSIGHTS_DIR)).filter((file) => file.endsWith('.json')).sort(); + return (await readdir(INSIGHTS_DIR)) + .filter((file) => file.endsWith('.json')) + .sort(); } test('every insight event is registered in the telemetry contract', async () => { const referenced = new Set(); for (const file of await insightFiles()) { - const insight = await readJson<{ events?: Array<{ event?: string }>; steps?: Array<{ event?: string }> }>( - join(INSIGHTS_DIR, file), - ); + const insight = await readJson<{ + events?: Array<{ event?: string }>; + steps?: Array<{ event?: string }>; + }>(join(INSIGHTS_DIR, file)); for (const item of [...(insight.events ?? []), ...(insight.steps ?? [])]) { if (item.event) referenced.add(item.event); } } - const unregistered = [...referenced].filter((event) => !(event in TELEMETRY_EVENT_CONTRACT)).sort(); + const unregistered = [...referenced] + .filter((event) => !(event in TELEMETRY_EVENT_CONTRACT)) + .sort(); assert.deepEqual( unregistered, [], - `Insights reference events missing from TELEMETRY_EVENT_CONTRACT:\n${unregistered.join('\n')}`, + `Insights reference events missing from TELEMETRY_EVENT_CONTRACT:\n${unregistered.join( + '\n' + )}` ); }); test('insight breakdown properties are allowed by the event contract', async () => { const violations: string[] = []; for (const file of await insightFiles()) { - const insight = await readJson<{ slug: string; breakdown?: string; events?: Array<{ event?: string }> }>( - join(INSIGHTS_DIR, file), - ); + const insight = await readJson<{ + slug: string; + breakdown?: string; + events?: Array<{ event?: string }>; + }>(join(INSIGHTS_DIR, file)); if (!insight.breakdown) continue; for (const item of insight.events ?? []) { const event = item.event; if (!event) continue; const allowed = TELEMETRY_EVENT_CONTRACT[event]?.allowedBreakdowns ?? []; if (!allowed.includes(insight.breakdown)) { - violations.push(`${insight.slug}: ${event} breaks down by ${insight.breakdown}`); + violations.push( + `${insight.slug}: ${event} breaks down by ${insight.breakdown}` + ); } } } @@ -60,7 +71,9 @@ test('insight breakdown properties are allowed by the event contract', async () assert.deepEqual( violations, [], - `Insight breakdowns are not allowed by the telemetry contract:\n${violations.join('\n')}`, + `Insight breakdowns are not allowed by the telemetry contract:\n${violations.join( + '\n' + )}` ); }); @@ -78,7 +91,9 @@ test('insight property filters are allowed by the event contract', async () => { const allowed = TELEMETRY_EVENT_CONTRACT[event]?.allowedProperties ?? []; for (const property of item.properties ?? []) { if (property.key && !allowed.includes(property.key)) { - violations.push(`${insight.slug}: ${event} filters by ${property.key}`); + violations.push( + `${insight.slug}: ${event} filters by ${property.key}` + ); } } } @@ -87,65 +102,134 @@ test('insight property filters are allowed by the event contract', async () => { assert.deepEqual( violations, [], - `Insight property filters are not allowed by the telemetry contract:\n${violations.join('\n')}`, + `Insight property filters are not allowed by the telemetry contract:\n${violations.join( + '\n' + )}` + ); +}); + +test('package telemetry dashboard filters install insights away from CI context', async () => { + const dashboard = await readJson<{ tiles: Array<{ insight: string }> }>( + join(HERE, 'dashboards', 'package-telemetry.json') + ); + + const violations: string[] = []; + for (const tile of dashboard.tiles) { + const insight = await readJson<{ + slug: string; + events?: Array<{ + event?: string; + properties?: Array<{ + key?: string; + value?: unknown; + operator?: string; + }>; + }>; + }>(join(INSIGHTS_DIR, `${tile.insight}.json`)); + + for (const item of insight.events ?? []) { + if (item.event !== 'ngaf:postinstall') continue; + const excludesCiContext = (item.properties ?? []).some( + (property) => + property.key === 'install_context' && + property.value === 'ci' && + property.operator === 'is_not' + ); + if (!excludesCiContext) violations.push(insight.slug); + } + } + + assert.deepEqual( + violations, + [], + `Package telemetry insights must exclude install_context=ci:\n${violations.join( + '\n' + )}` ); }); test('runtime dashboard covers every runtime event exactly once', async () => { const dashboard = await readJson<{ tiles: Array<{ insight: string }> }>( - join(HERE, 'dashboards', 'runtime-telemetry.json'), + join(HERE, 'dashboards', 'runtime-telemetry.json') ); const coveredEventCounts = new Map(); for (const tile of dashboard.tiles) { const insight = await readJson<{ events?: Array<{ event?: string }> }>( - join(INSIGHTS_DIR, `${tile.insight}.json`), + join(INSIGHTS_DIR, `${tile.insight}.json`) ); for (const item of insight.events ?? []) { if (item.event) { - coveredEventCounts.set(item.event, (coveredEventCounts.get(item.event) ?? 0) + 1); + coveredEventCounts.set( + item.event, + (coveredEventCounts.get(item.event) ?? 0) + 1 + ); } } } - const actualCoverage = [...coveredEventCounts.entries()] - .sort(([leftEvent], [rightEvent]) => leftEvent.localeCompare(rightEvent)); - const expectedCoverage: Array<[string, number]> = NGAF_RUNTIME_EVENTS - .map((event): [string, number] => [event, 1]) - .sort(([leftEvent], [rightEvent]) => leftEvent.localeCompare(rightEvent)); + const actualCoverage = [...coveredEventCounts.entries()].sort( + ([leftEvent], [rightEvent]) => leftEvent.localeCompare(rightEvent) + ); + const expectedCoverage: Array<[string, number]> = NGAF_RUNTIME_EVENTS.map( + (event): [string, number] => [event, 1] + ).sort(([leftEvent], [rightEvent]) => leftEvent.localeCompare(rightEvent)); assert.deepEqual(actualCoverage, expectedCoverage); }); test('public AgentRuntimeTelemetryEvent union matches the runtime event contract', async () => { const runtimeTelemetrySource = await readFile( - join(REPO_ROOT, 'libs', 'chat', 'src', 'lib', 'agent', 'runtime-telemetry.ts'), - 'utf8', + join( + REPO_ROOT, + 'libs', + 'chat', + 'src', + 'lib', + 'agent', + 'runtime-telemetry.ts' + ), + 'utf8' + ); + const match = runtimeTelemetrySource.match( + /export type AgentRuntimeTelemetryEvent =([\s\S]*?);/ ); - const match = runtimeTelemetrySource.match(/export type AgentRuntimeTelemetryEvent =([\s\S]*?);/); assert(match, 'AgentRuntimeTelemetryEvent type not found'); - const exportedEvents = [...match[1].matchAll(/'([^']+)'/g)].map((m) => m[1]).sort(); + const exportedEvents = [...match[1].matchAll(/'([^']+)'/g)] + .map((m) => m[1]) + .sort(); assert.deepEqual(exportedEvents, [...NGAF_RUNTIME_EVENTS].sort()); }); test('sensitive runtime fields are forbidden and never allowed by any event contract', () => { assert.deepEqual( [...TELEMETRY_FORBIDDEN_PROPERTIES].sort(), - ['apiUrl', 'assistantId', 'error', 'errorMessage', 'messages', 'prompt', 'query', 'threadId'].sort(), + [ + 'apiUrl', + 'assistantId', + 'error', + 'errorMessage', + 'messages', + 'prompt', + 'query', + 'threadId', + ].sort() ); const forbiddenProperties = new Set(TELEMETRY_FORBIDDEN_PROPERTIES); - const violations = Object.entries(TELEMETRY_EVENT_CONTRACT) - .flatMap(([event, contract]) => + const violations = Object.entries(TELEMETRY_EVENT_CONTRACT).flatMap( + ([event, contract]) => contract.allowedProperties .filter((property) => forbiddenProperties.has(property)) - .map((property) => `${event}: ${property}`), - ); + .map((property) => `${event}: ${property}`) + ); assert.deepEqual( violations, [], - `Sensitive properties must not be allowed by telemetry contracts:\n${violations.join('\n')}`, + `Sensitive properties must not be allowed by telemetry contracts:\n${violations.join( + '\n' + )}` ); }); diff --git a/tools/posthog/telemetry-contract.ts b/tools/posthog/telemetry-contract.ts index 1f111563f..8cd7a1f51 100644 --- a/tools/posthog/telemetry-contract.ts +++ b/tools/posthog/telemetry-contract.ts @@ -26,6 +26,7 @@ type TelemetryEventContract = { const installProperties = [ 'arch', 'global_install', + 'install_context', 'node', 'node_version', 'os', @@ -71,107 +72,146 @@ const cockpitShellProperties = [ 'to_mode', ] as const; -export const TELEMETRY_EVENT_CONTRACT: Record = { - '$pageview': { - requiredProperties: [], - allowedProperties: ['$pathname', 'title'], - allowedBreakdowns: ['$pathname'], - }, - 'cockpit:activation_complete': { - requiredProperties: ['capability'], - allowedProperties: ['capability'], - allowedBreakdowns: ['capability'], - }, - 'cockpit:chat_first_message': { - requiredProperties: ['capability'], - allowedProperties: ['capability'], - allowedBreakdowns: ['capability'], - }, - 'cockpit:code_copied': { - requiredProperties: ['capability'], - allowedProperties: cockpitShellProperties, - allowedBreakdowns: ['capability', 'surface'], - }, - 'cockpit:generative_component_rendered': { - requiredProperties: ['capability'], - allowedProperties: ['capability'], - allowedBreakdowns: ['capability'], - }, - 'cockpit:interrupt_handled': { - requiredProperties: ['capability'], - allowedProperties: ['capability'], - allowedBreakdowns: ['capability'], - }, - 'cockpit:mode_switched': { - requiredProperties: ['capability'], - allowedProperties: cockpitShellProperties, - allowedBreakdowns: ['capability', 'from_mode', 'to_mode'], - }, - 'cockpit:recipe_opened': { - requiredProperties: ['capability'], - allowedProperties: cockpitShellProperties, - allowedBreakdowns: ['capability', 'category', 'from_capability'], - }, - 'cockpit:thread_persisted': { - requiredProperties: ['capability'], - allowedProperties: ['capability'], - allowedBreakdowns: ['capability'], - }, - 'cockpit:transport_connected': { - requiredProperties: ['capability'], - allowedProperties: ['capability'], - allowedBreakdowns: ['capability'], - }, - 'marketing:cta_click': { - requiredProperties: ['cta_id'], - allowedProperties: ctaProperties, - allowedBreakdowns: ['cta_id', 'source_page', 'source_section', 'surface', 'track'], - }, - 'ngaf:browser_chat_init': { - requiredProperties: ['surface'], - allowedProperties: runtimeProperties, - allowedBreakdowns: ['surface'], - }, - 'ngaf:browser_provided': { - requiredProperties: [], - allowedProperties: runtimeProperties, - allowedBreakdowns: ['surface'], - }, - 'ngaf:postinstall': { - requiredProperties: ['pkg', 'version'], - allowedProperties: installProperties, - allowedBreakdowns: [ - 'global_install', - 'os', - 'package_manager', - 'package_manager_os', - 'package_manager_workspaces', - 'pkg', - ], - }, - 'ngaf:runtime_instance_created': { - requiredProperties: ['transport'], - allowedProperties: runtimeProperties, - allowedBreakdowns: ['model', 'provider', 'requestType', 'surface', 'transport'], - }, - 'ngaf:runtime_request_created': { - requiredProperties: ['transport'], - allowedProperties: runtimeProperties, - allowedBreakdowns: ['model', 'provider', 'requestType', 'surface', 'transport'], - }, - 'ngaf:stream_started': { - requiredProperties: ['transport'], - allowedProperties: runtimeProperties, - allowedBreakdowns: ['model', 'provider', 'requestType', 'surface', 'transport'], - }, - 'ngaf:stream_ended': { - requiredProperties: ['transport'], - allowedProperties: runtimeProperties, - allowedBreakdowns: ['model', 'provider', 'requestType', 'surface', 'transport'], - }, - 'ngaf:stream_errored': { - requiredProperties: ['transport'], - allowedProperties: runtimeProperties, - allowedBreakdowns: ['errorClass', 'model', 'provider', 'requestType', 'surface', 'transport'], - }, -}; +export const TELEMETRY_EVENT_CONTRACT: Record = + { + $pageview: { + requiredProperties: [], + allowedProperties: ['$pathname', 'title'], + allowedBreakdowns: ['$pathname'], + }, + 'cockpit:activation_complete': { + requiredProperties: ['capability'], + allowedProperties: ['capability'], + allowedBreakdowns: ['capability'], + }, + 'cockpit:chat_first_message': { + requiredProperties: ['capability'], + allowedProperties: ['capability'], + allowedBreakdowns: ['capability'], + }, + 'cockpit:code_copied': { + requiredProperties: ['capability'], + allowedProperties: cockpitShellProperties, + allowedBreakdowns: ['capability', 'surface'], + }, + 'cockpit:generative_component_rendered': { + requiredProperties: ['capability'], + allowedProperties: ['capability'], + allowedBreakdowns: ['capability'], + }, + 'cockpit:interrupt_handled': { + requiredProperties: ['capability'], + allowedProperties: ['capability'], + allowedBreakdowns: ['capability'], + }, + 'cockpit:mode_switched': { + requiredProperties: ['capability'], + allowedProperties: cockpitShellProperties, + allowedBreakdowns: ['capability', 'from_mode', 'to_mode'], + }, + 'cockpit:recipe_opened': { + requiredProperties: ['capability'], + allowedProperties: cockpitShellProperties, + allowedBreakdowns: ['capability', 'category', 'from_capability'], + }, + 'cockpit:thread_persisted': { + requiredProperties: ['capability'], + allowedProperties: ['capability'], + allowedBreakdowns: ['capability'], + }, + 'cockpit:transport_connected': { + requiredProperties: ['capability'], + allowedProperties: ['capability'], + allowedBreakdowns: ['capability'], + }, + 'marketing:cta_click': { + requiredProperties: ['cta_id'], + allowedProperties: ctaProperties, + allowedBreakdowns: [ + 'cta_id', + 'source_page', + 'source_section', + 'surface', + 'track', + ], + }, + 'ngaf:browser_chat_init': { + requiredProperties: ['surface'], + allowedProperties: runtimeProperties, + allowedBreakdowns: ['surface'], + }, + 'ngaf:browser_provided': { + requiredProperties: [], + allowedProperties: runtimeProperties, + allowedBreakdowns: ['surface'], + }, + 'ngaf:postinstall': { + requiredProperties: ['pkg', 'version'], + allowedProperties: installProperties, + allowedBreakdowns: [ + 'global_install', + 'install_context', + 'os', + 'package_manager', + 'package_manager_os', + 'package_manager_workspaces', + 'pkg', + ], + }, + 'ngaf:runtime_instance_created': { + requiredProperties: ['transport'], + allowedProperties: runtimeProperties, + allowedBreakdowns: [ + 'model', + 'provider', + 'requestType', + 'surface', + 'transport', + ], + }, + 'ngaf:runtime_request_created': { + requiredProperties: ['transport'], + allowedProperties: runtimeProperties, + allowedBreakdowns: [ + 'model', + 'provider', + 'requestType', + 'surface', + 'transport', + ], + }, + 'ngaf:stream_started': { + requiredProperties: ['transport'], + allowedProperties: runtimeProperties, + allowedBreakdowns: [ + 'model', + 'provider', + 'requestType', + 'surface', + 'transport', + ], + }, + 'ngaf:stream_ended': { + requiredProperties: ['transport'], + allowedProperties: runtimeProperties, + allowedBreakdowns: [ + 'model', + 'provider', + 'requestType', + 'surface', + 'transport', + ], + }, + 'ngaf:stream_errored': { + requiredProperties: ['transport'], + allowedProperties: runtimeProperties, + allowedBreakdowns: [ + 'errorClass', + 'model', + 'provider', + 'requestType', + 'surface', + 'transport', + ], + }, + };