diff --git a/.agents/skills/telemetry/SKILL.md b/.agents/skills/telemetry/SKILL.md new file mode 100644 index 00000000000..499f5ed2276 --- /dev/null +++ b/.agents/skills/telemetry/SKILL.md @@ -0,0 +1,70 @@ +--- +name: n8n:telemetry +description: >- + Guides adding, changing, and reviewing telemetry through the `@n8n/telemetry` + event registry. Use when working on telemetry, analytics, tracking, product + events, `track()` calls, or RudderStack/PostHog product events, in frontend + or backend code — and whenever you need to find which registered telemetry + events exist, what an event means, or what properties it carries. +--- + +# Telemetry + +## The registry + +Events migrated to the registry live in `packages/@n8n/telemetry` as one entry per event — its exact emitted name, a description, and a zod schema typing its properties — organized per product domain in `src/events/` and composed into `TELEMETRY_EVENT..`. The package defines registered events and never depends on transport SDKs. + +**To find which events are registered, what they mean, or what properties they carry, run the catalog first:** + +```bash +pnpm --filter @n8n/telemetry catalog # human-readable, grouped by domain +pnpm --filter @n8n/telemetry catalog --json # structured, for programmatic use +``` + +The registry is being adopted incrementally. Events not yet registered do not appear in the catalog, so search `track()` call sites when the catalog has no match. + +Pass the entry itself to `track()` — it resolves the emitted name internally: + +```ts +import { TELEMETRY_EVENT } from '@n8n/telemetry'; + +telemetry.track(TELEMETRY_EVENT.PLATFORM.USER_IS_PART_OF_EXPERIMENT, { + name: experimentName, + variant, +}); +``` + +Both `track()` implementations accept registry entries and plain strings. Plain strings remain supported for events that have not yet migrated: + +- Frontend: `packages/frontend/editor-ui/src/app/plugins/telemetry/index.ts` +- Backend: `packages/cli/src/telemetry/index.ts` + +Entries get property autocomplete and compile-time checks — typo'd, missing, or wrongly typed properties fail typecheck. When the telemetry transport is initialized, `track()` additionally validates registered-event payloads via `getEventValidationError` (shared from `@n8n/telemetry`) and logs a warning on mismatch, including unrecognized properties that slipped past structural typing. A validation warning does not stop the event from being emitted. + +## Adding an event + +1. **Check the catalog first** (`pnpm --filter @n8n/telemetry catalog`). If an existing event covers the same user action from another surface, augment it with a property instead of adding a near-duplicate event. +2. **Pick the domain by the event's subject** — what the event is about, never the surface that triggered it. `User opened Credential modal` is CREDENTIALS whether opened from the NDV, template setup, or chat. The trigger context goes into a `source` property. +3. **Name it with the house grammar:** sentence case, actor first, past-tense verb, specific object (`User pinned node data`). No template interpolation in names — variability goes into properties. The name must snake_case cleanly into a BigQuery table name: no punctuation beyond spaces, no casing that collides after snake_casing. +4. **Write the entry `description`** stating what the event means and when it fires — a registry test rejects blank descriptions. Document individual properties with `.describe()` where the key alone is not obvious. +5. **Type the properties with zod (`import { z } from 'zod/v4'`):** `snake_case` keys, explicit `.optional()` where a call site may omit a value, `z.looseObject()`/`.catchall()` for genuinely dynamic remainders. Schemas must stay JSON-Schema-representable — no transforms, refinements, or `z.date()` (a registry test enforces this via `z.toJSONSchema()`). +6. **Place the emission:** frontend via `useTelemetry().track(...)`; backend either through a `RelayEventMap` handler in `packages/cli/src/events/relays/telemetry.event-relay.ts` (event-bus-driven) or a direct `Telemetry.track(...)` call — both reference the same registry entry. + +## Hard rules + +- **Never rename an emitted event.** BigQuery materializes one table per event name; a rename orphans downstream history. A rename is delete + create, names are never reused, and removals must be coordinated with the data team before deleting the registry entry. +- **Never duplicate an event name** — one entry per event across all domains, referenced by every call site (even FE + BE). CI fails on collision. +- **Properties evolve additively only.** Changing a property's type forks warehouse columns even under a stable name. Mark deprecations on the schema (`.meta({ deprecated: true })`) instead of removing. +- **Breaking changes need a data-team heads-up** in Slack plus a Notion note before they ship. + +## Testing + +Do not retype event-name literals in tests: + +- In call-site tests, mock `useTelemetry().track` or the backend `Telemetry.track` service and expect the registry entry itself with the payload. +- In frontend transport tests, expect `window.rudderanalytics.track` to receive `entry.name` and the augmented payload. +- In backend transport tests, expect the RudderStack payload's `event` field to equal `entry.name` and its `properties` to include the event payload. + +## Related + +Experiment exposure and metric events follow `n8n:experiments` (`.agents/skills/experiments/SKILL.md`). diff --git a/.claude/plugins/n8n/skills/telemetry b/.claude/plugins/n8n/skills/telemetry new file mode 120000 index 00000000000..a7106b8b73f --- /dev/null +++ b/.claude/plugins/n8n/skills/telemetry @@ -0,0 +1 @@ +../../../../.agents/skills/telemetry \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index e77f93d3236..7f4f447514b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,6 +22,10 @@ frontend, and extensible node-based workflow engine. relevant to the surrounding code, not just to the current task - We use Linear as a ticket tracking system - We use Posthog for feature flags +- To find registered telemetry events (names, descriptions, properties), run + `pnpm --filter @n8n/telemetry catalog` (`--json` for structured output). The + registry is being adopted incrementally, so search call sites if the catalog + has no match. The `n8n:telemetry` skill covers adding or changing events - When starting to work on a new ticket – create a new branch from fresh master with the name specified in Linear ticket - When creating a new branch for a ticket in Linear - use the branch name diff --git a/packages/@n8n/telemetry/README.md b/packages/@n8n/telemetry/README.md new file mode 100644 index 00000000000..7acf9799ca8 --- /dev/null +++ b/packages/@n8n/telemetry/README.md @@ -0,0 +1,43 @@ +# @n8n/telemetry + +Registry definitions for telemetry events: event names, descriptions, their zod property schemas, and telemetry constants (e.g. the PostHog events blacklist). Reference entries as `TELEMETRY_EVENT..` and pass them to `track()` for property autocomplete and compile-time checks. + +Definitions only — this package never depends on PostHog, RudderStack, or any other transport; zod is the only runtime dependency. Transports live in the editor-ui telemetry plugin and the cli telemetry service. + +To find which events are registered, what they mean, and what properties they carry, use the catalog: + +```bash +pnpm --filter @n8n/telemetry catalog # human-readable, grouped by domain +pnpm --filter @n8n/telemetry catalog --json # structured, for programmatic use +``` + +The registry is being adopted incrementally. Events not yet registered do not appear in the catalog, so search `track()` call sites when the catalog has no match. + +Define an event in its domain file under `src/events/`: + +```ts +import { z } from 'zod/v4'; + +import { defineTelemetryEvents } from '../define'; + +export const WORKFLOWS_TELEMETRY = defineTelemetryEvents({ + USER_PINNED_NODE_DATA: { + name: 'User pinned node data', + description: 'Fires when the user pins run data on a node.', + properties: z.object({ workflow_id: z.string(), source: z.enum(['canvas', 'ndv']) }), + }, +}); +``` + +Then add the domain to `src/telemetry-events.ts` so it is exported and included in the catalog: + +```ts +import type { TelemetryEventRegistry } from './define'; +import { PLATFORM_TELEMETRY } from './events/platform'; +import { WORKFLOWS_TELEMETRY } from './events/workflows'; + +export const TELEMETRY_EVENT = { + PLATFORM: PLATFORM_TELEMETRY, + WORKFLOWS: WORKFLOWS_TELEMETRY, +} satisfies TelemetryEventRegistry; +``` diff --git a/packages/@n8n/telemetry/bin/catalog.mjs b/packages/@n8n/telemetry/bin/catalog.mjs new file mode 100644 index 00000000000..75f60913109 --- /dev/null +++ b/packages/@n8n/telemetry/bin/catalog.mjs @@ -0,0 +1,7 @@ +import { buildCatalog, formatCatalog, TELEMETRY_EVENT } from '../dist/index.js'; + +const catalog = buildCatalog(TELEMETRY_EVENT); +const output = process.argv.includes('--json') + ? JSON.stringify(catalog, null, 2) + : formatCatalog(catalog); +console.log(output); diff --git a/packages/@n8n/telemetry/eslint.config.mjs b/packages/@n8n/telemetry/eslint.config.mjs new file mode 100644 index 00000000000..ea6ea0c8248 --- /dev/null +++ b/packages/@n8n/telemetry/eslint.config.mjs @@ -0,0 +1,8 @@ +import { defineConfig } from 'eslint/config'; +import { baseConfig } from '@n8n/eslint-config/base'; + +export default defineConfig({ ignores: ['bin/**'] }, baseConfig, { + rules: { + 'unicorn/filename-case': ['error', { case: 'kebabCase' }], + }, +}); diff --git a/packages/@n8n/telemetry/package.json b/packages/@n8n/telemetry/package.json new file mode 100644 index 00000000000..db50c67a48e --- /dev/null +++ b/packages/@n8n/telemetry/package.json @@ -0,0 +1,40 @@ +{ + "name": "@n8n/telemetry", + "version": "1.0.0", + "scripts": { + "clean": "rimraf dist .turbo", + "dev": "pnpm watch", + "typecheck": "tsc --noEmit", + "build": "tsc -p tsconfig.build.json", + "build:unchecked": "tsc -p tsconfig.build.json --noCheck", + "format": "biome format --write .", + "format:check": "biome ci .", + "lint": "eslint . --quiet", + "lint:fix": "eslint . --fix", + "watch": "tsc -p tsconfig.build.json --watch", + "catalog": "pnpm --silent build:unchecked && node bin/catalog.mjs", + "test": "vitest run", + "test:unit": "vitest run" + }, + "main": "dist/index.js", + "module": "src/index.ts", + "types": "dist/index.d.ts", + "files": [ + "dist/**/*" + ], + "dependencies": { + "zod": "catalog:" + }, + "devDependencies": { + "@n8n/eslint-config": "workspace:*", + "@n8n/typescript-config": "workspace:*", + "@n8n/vitest-config": "workspace:*", + "@types/node": "catalog:", + "@vitest/coverage-v8": "catalog:", + "eslint": "catalog:", + "typescript": "catalog:typescript", + "vite": "catalog:", + "vitest": "catalog:" + }, + "license": "LicenseRef-n8n-sustainable-use" +} diff --git a/packages/@n8n/telemetry/src/__tests__/catalog.test.ts b/packages/@n8n/telemetry/src/__tests__/catalog.test.ts new file mode 100644 index 00000000000..11b7a5f8732 --- /dev/null +++ b/packages/@n8n/telemetry/src/__tests__/catalog.test.ts @@ -0,0 +1,70 @@ +import { z } from 'zod/v4'; + +import { buildCatalog, formatCatalog } from '../catalog'; +import { defineTelemetryEvents } from '../define'; + +const WORKFLOWS_FIXTURE = defineTelemetryEvents({ + USER_CLICKED_EXECUTE_WORKFLOW: { + name: 'User clicked execute workflow button', + description: 'User clicked the execute workflow button.', + properties: z.object({ + workflow_id: z.string().describe('Workflow identifier'), + source: z.enum(['canvas', 'ndv']), + push_ref: z.string().optional(), + }), + }, +}); + +describe('buildCatalog', () => { + it('lists every event with its description and property details', () => { + const catalog = buildCatalog({ WORKFLOWS: WORKFLOWS_FIXTURE }); + + expect(catalog).toEqual([ + { + domain: 'WORKFLOWS', + key: 'USER_CLICKED_EXECUTE_WORKFLOW', + name: 'User clicked execute workflow button', + description: 'User clicked the execute workflow button.', + deprecated: false, + properties: [ + { + name: 'workflow_id', + type: 'string', + optional: false, + description: 'Workflow identifier', + }, + { name: 'source', type: '"canvas" | "ndv"', optional: false }, + { name: 'push_ref', type: 'string', optional: true }, + ], + }, + ]); + }); +}); + +describe('buildCatalog deprecation flag', () => { + it('marks entries whose schema carries deprecated metadata', () => { + const DEPRECATED_FIXTURE = defineTelemetryEvents({ + USER_USED_LEGACY_FEATURE: { + name: 'User used legacy feature', + description: 'Fires when the user touches the legacy feature.', + properties: z.object({ workflow_id: z.string() }).meta({ deprecated: true }), + }, + }); + + const [entry] = buildCatalog({ LEGACY: DEPRECATED_FIXTURE }); + + expect(entry.deprecated).toBe(true); + }); +}); + +describe('formatCatalog', () => { + it('renders a grouped human-readable listing', () => { + const output = formatCatalog(buildCatalog({ WORKFLOWS: WORKFLOWS_FIXTURE })); + + expect(output).toContain('WORKFLOWS'); + expect(output).toContain( + 'User clicked execute workflow button — User clicked the execute workflow button.', + ); + expect(output).toContain('push_ref?: string'); + }); +}); diff --git a/packages/@n8n/telemetry/src/__tests__/registry-checks.test.ts b/packages/@n8n/telemetry/src/__tests__/registry-checks.test.ts new file mode 100644 index 00000000000..6e550a51a4a --- /dev/null +++ b/packages/@n8n/telemetry/src/__tests__/registry-checks.test.ts @@ -0,0 +1,152 @@ +import { readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { z } from 'zod/v4'; + +import { defineTelemetryEvents } from '../define'; +import { collectDuplicateNames, validateEntrySchemas } from '../registry-checks'; +import { TELEMETRY_EVENT } from '../telemetry-events'; + +const WORKFLOWS_FIXTURE = defineTelemetryEvents({ + USER_CREATED_WORKFLOW: { + name: 'User created workflow', + description: 'Fires when the user creates a workflow.', + properties: z.object({ workflow_id: z.string() }), + }, + USER_DELETED_WORKFLOW: { + name: 'User deleted workflow', + description: 'Fires when the user deletes a workflow.', + properties: z.object({ workflow_id: z.string(), source: z.enum(['canvas', 'list']) }), + }, +}); + +const CREDENTIALS_FIXTURE = defineTelemetryEvents({ + USER_CREATED_CREDENTIAL: { + name: 'User created credential', + description: 'Fires when the user creates a credential.', + properties: z.looseObject({ credential_type: z.string().optional() }), + }, +}); + +describe('collectDuplicateNames', () => { + it('returns nothing when every name is unique across domains', () => { + const duplicates = collectDuplicateNames({ + WORKFLOWS: WORKFLOWS_FIXTURE, + CREDENTIALS: CREDENTIALS_FIXTURE, + }); + + expect(duplicates).toEqual([]); + }); + + it('detects the same name registered in two domains', () => { + const CLASHING_FIXTURE = defineTelemetryEvents({ + USER_CREATED_WORKFLOW_FROM_TEMPLATE: { + name: 'User created workflow', + description: 'Fires when the user creates a workflow from a template.', + properties: z.object({ template_id: z.string() }), + }, + }); + + const duplicates = collectDuplicateNames({ + WORKFLOWS: WORKFLOWS_FIXTURE, + TEMPLATES: CLASHING_FIXTURE, + }); + + expect(duplicates).toEqual(['User created workflow']); + }); + + it('detects the same name registered twice within one domain', () => { + const DOUBLED_FIXTURE = defineTelemetryEvents({ + USER_OPENED_MODAL: { + name: 'User opened modal', + description: 'Fires when the user opens a modal.', + properties: z.object({}), + }, + USER_OPENED_MODAL_AGAIN: { + name: 'User opened modal', + description: 'Fires when the user opens a modal.', + properties: z.object({}), + }, + }); + + expect(collectDuplicateNames({ MODALS: DOUBLED_FIXTURE })).toEqual(['User opened modal']); + }); +}); + +describe('validateEntrySchemas', () => { + it('accepts documented object and loose-object schemas', () => { + const errors = validateEntrySchemas({ + WORKFLOWS: WORKFLOWS_FIXTURE, + CREDENTIALS: CREDENTIALS_FIXTURE, + }); + + expect(errors).toEqual([]); + }); + + it('rejects an entry whose properties are not an object schema', () => { + const BROKEN_FIXTURE = defineTelemetryEvents({ + BAD_ENTRY: { + name: 'Bad entry', + description: 'Fires when a bad entry is tested.', + properties: z.string(), + }, + }); + + const errors = validateEntrySchemas({ BROKEN: BROKEN_FIXTURE }); + + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('BROKEN.BAD_ENTRY'); + expect(errors[0]).toContain('must be a zod object schema'); + }); + + it('rejects an entry whose description is blank', () => { + const BROKEN_FIXTURE = defineTelemetryEvents({ + BLANK_ENTRY: { + name: 'Blank entry', + description: ' ', + properties: z.object({ workflow_id: z.string() }), + }, + }); + + const errors = validateEntrySchemas({ BROKEN: BROKEN_FIXTURE }); + + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('BROKEN.BLANK_ENTRY'); + expect(errors[0]).toContain('description must not be empty'); + }); + + it('rejects an entry whose schema cannot be represented as JSON Schema', () => { + const BROKEN_FIXTURE = defineTelemetryEvents({ + DATED_ENTRY: { + name: 'Dated entry', + description: 'Fires when something dated happens.', + properties: z.object({ happened_at: z.date() }), + }, + }); + + const errors = validateEntrySchemas({ BROKEN: BROKEN_FIXTURE }); + + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('BROKEN.DATED_ENTRY'); + expect(errors[0]).toContain('not JSON-Schema-representable'); + }); +}); + +describe('TELEMETRY_EVENT', () => { + it('has no duplicate event names', () => { + expect(collectDuplicateNames(TELEMETRY_EVENT)).toEqual([]); + }); + + it('has a documented, JSON-Schema-representable schema on every entry', () => { + expect(validateEntrySchemas(TELEMETRY_EVENT)).toEqual([]); + }); + + it('composes every domain file under src/events/', () => { + const eventsDir = join(process.cwd(), 'src', 'events'); + const domainsFromFiles = readdirSync(eventsDir) + .filter((file) => file.endsWith('.ts')) + .map((file) => file.replace(/\.ts$/, '').replaceAll('-', '_').toUpperCase()) + .sort(); + + expect(Object.keys(TELEMETRY_EVENT).sort()).toEqual(domainsFromFiles); + }); +}); diff --git a/packages/@n8n/telemetry/src/__tests__/types.test-d.ts b/packages/@n8n/telemetry/src/__tests__/types.test-d.ts new file mode 100644 index 00000000000..c87de625d23 --- /dev/null +++ b/packages/@n8n/telemetry/src/__tests__/types.test-d.ts @@ -0,0 +1,53 @@ +import { z } from 'zod/v4'; + +import type { InferTelemetryProps, TelemetryEventDef } from '../define'; +import { defineTelemetryEvents } from '../define'; + +const FIXTURE = defineTelemetryEvents({ + USER_CLICKED_EXECUTE_WORKFLOW: { + name: 'User clicked execute workflow button', + description: 'Fires when the user clicks the execute workflow button.', + properties: z.object({ + workflow_id: z.string(), + source: z.enum(['canvas', 'ndv']), + push_ref: z.string().optional(), + }), + }, + USER_PINNED_NODE_DATA: { + name: 'User pinned node data', + description: 'Fires when the user pins run data on a node.', + properties: z.object({ node_type: z.string() }), + }, +}); + +declare function track( + event: T, + properties: InferTelemetryProps, +): void; + +track(FIXTURE.USER_CLICKED_EXECUTE_WORKFLOW, { workflow_id: '1', source: 'canvas' }); +track(FIXTURE.USER_CLICKED_EXECUTE_WORKFLOW, { workflow_id: '1', source: 'ndv', push_ref: 'r1' }); + +// @ts-expect-error unknown property name +track(FIXTURE.USER_CLICKED_EXECUTE_WORKFLOW, { workflow_id: '1', source: 'canvas', wrong: true }); + +// @ts-expect-error value outside the enum +track(FIXTURE.USER_CLICKED_EXECUTE_WORKFLOW, { workflow_id: '1', source: 'shortcut' }); + +// @ts-expect-error missing required property +track(FIXTURE.USER_CLICKED_EXECUTE_WORKFLOW, { source: 'canvas' }); + +// @ts-expect-error a plain string is not a registry entry +track('User clicked execute workflow button', { workflow_id: '1', source: 'canvas' }); + +declare function trackWithFallback( + event: T, + properties: InferTelemetryProps, +): void; +declare function trackWithFallback(event: string, properties?: Record): void; + +trackWithFallback(FIXTURE.USER_PINNED_NODE_DATA, { node_type: 'n8n-nodes-base.set' }); +trackWithFallback('Some unregistered event', { anything: true }); + +export const NAME_CHECK: 'User clicked execute workflow button' = + FIXTURE.USER_CLICKED_EXECUTE_WORKFLOW.name; diff --git a/packages/@n8n/telemetry/src/__tests__/validate.test.ts b/packages/@n8n/telemetry/src/__tests__/validate.test.ts new file mode 100644 index 00000000000..9faf340abf1 --- /dev/null +++ b/packages/@n8n/telemetry/src/__tests__/validate.test.ts @@ -0,0 +1,109 @@ +import { z } from 'zod/v4'; + +import { defineTelemetryEvents } from '../define'; +import { getEventValidationError } from '../validate'; + +const FIXTURE = defineTelemetryEvents({ + USER_CLICKED_EXECUTE_WORKFLOW: { + name: 'User clicked execute workflow button', + description: 'Fires when the user clicks the execute workflow button.', + properties: z.object({ + workflow_id: z.string(), + source: z.enum(['canvas', 'ndv']), + push_ref: z.string().optional(), + }), + }, + USER_CREATED_CREDENTIAL: { + name: 'User created credential', + description: 'Fires when the user creates a credential.', + properties: z.looseObject({ + credential_type: z.string(), + context: z.looseObject({ source: z.string() }).optional(), + metadata: z.object({}).catchall(z.unknown()).optional(), + }), + }, + USER_OPENED_PANEL: { + name: 'User opened panel', + description: 'Fires when the user opens a panel.', + properties: z.object({ + context: z.object({ source: z.string() }), + items: z.array(z.object({ item_id: z.string() })).optional(), + }), + }, +}); + +describe('getEventValidationError', () => { + it('returns null when properties match the schema', () => { + const error = getEventValidationError(FIXTURE.USER_CLICKED_EXECUTE_WORKFLOW, { + workflow_id: 'wf-1', + source: 'canvas', + }); + + expect(error).toBeNull(); + }); + + it('reports wrongly typed properties with their path', () => { + const error = getEventValidationError(FIXTURE.USER_CLICKED_EXECUTE_WORKFLOW, { + workflow_id: 123, + source: 'canvas', + }); + + expect(error).toContain('User clicked execute workflow button'); + expect(error).toContain('workflow_id'); + }); + + it('reports missing required properties', () => { + const error = getEventValidationError(FIXTURE.USER_CLICKED_EXECUTE_WORKFLOW, { + source: 'canvas', + }); + + expect(error).toContain('workflow_id'); + }); + + it('reports unrecognized properties on plain object schemas', () => { + const error = getEventValidationError(FIXTURE.USER_CLICKED_EXECUTE_WORKFLOW, { + workflow_id: 'wf-1', + source: 'canvas', + extra_junk: 1, + }); + + expect(error).toContain('extra_junk: unrecognized property'); + }); + + it('reports unrecognized properties inside nested objects', () => { + const error = getEventValidationError(FIXTURE.USER_OPENED_PANEL, { + context: { source: 'canvas', typo: true }, + }); + + expect(error).toContain('context.typo: unrecognized property'); + }); + + it('reports unrecognized properties inside arrays', () => { + const error = getEventValidationError(FIXTURE.USER_OPENED_PANEL, { + context: { source: 'canvas' }, + items: [{ item_id: 'item-1', typo: true }], + }); + + expect(error).toContain('items.0.typo: unrecognized property'); + }); + + it('allows extra properties on intentionally loose schemas', () => { + const error = getEventValidationError(FIXTURE.USER_CREATED_CREDENTIAL, { + credential_type: 'notionApi', + context: { source: 'canvas', anything_nested: true }, + metadata: { anything_dynamic: true }, + anything_else: true, + }); + + expect(error).toBeNull(); + }); + + it('reports instead of throwing when properties are not an object at all', () => { + expect(() => + getEventValidationError(FIXTURE.USER_CLICKED_EXECUTE_WORKFLOW, undefined), + ).not.toThrow(); + expect(getEventValidationError(FIXTURE.USER_CLICKED_EXECUTE_WORKFLOW, undefined)).toContain( + 'failed schema validation', + ); + }); +}); diff --git a/packages/@n8n/telemetry/src/catalog.ts b/packages/@n8n/telemetry/src/catalog.ts new file mode 100644 index 00000000000..1ed26392a5c --- /dev/null +++ b/packages/@n8n/telemetry/src/catalog.ts @@ -0,0 +1,88 @@ +import { z } from 'zod/v4'; + +import type { TelemetryEventRegistry } from './define'; + +export type TelemetryCatalogProperty = { + name: string; + type: string; + optional: boolean; + description?: string; +}; + +export type TelemetryCatalogEntry = { + domain: string; + key: string; + name: string; + description: string; + deprecated: boolean; + properties: TelemetryCatalogProperty[]; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function typeSummary(schema: Record): string { + if (Array.isArray(schema.enum)) { + return schema.enum.map((value) => JSON.stringify(value)).join(' | '); + } + if (Array.isArray(schema.anyOf)) { + return schema.anyOf.filter(isRecord).map(typeSummary).join(' | '); + } + if (typeof schema.type === 'string') return schema.type; + if (Array.isArray(schema.type)) return schema.type.join(' | '); + return 'unknown'; +} + +export function buildCatalog(registry: TelemetryEventRegistry): TelemetryCatalogEntry[] { + const catalog: TelemetryCatalogEntry[] = []; + for (const [domain, events] of Object.entries(registry)) { + for (const [key, entry] of Object.entries(events)) { + const schema: unknown = z.toJSONSchema(entry.properties); + const schemaRecord = isRecord(schema) ? schema : {}; + const required = new Set(Array.isArray(schemaRecord.required) ? schemaRecord.required : []); + const schemaProperties = isRecord(schemaRecord.properties) + ? Object.entries(schemaRecord.properties) + : []; + catalog.push({ + domain, + key, + name: entry.name, + description: entry.description, + deprecated: schemaRecord.deprecated === true, + properties: schemaProperties.map(([propertyName, property]) => { + const propertyRecord = isRecord(property) ? property : {}; + return { + name: propertyName, + type: typeSummary(propertyRecord), + optional: !required.has(propertyName), + ...(typeof propertyRecord.description === 'string' + ? { description: propertyRecord.description } + : {}), + }; + }), + }); + } + } + return catalog; +} + +export function formatCatalog(catalog: TelemetryCatalogEntry[]): string { + const lines: string[] = []; + let currentDomain = ''; + for (const entry of catalog) { + if (entry.domain !== currentDomain) { + if (currentDomain) lines.push(''); + currentDomain = entry.domain; + lines.push(entry.domain); + } + lines.push(` ${entry.name}${entry.deprecated ? ' (deprecated)' : ''} — ${entry.description}`); + for (const property of entry.properties) { + const propertyDescription = property.description ? ` — ${property.description}` : ''; + lines.push( + ` ${property.name}${property.optional ? '?' : ''}: ${property.type}${propertyDescription}`, + ); + } + } + return lines.join('\n'); +} diff --git a/packages/@n8n/telemetry/src/constants.ts b/packages/@n8n/telemetry/src/constants.ts new file mode 100644 index 00000000000..cf87a73d0aa --- /dev/null +++ b/packages/@n8n/telemetry/src/constants.ts @@ -0,0 +1,6 @@ +import type { TelemetryEventDef } from './define'; +import { TELEMETRY_EVENT } from './telemetry-events'; + +export const POSTHOG_EVENTS_BLACKLIST: readonly TelemetryEventDef[] = [ + TELEMETRY_EVENT.PLATFORM.USER_IS_PART_OF_EXPERIMENT, +]; diff --git a/packages/@n8n/telemetry/src/define.ts b/packages/@n8n/telemetry/src/define.ts new file mode 100644 index 00000000000..6e0f3ee9cfd --- /dev/null +++ b/packages/@n8n/telemetry/src/define.ts @@ -0,0 +1,17 @@ +import type { z, ZodType } from 'zod/v4'; + +export type TelemetryEventDef = { + readonly name: string; + readonly description: string; + readonly properties: ZodType; +}; + +export type InferTelemetryProps = z.infer; + +export type TelemetryEventRegistry = Record>; + +export function defineTelemetryEvents>( + events: T, +): T { + return events; +} diff --git a/packages/@n8n/telemetry/src/events/platform.ts b/packages/@n8n/telemetry/src/events/platform.ts new file mode 100644 index 00000000000..e455fbae6f3 --- /dev/null +++ b/packages/@n8n/telemetry/src/events/platform.ts @@ -0,0 +1,15 @@ +import { z } from 'zod/v4'; + +import { defineTelemetryEvents } from '../define'; + +export const PLATFORM_TELEMETRY = defineTelemetryEvents({ + USER_IS_PART_OF_EXPERIMENT: { + name: 'User is part of experiment', + description: + 'User was assigned a variant of a running experiment, reported when feature flags resolve in the editor.', + properties: z.object({ + name: z.string().describe('Experiment feature-flag key'), + variant: z.union([z.string(), z.boolean()]).describe('Variant assigned to the user'), + }), + }, +}); diff --git a/packages/@n8n/telemetry/src/index.ts b/packages/@n8n/telemetry/src/index.ts new file mode 100644 index 00000000000..56203fa09da --- /dev/null +++ b/packages/@n8n/telemetry/src/index.ts @@ -0,0 +1,8 @@ +export { buildCatalog, formatCatalog } from './catalog'; +export type { TelemetryCatalogEntry, TelemetryCatalogProperty } from './catalog'; +export { POSTHOG_EVENTS_BLACKLIST } from './constants'; +export { defineTelemetryEvents } from './define'; +export type { InferTelemetryProps, TelemetryEventDef, TelemetryEventRegistry } from './define'; +export { collectDuplicateNames, validateEntrySchemas } from './registry-checks'; +export { TELEMETRY_EVENT } from './telemetry-events'; +export { getEventValidationError } from './validate'; diff --git a/packages/@n8n/telemetry/src/registry-checks.ts b/packages/@n8n/telemetry/src/registry-checks.ts new file mode 100644 index 00000000000..5f35612bc4b --- /dev/null +++ b/packages/@n8n/telemetry/src/registry-checks.ts @@ -0,0 +1,40 @@ +import { z } from 'zod/v4'; + +import type { TelemetryEventRegistry } from './define'; + +export function collectDuplicateNames(registry: TelemetryEventRegistry): string[] { + const seen = new Set(); + const duplicates = new Set(); + for (const domain of Object.values(registry)) { + for (const entry of Object.values(domain)) { + if (seen.has(entry.name)) { + duplicates.add(entry.name); + } + seen.add(entry.name); + } + } + return [...duplicates]; +} + +export function validateEntrySchemas(registry: TelemetryEventRegistry): string[] { + const errors: string[] = []; + for (const [domainKey, domain] of Object.entries(registry)) { + for (const [entryKey, entry] of Object.entries(domain)) { + const id = `${domainKey}.${entryKey} ("${entry.name}")`; + if (!entry.description.trim()) { + errors.push(`${id}: description must not be empty`); + } + if (!(entry.properties instanceof z.ZodObject)) { + errors.push(`${id}: properties must be a zod object schema`); + continue; + } + try { + z.toJSONSchema(entry.properties); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + errors.push(`${id}: properties are not JSON-Schema-representable: ${reason}`); + } + } + } + return errors; +} diff --git a/packages/@n8n/telemetry/src/telemetry-events.ts b/packages/@n8n/telemetry/src/telemetry-events.ts new file mode 100644 index 00000000000..8b3925ce462 --- /dev/null +++ b/packages/@n8n/telemetry/src/telemetry-events.ts @@ -0,0 +1,6 @@ +import type { TelemetryEventRegistry } from './define'; +import { PLATFORM_TELEMETRY } from './events/platform'; + +export const TELEMETRY_EVENT = { + PLATFORM: PLATFORM_TELEMETRY, +} satisfies TelemetryEventRegistry; diff --git a/packages/@n8n/telemetry/src/validate.ts b/packages/@n8n/telemetry/src/validate.ts new file mode 100644 index 00000000000..ba3a29698fe --- /dev/null +++ b/packages/@n8n/telemetry/src/validate.ts @@ -0,0 +1,147 @@ +import { z } from 'zod/v4'; + +import type { TelemetryEventDef } from './define'; + +type ValidationPath = Array; +type ObjectSchema = z.ZodObject>; +type UnionSchema = z.ZodUnion; +type ArraySchema = z.ZodArray; +type RecordSchema = z.ZodRecord; +type ReadonlySchema = z.ZodReadonly; +type WrappedSchema = + | z.ZodOptional + | z.ZodNullable + | z.ZodDefault + | z.ZodNonOptional + | z.ZodCatch + | z.ZodLazy; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function formatPath(path: ValidationPath): string { + return path.map(String).join('.') || '(root)'; +} + +function isObjectSchema(schema: z.ZodType): schema is ObjectSchema { + return schema instanceof z.ZodObject; +} + +function isUnionSchema(schema: z.ZodType): schema is UnionSchema { + return schema instanceof z.ZodUnion; +} + +function isArraySchema(schema: z.ZodType): schema is ArraySchema { + return schema instanceof z.ZodArray; +} + +function isRecordSchema(schema: z.ZodType): schema is RecordSchema { + return schema instanceof z.ZodRecord; +} + +function isWrappedSchema(schema: z.ZodType): schema is WrappedSchema { + return ( + schema instanceof z.ZodOptional || + schema instanceof z.ZodNullable || + schema instanceof z.ZodDefault || + schema instanceof z.ZodNonOptional || + schema instanceof z.ZodCatch || + schema instanceof z.ZodLazy + ); +} + +function isReadonlySchema(schema: z.ZodType): schema is ReadonlySchema { + return schema instanceof z.ZodReadonly; +} + +function isSchema(value: unknown): value is z.ZodType { + return value instanceof z.ZodType; +} + +function getWrappedSchema(schema: z.ZodType): z.ZodType | undefined { + if (isWrappedSchema(schema)) { + return schema.unwrap(); + } + + if (isReadonlySchema(schema)) { + return schema.def.innerType; + } + + return undefined; +} + +function getUnrecognizedPropertyIssues( + schema: z.ZodType, + value: unknown, + path: ValidationPath = [], +): string[] { + const wrappedSchema = getWrappedSchema(schema); + if (wrappedSchema !== undefined) { + return getUnrecognizedPropertyIssues(wrappedSchema, value, path); + } + + if (isObjectSchema(schema)) { + if (!isRecord(value)) return []; + + const issues: string[] = []; + for (const [key, childValue] of Object.entries(value)) { + const childPath = [...path, key]; + const childSchema = schema.shape[key]; + if (childSchema !== undefined) { + issues.push(...getUnrecognizedPropertyIssues(childSchema, childValue, childPath)); + } else if (schema.def.catchall === undefined) { + issues.push(`${formatPath(childPath)}: unrecognized property`); + } else if (isSchema(schema.def.catchall)) { + issues.push(...getUnrecognizedPropertyIssues(schema.def.catchall, childValue, childPath)); + } + } + return issues; + } + + if (isArraySchema(schema)) { + if (!Array.isArray(value)) return []; + return value.flatMap((item, index) => + getUnrecognizedPropertyIssues(schema.element, item, [...path, index]), + ); + } + + if (isRecordSchema(schema)) { + if (!isRecord(value)) return []; + return Object.entries(value).flatMap(([key, childValue]) => + getUnrecognizedPropertyIssues(schema.valueType, childValue, [...path, key]), + ); + } + + if (isUnionSchema(schema)) { + const matchingOptions = schema.options.filter((option) => option.safeParse(value).success); + if (matchingOptions.length === 0) return []; + + return matchingOptions + .map((option) => getUnrecognizedPropertyIssues(option, value, path)) + .reduce((fewestIssues, optionIssues) => + optionIssues.length < fewestIssues.length ? optionIssues : fewestIssues, + ); + } + + return []; +} + +export function getEventValidationError( + event: TelemetryEventDef, + properties: unknown, +): string | null { + const issues: string[] = []; + + const result = event.properties.safeParse(properties); + if (!result.success) { + for (const issue of result.error.issues) { + issues.push(`${issue.path.map(String).join('.') || '(root)'}: ${issue.message}`); + } + } + + issues.push(...getUnrecognizedPropertyIssues(event.properties, properties)); + + if (issues.length === 0) return null; + return `Telemetry event "${event.name}" failed schema validation: ${issues.join('; ')}`; +} diff --git a/packages/@n8n/telemetry/tsconfig.build.json b/packages/@n8n/telemetry/tsconfig.build.json new file mode 100644 index 00000000000..1e88f16fb0e --- /dev/null +++ b/packages/@n8n/telemetry/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": ["./tsconfig.json", "@n8n/typescript-config/tsconfig.build.go.json"], + "compilerOptions": { + "composite": true, + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/__tests__/**"] +} diff --git a/packages/@n8n/telemetry/tsconfig.json b/packages/@n8n/telemetry/tsconfig.json new file mode 100644 index 00000000000..22bf16aea29 --- /dev/null +++ b/packages/@n8n/telemetry/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@n8n/typescript-config/tsconfig.common.go.json", + "compilerOptions": { + "types": ["vitest/globals", "node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/@n8n/telemetry/vite.config.ts b/packages/@n8n/telemetry/vite.config.ts new file mode 100644 index 00000000000..a8de5b43fdb --- /dev/null +++ b/packages/@n8n/telemetry/vite.config.ts @@ -0,0 +1,4 @@ +import { defineConfig, mergeConfig } from 'vite'; +import { vitestConfig } from '@n8n/vitest-config/node'; + +export default mergeConfig(defineConfig({}), vitestConfig); diff --git a/packages/cli/package.json b/packages/cli/package.json index 0bd7981b7d7..7d8ccae9c80 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -167,6 +167,7 @@ "@n8n/scheduler": "workspace:*", "@n8n/syslog-client": "workspace:*", "@n8n/task-runner": "workspace:*", + "@n8n/telemetry": "workspace:*", "@n8n/typeorm": "workspace:*", "@n8n/utils": "workspace:*", "@n8n/workflow-sdk": "workspace:*", diff --git a/packages/cli/src/telemetry/__tests__/telemetry.test.ts b/packages/cli/src/telemetry/__tests__/telemetry.test.ts index 891f34d6616..e735b3d5871 100644 --- a/packages/cli/src/telemetry/__tests__/telemetry.test.ts +++ b/packages/cli/src/telemetry/__tests__/telemetry.test.ts @@ -1,10 +1,13 @@ +import type { Logger } from '@n8n/backend-common'; import type { OutboundHttp } from '@n8n/backend-network'; import { mockInstance } from '@n8n/backend-test-utils'; import type { GlobalConfig } from '@n8n/config'; +import { defineTelemetryEvents } from '@n8n/telemetry'; import type RudderStack from '@rudderstack/rudder-sdk-node'; import { InstanceSettings } from 'n8n-core'; import type { MockInstance } from 'vitest'; import { mock } from 'vitest-mock-extended'; +import { z } from 'zod/v4'; import { PostHogClient } from '@/posthog'; import { Telemetry } from '@/telemetry'; @@ -1102,6 +1105,80 @@ describe('Telemetry', () => { ); }); }); + + describe('track() with registry entries', () => { + const TEST_TELEMETRY = defineTelemetryEvents({ + USER_TESTED_REGISTRY_ENTRY: { + name: 'User tested registry entry', + description: 'Fires when the registry entry pipeline is exercised in tests.', + properties: z.object({ workflow_id: z.string() }), + }, + }); + + test('should emit the entry name and properties through the standard pipeline', () => { + telemetry.track(TEST_TELEMETRY.USER_TESTED_REGISTRY_ENTRY, { workflow_id: 'wf-1' }); + + expect(mockRudderStack.track).toHaveBeenCalledWith( + expect.objectContaining({ + event: 'User tested registry entry', + properties: expect.objectContaining({ workflow_id: 'wf-1' }), + context: expect.objectContaining({ ip: '0.0.0.0' }), + }), + ); + }); + + test('should warn and still emit when properties do not match the schema', () => { + const logger = mock(); + const validationTelemetry = new Telemetry( + logger, + new PostHogClient(instanceSettings, mock()), + mock(), + instanceSettings, + mock(), + globalConfig, + mock(), + mock(), + ); + // @ts-expect-error Assigning to private property + validationTelemetry.rudderStack = mockRudderStack; + + expect(() => + validationTelemetry.track(TEST_TELEMETRY.USER_TESTED_REGISTRY_ENTRY, { + workflow_id: 123 as unknown as string, + }), + ).not.toThrow(); + + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('"User tested registry entry" failed schema validation'), + ); + expect(mockRudderStack.track).toHaveBeenCalledWith( + expect.objectContaining({ + event: 'User tested registry entry', + properties: expect.objectContaining({ workflow_id: 123 }), + }), + ); + }); + + test('should warn about schema mismatches even when RudderStack is not initialized', () => { + const logger = mock(); + const uninitializedTelemetry = new Telemetry( + logger, + new PostHogClient(instanceSettings, mock()), + mock(), + instanceSettings, + mock(), + globalConfig, + mock(), + mock(), + ); + + uninitializedTelemetry.track(TEST_TELEMETRY.USER_TESTED_REGISTRY_ENTRY, { + workflow_id: 123 as unknown as string, + }); + + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('failed schema validation')); + }); + }); }); const fakeTestSystemTime = (dateTime: string | Date): Date => { diff --git a/packages/cli/src/telemetry/index.ts b/packages/cli/src/telemetry/index.ts index a1cde982cd0..10cb42c85e0 100644 --- a/packages/cli/src/telemetry/index.ts +++ b/packages/cli/src/telemetry/index.ts @@ -9,6 +9,8 @@ import { } from '@n8n/db'; import { OnShutdown } from '@n8n/decorators'; import { Container, Service } from '@n8n/di'; +import type { InferTelemetryProps, TelemetryEventDef } from '@n8n/telemetry'; +import { getEventValidationError } from '@n8n/telemetry'; import type RudderStack from '@rudderstack/rudder-sdk-node'; import type { AxiosRequestConfig } from 'axios'; import { ErrorReporter, InstanceSettings } from 'n8n-core'; @@ -558,7 +560,16 @@ export class Telemetry { this.userCloudId = userCloudId; } - track(eventName: string, properties: ITelemetryTrackProperties = {}) { + track(event: T, properties: InferTelemetryProps): void; + track(eventName: string, properties?: ITelemetryTrackProperties): void; + track(event: string | TelemetryEventDef, properties: ITelemetryTrackProperties = {}) { + const eventName = typeof event === 'string' ? event : event.name; + + if (typeof event !== 'string') { + const validationError = getEventValidationError(event, properties); + if (validationError) this.logger.warn(validationError); + } + if (!this.rudderStack) { return; } diff --git a/packages/cli/tsconfig.build.json b/packages/cli/tsconfig.build.json index bde25d585d3..27280ba1e23 100644 --- a/packages/cli/tsconfig.build.json +++ b/packages/cli/tsconfig.build.json @@ -2,7 +2,12 @@ "extends": ["./tsconfig.json", "@n8n/typescript-config/tsconfig.build.go.json"], "compilerOptions": { "rootDir": "src", - "outDir": "dist" + "outDir": "dist", + "paths": { + "@/*": ["./src/*"], + "@test/*": ["./test/shared/*"], + "@test-integration/*": ["./test/integration/shared/*"] + } }, "include": ["src/**/*.ts"], "exclude": ["test/**", "src/**/__tests__/**"] diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index 2ea18dcc807..e662c44fb55 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -9,6 +9,9 @@ "lib": ["es2023", "dom"], "types": ["node", "vitest/globals", "vite/client"], "paths": { + // Direct cli checks should not require @n8n/telemetry to be built first. + "@n8n/telemetry": ["../@n8n/telemetry/src/index.ts"], + "@n8n/telemetry/*": ["../@n8n/telemetry/src/*"], "@/*": ["./src/*"], "@test/*": ["./test/shared/*"], "@test-integration/*": ["./test/integration/shared/*"] diff --git a/packages/cli/vitest.config.base.ts b/packages/cli/vitest.config.base.ts index 5d44dde9cd4..ac47bf44d07 100644 --- a/packages/cli/vitest.config.base.ts +++ b/packages/cli/vitest.config.base.ts @@ -16,6 +16,7 @@ const alias = { '@test': path.resolve(__dirname, 'test/shared'), '@n8n/mcp-apps/server': path.resolve(__dirname, '../@n8n/mcp-apps/src/server/index.ts'), '@n8n/backend-test-utils': path.resolve(__dirname, '../@n8n/backend-test-utils/src/index.ts'), + '@n8n/telemetry': path.resolve(__dirname, '../@n8n/telemetry/src'), '@': path.resolve(__dirname, 'src'), }; diff --git a/packages/frontend/@n8n/composables/package.json b/packages/frontend/@n8n/composables/package.json index ec1e7489f7f..f6e50d9f8e0 100644 --- a/packages/frontend/@n8n/composables/package.json +++ b/packages/frontend/@n8n/composables/package.json @@ -29,6 +29,7 @@ }, "dependencies": { "@n8n/api-types": "workspace:*", + "@n8n/telemetry": "workspace:*", "@vueuse/core": "catalog:frontend", "lodash": "catalog:", "n8n-workflow": "workspace:*", diff --git a/packages/frontend/@n8n/composables/src/useTelemetry.ts b/packages/frontend/@n8n/composables/src/useTelemetry.ts index 2ad9ab5f74f..4d8c41f4753 100644 --- a/packages/frontend/@n8n/composables/src/useTelemetry.ts +++ b/packages/frontend/@n8n/composables/src/useTelemetry.ts @@ -1,4 +1,5 @@ import type { ITelemetrySettings } from '@n8n/api-types'; +import type { InferTelemetryProps, TelemetryEventDef } from '@n8n/telemetry'; import type { IDataObject, ITelemetryTrackProperties, NodeParameterValueType } from 'n8n-workflow'; import { hasInjectionContext, inject, type InjectionKey } from 'vue'; import type { RouteLocation } from 'vue-router'; @@ -40,6 +41,7 @@ export interface Telemetry { options: TelemetryIdentifyOptions & { versionCli: string }, ): void; identify(options: TelemetryIdentifyOptions): void; + track(event: T, properties: InferTelemetryProps): void; track(event: string, properties?: ITelemetryTrackProperties): void; page(route: RouteLocation): void; reset(): void; diff --git a/packages/frontend/editor-ui/package.json b/packages/frontend/editor-ui/package.json index 33c5c4dfbbb..2daf06f4101 100644 --- a/packages/frontend/editor-ui/package.json +++ b/packages/frontend/editor-ui/package.json @@ -54,6 +54,7 @@ "@n8n/permissions": "workspace:*", "@n8n/rest-api-client": "workspace:*", "@n8n/stores": "workspace:*", + "@n8n/telemetry": "workspace:*", "@n8n/utils": "workspace:*", "@pinecone-database/connect": "0.0.4", "@replit/codemirror-indentation-markers": "^6.5.3", diff --git a/packages/frontend/editor-ui/src/app/constants/index.ts b/packages/frontend/editor-ui/src/app/constants/index.ts index 968f4a22bd2..91a811c71bc 100644 --- a/packages/frontend/editor-ui/src/app/constants/index.ts +++ b/packages/frontend/editor-ui/src/app/constants/index.ts @@ -7,7 +7,6 @@ export * from './emails'; export * from './enterprise'; export * from './events'; export * from './experiments'; -export * from './telemetry'; export * from './injectionKeys'; export * from './limits'; export * from './localStorage'; diff --git a/packages/frontend/editor-ui/src/app/constants/telemetry.ts b/packages/frontend/editor-ui/src/app/constants/telemetry.ts deleted file mode 100644 index fd0f261672d..00000000000 --- a/packages/frontend/editor-ui/src/app/constants/telemetry.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const TELEMETRY_EVENTS = { - IS_PART_OF_EXPERIMENT: 'User is part of experiment', -}; -export const POSTHOG_EVENTS_BLACKLIST = [TELEMETRY_EVENTS.IS_PART_OF_EXPERIMENT]; diff --git a/packages/frontend/editor-ui/src/app/plugins/telemetry.test.ts b/packages/frontend/editor-ui/src/app/plugins/telemetry.test.ts index 188a6cabb8f..3d0af52e1bc 100644 --- a/packages/frontend/editor-ui/src/app/plugins/telemetry.test.ts +++ b/packages/frontend/editor-ui/src/app/plugins/telemetry.test.ts @@ -1,8 +1,10 @@ import { SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils'; import { TelemetryService } from '@/app/plugins/telemetry'; import { useSettingsStore } from '@/app/stores/settings.store'; +import { defineTelemetryEvents } from '@n8n/telemetry'; import merge from 'lodash/merge'; import { createPinia, setActivePinia } from 'pinia'; +import { z } from 'zod/v4'; let telemetry: TelemetryService; @@ -229,6 +231,72 @@ describe('telemetry', () => { }); }); + describe('track function with registry entries', () => { + const TEST_TELEMETRY = defineTelemetryEvents({ + USER_TESTED_REGISTRY_ENTRY: { + name: 'User tested registry entry', + description: 'Fires when the registry entry pipeline is exercised in tests.', + properties: z.object({ workflow_id: z.string() }), + }, + }); + + it('should emit the entry name and properties through the standard pipeline', () => { + const trackFunction = vi.spyOn(window.rudderanalytics, 'track'); + + telemetry.track(TEST_TELEMETRY.USER_TESTED_REGISTRY_ENTRY, { workflow_id: 'wf-1' }); + + expect(trackFunction).toHaveBeenCalledTimes(1); + expect(trackFunction).toHaveBeenCalledWith( + 'User tested registry entry', + { + workflow_id: 'wf-1', + version_cli: MOCK_VERSION_CLI, + }, + { context: { ip: '0.0.0.0' } }, + ); + }); + + it('should warn and still emit when properties do not match the schema', () => { + const trackFunction = vi.spyOn(window.rudderanalytics, 'track'); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + expect(() => + telemetry.track(TEST_TELEMETRY.USER_TESTED_REGISTRY_ENTRY, { + workflow_id: 123 as unknown as string, + }), + ).not.toThrow(); + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('"User tested registry entry" failed schema validation'), + ); + expect(trackFunction).toHaveBeenCalledWith( + 'User tested registry entry', + expect.objectContaining({ workflow_id: 123 }), + { context: { ip: '0.0.0.0' } }, + ); + + warnSpy.mockRestore(); + }); + + it('should warn about schema mismatches even when RudderStack is not available', () => { + const originalRudderanalytics = window.rudderanalytics; + // @ts-expect-error simulating a disabled telemetry client + window.rudderanalytics = undefined; + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + telemetry.track(TEST_TELEMETRY.USER_TESTED_REGISTRY_ENTRY, { + workflow_id: 123 as unknown as string, + }); + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('"User tested registry entry" failed schema validation'), + ); + + window.rudderanalytics = originalRudderanalytics; + warnSpy.mockRestore(); + }); + }); + describe('trackNodeParametersValuesChange - advanced HITL', () => { it('tracks the enable event when Slack captureResponder is turned on', () => { const trackSpy = vi.spyOn(telemetry, 'track'); diff --git a/packages/frontend/editor-ui/src/app/plugins/telemetry/index.ts b/packages/frontend/editor-ui/src/app/plugins/telemetry/index.ts index bc875075b18..18f369e9f90 100644 --- a/packages/frontend/editor-ui/src/app/plugins/telemetry/index.ts +++ b/packages/frontend/editor-ui/src/app/plugins/telemetry/index.ts @@ -1,5 +1,8 @@ import type { Plugin } from 'vue'; import type { ITelemetrySettings } from '@n8n/api-types'; +import type { InferTelemetryProps, TelemetryEventDef } from '@n8n/telemetry'; +import { getEventValidationError } from '@n8n/telemetry'; +import { POSTHOG_EVENTS_BLACKLIST } from '@n8n/telemetry'; import type { ITelemetryTrackProperties, IDataObject } from 'n8n-workflow'; import type { RouteLocation } from 'vue-router'; @@ -11,7 +14,6 @@ import { MICROSOFT_TEAMS_NODE_TYPE, SLACK_NODE_TYPE, TELEGRAM_NODE_TYPE, - POSTHOG_EVENTS_BLACKLIST, } from '@/app/constants'; import { setTelemetry, @@ -24,6 +26,10 @@ import { useSettingsStore } from '@/app/stores/settings.store'; import { useUIStore } from '@/app/stores/ui.store'; import { usePostHog } from '@/app/stores/posthog.store'; +const POSTHOG_BLACKLISTED_EVENT_NAMES = new Set( + POSTHOG_EVENTS_BLACKLIST.map((blacklisted) => blacklisted.name), +); + // `Telemetry` is the shared contract; consumers annotate with it. The concrete // implementation below is registered as the app's instance at bootstrap. export type { Telemetry, TelemetryIdentifyOptions } from '@n8n/composables/useTelemetry'; @@ -104,7 +110,16 @@ export class TelemetryService implements Telemetry { } } - track(event: string, properties?: ITelemetryTrackProperties) { + track(event: T, properties: InferTelemetryProps): void; + track(event: string, properties?: ITelemetryTrackProperties): void; + track(event: string | TelemetryEventDef, properties?: ITelemetryTrackProperties) { + const eventName = typeof event === 'string' ? event : event.name; + + if (typeof event !== 'string') { + const validationError = getEventValidationError(event, properties); + if (validationError) console.warn(validationError); + } + if (!this.rudderStack) return; const posthogSessionId = window.posthog?.get_session_id?.(); @@ -115,15 +130,15 @@ export class TelemetryService implements Telemetry { posthog_session_id: posthogSessionId, }; - this.rudderStack.track(event, updatedProperties, { + this.rudderStack.track(eventName, updatedProperties, { context: { // provide a fake IP address to instruct RudderStack to not use the user's IP address ip: '0.0.0.0', }, }); - if (!POSTHOG_EVENTS_BLACKLIST.includes(event)) { - usePostHog().capture(event, updatedProperties); + if (!POSTHOG_BLACKLISTED_EVENT_NAMES.has(eventName)) { + usePostHog().capture(eventName, updatedProperties); } } diff --git a/packages/frontend/editor-ui/src/app/stores/posthog.store.ts b/packages/frontend/editor-ui/src/app/stores/posthog.store.ts index 15e0d81d4f9..6797208354b 100644 --- a/packages/frontend/editor-ui/src/app/stores/posthog.store.ts +++ b/packages/frontend/editor-ui/src/app/stores/posthog.store.ts @@ -6,11 +6,8 @@ import { useUsersStore } from '@/features/settings/users/users.store'; import { useRootStore } from '@n8n/stores/useRootStore'; import { useSettingsStore } from '@/app/stores/settings.store'; import type { FeatureFlags, IDataObject } from 'n8n-workflow'; -import { - EXPERIMENTS_TO_TRACK, - LOCAL_STORAGE_EXPERIMENT_OVERRIDES, - TELEMETRY_EVENTS, -} from '@/app/constants'; +import { EXPERIMENTS_TO_TRACK, LOCAL_STORAGE_EXPERIMENT_OVERRIDES } from '@/app/constants'; +import { TELEMETRY_EVENT } from '@n8n/telemetry'; import { useDebounce } from '@n8n/composables/useDebounce'; import { useTelemetry } from '@/app/composables/useTelemetry'; @@ -144,7 +141,7 @@ export const usePostHog = defineStore('posthog', () => { return; } - telemetry.track(TELEMETRY_EVENTS.IS_PART_OF_EXPERIMENT, { + telemetry.track(TELEMETRY_EVENT.PLATFORM.USER_IS_PART_OF_EXPERIMENT, { name, variant, }); diff --git a/packages/frontend/editor-ui/src/experiments/personalizedTemplatesV3/stores/personalizedTemplatesV3.store.ts b/packages/frontend/editor-ui/src/experiments/personalizedTemplatesV3/stores/personalizedTemplatesV3.store.ts index 03c8abe30a9..55628ac5671 100644 --- a/packages/frontend/editor-ui/src/experiments/personalizedTemplatesV3/stores/personalizedTemplatesV3.store.ts +++ b/packages/frontend/editor-ui/src/experiments/personalizedTemplatesV3/stores/personalizedTemplatesV3.store.ts @@ -1,5 +1,6 @@ import { useTelemetry } from '@/app/composables/useTelemetry'; -import { PERSONALIZED_TEMPLATES_V3, TELEMETRY_EVENTS, VIEWS } from '@/app/constants'; +import { PERSONALIZED_TEMPLATES_V3, VIEWS } from '@/app/constants'; +import { TELEMETRY_EVENT } from '@n8n/telemetry'; import { useCloudPlanStore } from '@/app/stores/cloudPlan.store'; import { usePostHog } from '@/app/stores/posthog.store'; import { useSettingsStore } from '@/app/stores/settings.store'; @@ -117,7 +118,7 @@ export const usePersonalizedTemplatesV3Store = defineStore(STORES.PERSONALIZED_T const variant = posthogStore.getVariant(PERSONALIZED_TEMPLATES_V3.name); if (variant) { - telemetry.track(TELEMETRY_EVENTS.IS_PART_OF_EXPERIMENT, { + telemetry.track(TELEMETRY_EVENT.PLATFORM.USER_IS_PART_OF_EXPERIMENT, { name: PERSONALIZED_TEMPLATES_V3.name, variant, }); diff --git a/packages/frontend/editor-ui/src/experiments/resourceCenter/stores/__tests__/resourceCenter.store.test.ts b/packages/frontend/editor-ui/src/experiments/resourceCenter/stores/__tests__/resourceCenter.store.test.ts index 764b8c7d2cb..7e5bf2da8e1 100644 --- a/packages/frontend/editor-ui/src/experiments/resourceCenter/stores/__tests__/resourceCenter.store.test.ts +++ b/packages/frontend/editor-ui/src/experiments/resourceCenter/stores/__tests__/resourceCenter.store.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { setActivePinia, createPinia } from 'pinia'; import { useResourceCenterStore } from '../resourceCenter.store'; -import { TELEMETRY_EVENTS } from '@/app/constants'; +import { TELEMETRY_EVENT } from '@n8n/telemetry'; const mocks = vi.hoisted(() => ({ track: vi.fn(), @@ -98,7 +98,11 @@ describe('resourceCenter.store', () => { useResourceCenterStore(); expect(mocks.track).not.toHaveBeenCalledWith( - TELEMETRY_EVENTS.IS_PART_OF_EXPERIMENT, + TELEMETRY_EVENT.PLATFORM.USER_IS_PART_OF_EXPERIMENT, + expect.anything(), + ); + expect(mocks.track).not.toHaveBeenCalledWith( + TELEMETRY_EVENT.PLATFORM.USER_IS_PART_OF_EXPERIMENT.name, expect.anything(), ); }); diff --git a/packages/frontend/editor-ui/tsconfig.json b/packages/frontend/editor-ui/tsconfig.json index 33cb254d64c..51e7b311aab 100644 --- a/packages/frontend/editor-ui/tsconfig.json +++ b/packages/frontend/editor-ui/tsconfig.json @@ -30,6 +30,8 @@ "@n8n/design-system*": ["../@n8n/design-system/src*"], "@n8n/i18n*": ["../@n8n/i18n/src*"], "@n8n/stores*": ["../@n8n/stores/src*"], + "@n8n/telemetry": ["../../@n8n/telemetry/src/index.ts"], + "@n8n/telemetry*": ["../../@n8n/telemetry/src*"], "@n8n/api-types*": ["../../@n8n/api-types/src*"], "@n8n/utils*": ["../../@n8n/utils/src*"], "@n8n/expression-runtime": ["./vite/expression-runtime-stub.ts"] diff --git a/packages/frontend/editor-ui/vite.config.mts b/packages/frontend/editor-ui/vite.config.mts index 843c81321b4..51733523900 100644 --- a/packages/frontend/editor-ui/vite.config.mts +++ b/packages/frontend/editor-ui/vite.config.mts @@ -75,6 +75,14 @@ const alias = [ find: /^@n8n\/stores(.+)$/, replacement: resolve(packagesDir, 'frontend', '@n8n', 'stores', 'src$1'), }, + { + find: /^@n8n\/telemetry$/, + replacement: resolve(packagesDir, '@n8n', 'telemetry', 'src/index.ts'), + }, + { + find: /^@n8n\/telemetry(.+)$/, + replacement: resolve(packagesDir, '@n8n', 'telemetry', 'src$1'), + }, { find: /^@n8n\/utils(.+)$/, replacement: resolve(packagesDir, '@n8n', 'utils', 'src$1'), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d8872be9fa8..f551de5fae7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3681,6 +3681,40 @@ importers: specifier: 'catalog:' version: 3.1.0(typescript@7.0.2)(vitest@4.1.9) + packages/@n8n/telemetry: + dependencies: + zod: + specifier: 3.25.67 + version: 3.25.67 + devDependencies: + '@n8n/eslint-config': + specifier: workspace:* + version: link:../eslint-config + '@n8n/typescript-config': + specifier: workspace:* + version: link:../typescript-config + '@n8n/vitest-config': + specifier: workspace:* + version: link:../vitest-config + '@types/node': + specifier: ^20.17.50 + version: 20.19.41 + '@vitest/coverage-v8': + specifier: 'catalog:' + version: 4.1.9(@vitest/browser@4.1.9)(vitest@4.1.9) + eslint: + specifier: 'catalog:' + version: 9.29.0(jiti@2.6.1) + typescript: + specifier: catalog:typescript + version: 7.0.2 + vite: + specifier: 'catalog:' + version: 8.0.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@20.19.41)(esbuild@0.28.1)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.16.1)(tsx@4.19.3)(yaml@2.8.3) + vitest: + specifier: 'catalog:' + version: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@20.19.41)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(jsdom@23.0.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(vite@8.0.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@20.19.41)(esbuild@0.28.1)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.16.1)(tsx@4.19.3)(yaml@2.8.3)) + packages/@n8n/tournament: dependencies: ast-types: @@ -4079,6 +4113,9 @@ importers: '@n8n/task-runner': specifier: workspace:* version: link:../@n8n/task-runner + '@n8n/telemetry': + specifier: workspace:* + version: link:../@n8n/telemetry '@n8n/typeorm': specifier: workspace:* version: link:../@n8n/typeorm @@ -4932,6 +4969,9 @@ importers: '@n8n/api-types': specifier: workspace:* version: link:../../../@n8n/api-types + '@n8n/telemetry': + specifier: workspace:* + version: link:../../../@n8n/telemetry '@vueuse/core': specifier: catalog:frontend version: 14.3.0(vue@3.5.26(typescript@6.0.2)) @@ -5702,6 +5742,9 @@ importers: '@n8n/stores': specifier: workspace:* version: link:../@n8n/stores + '@n8n/telemetry': + specifier: workspace:* + version: link:../../@n8n/telemetry '@n8n/utils': specifier: workspace:* version: link:../../@n8n/utils