diff --git a/packages/@n8n/telemetry/src/events/node-type-policies.ts b/packages/@n8n/telemetry/src/events/node-type-policies.ts index 4c242f5a786..654ea5d0b8e 100644 --- a/packages/@n8n/telemetry/src/events/node-type-policies.ts +++ b/packages/@n8n/telemetry/src/events/node-type-policies.ts @@ -16,6 +16,20 @@ const scope = z .enum(['instance', 'project']) .describe('Which scope the policy was written at; project policies compose under instance'); +/** + * Exported so callers that build these events from a domain event (which keeps `kind` as a + * plain string, so a later kind needs no change there) can narrow it against the same set this + * schema validates against, instead of redeclaring the list and risking drift. + */ +export const POLICY_KINDS = ['node-types', 'credential-types'] as const; +export type PolicyKind = (typeof POLICY_KINDS)[number]; + +const kind = z + .enum(POLICY_KINDS) + .describe( + 'Which type family the policy governs. Credential type policies report through these same events instead of a second set, so this is what tells the two apart.', + ); + const projectId = z.string().optional().describe('Absent at instance scope'); const ruleCounts = { @@ -35,6 +49,7 @@ export const NODE_TYPE_POLICIES_TELEMETRY = defineTelemetryEvents({ properties: z.object({ user_id: userId, source, + kind, scope, project_id: projectId, default_action: z @@ -80,6 +95,7 @@ export const NODE_TYPE_POLICIES_TELEMETRY = defineTelemetryEvents({ properties: z.object({ user_id: userId, source, + kind, operation: z.enum(['created', 'updated', 'deleted']), policy_id: z.string(), ...ruleCounts, @@ -93,6 +109,7 @@ export const NODE_TYPE_POLICIES_TELEMETRY = defineTelemetryEvents({ properties: z.object({ user_id: userId, source, + kind, scope, project_id: projectId, scope_id: z.string(), diff --git a/packages/@n8n/telemetry/src/index.ts b/packages/@n8n/telemetry/src/index.ts index bf4a9127af4..d1fac8dced0 100644 --- a/packages/@n8n/telemetry/src/index.ts +++ b/packages/@n8n/telemetry/src/index.ts @@ -8,6 +8,8 @@ export type { TelemetryEventInput, TelemetryEventRegistry, } from './define'; +export { POLICY_KINDS } from './events/node-type-policies'; +export type { PolicyKind } from './events/node-type-policies'; export { redactTelemetryProperties, redactTelemetryText } from './redaction'; export type { TelemetryTextOptions } from './redaction'; export { collectDuplicateNames, validateEntrySchemas } from './registry-checks'; diff --git a/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts b/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts index 80e14494a39..10634eb3874 100644 --- a/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts +++ b/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts @@ -712,6 +712,7 @@ describe('TelemetryEventRelay', () => { { user_id: 'user123', source: 'user', + kind: 'node-types', scope: 'instance', default_action: 'deny', previous_default_action: null, @@ -735,6 +736,45 @@ describe('TelemetryEventRelay', () => { ); }); + it('should report a credential type policy save with its own kind', () => { + const event: RelayEventMap['node-type-policy-saved'] = { + updatedBy: 'user123', + kind: 'credential-types', + projectId: null, + scopeId: 'scope-1', + before: null, + after: { defaultAction: 'deny', version: 1 }, + rulesBefore: null, + rulesAfter: [], + warningCount: 0, + }; + + eventService.emit('node-type-policy-saved', event); + + expect(telemetry.track).toHaveBeenCalledWith( + TELEMETRY_EVENT.NODE_TYPE_POLICIES.USER_SAVED_NODE_TYPE_POLICY, + expect.objectContaining({ kind: 'credential-types' }), + ); + }); + + it('should drop an unrecognized kind instead of reporting it', () => { + const event: RelayEventMap['node-type-policy-saved'] = { + updatedBy: 'user123', + kind: 'not-a-registered-kind', + projectId: null, + scopeId: 'scope-1', + before: null, + after: { defaultAction: 'deny', version: 1 }, + rulesBefore: null, + rulesAfter: [], + warningCount: 0, + }; + + eventService.emit('node-type-policy-saved', event); + + expect(telemetry.track).not.toHaveBeenCalled(); + }); + it('should track a project-scope save over an existing policy', () => { const event: RelayEventMap['node-type-policy-saved'] = { updatedBy: 'user123', @@ -801,6 +841,7 @@ describe('TelemetryEventRelay', () => { { user_id: 'user123', source: 'user', + kind: 'node-types', operation: 'created', policy_id: 'policy-1', rule_count: 1, @@ -812,6 +853,37 @@ describe('TelemetryEventRelay', () => { ); }); + it('should report a credential type policy document with its own kind', () => { + const event: RelayEventMap['node-type-policy-document-created'] = { + updatedBy: 'user123', + kind: 'credential-types', + policyId: 'policy-1', + origin: 'document-api', + after: { rules: [], version: 1 }, + }; + + eventService.emit('node-type-policy-document-created', event); + + expect(telemetry.track).toHaveBeenCalledWith( + TELEMETRY_EVENT.NODE_TYPE_POLICIES.USER_UPDATED_NODE_TYPE_POLICY_DOCUMENT, + expect.objectContaining({ kind: 'credential-types' }), + ); + }); + + it('should drop a policy document with an unrecognized kind instead of reporting it', () => { + const event: RelayEventMap['node-type-policy-document-created'] = { + updatedBy: 'user123', + kind: 'not-a-registered-kind', + policyId: 'policy-1', + origin: 'document-api', + after: { rules: [], version: 1 }, + }; + + eventService.emit('node-type-policy-document-created', event); + + expect(telemetry.track).not.toHaveBeenCalled(); + }); + it('should track an updated policy document', () => { const event: RelayEventMap['node-type-policy-document-updated'] = { updatedBy: 'user123', @@ -1005,6 +1077,7 @@ describe('TelemetryEventRelay', () => { { user_id: 'user123', source: 'user', + kind: 'node-types', scope: 'project', project_id: 'project-1', scope_id: 'scope-2', @@ -1014,6 +1087,39 @@ describe('TelemetryEventRelay', () => { }, ); }); + + it('should report credential type policy attachments with their own kind', () => { + const event: RelayEventMap['node-type-policy-attachments-updated'] = { + updatedBy: 'user123', + kind: 'credential-types', + projectId: null, + scopeId: 'scope-1', + before: { attachments: [], version: 1 }, + after: { attachments: [], version: 2 }, + }; + + eventService.emit('node-type-policy-attachments-updated', event); + + expect(telemetry.track).toHaveBeenCalledWith( + TELEMETRY_EVENT.NODE_TYPE_POLICIES.USER_UPDATED_NODE_TYPE_POLICY_ATTACHMENTS, + expect.objectContaining({ kind: 'credential-types' }), + ); + }); + + it('should drop attachments with an unrecognized kind instead of reporting them', () => { + const event: RelayEventMap['node-type-policy-attachments-updated'] = { + updatedBy: 'user123', + kind: 'not-a-registered-kind', + projectId: null, + scopeId: 'scope-1', + before: { attachments: [], version: 1 }, + after: { attachments: [], version: 2 }, + }; + + eventService.emit('node-type-policy-attachments-updated', event); + + expect(telemetry.track).not.toHaveBeenCalled(); + }); }); describe('external secrets events', () => { diff --git a/packages/cli/src/events/relays/telemetry.event-relay.ts b/packages/cli/src/events/relays/telemetry.event-relay.ts index 8e1dbb1c5ea..7bcfb3ed8cf 100644 --- a/packages/cli/src/events/relays/telemetry.event-relay.ts +++ b/packages/cli/src/events/relays/telemetry.event-relay.ts @@ -11,7 +11,7 @@ import { } from '@n8n/db'; import { Container, Service } from '@n8n/di'; import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions'; -import { TELEMETRY_EVENT } from '@n8n/telemetry'; +import { POLICY_KINDS, TELEMETRY_EVENT, type PolicyKind } from '@n8n/telemetry'; import { snakeCase } from 'change-case'; import { BinaryDataConfig, InstanceSettings } from 'n8n-core'; import type { @@ -81,6 +81,18 @@ function policyScope(projectId: string | null): { return projectId === null ? { scope: 'instance' } : { scope: 'project', project_id: projectId }; } +/** + * The domain event keeps `kind` as a plain string, so a later ticket can add controllers for a + * new kind (e.g. credential types) without changing the service or event map. Telemetry still + * wants a closed set, so this narrows against the same `POLICY_KINDS` the schema validates + * against — an unrecognized kind is a bug in the caller (a new kind landed without registering + * it in the telemetry package), not something to crash telemetry over, so it is dropped rather + * than reported. + */ +function isPolicyKind(kind: string): kind is PolicyKind { + return (POLICY_KINDS as readonly string[]).includes(kind); +} + function countRuleActions(rules: readonly PolicyRule[]) { return { rule_count: rules.length, @@ -528,6 +540,7 @@ export class TelemetryEventRelay extends EventRelay { private nodeTypePolicySaved({ updatedBy, + kind, projectId, before, after, @@ -535,8 +548,11 @@ export class TelemetryEventRelay extends EventRelay { rulesAfter, warningCount, }: RelayEventMap['node-type-policy-saved']) { + if (!isPolicyKind(kind)) return; + this.telemetry.track(TELEMETRY_EVENT.NODE_TYPE_POLICIES.USER_SAVED_NODE_TYPE_POLICY, { ...policyActor(updatedBy), + kind, ...policyScope(projectId), default_action: after.defaultAction, previous_default_action: before?.defaultAction ?? null, @@ -560,17 +576,19 @@ export class TelemetryEventRelay extends EventRelay { */ private nodeTypePolicyDocumentCreated({ updatedBy, + kind, policyId, origin, after, }: RelayEventMap['node-type-policy-document-created']) { if (origin === 'composed-save') return; - this.trackPolicyDocument(updatedBy, policyId, 'created', after.rules, null); + this.trackPolicyDocument(updatedBy, kind, policyId, 'created', after.rules, null); } private nodeTypePolicyDocumentUpdated({ updatedBy, + kind, policyId, origin, before, @@ -578,28 +596,33 @@ export class TelemetryEventRelay extends EventRelay { }: RelayEventMap['node-type-policy-document-updated']) { if (origin === 'composed-save') return; - this.trackPolicyDocument(updatedBy, policyId, 'updated', after.rules, before.rules); + this.trackPolicyDocument(updatedBy, kind, policyId, 'updated', after.rules, before.rules); } private nodeTypePolicyDocumentDeleted({ updatedBy, + kind, policyId, before, }: RelayEventMap['node-type-policy-document-deleted']) { - this.trackPolicyDocument(updatedBy, policyId, 'deleted', [], before.rules); + this.trackPolicyDocument(updatedBy, kind, policyId, 'deleted', [], before.rules); } private trackPolicyDocument( updatedBy: string, + kind: string, policyId: string, operation: 'created' | 'updated' | 'deleted', rulesAfter: readonly PolicyRule[], rulesBefore: readonly PolicyRule[] | null, ) { + if (!isPolicyKind(kind)) return; + this.telemetry.track( TELEMETRY_EVENT.NODE_TYPE_POLICIES.USER_UPDATED_NODE_TYPE_POLICY_DOCUMENT, { ...policyActor(updatedBy), + kind, operation, policy_id: policyId, ...countRuleActions(rulesAfter), @@ -610,15 +633,19 @@ export class TelemetryEventRelay extends EventRelay { private nodeTypePolicyAttachmentsUpdated({ updatedBy, + kind, projectId, scopeId, before, after, }: RelayEventMap['node-type-policy-attachments-updated']) { + if (!isPolicyKind(kind)) return; + this.telemetry.track( TELEMETRY_EVENT.NODE_TYPE_POLICIES.USER_UPDATED_NODE_TYPE_POLICY_ATTACHMENTS, { ...policyActor(updatedBy), + kind, ...policyScope(projectId), scope_id: scopeId, attachment_count: after.attachments.length,