mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-04 10:39:23 +02:00
feat(core): Track OpenTelemetry usage telemetry (#31570)
This commit is contained in:
parent
b430039349
commit
c32a33cda2
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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': {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user