feat(core): Add workflow name label to workflow metrics (#16837)

Co-authored-by: Marc Littlemore <marc@n8n.io>
This commit is contained in:
Israel Shenkar
2025-07-08 11:11:03 +03:00
committed by GitHub
parent 4ca9826d7e
commit 0cc54ecf6d
7 changed files with 393 additions and 52 deletions

View File

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

View File

@@ -176,6 +176,7 @@ describe('GlobalConfig', () => {
enable: false,
prefix: 'n8n_',
includeWorkflowIdLabel: false,
includeWorkflowNameLabel: false,
includeDefaultMetrics: true,
includeMessageEventBusMetrics: false,
includeNodeTypeLabel: false,

View File

@@ -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<express.Application>();
const eventBus = mock<MessageEventBus>();
const eventService = mock<EventService>();
const instanceSettings = mock<InstanceSettings>({ instanceType: 'main' });
const workflowRepository = mock<WorkflowRepository>();
const prometheusMetricsService = new PrometheusMetricsService(
mock(),
eventBus,
globalConfig,
eventService,
instanceSettings,
workflowRepository,
);
app = mock<express.Application>();
eventBus = mock<MessageEventBus>();
eventService = mock<EventService>();
instanceSettings = mock<InstanceSettings>({ instanceType: 'main' });
workflowRepository = mock<WorkflowRepository>();
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);
});
});
});

View File

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

View File

@@ -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<string, string> = 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<string, string> {
const labels: Record<string, string> = {};
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;
}
}

View File

@@ -4,6 +4,7 @@ export type MetricLabel =
| 'credentialsType'
| 'nodeType'
| 'workflowId'
| 'workflowName'
| 'apiPath'
| 'apiMethod'
| 'apiStatusCode';

View File

@@ -31,6 +31,7 @@ globalConfig.endpoints.metrics = {
includeCredentialTypeLabel: false,
includeNodeTypeLabel: false,
includeWorkflowIdLabel: false,
includeWorkflowNameLabel: false,
includeApiPathLabel: true,
includeApiMethodLabel: true,
includeApiStatusCodeLabel: true,