feat(core): Track OpenTelemetry usage telemetry (#31570)

This commit is contained in:
Irénée 2026-06-03 11:03:44 +01:00 committed by GitHub
parent b430039349
commit c32a33cda2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 357 additions and 4 deletions

View File

@ -117,6 +117,47 @@ describe('ProjectController', () => {
});
});
it('emits team-project-updated with custom telemetry tag count on updateProject', async () => {
const projectId = 'p1';
const payload = {
name: 'Updated Project',
customTelemetryTags: [
{ key: 'env', value: 'production' },
{ key: 'team', value: 'engineering' },
],
};
const res = makeRes();
await controller.updateProject(req, res, payload as any, projectId);
expect(projectsService.updateProject).toHaveBeenCalledWith(projectId, payload);
expect(projectsService.getProjectRelations).not.toHaveBeenCalled();
expect(eventService.emit).toHaveBeenCalledWith('team-project-updated', {
userId: 'actor-user',
role: 'global:owner',
projectId,
otelProjectCustomTagsCount: 2,
});
});
it('emits team-project-updated without custom telemetry tag count on updateProject without tags', async () => {
const projectId = 'p1';
const payload = { name: 'Updated Project' };
const res = makeRes();
await controller.updateProject(req, res, payload as any, projectId);
expect(projectsService.updateProject).toHaveBeenCalledWith(projectId, payload);
expect(projectsService.getProjectRelations).not.toHaveBeenCalled();
expect(eventService.emit).toHaveBeenCalledWith('team-project-updated', {
userId: 'actor-user',
role: 'global:owner',
projectId,
});
});
it('emits team-project-updated with full members list on addProjectUsers', async () => {
// Arrange
const projectId = 'p1';

View File

@ -257,12 +257,20 @@ export class ProjectController {
@Patch('/:projectId')
@ProjectScope('project:update')
async updateProject(
_req: AuthenticatedRequest,
req: AuthenticatedRequest,
_res: Response,
@Body payload: UpdateProjectDto,
@Param('projectId') projectId: string,
) {
await this.projectsService.updateProject(projectId, payload);
this.eventService.emit('team-project-updated', {
userId: req.user.id,
role: req.user.role.slug,
projectId,
...(payload.customTelemetryTags !== undefined
? { otelProjectCustomTagsCount: payload.customTelemetryTags.length }
: {}),
});
}
@Post('/:projectId/users')

View File

@ -12,6 +12,7 @@ import {
type WorkflowRepository,
GLOBAL_OWNER_ROLE,
} from '@n8n/db';
import { Container } from '@n8n/di';
import { mock } from 'jest-mock-extended';
import { type BinaryDataConfig, InstanceSettings } from 'n8n-core';
import {
@ -30,6 +31,7 @@ import { EventService } from '@/events/event.service';
import type { RelayEventMap } from '@/events/maps/relay.event-map';
import { TelemetryEventRelay, getSemanticVersioning } from '@/events/relays/telemetry.event-relay';
import type { License } from '@/license';
import { OtelConfig } from '@/modules/otel/otel.config';
import type { Telemetry } from '@/telemetry';
const flushPromises = async () => await new Promise((resolve) => setImmediate(resolve));
@ -140,6 +142,9 @@ describe('TelemetryEventRelay', () => {
beforeEach(() => {
jest.clearAllMocks();
globalConfig.diagnostics.enabled = true;
const otelConfig = Container.get(OtelConfig);
otelConfig.enabled = false;
otelConfig.includeNodeSpans = true;
});
describe('init', () => {
@ -221,6 +226,40 @@ describe('TelemetryEventRelay', () => {
});
});
it('should track on `team-project-updated` event without members', () => {
const event: RelayEventMap['team-project-updated'] = {
userId: 'user123',
role: 'global:owner',
projectId: 'project123',
};
eventService.emit('team-project-updated', event);
expect(telemetry.track).toHaveBeenCalledWith('Project settings updated', {
user_id: 'user123',
role: 'global:owner',
project_id: 'project123',
});
});
it('should track project custom telemetry tag count on `team-project-updated` event', () => {
const event: RelayEventMap['team-project-updated'] = {
userId: 'user123',
role: 'global:owner',
projectId: 'project123',
otelProjectCustomTagsCount: 2,
};
eventService.emit('team-project-updated', event);
expect(telemetry.track).toHaveBeenCalledWith('Project settings updated', {
user_id: 'user123',
role: 'global:owner',
project_id: 'project123',
otel_project_custom_tags_count: 2,
});
});
it('should track on `team-project-deleted` event', () => {
const event: RelayEventMap['team-project-deleted'] = {
userId: 'user123',
@ -1239,10 +1278,75 @@ describe('TelemetryEventRelay', () => {
public_api: false,
project_id: 'project123',
project_type: 'personal',
otel_workflow_custom_tags_count: 0,
otel_nodes_with_custom_tags_count: 0,
otel_node_custom_tags_count: 0,
source: 'ui',
});
});
it('should track OTEL custom telemetry tag counts on `workflow-created` event', async () => {
const event: RelayEventMap['workflow-created'] = {
user: {
id: 'user123',
email: 'user@example.com',
firstName: 'John',
lastName: 'Doe',
role: { slug: GLOBAL_OWNER_ROLE.slug },
},
workflow: mock<IWorkflowBase>({
id: 'workflow123',
name: 'Test Workflow',
settings: {
customTelemetryTags: [{ key: 'env', value: 'production' }],
},
nodes: [
{
id: 'node-1',
name: 'Node 1',
type: 'n8n-nodes-base.noOp',
typeVersion: 1,
position: [0, 0],
parameters: {},
customTelemetryTags: {
tag: [
{ key: 'node-env', value: 'production' },
{ key: 'node-team', value: 'engineering' },
],
},
},
{
id: 'node-2',
name: 'Node 2',
type: 'n8n-nodes-base.noOp',
typeVersion: 1,
position: [0, 0],
parameters: {},
customTelemetryTags: {
tag: [{ key: 'node-region', value: 'eu' }],
},
},
],
}),
publicApi: false,
projectId: 'project123',
projectType: 'personal',
};
eventService.emit('workflow-created', event);
await flushPromises();
expect(telemetry.track).toHaveBeenCalledWith(
'User created workflow',
expect.objectContaining({
otel_workflow_custom_tags_count: 1,
otel_nodes_with_custom_tags_count: 2,
otel_node_custom_tags_count: 3,
}),
);
});
it('should truncate node_graph_string when it exceeds size limit', async () => {
const largeNodeGraph: INodesGraphResult = {
nodeGraph: {
@ -1297,6 +1401,9 @@ describe('TelemetryEventRelay', () => {
public_api: false,
project_id: 'project123',
project_type: 'personal',
otel_workflow_custom_tags_count: 0,
otel_nodes_with_custom_tags_count: 0,
otel_node_custom_tags_count: 0,
source: 'ui',
});
});
@ -1528,10 +1635,84 @@ describe('TelemetryEventRelay', () => {
ai_builder_assisted: false,
identity_extractor_changed: false,
redaction_policy: undefined,
otel_workflow_custom_tags_count: 0,
otel_nodes_with_custom_tags_count: 0,
otel_node_custom_tags_count: 0,
source: 'ui',
});
});
it('should track OTEL custom telemetry tag counts on `workflow-saved` event', async () => {
const event: RelayEventMap['workflow-saved'] = {
user: {
id: 'user123',
email: 'user@example.com',
firstName: 'John',
lastName: 'Doe',
role: { slug: GLOBAL_OWNER_ROLE.slug },
},
workflow: mock<IWorkflowDb>({
id: 'workflow123',
name: 'Test Workflow',
settings: {
customTelemetryTags: [
{ key: 'env', value: 'production' },
{ key: 'team', value: 'engineering' },
],
},
nodes: [
{
id: 'node-1',
name: 'Node 1',
type: 'n8n-nodes-base.noOp',
typeVersion: 1,
position: [0, 0],
parameters: {},
customTelemetryTags: {
tag: [
{ key: 'node-env', value: 'production' },
{ key: 'node-team', value: 'engineering' },
],
},
},
{
id: 'node-2',
name: 'Node 2',
type: 'n8n-nodes-base.noOp',
typeVersion: 1,
position: [0, 0],
parameters: {},
customTelemetryTags: {
tag: [{ key: 'node-region', value: 'eu' }],
},
},
{
id: 'node-3',
name: 'Node 3',
type: 'n8n-nodes-base.noOp',
typeVersion: 1,
position: [0, 0],
parameters: {},
},
],
}),
publicApi: false,
};
eventService.emit('workflow-saved', event);
await flushPromises();
expect(telemetry.track).toHaveBeenCalledWith(
'User saved workflow',
expect.objectContaining({
otel_workflow_custom_tags_count: 2,
otel_nodes_with_custom_tags_count: 2,
otel_node_custom_tags_count: 3,
}),
);
});
it('should track resolver settings when credentialResolverId changes', async () => {
const event: RelayEventMap['workflow-saved'] = {
user: {
@ -1577,6 +1758,9 @@ describe('TelemetryEventRelay', () => {
credential_resolver_id: 'resolver-123',
identity_extractor_changed: false,
redaction_policy: undefined,
otel_workflow_custom_tags_count: 0,
otel_nodes_with_custom_tags_count: 0,
otel_node_custom_tags_count: 0,
source: 'ui',
});
});
@ -1942,6 +2126,11 @@ describe('TelemetryEventRelay', () => {
},
}),
);
expect(telemetry.identify).toHaveBeenCalledWith(
expect.not.objectContaining({
otel: expect.anything(),
}),
);
expect(telemetry.track).toHaveBeenCalledWith(
'Instance started',
expect.objectContaining({
@ -1954,6 +2143,38 @@ describe('TelemetryEventRelay', () => {
metrics_category_logs: false,
metrics_category_queue: false,
},
otel: {
enabled: false,
include_node_spans: true,
},
}),
);
});
it('should track OTEL startup configuration on `server-started` event', async () => {
const otelConfig = Container.get(OtelConfig);
otelConfig.enabled = true;
otelConfig.includeNodeSpans = false;
workflowRepository.findOne.mockResolvedValue(null);
eventService.emit('server-started');
await flushPromises();
expect(telemetry.track).toHaveBeenCalledWith(
'Instance started',
expect.objectContaining({
otel: {
enabled: true,
include_node_spans: false,
},
}),
);
expect(telemetry.groupIdentify).toHaveBeenCalledWith(
expect.objectContaining({
traits: expect.not.objectContaining({
otel: expect.anything(),
}),
}),
);
});

View File

@ -574,8 +574,9 @@ export type RelayEventMap = {
'team-project-updated': {
userId: string;
role: string;
members: ProjectRelation[];
members?: ProjectRelation[];
projectId: string;
otelProjectCustomTagsCount?: number;
};
'team-project-deleted': {

View File

@ -7,7 +7,7 @@ import {
WorkflowRepository,
type IWorkflowDb,
} from '@n8n/db';
import { Service } from '@n8n/di';
import { Container, Service } from '@n8n/di';
import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions';
import { snakeCase } from 'change-case';
import { BinaryDataConfig, InstanceSettings } from 'n8n-core';
@ -16,6 +16,7 @@ import type {
INode,
INodesGraphResult,
ITelemetryTrackProperties,
IWorkflowBase,
JsonValue,
} from 'n8n-workflow';
import {
@ -43,6 +44,18 @@ import { Telemetry } from '../../telemetry';
// Max size for node_graph_string to avoid exceeding telemetry payload limits (32 KB), leaving room for other fields
const MAX_NODE_GRAPH_STRING_SIZE = 24 * 1024;
function countWorkflowCustomTelemetryTags(workflow: IWorkflowDb | IWorkflowBase): number {
return workflow.settings?.customTelemetryTags?.length ?? 0;
}
function countNodesWithCustomTelemetryTags(nodes: INode[]): number {
return nodes.filter((node) => (node.customTelemetryTags?.tag?.length ?? 0) > 0).length;
}
function countNodeCustomTelemetryTags(nodes: INode[]): number {
return nodes.reduce((total, node) => total + (node.customTelemetryTags?.tag?.length ?? 0), 0);
}
function limitNodeGraphStringSize(nodeGraphString: string): string {
if (Buffer.byteLength(nodeGraphString, 'utf8') > MAX_NODE_GRAPH_STRING_SIZE) return '{}';
@ -196,12 +209,14 @@ export class TelemetryEventRelay extends EventRelay {
role,
members,
projectId,
otelProjectCustomTagsCount,
}: RelayEventMap['team-project-updated']) {
this.telemetry.track('Project settings updated', {
user_id: userId,
role,
members: members.map(({ userId: user_id, role }) => ({ user_id, role })),
project_id: projectId,
members: members?.map(({ userId: user_id, role }) => ({ user_id, role })),
otel_project_custom_tags_count: otelProjectCustomTagsCount,
});
}
@ -817,6 +832,9 @@ export class TelemetryEventRelay extends EventRelay {
project_id: projectId,
project_type: projectType,
meta: JSON.stringify(workflow.meta),
otel_workflow_custom_tags_count: countWorkflowCustomTelemetryTags(workflow),
otel_nodes_with_custom_tags_count: countNodesWithCustomTelemetryTags(workflow.nodes),
otel_node_custom_tags_count: countNodeCustomTelemetryTags(workflow.nodes),
uiContext,
source,
});
@ -984,6 +1002,9 @@ export class TelemetryEventRelay extends EventRelay {
credential_resolver_id: credentialResolverId,
identity_extractor_changed: identityExtractorChanged,
redaction_policy: redactionPolicy,
otel_workflow_custom_tags_count: countWorkflowCustomTelemetryTags(workflow),
otel_nodes_with_custom_tags_count: countNodesWithCustomTelemetryTags(workflow.nodes),
otel_node_custom_tags_count: countNodeCustomTelemetryTags(workflow.nodes),
source,
});
}
@ -1186,6 +1207,7 @@ export class TelemetryEventRelay extends EventRelay {
private async serverStarted() {
const cpus = os.cpus();
const otel = await this.getOtelTelemetryInfo();
const isS3Selected = this.binaryDataConfig.mode === 's3';
const isS3Available = this.binaryDataConfig.availableModes.includes('s3');
@ -1309,9 +1331,20 @@ export class TelemetryEventRelay extends EventRelay {
this.telemetry.track('Instance started', {
...info,
earliest_workflow_created: firstWorkflow?.createdAt,
otel,
});
}
private async getOtelTelemetryInfo() {
const { OtelConfig } = await import('@/modules/otel/otel.config');
const otelConfig = Container.get(OtelConfig);
return {
enabled: otelConfig.enabled,
include_node_spans: otelConfig.includeNodeSpans,
};
}
private getLicenseFeatures() {
return {
// Features

View File

@ -54,6 +54,23 @@ properties:
credentials:
type: object
example: { jiraSoftwareCloudApi: { id: '35', name: 'jiraApi' } }
customTelemetryTags:
type: object
additionalProperties: false
properties:
tag:
type: array
items:
type: object
additionalProperties: false
required:
- key
- value
properties:
key:
type: string
value:
type: string
createdAt:
type: string
format: date-time

View File

@ -1416,6 +1416,28 @@ describe('POST /workflows', () => {
test('should create workflow', async () => {
const payload = {
...mockPostWorkflowPayload(),
nodes: [
triggerNode,
{
id: 'uuid-5678',
parameters: {},
name: 'Tagged NoOp',
type: 'n8n-nodes-base.noOp',
typeVersion: 1,
position: [460, 300],
customTelemetryTags: {
tag: [
{ key: 'node-env', value: 'production' },
{ key: 'node-team', value: 'engineering' },
],
},
},
],
connections: {
Start: {
main: [[{ node: 'Tagged NoOp', type: 'main', index: 0 }]],
},
},
staticData: null,
settings: {
saveExecutionProgress: true,
@ -1473,6 +1495,8 @@ describe('POST /workflows', () => {
expect(sharedWorkflow?.workflow.name).toBe(name);
expect(sharedWorkflow?.workflow.createdAt.toISOString()).toBe(createdAt);
expect(sharedWorkflow?.workflow.nodes).toEqual(payload.nodes);
expect(sharedWorkflow?.workflow.settings).toEqual(payload.settings);
expect(sharedWorkflow?.role).toEqual('workflow:owner');
});
@ -1754,6 +1778,12 @@ describe('PUT /workflows/:id', () => {
type: 'n8n-nodes-base.cron',
typeVersion: 1,
position: [400, 300],
customTelemetryTags: {
tag: [
{ key: 'node-env', value: 'production' },
{ key: 'node-team', value: 'engineering' },
],
},
},
],
connections: {},
@ -1812,6 +1842,8 @@ describe('PUT /workflows/:id', () => {
});
expect(sharedWorkflow?.workflow.name).toBe(payload.name);
expect(sharedWorkflow?.workflow.nodes).toEqual(payload.nodes);
expect(sharedWorkflow?.workflow.settings).toEqual(payload.settings);
expect(sharedWorkflow?.workflow.updatedAt.getTime()).toBeGreaterThan(
workflow.updatedAt.getTime(),
);