feat(core): Add kind property to node type policy telemetry events (no-changelog) (#38749)

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Rúben Castro 2026-09-17 14:53:20 +00:00 committed by GitHub
parent b26bb7f452
commit 477481fec3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 156 additions and 4 deletions

View File

@ -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(),

View File

@ -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';

View File

@ -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', () => {

View File

@ -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,