mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +00:00
feat(core): Add workflow name label to workflow metrics (#16837)
Co-authored-by: Marc Littlemore <marc@n8n.io>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -176,6 +176,7 @@ describe('GlobalConfig', () => {
|
||||
enable: false,
|
||||
prefix: 'n8n_',
|
||||
includeWorkflowIdLabel: false,
|
||||
includeWorkflowNameLabel: false,
|
||||
includeDefaultMetrics: true,
|
||||
includeMessageEventBusMetrics: false,
|
||||
includeNodeTypeLabel: false,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ export type MetricLabel =
|
||||
| 'credentialsType'
|
||||
| 'nodeType'
|
||||
| 'workflowId'
|
||||
| 'workflowName'
|
||||
| 'apiPath'
|
||||
| 'apiMethod'
|
||||
| 'apiStatusCode';
|
||||
|
||||
@@ -31,6 +31,7 @@ globalConfig.endpoints.metrics = {
|
||||
includeCredentialTypeLabel: false,
|
||||
includeNodeTypeLabel: false,
|
||||
includeWorkflowIdLabel: false,
|
||||
includeWorkflowNameLabel: false,
|
||||
includeApiPathLabel: true,
|
||||
includeApiMethodLabel: true,
|
||||
includeApiStatusCodeLabel: true,
|
||||
|
||||
Reference in New Issue
Block a user