diff --git a/packages/cli/src/controllers/__tests__/project.controller.test.ts b/packages/cli/src/controllers/__tests__/project.controller.test.ts index 04e190ce20a..99e8da922af 100644 --- a/packages/cli/src/controllers/__tests__/project.controller.test.ts +++ b/packages/cli/src/controllers/__tests__/project.controller.test.ts @@ -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'; diff --git a/packages/cli/src/controllers/project.controller.ts b/packages/cli/src/controllers/project.controller.ts index 3af7c6f2301..43753447aba 100644 --- a/packages/cli/src/controllers/project.controller.ts +++ b/packages/cli/src/controllers/project.controller.ts @@ -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') 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 a0b81a356cf..b697ade4de4 100644 --- a/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts +++ b/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts @@ -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({ + 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({ + 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(), + }), }), ); }); diff --git a/packages/cli/src/events/maps/relay.event-map.ts b/packages/cli/src/events/maps/relay.event-map.ts index 6feb790b9ac..f7b690d04a6 100644 --- a/packages/cli/src/events/maps/relay.event-map.ts +++ b/packages/cli/src/events/maps/relay.event-map.ts @@ -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': { diff --git a/packages/cli/src/events/relays/telemetry.event-relay.ts b/packages/cli/src/events/relays/telemetry.event-relay.ts index bcd9b01b39a..95b8ff84923 100644 --- a/packages/cli/src/events/relays/telemetry.event-relay.ts +++ b/packages/cli/src/events/relays/telemetry.event-relay.ts @@ -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 diff --git a/packages/cli/src/public-api/v1/handlers/workflows/spec/schemas/node.yml b/packages/cli/src/public-api/v1/handlers/workflows/spec/schemas/node.yml index 3206c35381e..847f876ca3a 100644 --- a/packages/cli/src/public-api/v1/handlers/workflows/spec/schemas/node.yml +++ b/packages/cli/src/public-api/v1/handlers/workflows/spec/schemas/node.yml @@ -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 diff --git a/packages/cli/test/integration/public-api/workflows.test.ts b/packages/cli/test/integration/public-api/workflows.test.ts index f292753f79f..df0e40eb3c9 100644 --- a/packages/cli/test/integration/public-api/workflows.test.ts +++ b/packages/cli/test/integration/public-api/workflows.test.ts @@ -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(), );