mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-29 19:10:43 +08:00
feat: Add @n8n/telemetry event registry with typed track() support (no-changelog) (#34662)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
b0a14728d7
commit
855c283101
@@ -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.<DOMAIN>.<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`).
|
||||
+1
@@ -0,0 +1 @@
|
||||
../../../../.agents/skills/telemetry
|
||||
@@ -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
|
||||
|
||||
@@ -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.<DOMAIN>.<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;
|
||||
```
|
||||
@@ -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);
|
||||
@@ -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' }],
|
||||
},
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<T extends TelemetryEventDef>(
|
||||
event: T,
|
||||
properties: InferTelemetryProps<T>,
|
||||
): 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<T extends TelemetryEventDef>(
|
||||
event: T,
|
||||
properties: InferTelemetryProps<T>,
|
||||
): void;
|
||||
declare function trackWithFallback(event: string, properties?: Record<string, unknown>): 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;
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
function typeSummary(schema: Record<string, unknown>): 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');
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
@@ -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<T extends TelemetryEventDef> = z.infer<T['properties']>;
|
||||
|
||||
export type TelemetryEventRegistry = Record<string, Record<string, TelemetryEventDef>>;
|
||||
|
||||
export function defineTelemetryEvents<const T extends Record<string, TelemetryEventDef>>(
|
||||
events: T,
|
||||
): T {
|
||||
return events;
|
||||
}
|
||||
@@ -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'),
|
||||
}),
|
||||
},
|
||||
});
|
||||
@@ -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';
|
||||
@@ -0,0 +1,40 @@
|
||||
import { z } from 'zod/v4';
|
||||
|
||||
import type { TelemetryEventRegistry } from './define';
|
||||
|
||||
export function collectDuplicateNames(registry: TelemetryEventRegistry): string[] {
|
||||
const seen = new Set<string>();
|
||||
const duplicates = new Set<string>();
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { TelemetryEventRegistry } from './define';
|
||||
import { PLATFORM_TELEMETRY } from './events/platform';
|
||||
|
||||
export const TELEMETRY_EVENT = {
|
||||
PLATFORM: PLATFORM_TELEMETRY,
|
||||
} satisfies TelemetryEventRegistry;
|
||||
@@ -0,0 +1,147 @@
|
||||
import { z } from 'zod/v4';
|
||||
|
||||
import type { TelemetryEventDef } from './define';
|
||||
|
||||
type ValidationPath = Array<string | number>;
|
||||
type ObjectSchema = z.ZodObject<Record<string, z.ZodType>>;
|
||||
type UnionSchema = z.ZodUnion<readonly z.ZodType[]>;
|
||||
type ArraySchema = z.ZodArray<z.ZodType>;
|
||||
type RecordSchema = z.ZodRecord<z.ZodString, z.ZodType>;
|
||||
type ReadonlySchema = z.ZodReadonly<z.ZodType>;
|
||||
type WrappedSchema =
|
||||
| z.ZodOptional<z.ZodType>
|
||||
| z.ZodNullable<z.ZodType>
|
||||
| z.ZodDefault<z.ZodType>
|
||||
| z.ZodNonOptional<z.ZodType>
|
||||
| z.ZodCatch<z.ZodType>
|
||||
| z.ZodLazy<z.ZodType>;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
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('; ')}`;
|
||||
}
|
||||
@@ -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__/**"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "@n8n/typescript-config/tsconfig.common.go.json",
|
||||
"compilerOptions": {
|
||||
"types": ["vitest/globals", "node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { defineConfig, mergeConfig } from 'vite';
|
||||
import { vitestConfig } from '@n8n/vitest-config/node';
|
||||
|
||||
export default mergeConfig(defineConfig({}), vitestConfig);
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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<Logger>();
|
||||
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<Logger>();
|
||||
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 => {
|
||||
|
||||
@@ -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<T extends TelemetryEventDef>(event: T, properties: InferTelemetryProps<T>): 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;
|
||||
}
|
||||
|
||||
@@ -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__/**"]
|
||||
|
||||
@@ -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/*"]
|
||||
|
||||
@@ -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'),
|
||||
};
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@n8n/api-types": "workspace:*",
|
||||
"@n8n/telemetry": "workspace:*",
|
||||
"@vueuse/core": "catalog:frontend",
|
||||
"lodash": "catalog:",
|
||||
"n8n-workflow": "workspace:*",
|
||||
|
||||
@@ -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<T extends TelemetryEventDef>(event: T, properties: InferTelemetryProps<T>): void;
|
||||
track(event: string, properties?: ITelemetryTrackProperties): void;
|
||||
page(route: RouteLocation): void;
|
||||
reset(): void;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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];
|
||||
@@ -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');
|
||||
|
||||
@@ -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<T extends TelemetryEventDef>(event: T, properties: InferTelemetryProps<T>): 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
+3
-2
@@ -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,
|
||||
});
|
||||
|
||||
+6
-2
@@ -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(),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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'),
|
||||
|
||||
Generated
+43
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user