diff --git a/packages/@n8n/config/src/configs/endpoints.config.ts b/packages/@n8n/config/src/configs/endpoints.config.ts index 39637f464e..7279b3f8b2 100644 --- a/packages/@n8n/config/src/configs/endpoints.config.ts +++ b/packages/@n8n/config/src/configs/endpoints.config.ts @@ -61,6 +61,10 @@ class PrometheusMetricsConfig { /** How often (in seconds) to update active workflow metric */ @Env('N8N_METRICS_ACTIVE_WORKFLOW_METRIC_INTERVAL') activeWorkflowCountInterval: number = 60; + + /** Whether to include a label for workflow name on workflow metrics. */ + @Env('N8N_METRICS_INCLUDE_WORKFLOW_NAME_LABEL') + includeWorkflowNameLabel: boolean = false; } @Config diff --git a/packages/@n8n/config/test/config.test.ts b/packages/@n8n/config/test/config.test.ts index 6d7b569dc7..73c11a74c6 100644 --- a/packages/@n8n/config/test/config.test.ts +++ b/packages/@n8n/config/test/config.test.ts @@ -176,6 +176,7 @@ describe('GlobalConfig', () => { enable: false, prefix: 'n8n_', includeWorkflowIdLabel: false, + includeWorkflowNameLabel: false, includeDefaultMetrics: true, includeMessageEventBusMetrics: false, includeNodeTypeLabel: false, diff --git a/packages/cli/src/metrics/__tests__/prometheus-metrics.service.test.ts b/packages/cli/src/metrics/__tests__/prometheus-metrics.service.test.ts index 5043fb1c33..943166f681 100644 --- a/packages/cli/src/metrics/__tests__/prometheus-metrics.service.test.ts +++ b/packages/cli/src/metrics/__tests__/prometheus-metrics.service.test.ts @@ -10,6 +10,7 @@ import promClient from 'prom-client'; import config from '@/config'; import type { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus'; import type { EventService } from '@/events/event.service'; +import { EventMessageTypeNames } from 'n8n-workflow'; import { PrometheusMetricsService } from '../prometheus-metrics.service'; @@ -23,51 +24,65 @@ jest.mock('prom-client'); jest.mock('express-prom-bundle', () => jest.fn(() => mockMiddleware)); describe('PrometheusMetricsService', () => { - promClient.Counter.prototype.inc = jest.fn(); + let globalConfig: GlobalConfig; + let app: express.Application; + let eventBus: MessageEventBus; + let eventService: EventService; + let instanceSettings: InstanceSettings; + let workflowRepository: WorkflowRepository; + let prometheusMetricsService: PrometheusMetricsService; - const globalConfig = mockInstance(GlobalConfig, { - endpoints: { - metrics: { - prefix: 'n8n_', - includeDefaultMetrics: false, - includeApiEndpoints: false, - includeCacheMetrics: false, - includeMessageEventBusMetrics: false, - includeCredentialTypeLabel: false, - includeNodeTypeLabel: false, - includeWorkflowIdLabel: false, - includeApiPathLabel: false, - includeApiMethodLabel: false, - includeApiStatusCodeLabel: false, - includeQueueMetrics: false, + beforeEach(() => { + globalConfig = mockInstance(GlobalConfig, { + endpoints: { + metrics: { + prefix: 'n8n_', + includeDefaultMetrics: false, + includeApiEndpoints: false, + includeCacheMetrics: false, + includeMessageEventBusMetrics: false, + includeCredentialTypeLabel: false, + includeNodeTypeLabel: false, + includeWorkflowIdLabel: false, + includeApiPathLabel: false, + includeApiMethodLabel: false, + includeApiStatusCodeLabel: false, + includeQueueMetrics: false, + includeWorkflowNameLabel: false, + }, + rest: 'rest', + form: 'form', + formTest: 'form-test', + formWaiting: 'form-waiting', + webhook: 'webhook', + webhookTest: 'webhook-test', + webhookWaiting: 'webhook-waiting', }, - rest: 'rest', - form: 'form', - formTest: 'form-test', - formWaiting: 'form-waiting', - webhook: 'webhook', - webhookTest: 'webhook-test', - webhookWaiting: 'webhook-waiting', - }, - }); + }); - const app = mock(); - const eventBus = mock(); - const eventService = mock(); - const instanceSettings = mock({ instanceType: 'main' }); - const workflowRepository = mock(); - const prometheusMetricsService = new PrometheusMetricsService( - mock(), - eventBus, - globalConfig, - eventService, - instanceSettings, - workflowRepository, - ); + app = mock(); + eventBus = mock(); + eventService = mock(); + instanceSettings = mock({ instanceType: 'main' }); + workflowRepository = mock(); + + prometheusMetricsService = new PrometheusMetricsService( + mock(), + eventBus, + globalConfig, + eventService, + instanceSettings, + workflowRepository, + ); + + promClient.Counter.prototype.inc = jest.fn(); + (promClient.validateMetricName as jest.Mock).mockReturnValue(true); + }); afterEach(() => { jest.clearAllMocks(); prometheusMetricsService.disableAllMetrics(); + prometheusMetricsService.disableAllLabels(); }); describe('constructor', () => { @@ -254,4 +269,265 @@ describe('PrometheusMetricsService', () => { }); }); }); + + describe('when event bus events are sent', () => { + // Helper to find the event handler function registered by initEventBusMetrics + const getEventHandler = () => { + const eventBusOnCall = (eventBus.on as jest.Mock).mock.calls.find( + (call) => call[0] === 'metrics.eventBus.event', + ); + // The handler is the second argument in the .on(eventName, handler) call + return eventBusOnCall ? eventBusOnCall[1] : undefined; + }; + + it('should create a counter with `credential_type` label for user credentials audit events', async () => { + prometheusMetricsService.enableMetric('logs'); + prometheusMetricsService.enableLabels(['credentialsType']); + + await prometheusMetricsService.init(app); + + const eventHandler = getEventHandler(); + const mockEvent = { + __type: EventMessageTypeNames.audit, + eventName: 'n8n.audit.user.credentials.created', + payload: { credentialType: 'n8n-nodes-base.googleApi' }, + }; + + eventHandler(mockEvent); + + expect(promClient.Counter).toHaveBeenCalledWith({ + name: 'n8n_audit_user_credentials_created_total', + help: 'Total number of n8n.audit.user.credentials.created events.', + labelNames: ['credential_type'], + }); + + expect(promClient.Counter.prototype.inc).toHaveBeenCalledWith( + { credential_type: 'n8n-nodes-base_googleApi' }, + 1, + ); + }); + + it('should create a counter with `workflow_id` label for workflow audit events', async () => { + prometheusMetricsService.enableMetric('logs'); + prometheusMetricsService.enableLabels(['workflowId']); + + await prometheusMetricsService.init(app); + + const eventHandler = getEventHandler(); + const mockEvent = { + __type: EventMessageTypeNames.audit, + eventName: 'n8n.audit.workflow.created', + payload: { workflowId: 'wf_123' }, + }; + eventHandler(mockEvent); + + expect(promClient.Counter).toHaveBeenCalledWith({ + name: 'n8n_audit_workflow_created_total', + help: 'Total number of n8n.audit.workflow.created events.', + labelNames: ['workflow_id'], + }); + + expect(promClient.Counter.prototype.inc).toHaveBeenCalledWith({ workflow_id: 'wf_123' }, 1); + }); + + it('should create a counter with `workflow_name` label for workflow audit events', async () => { + prometheusMetricsService.enableMetric('logs'); + prometheusMetricsService.enableLabels(['workflowName']); + + await prometheusMetricsService.init(app); + + const eventHandler = getEventHandler(); + const mockEvent = { + __type: EventMessageTypeNames.audit, + eventName: 'n8n.audit.workflow.created', + payload: { workflowName: 'Fake Workflow Name' }, + }; + eventHandler(mockEvent); + + expect(promClient.Counter).toHaveBeenCalledWith({ + name: 'n8n_audit_workflow_created_total', + help: 'Total number of n8n.audit.workflow.created events.', + labelNames: ['workflow_name'], + }); + + expect(promClient.Counter.prototype.inc).toHaveBeenCalledWith( + { workflow_name: 'Fake Workflow Name' }, + 1, + ); + }); + + it('should create a counter with `node_type` label for node events', async () => { + prometheusMetricsService.enableMetric('logs'); + prometheusMetricsService.enableLabels(['nodeType']); + await prometheusMetricsService.init(app); + + const eventHandler = getEventHandler(); + const mockEvent = { + __type: EventMessageTypeNames.node, + eventName: 'n8n.node.execution.started', + payload: { nodeType: 'n8n-nodes-base.if' }, + }; + + eventHandler(mockEvent); + + expect(promClient.Counter).toHaveBeenCalledWith({ + name: 'n8n_node_execution_started_total', + help: 'Total number of n8n.node.execution.started events.', + labelNames: ['node_type'], + }); + + expect(promClient.Counter.prototype.inc).toHaveBeenCalledWith({ node_type: 'base_if' }, 1); + }); + + it('should create a counter with `workflow_id` label for node events', async () => { + prometheusMetricsService.enableMetric('logs'); + prometheusMetricsService.enableLabels(['workflowId']); + await prometheusMetricsService.init(app); + + const eventHandler = getEventHandler(); + const mockEvent = { + __type: EventMessageTypeNames.node, + eventName: 'n8n.node.execution.started', + payload: { workflowId: 'wf_123' }, + }; + + eventHandler(mockEvent); + + expect(promClient.Counter).toHaveBeenCalledWith({ + name: 'n8n_node_execution_started_total', + help: 'Total number of n8n.node.execution.started events.', + labelNames: ['workflow_id'], + }); + + expect(promClient.Counter.prototype.inc).toHaveBeenCalledWith({ workflow_id: 'wf_123' }, 1); + }); + + it('should create a counter with `workflow_name` label for node events', async () => { + prometheusMetricsService.enableMetric('logs'); + prometheusMetricsService.enableLabels(['workflowName']); + await prometheusMetricsService.init(app); + + const eventHandler = getEventHandler(); + const mockEvent = { + __type: EventMessageTypeNames.node, + eventName: 'n8n.node.execution.started', + payload: { workflowName: 'Fake Workflow Name' }, + }; + + eventHandler(mockEvent); + + expect(promClient.Counter).toHaveBeenCalledWith({ + name: 'n8n_node_execution_started_total', + help: 'Total number of n8n.node.execution.started events.', + labelNames: ['workflow_name'], + }); + + expect(promClient.Counter.prototype.inc).toHaveBeenCalledWith( + { workflow_name: 'Fake Workflow Name' }, + 1, + ); + }); + + it('should create a counter with workflow and node type labels for node events', async () => { + prometheusMetricsService.enableMetric('logs'); + prometheusMetricsService.enableLabels(['workflowId', 'workflowName', 'nodeType']); + await prometheusMetricsService.init(app); + + const eventHandler = getEventHandler(); + const mockEvent = { + __type: EventMessageTypeNames.node, + eventName: 'n8n.node.execution.started', + payload: { + workflowId: 'wf_123', + workflowName: 'Fake Workflow Name', + nodeType: 'n8n-nodes-base.if', + }, + }; + + eventHandler(mockEvent); + + expect(promClient.Counter).toHaveBeenCalledWith({ + name: 'n8n_node_execution_started_total', + help: 'Total number of n8n.node.execution.started events.', + labelNames: ['workflow_id', 'workflow_name', 'node_type'], + }); + + expect(promClient.Counter.prototype.inc).toHaveBeenCalledWith( + { + workflow_id: 'wf_123', + workflow_name: 'Fake Workflow Name', + node_type: 'base_if', + }, + 1, + ); + }); + + it('should create a counter with `workflow_id` label for workflow events', async () => { + prometheusMetricsService.enableMetric('logs'); + prometheusMetricsService.enableLabels(['workflowId']); + await prometheusMetricsService.init(app); + + const eventHandler = getEventHandler(); + const mockEvent = { + __type: EventMessageTypeNames.workflow, + eventName: 'n8n.workflow.execution.finished', + payload: { workflowId: 'wf_456' }, + }; + eventHandler(mockEvent); + + expect(promClient.Counter).toHaveBeenCalledWith({ + name: 'n8n_workflow_execution_finished_total', + help: 'Total number of n8n.workflow.execution.finished events.', + labelNames: ['workflow_id'], + }); + + expect(promClient.Counter.prototype.inc).toHaveBeenCalledWith({ workflow_id: 'wf_456' }, 1); + }); + + it('should create a counter with `workflow_name` label for workflow events', async () => { + prometheusMetricsService.enableMetric('logs'); + prometheusMetricsService.enableLabels(['workflowName']); + await prometheusMetricsService.init(app); + + const eventHandler = getEventHandler(); + const mockEvent = { + __type: EventMessageTypeNames.workflow, + eventName: 'n8n.workflow.execution.finished', + payload: { workflowName: 'Fake Workflow Name' }, + }; + eventHandler(mockEvent); + + expect(promClient.Counter).toHaveBeenCalledWith({ + name: 'n8n_workflow_execution_finished_total', + help: 'Total number of n8n.workflow.execution.finished events.', + labelNames: ['workflow_name'], + }); + + expect(promClient.Counter.prototype.inc).toHaveBeenCalledWith( + { workflow_name: 'Fake Workflow Name' }, + 1, + ); + }); + + it('should create a counter with no labels if the corresponding config is disabled', async () => { + prometheusMetricsService.enableMetric('logs'); + await prometheusMetricsService.init(app); + + const eventHandler = getEventHandler(); + const mockEvent = { + __type: EventMessageTypeNames.workflow, + eventName: 'n8n.workflow.execution.finished', + payload: { workflowId: 'wf_789' }, + }; + eventHandler(mockEvent); + + expect(promClient.Counter).toHaveBeenCalledWith({ + name: 'n8n_workflow_execution_finished_total', + help: 'Total number of n8n.workflow.execution.finished events.', + labelNames: [], // Expecting no labels + }); + + expect(promClient.Counter.prototype.inc).toHaveBeenCalledWith({}, 1); + }); + }); }); diff --git a/packages/cli/src/metrics/__tests__/prometheus-metrics.service.unmocked.test.ts b/packages/cli/src/metrics/__tests__/prometheus-metrics.service.unmocked.test.ts index a01b53c8ef..c478fb0c8c 100644 --- a/packages/cli/src/metrics/__tests__/prometheus-metrics.service.unmocked.test.ts +++ b/packages/cli/src/metrics/__tests__/prometheus-metrics.service.unmocked.test.ts @@ -42,6 +42,7 @@ describe('workflow_success_total', () => { prefix: '', includeMessageEventBusMetrics: true, includeWorkflowIdLabel: true, + includeWorkflowNameLabel: false, }, }, }); @@ -76,6 +77,49 @@ workflow_success_total{workflow_id="1234"} 1" `); }); + test('support workflow name labels', async () => { + // ARRANGE + const globalConfig = mockInstance(GlobalConfig, { + endpoints: { + metrics: { + prefix: '', + includeMessageEventBusMetrics: true, + includeWorkflowIdLabel: false, + includeWorkflowNameLabel: true, + }, + }, + }); + + const prometheusMetricsService = new PrometheusMetricsService( + mock(), + eventBus, + globalConfig, + eventService, + instanceSettings, + workflowRepository, + ); + + await prometheusMetricsService.init(app); + + // ACT + const event = new EventMessageWorkflow({ + eventName: 'n8n.workflow.success', + payload: { workflowName: 'wf_1234' }, + }); + + eventBus.emit('metrics.eventBus.event', event); + + // ASSERT + const workflowSuccessCounter = + await promClient.register.getSingleMetricAsString('workflow_success_total'); + + expect(workflowSuccessCounter).toMatchInlineSnapshot(` +"# HELP workflow_success_total Total number of n8n.workflow.success events. +# TYPE workflow_success_total counter +workflow_success_total{workflow_name="wf_1234"} 1" +`); + }); + test('support a custom prefix', async () => { // ARRANGE const globalConfig = mockInstance(GlobalConfig, { diff --git a/packages/cli/src/metrics/prometheus-metrics.service.ts b/packages/cli/src/metrics/prometheus-metrics.service.ts index 9dec9e41d9..b25f75c979 100644 --- a/packages/cli/src/metrics/prometheus-metrics.service.ts +++ b/packages/cli/src/metrics/prometheus-metrics.service.ts @@ -51,6 +51,7 @@ export class PrometheusMetricsService { apiPath: this.globalConfig.endpoints.metrics.includeApiPathLabel, apiMethod: this.globalConfig.endpoints.metrics.includeApiMethodLabel, apiStatusCode: this.globalConfig.endpoints.metrics.includeApiStatusCodeLabel, + workflowName: this.globalConfig.endpoints.metrics.includeWorkflowNameLabel, }, }; @@ -317,32 +318,45 @@ export class PrometheusMetricsService { case EventMessageTypeNames.audit: if (eventName.startsWith('n8n.audit.user.credentials')) { return this.includes.labels.credentialsType - ? { credential_type: (event.payload.credentialType ?? 'unknown').replace(/\./g, '_') } + ? { + credential_type: String( + (event.payload.credentialType ?? 'unknown').replace(/\./g, '_'), + ), + } : {}; } if (eventName.startsWith('n8n.audit.workflow')) { - return this.includes.labels.workflowId - ? { workflow_id: payload.workflowId ?? 'unknown' } - : {}; + return this.buildWorkflowLabels(payload); } break; case EventMessageTypeNames.node: - return this.includes.labels.nodeType - ? { - node_type: (payload.nodeType ?? 'unknown') - .replace('n8n-nodes-', '') - .replace(/\./g, '_'), - } - : {}; + const nodeLabels: Record = this.buildWorkflowLabels(payload); + + if (this.includes.labels.nodeType) { + nodeLabels.node_type = String( + (payload.nodeType ?? 'unknown').replace('n8n-nodes-', '').replace(/\./g, '_'), + ); + } + + return nodeLabels; case EventMessageTypeNames.workflow: - return this.includes.labels.workflowId - ? { workflow_id: payload.workflowId ?? 'unknown' } - : {}; + return this.buildWorkflowLabels(payload); } return {}; } + + private buildWorkflowLabels(payload: any): Record { + const labels: Record = {}; + if (this.includes.labels.workflowId) { + labels.workflow_id = String(payload.workflowId ?? 'unknown'); + } + if (this.includes.labels.workflowName) { + labels.workflow_name = String(payload.workflowName ?? 'unknown'); + } + return labels; + } } diff --git a/packages/cli/src/metrics/types.ts b/packages/cli/src/metrics/types.ts index 3b68d5408a..a998054d3d 100644 --- a/packages/cli/src/metrics/types.ts +++ b/packages/cli/src/metrics/types.ts @@ -4,6 +4,7 @@ export type MetricLabel = | 'credentialsType' | 'nodeType' | 'workflowId' + | 'workflowName' | 'apiPath' | 'apiMethod' | 'apiStatusCode'; diff --git a/packages/cli/test/integration/prometheus-metrics.test.ts b/packages/cli/test/integration/prometheus-metrics.test.ts index 9ca3b2f424..495171df16 100644 --- a/packages/cli/test/integration/prometheus-metrics.test.ts +++ b/packages/cli/test/integration/prometheus-metrics.test.ts @@ -31,6 +31,7 @@ globalConfig.endpoints.metrics = { includeCredentialTypeLabel: false, includeNodeTypeLabel: false, includeWorkflowIdLabel: false, + includeWorkflowNameLabel: false, includeApiPathLabel: true, includeApiMethodLabel: true, includeApiStatusCodeLabel: true,