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:
Filipe Tavares
2026-07-23 16:09:54 +00:00
committed by GitHub
co-authored by Claude Fable 5
parent b0a14728d7
commit 855c283101
41 changed files with 1162 additions and 22 deletions
+70
View File
@@ -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
View File
@@ -0,0 +1 @@
../../../../.agents/skills/telemetry
+4
View File
@@ -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
+43
View File
@@ -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;
```
+7
View File
@@ -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' }],
},
});
+40
View File
@@ -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',
);
});
});
+88
View File
@@ -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');
}
+6
View File
@@ -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,
];
+17
View File
@@ -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'),
}),
},
});
+8
View File
@@ -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;
+147
View File
@@ -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__/**"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "@n8n/typescript-config/tsconfig.common.go.json",
"compilerOptions": {
"types": ["vitest/globals", "node"]
},
"include": ["src/**/*.ts"]
}
+4
View File
@@ -0,0 +1,4 @@
import { defineConfig, mergeConfig } from 'vite';
import { vitestConfig } from '@n8n/vitest-config/node';
export default mergeConfig(defineConfig({}), vitestConfig);
+1
View File
@@ -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 => {
+12 -1
View File
@@ -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;
}
+6 -1
View File
@@ -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__/**"]
+3
View File
@@ -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/*"]
+1
View File
@@ -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;
+1
View File
@@ -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,
});
@@ -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,
});
@@ -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'),
+43
View File
@@ -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