feat(core): Include reportTime and lastSuccessfulReport of instance-reporting in module-settings endpoint (no-changelog) (backport to release-candidate/2.39.x) (#38632)

Co-authored-by: Konstantin Tieber <46342664+konstantintieber@users.noreply.github.com>
This commit is contained in:
n8n-assistant[bot] 2026-09-14 15:28:34 +00:00 committed by GitHub
parent c3dcbbef45
commit bb72aa27ee
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 781 additions and 110 deletions

View File

@ -336,6 +336,17 @@ export type FrontendModuleSettings = {
autoExposeNewWorkflows: boolean;
};
/**
* Client settings for the instance-reporting module. Present only when the
* module is enabled on this instance.
*/
'instance-reporting'?: {
/** Whether a receiver is configured, i.e. whether reports are actually sent. */
enabled: boolean;
/** Minute of the UTC day the daily report fires at, as `HH:mm`. Absent when disabled. */
reportTime?: string;
};
/**
* Client settings for the encryption-key-manager module.
*/

View File

@ -11,6 +11,7 @@ export type * from './community-node-types';
export type * from './quick-connect';
export * from './agents/index';
export * from './instance-registry-types';
export type * from './instance-reporting';
export type * from './worker-pools';
export * from './redaction-enforcement';
export * from './redaction-enforcement-floor';

View File

@ -0,0 +1,7 @@
export type InstanceReportingStatus = {
/**
* When the receiver last accepted a report, as an ISO 8601 UTC string, or
* `null` when it never did.
*/
lastSuccessfulReport: string | null;
};

View File

@ -419,7 +419,7 @@ export default defineConfig(
'./src/license.ts',
'./src/modules/agents/integrations/n8n-checkpoint-storage.ts',
'./src/modules/instance-ai/instance-ai.service.ts',
'./src/modules/instance-reporting/instance-reporting-scheduler.service.ts',
'./src/modules/instance-reporting.ee/instance-reporting-scheduler.service.ts',
'./src/services/pruning/executions-pruning.service.ts',
'./src/services/pruning/workflow-history-compaction.service.ts',
'./src/services/workflow-statistics-rollup.service.ts',

View File

@ -85,7 +85,7 @@ there.
| Env var | Default | Notes |
|---|---|---|
| `N8N_INSTANCE_REPORTING_BASE_URL` | `''` | Base URL of the receiver. The report is POSTed to `<base>/api/v1/instance-reports`. Left unset, the module loads but warns and never sends. |
| `N8N_INSTANCE_REPORTING_BASE_URL` | `''` | Base URL of the receiver. The report is POSTed to `<base>/api/v1/instance-reports`. Left unset, the module loads but warns and never sends: it starts no scheduler and claims no report time. |
| `N8N_INSTANCE_REPORTING_LABEL` | `''` | Sent as `label` in the payload, when set. |
| `N8N_INSTANCE_REPORTING_AUTH_TOKEN` | `''` | Sent as `Authorization: Bearer …`, when set. |
@ -99,5 +99,38 @@ instance calling at the same minute. The time is always UTC and never before
first; it shifts itself later, logging a warning, if
`N8N_INSIGHTS_COMPACTION_INTERVAL_MINUTES` is raised enough to require it.
## Client settings
`GET /rest/module-settings` carries an `instance-reporting` key when the module
is enabled on this instance:
```json
{
"instance-reporting": {
"enabled": true,
"reportTime": "07:42"
}
}
```
`enabled` says whether a receiver is configured. Without one the key reads
`{ "enabled": false }` and carries no `reportTime`, since no time is claimed. A
missing key means the module is not enabled at all.
These settings are built once, during module init, and served from a cache
afterwards, so they carry only values that stay the same for the lifetime of
the process.
## Reporting status
`GET /rest/instance-reporting/status` answers with the UTC instant the receiver
last accepted a report, or `null` when it never did:
```json
{ "lastSuccessfulReport": "2026-03-25T07:42:13.000Z" }
```
The route exists whenever the module is loaded, including without a receiver — the client decides whether to ask by reading `enabled` from the client settings.
See [.agents/specs/central-instance-monitoring.md](../../../../../.agents/specs/central-instance-monitoring.md)
for the full design.

View File

@ -1,8 +1,6 @@
import type { ModuleName, ModulesConfig } from '@n8n/backend-common';
import { mockLogger } from '@n8n/backend-test-utils';
import { Time } from '@n8n/constants';
import type { InstanceSettings } from 'n8n-core';
import { UserError } from 'n8n-workflow';
import type { Mocked } from 'vitest';
import { mock } from 'vitest-mock-extended';
@ -11,7 +9,6 @@ import type { EventService } from '@/events/event.service';
import type { InstanceMonitoringReportRepository } from '../database/repositories/instance-monitoring-report.repository';
import { InstanceReportingScheduler } from '../instance-reporting-scheduler.service';
import type { InstanceReportingSettingsService } from '../instance-reporting-settings.service';
import { InstanceReportingConfig } from '../instance-reporting.config';
import type { InstanceReportingService } from '../instance-reporting.service';
const REPORT_TIME = '07:42';
@ -28,24 +25,18 @@ interface Harness {
reportRepository: Mocked<InstanceMonitoringReportRepository>;
settingsService: Mocked<InstanceReportingSettingsService>;
instanceSettings: Mocked<InstanceSettings>;
modulesConfig: Mocked<ModulesConfig>;
eventService: Mocked<EventService>;
/** Fire the deferred `server-started` listeners (only needed when autoStart is false). */
startServer: () => void;
}
function makeHarness({
baseUrl = 'https://example.com',
isLeader = true,
disabledModules = [] as ModuleName[],
// The scheduler arms its first tick on `server-started`. By default the harness
// fires that listener the moment it is registered, so `init()` starts reporting
// as the tests expect. Pass false to hold it and fire it later via `startServer`.
autoStart = true,
} = {}): Harness {
const config = new InstanceReportingConfig();
config.instanceReportingBaseUrl = baseUrl;
const reportingService = mock<InstanceReportingService>();
// No attempt has been made yet, so nothing is holding the next one back.
reportingService.msUntilRetryAllowed.mockResolvedValue(0);
@ -61,7 +52,6 @@ function makeHarness({
instanceRole: isLeader ? 'leader' : 'follower',
isLeader,
});
const modulesConfig = mock<ModulesConfig>({ disabledModules });
const serverStartedListeners: Array<() => void> = [];
const eventService = mock<EventService>();
@ -81,12 +71,10 @@ function makeHarness({
});
const scheduler = new InstanceReportingScheduler(
config,
reportingService,
reportRepository,
settingsService,
instanceSettings,
modulesConfig,
eventService,
mockLogger(),
);
@ -97,7 +85,6 @@ function makeHarness({
reportRepository,
settingsService,
instanceSettings,
modulesConfig,
eventService,
startServer: () => serverStartedListeners.forEach((listener) => listener()),
};
@ -120,25 +107,10 @@ describe('InstanceReportingScheduler', () => {
});
describe('init', () => {
test('fails when the insights module is disabled', async () => {
const { scheduler } = makeHarness({ disabledModules: ['insights'] });
await expect(scheduler.init()).rejects.toThrow(UserError);
});
test('warns and stays idle when no base URL is configured', async () => {
const { scheduler, reportingService } = makeHarness({ baseUrl: '' });
await scheduler.init();
await vi.advanceTimersByTimeAsync(Time.days.toMilliseconds);
expect(reportingService.sendReport).not.toHaveBeenCalled();
});
test('does not start a timer on a follower', async () => {
const { scheduler, reportingService } = makeHarness({ isLeader: false });
await scheduler.init();
scheduler.init();
await vi.advanceTimersByTimeAsync(Time.days.toMilliseconds);
expect(reportingService.sendReport).not.toHaveBeenCalled();
@ -147,7 +119,7 @@ describe('InstanceReportingScheduler', () => {
test('fires at the configured time on the leader', async () => {
const { scheduler, reportingService } = makeHarness();
await scheduler.init();
scheduler.init();
await settle();
expect(reportingService.sendReport).not.toHaveBeenCalled();
@ -160,7 +132,7 @@ describe('InstanceReportingScheduler', () => {
test('fires once a day thereafter', async () => {
const { scheduler, reportingService } = makeHarness();
await scheduler.init();
scheduler.init();
await vi.advanceTimersByTimeAsync(3 * Time.days.toMilliseconds);
expect(reportingService.sendReport).toHaveBeenCalledTimes(3);
@ -172,7 +144,7 @@ describe('InstanceReportingScheduler', () => {
vi.setSystemTime(new Date(AFTER_SLOT));
const { scheduler, reportingService, startServer } = makeHarness({ autoStart: false });
await scheduler.init();
scheduler.init();
await settle();
expect(reportingService.sendReport).not.toHaveBeenCalled();
@ -188,7 +160,7 @@ describe('InstanceReportingScheduler', () => {
vi.setSystemTime(new Date(AFTER_SLOT));
const { scheduler, reportingService } = makeHarness();
await scheduler.init();
scheduler.init();
await settle();
expect(reportingService.sendReport).toHaveBeenCalledTimes(1);
@ -199,7 +171,7 @@ describe('InstanceReportingScheduler', () => {
const { scheduler, reportingService, reportRepository } = makeHarness();
reportRepository.hasSettledToday.mockResolvedValue(true);
await scheduler.init();
scheduler.init();
await settle();
expect(reportingService.sendReport).not.toHaveBeenCalled();
@ -209,7 +181,7 @@ describe('InstanceReportingScheduler', () => {
vi.setSystemTime(new Date(AFTER_SLOT));
const { scheduler, reportingService, reportRepository } = makeHarness();
await scheduler.init();
scheduler.init();
await settle();
// The catch-up delivered it, so the rest of the day has nothing to do.
reportRepository.hasSettledToday.mockResolvedValue(true);
@ -224,7 +196,7 @@ describe('InstanceReportingScheduler', () => {
vi.setSystemTime(new Date(AFTER_SLOT));
const { scheduler, reportingService, instanceSettings } = makeHarness({ isLeader: false });
await scheduler.init();
scheduler.init();
await settle();
expect(reportingService.sendReport).not.toHaveBeenCalled();
@ -246,7 +218,7 @@ describe('InstanceReportingScheduler', () => {
vi.setSystemTime(new Date(AFTER_SLOT));
const { scheduler, reportingService, startServer } = makeHarness({ autoStart: false });
await scheduler.init();
scheduler.init();
scheduler.stop();
startServer();
await settle();
@ -257,7 +229,7 @@ describe('InstanceReportingScheduler', () => {
test('stops reporting when this main steps down', async () => {
const { scheduler, reportingService } = makeHarness();
await scheduler.init();
scheduler.init();
await settle();
scheduler.stop();
await vi.advanceTimersByTimeAsync(2 * Time.days.toMilliseconds);
@ -268,7 +240,7 @@ describe('InstanceReportingScheduler', () => {
test('shutdown stops the timer and blocks a later takeover', async () => {
const { scheduler, reportingService } = makeHarness();
await scheduler.init();
scheduler.init();
await settle();
scheduler.shutdown();
scheduler.start();
@ -284,7 +256,7 @@ describe('InstanceReportingScheduler', () => {
const { scheduler, reportingService } = makeHarness();
reportingService.sendReport.mockRejectedValueOnce(new Error('Network error'));
await scheduler.init();
scheduler.init();
await settle();
expect(reportingService.sendReport).toHaveBeenCalledTimes(1);
@ -303,7 +275,7 @@ describe('InstanceReportingScheduler', () => {
async () => reportingService.sendReport.mock.calls.length >= 3,
);
await scheduler.init();
scheduler.init();
await vi.advanceTimersByTimeAsync(Time.hours.toMilliseconds);
expect(reportingService.sendReport).toHaveBeenCalledTimes(3);
@ -316,7 +288,7 @@ describe('InstanceReportingScheduler', () => {
const { scheduler, reportingService } = makeHarness();
reportingService.msUntilRetryAllowed.mockResolvedValueOnce(2 * Time.minutes.toMilliseconds);
await scheduler.init();
scheduler.init();
await settle();
expect(reportingService.sendReport).not.toHaveBeenCalled();
@ -329,7 +301,7 @@ describe('InstanceReportingScheduler', () => {
const { scheduler, settingsService } = makeHarness();
settingsService.getReportTime.mockRejectedValueOnce(new Error('DB down'));
await scheduler.init();
scheduler.init();
await settle();
expect(settingsService.getReportTime).toHaveBeenCalledTimes(1);
@ -343,7 +315,7 @@ describe('InstanceReportingScheduler', () => {
test('a fire before the slot reports nothing and re-arms', async () => {
const { scheduler, reportingService } = makeHarness();
await scheduler.init();
scheduler.init();
await settle();
// Clock jumps backwards an hour, well before the slot.
vi.setSystemTime(new Date('2026-03-26T06:41:00.000Z'));

View File

@ -0,0 +1,28 @@
import { mock } from 'vitest-mock-extended';
import type { InstanceMonitoringReportRepository } from '../database/repositories/instance-monitoring-report.repository';
import { InstanceReportingController } from '../instance-reporting.controller';
const LAST_DELIVERY = new Date('2026-03-25T07:42:13.000Z');
describe('InstanceReportingController', () => {
describe('getStatus()', () => {
it('reports when the receiver last accepted a report', async () => {
const reportRepository = mock<InstanceMonitoringReportRepository>();
reportRepository.findLastDeliveryTime.mockResolvedValue(LAST_DELIVERY);
const status = await new InstanceReportingController(reportRepository).getStatus();
expect(status).toEqual({ lastSuccessfulReport: LAST_DELIVERY.toISOString() });
});
it('reports a null last delivery until the receiver accepts a report', async () => {
const reportRepository = mock<InstanceMonitoringReportRepository>();
reportRepository.findLastDeliveryTime.mockResolvedValue(null);
const status = await new InstanceReportingController(reportRepository).getStatus();
expect(status).toEqual({ lastSuccessfulReport: null });
});
});
});

View File

@ -0,0 +1,115 @@
import type { FrontendModuleSettings } from '@n8n/api-types';
import type { ModuleName } from '@n8n/backend-common';
import { Logger, ModulesConfig } from '@n8n/backend-common';
import { mockLogger } from '@n8n/backend-test-utils';
import type { Controller } from '@n8n/decorators';
import { ControllerRegistryMetadata, ModuleMetadata } from '@n8n/decorators';
import { Container } from '@n8n/di';
import { UserError } from 'n8n-workflow';
import { mock } from 'vitest-mock-extended';
import { InstanceMonitoringReportRepository } from '../database/repositories/instance-monitoring-report.repository';
import { InstanceReportingScheduler } from '../instance-reporting-scheduler.service';
import { InstanceReportingSettingsService } from '../instance-reporting-settings.service';
import { InstanceReportingConfig } from '../instance-reporting.config';
import { InstanceReportingModule } from '../instance-reporting.module';
// Compile-time pin: the settings this module returns are read by the frontend
// under this exact key, so the decorator name must stay a member of
// `FrontendModuleSettings`.
const MODULE_NAME = 'instance-reporting' satisfies keyof FrontendModuleSettings;
const REPORT_TIME = '07:42';
const LAST_DELIVERY = new Date('2026-03-25T07:42:13.000Z');
function setUpContainer({
baseUrl = 'https://example.com',
disabledModules = [] as ModuleName[],
lastDelivery = LAST_DELIVERY as Date | null,
} = {}) {
const config = new InstanceReportingConfig();
config.instanceReportingBaseUrl = baseUrl;
Container.set(InstanceReportingConfig, config);
Container.set(ModulesConfig, mock<ModulesConfig>({ disabledModules }));
Container.set(Logger, mockLogger());
const settingsService = mock<InstanceReportingSettingsService>();
settingsService.getReportTime.mockResolvedValue(REPORT_TIME);
Container.set(InstanceReportingSettingsService, settingsService);
const reportRepository = mock<InstanceMonitoringReportRepository>();
reportRepository.findLastDeliveryTime.mockResolvedValue(lastDelivery);
Container.set(InstanceMonitoringReportRepository, reportRepository);
const scheduler = mock<InstanceReportingScheduler>();
Container.set(InstanceReportingScheduler, scheduler);
return { settingsService, reportRepository, scheduler };
}
describe('InstanceReportingModule', () => {
describe('settings()', () => {
it('reports the time the instance is due to report at', async () => {
setUpContainer();
const settings = await new InstanceReportingModule().settings();
expect(settings).toEqual({ enabled: true, reportTime: REPORT_TIME });
});
it('reports as disabled, claiming no time and reading nothing, without a receiver', async () => {
const { settingsService, reportRepository } = setUpContainer({ baseUrl: '' });
const settings = await new InstanceReportingModule().settings();
expect(settings).toEqual({ enabled: false });
expect(settingsService.getReportTime).not.toHaveBeenCalled();
expect(reportRepository.findLastDeliveryTime).not.toHaveBeenCalled();
});
});
describe('init()', () => {
it('starts the scheduler', async () => {
const { scheduler } = setUpContainer();
await new InstanceReportingModule().init();
expect(scheduler.init).toHaveBeenCalled();
});
it('fails when the insights module is disabled', async () => {
setUpContainer({ disabledModules: ['insights'] });
await expect(new InstanceReportingModule().init()).rejects.toThrow(UserError);
});
it('leaves the scheduler alone when no receiver is configured', async () => {
const { scheduler } = setUpContainer({ baseUrl: '' });
await new InstanceReportingModule().init();
expect(scheduler.init).not.toHaveBeenCalled();
});
// The route belongs to the loaded module, not to the receiver, so a client
// that asks anyway gets an answer instead of a 404.
it('registers the status route even when no receiver is configured', async () => {
setUpContainer({ baseUrl: '' });
await new InstanceReportingModule().init();
const { InstanceReportingController } = await import('../instance-reporting.controller.js');
const metadata = Container.get(ControllerRegistryMetadata).getControllerMetadata(
InstanceReportingController as Controller,
);
expect(metadata.basePath).toBe('/instance-reporting');
expect(metadata.routes.get('getStatus')?.path).toBe('/status');
});
});
it('is registered under the module-settings key the frontend reads', () => {
expect(Container.get(ModuleMetadata).get(MODULE_NAME)?.class).toBe(InstanceReportingModule);
});
});

View File

@ -166,6 +166,43 @@ describe('InstanceMonitoringReportRepository', () => {
});
});
describe('findLastDeliveryTime', () => {
test('is null when nothing was ever delivered', async () => {
await repository.createPending(DATA_POINTS);
await expect(repository.findLastDeliveryTime()).resolves.toBeNull();
});
test('returns the latest delivery time across delivered reports', async () => {
const earlier = await repository.createPending(DATA_POINTS);
await repository.markDelivered(earlier.id, new Date('2026-03-24T07:42:00.000Z'));
const latest = await repository.createPending(DATA_POINTS);
const latestDeliveredAt = new Date('2026-03-25T07:42:13.000Z');
await repository.markDelivered(latest.id, latestDeliveredAt);
const lastDelivery = await repository.findLastDeliveryTime();
expect(lastDelivery?.toISOString()).toBe(latestDeliveredAt.toISOString());
});
test('ignores reports that never reached the receiver', async () => {
const delivered = await repository.createPending(DATA_POINTS);
const deliveredAt = new Date('2026-03-24T07:42:00.000Z');
await repository.markDelivered(delivered.id, deliveredAt);
const failed = await repository.createPending(DATA_POINTS);
await repository.recordFailure(failed.id, 'Network error', new Date());
const skipped = await repository.createPending(DATA_POINTS);
await repository.markSkipped(skipped.id);
await repository.createPending(DATA_POINTS);
const lastDelivery = await repository.findLastDeliveryTime();
expect(lastDelivery?.toISOString()).toBe(deliveredAt.toISOString());
});
});
describe('markDelivered', () => {
test('stamps the delivery time, counts the attempt and clears any earlier error', async () => {
const { id } = await repository.createPending(DATA_POINTS);

View File

@ -1,5 +1,5 @@
import { Service } from '@n8n/di';
import { DataSource, MoreThanOrEqual, Not, Repository } from '@n8n/typeorm';
import { DataSource, IsNull, MoreThanOrEqual, Not, Repository } from '@n8n/typeorm';
import { v4 as uuid } from 'uuid';
import type { InstanceReportDataPoint } from '../entities/instance-monitoring-report';
@ -85,6 +85,19 @@ export class InstanceMonitoringReportRepository extends Repository<InstanceMonit
return days.length ? days.reduce((a, b) => (a > b ? a : b)) : null;
}
/**
* When the receiver last accepted a report, or `null` when it never did.
*/
async findLastDeliveryTime(): Promise<Date | null> {
const report = await this.findOne({
where: { status: 'delivered', deliveredAt: Not(IsNull()) },
order: { deliveredAt: 'DESC' },
select: ['deliveredAt'],
});
return report?.deliveredAt ?? null;
}
async markDelivered(id: string, deliveredAt: Date): Promise<void> {
await this.increment({ id }, 'attempts', 1);
await this.update(

View File

@ -1,18 +1,16 @@
import { Logger, ModulesConfig } from '@n8n/backend-common';
import { Logger } from '@n8n/backend-common';
import { Time } from '@n8n/constants';
import { OnLeaderStepdown, OnLeaderTakeover, OnShutdown } from '@n8n/decorators';
import { Service } from '@n8n/di';
import { InstanceSettings } from 'n8n-core';
import { UserError } from 'n8n-workflow';
import { strict } from 'node:assert';
import { EventService } from '@/events/event.service';
import { InstanceMonitoringReportRepository } from './database/repositories/instance-monitoring-report.repository';
import { InstanceReportingSettingsService } from './instance-reporting-settings.service';
import { InstanceReportingConfig } from './instance-reporting.config';
import { InstanceReportingService, RETRY_DELAY_MS } from './instance-reporting.service';
import { EventService } from '@/events/event.service';
const MINUTES_PER_DAY = 24 * 60;
/**
@ -43,12 +41,10 @@ export class InstanceReportingScheduler {
private readonly onServerStarted = () => this.start();
constructor(
private readonly config: InstanceReportingConfig,
private readonly reportingService: InstanceReportingService,
private readonly reportRepository: InstanceMonitoringReportRepository,
private readonly settingsService: InstanceReportingSettingsService,
private readonly instanceSettings: InstanceSettings,
private readonly modulesConfig: ModulesConfig,
private readonly eventService: EventService,
private readonly logger: Logger,
) {
@ -56,29 +52,16 @@ export class InstanceReportingScheduler {
}
/**
* Validate what the module needs, then start reporting if this instance leads.
* Start reporting if this instance leads.
*
* Runs from the module entrypoint, which the registry calls after
* Runs from the module entrypoint, which checks that the module has what it
* needs before it loads this class at all, and which the registry calls after
* `initOrchestration` has settled this instance's role, so `isLeader` is
* already meaningful here.
*/
async init(): Promise<void> {
init(): void {
strict(this.instanceSettings.instanceRole !== 'unset', 'Instance role is not set');
// The daily figure is read from insights, so the reporter cannot run without it.
if (this.modulesConfig.disabledModules.includes('insights')) {
throw new UserError(
'The `instance-reporting` module requires the `insights` module, but it is listed in N8N_DISABLED_MODULES. Remove `insights` from N8N_DISABLED_MODULES or remove `instance-reporting` from N8N_ENABLED_MODULES.',
);
}
if (!this.config.instanceReportingBaseUrl) {
this.logger.warn(
'Instance reporting is enabled but N8N_INSTANCE_REPORTING_BASE_URL is unset, so no reports will be sent',
);
return;
}
// Defer the first tick until the server has finished starting. A boot
// catch-up report can send right away, but log streaming module was not properly initialized
if (this.instanceSettings.isLeader) {
@ -87,11 +70,7 @@ export class InstanceReportingScheduler {
}
get isEnabled(): boolean {
return (
this.instanceSettings.instanceType === 'main' &&
this.instanceSettings.isLeader &&
this.config.instanceReportingBaseUrl !== ''
);
return this.instanceSettings.instanceType === 'main' && this.instanceSettings.isLeader;
}
/**

View File

@ -0,0 +1,16 @@
import type { InstanceReportingStatus } from '@n8n/api-types';
import { Get, RestController } from '@n8n/decorators';
import { InstanceMonitoringReportRepository } from './database/repositories/instance-monitoring-report.repository';
@RestController('/instance-reporting')
export class InstanceReportingController {
constructor(private readonly reportRepository: InstanceMonitoringReportRepository) {}
@Get('/status')
async getStatus(): Promise<InstanceReportingStatus> {
const lastDelivery = await this.reportRepository.findLastDeliveryTime();
return { lastSuccessfulReport: lastDelivery?.toISOString() ?? null };
}
}

View File

@ -0,0 +1,84 @@
import { Logger, ModulesConfig } from '@n8n/backend-common';
import type { ModuleInterface } from '@n8n/decorators';
import { BackendModule } from '@n8n/decorators';
import { Container } from '@n8n/di';
import { UserError } from 'n8n-workflow';
/**
* Reports this instance's billable execution numbers to a central monitoring
* receiver, once a day.
*
* Opt-in: not a default module, so it runs only when an operator lists it in
* `N8N_ENABLED_MODULES`. Main-only, and within a multi-main deployment only the
* leader holds the timer, so a cluster reports once rather than once per main.
*
* The daily figure comes from the insights module, but the receiver only sees
* data points, so that source is an implementation detail of
* `InstanceReportingService` rather than part of the reporting contract.
*/
@BackendModule({ name: 'instance-reporting', instanceTypes: ['main'] })
export class InstanceReportingModule implements ModuleInterface {
async init() {
// Imported before the receiver check, so the route exists whenever the
// module is loaded. The client gates off `enabled` in the module settings.
await import('./instance-reporting.controller.js');
// The daily figure is read from insights, so the reporter cannot run without it.
if (Container.get(ModulesConfig).disabledModules.includes('insights')) {
throw new UserError(
'The `instance-reporting` module requires the `insights` module, but it is listed in N8N_DISABLED_MODULES. Remove `insights` from N8N_DISABLED_MODULES or remove `instance-reporting` from N8N_ENABLED_MODULES.',
);
}
if (!(await this.isConfigured())) {
Container.get(Logger)
.scoped('instance-reporting')
.warn(
'Instance reporting is enabled but N8N_INSTANCE_REPORTING_BASE_URL is unset, so no reports will be sent',
);
return;
}
const { InstanceReportingScheduler } = await import(
'./instance-reporting-scheduler.service.js'
);
Container.get(InstanceReportingScheduler).init();
}
/**
* Settings exposed to the frontend under `/rest/module-settings`.
*
* Return values:
* { enabled: false } - module loaded but no receiver configured
* { enabled: true, reportTime: 'HH:mm' } - module loaded and receiver configured
*
* Built once at startup and cached for the process lifetime.
**/
async settings() {
if (!(await this.isConfigured())) return { enabled: false };
const { InstanceReportingSettingsService } = await import(
'./instance-reporting-settings.service.js'
);
const reportTime = await Container.get(InstanceReportingSettingsService).getReportTime();
return { enabled: true, reportTime };
}
async entities() {
const { InstanceMonitoringReport } = await import(
'./database/entities/instance-monitoring-report.js'
);
return [InstanceMonitoringReport];
}
/** Whether a receiver is configured, i.e. whether reports are actually sent. */
private async isConfigured(): Promise<boolean> {
const { InstanceReportingConfig } = await import('./instance-reporting.config.js');
return Container.get(InstanceReportingConfig).instanceReportingBaseUrl !== '';
}
}

View File

@ -1,34 +0,0 @@
import type { ModuleInterface } from '@n8n/decorators';
import { BackendModule } from '@n8n/decorators';
import { Container } from '@n8n/di';
/**
* Reports this instance's billable execution numbers to a central monitoring
* receiver, once a day.
*
* Opt-in: not a default module, so it runs only when an operator lists it in
* `N8N_ENABLED_MODULES`. Main-only, and within a multi-main deployment only the
* leader holds the timer, so a cluster reports once rather than once per main.
*
* The daily figure comes from the insights module, but the receiver only sees
* data points, so that source is an implementation detail of
* `InstanceReportingService` rather than part of the reporting contract.
*/
@BackendModule({ name: 'instance-reporting', instanceTypes: ['main'] })
export class InstanceReportingModule implements ModuleInterface {
async init() {
const { InstanceReportingScheduler } = await import(
'./instance-reporting-scheduler.service.js'
);
await Container.get(InstanceReportingScheduler).init();
}
async entities() {
const { InstanceMonitoringReport } = await import(
'./database/entities/instance-monitoring-report.js'
);
return [InstanceMonitoringReport];
}
}

View File

@ -6,6 +6,7 @@ export * from './credentialResolvers';
export * from './ctas';
export * from './eventbus.ee';
export * from './instance-registry';
export * from './instance-reporting';
export * from './instance-version-history';
export * from './events';
export * from './externalSecrets.ee';

View File

@ -0,0 +1,10 @@
import type { InstanceReportingStatus } from '@n8n/api-types';
import type { IRestApiContext } from '../types';
import { makeRestApiRequest } from '../utils';
export async function getInstanceReportingStatus(
context: IRestApiContext,
): Promise<InstanceReportingStatus> {
return await makeRestApiRequest(context, 'GET', '/instance-reporting/status');
}

View File

@ -0,0 +1,398 @@
{
"packages/@n8n/agents": [
"src/__tests__/agent-configuration.test.ts",
"src/__tests__/integration/agent-vector-store-pinecone.test.ts",
"src/evals/categorization.ts"
],
"packages/@n8n/ai-node-sdk": ["src/index.ts"],
"packages/@n8n/ai-utilities": [
"integration-tests/openai.test.ts",
"src/__tests__/adapters/langchain-chat-model.test.ts",
"src/adapters/langchain-chat-model.ts"
],
"packages/@n8n/ai-workflow-builder.ee": [
"evaluations/__tests__/argument-parser.test.ts",
"src/agents/planner.agent.ts",
"src/test/ai-workflow-builder-agent.service.test.ts"
],
"packages/@n8n/api-types": [
"src/__tests__/agent-builder-interactive.test.ts",
"src/agent-builder-interactive.ts"
],
"packages/@n8n/backend-common": ["src/__tests__/cli-parser.test.ts", "src/cli-parser.ts"],
"packages/@n8n/backend-network": [
"src/dns/__tests__/dns-resolver.test.ts",
"src/dns/dns-resolver.ts"
],
"packages/@n8n/backend-test-utils": ["src/db/projects.ts"],
"packages/@n8n/benchmark": ["src/commands/list.ts"],
"packages/@n8n/blob-storage": [
"src/__tests__/azure-byte-store.test.ts",
"src/azure-blob/azure-blob.config.ts"
],
"packages/@n8n/chat-hub": ["src/artifact.test.ts", "src/artifact.ts"],
"packages/@n8n/cli": [
"src/__tests__/client.test.ts",
"src/base-command.ts",
"src/client.ts",
"src/commands/login.ts",
"src/index.ts"
],
"packages/@n8n/client-oauth2": ["src/client-oauth2-token.ts", "test/client-oauth2.test.ts"],
"packages/@n8n/codemirror-lang-sql": ["src/complete.ts", "test/complete.test.ts"],
"packages/@n8n/codemirror-lang": [
"src/expressions/grammar.terms.ts",
"test/expressions/expressions.test.ts"
],
"packages/@n8n/computer-use": [
"src/__mocks__/@inquirer/prompts.ts",
"src/config-templates.test.ts"
],
"packages/@n8n/config": [
"src/configs/__tests__/ai.config.test.ts",
"src/configs/activity-log.config.ts"
],
"packages/@n8n/constants": ["src/api.ts"],
"packages/@n8n/crdt": [
"src/__tests__/helpers.ts",
"src/awareness/awareness.test.ts",
"src/awareness/index.ts"
],
"packages/@n8n/db": [
"src/connection/__tests__/backoff.test.ts",
"src/connection/backoff.ts",
"src/migrations/__tests__/migration-helpers.ts"
],
"packages/@n8n/decorators": [
"src/__tests__/memoized.test.ts",
"src/auth-handler/auth-handler-metadata.ts"
],
"packages/@n8n/di": ["src/__tests__/circular-depedency.test.ts", "src/di.ts"],
"packages/@n8n/engine": [
"src/admittance/__tests__/allow-all-admittance.test.ts",
"src/admittance/admittance-rejected.error.ts"
],
"packages/@n8n/errors": ["src/application.error.ts"],
"packages/@n8n/eslint-plugin-community-nodes": [
"src/plugin.ts",
"src/rules/ai-node-package-json.test.ts"
],
"packages/@n8n/extension-sdk": ["src/backend/define.ts"],
"packages/@n8n/imap": ["src/connection-options.test.ts", "src/connection-options.ts"],
"packages/@n8n/instance-ai": [
"evaluations/__tests__/agent-report-rendering.test.ts",
"evaluations/computer-use/report-html.ts",
"src/__tests__/binary-checks.test.ts",
"src/agent/aia-model-defaults.ts",
"src/tools/__tests__/activity.tool.test.ts"
],
"packages/@n8n/json-schema-to-zod": ["src/index.ts", "test/json-schema-to-zod.test.ts"],
"packages/@n8n/local-gateway": ["src/main/connect-origin.test.ts", "src/main/connect-origin.ts"],
"packages/@n8n/mcp-apps": [
"src/apps-manifest.ts",
"src/apps/workflow-preview/App.vue",
"src/apps/workflow-preview/composables/use-workflow-preview.test.ts"
],
"packages/@n8n/mcp-browser-extension": [
"src/approvedHosts.test.ts",
"src/approvedHosts.ts",
"src/ui/App.vue"
],
"packages/@n8n/mcp-browser": [
"src/__tests__/cdp-relay-attach.test.ts",
"src/adapters/agent-browser.ts"
],
"packages/@n8n/node-cli": [
"src/commands/build.test.ts",
"src/commands/build.ts",
"src/configs/eslint.ts",
"src/modules.d.ts",
"src/test-utils/command-tester.ts"
],
"packages/@n8n/node-engine-compatibility": [
"src/__tests__/acceptance-fixtures.ts",
"src/__tests__/engine-step-data-loader.test.ts",
"src/constants.ts"
],
"packages/@n8n/nodes-langchain": [
"credentials/AlibabaCloudApi.credentials.ts",
"credentials/test/AnthropicApi.credentials.test.ts",
"nodes/Guardrails/Guardrails.node.ts",
"nodes/Guardrails/helpers/__tests__/model.test.ts",
"nodes/mcp/McpClient/__test__/McpClient.node.test.ts"
],
"packages/@n8n/permissions": ["src/__tests__/schemas.test.ts", "src/constants.ee.ts"],
"packages/@n8n/scheduler": ["src/__tests__/dependency-purity.test.ts", "src/core/clock-skew.ts"],
"packages/@n8n/syslog-client": ["src/client.ts", "test/client.test.ts", "vite.config.ts"],
"packages/@n8n/task-runner": [
"src/__tests__/node-types.test.ts",
"src/config/base-runner-config.ts"
],
"packages/@n8n/telemetry": ["src/__tests__/catalog.test.ts", "src/catalog.ts"],
"packages/@n8n/tournament": ["src/Constants.ts", "test/ASTHooks.test.ts"],
"packages/@n8n/utils": [
"src/__tests__/setup.ts",
"src/assert.ts",
"src/client-assertion.test.ts"
],
"packages/@n8n/workflow-sdk": [
"src/__tests__/fixtures-download.ts",
"src/ast-interpreter/errors.ts",
"src/ast-interpreter/interpreter.test.ts",
"src/prompts/best-practices/guides/chatbot.ts",
"src/prompts/node-guidance/parameter-guides/embedding-nodes.ts"
],
"packages/cli": [
"src/__tests__/abstract-server.test.ts",
"src/abstract-server.ts",
"src/active-workflow-manager.ts",
"src/binary-data/database.manager.ts",
"src/commands/db/revert.ts",
"src/commands/import/credentials.ts",
"src/commands/ldap/reset.ts",
"src/controllers/project.controller.ts",
"src/credentials-helper.ts",
"src/credentials/credential-connection-status-provider.interface.ts",
"src/credentials/credential-connection-status-proxy.ts",
"src/credentials/credential-dependency.service.ts",
"src/credentials/credentials-finder.service.ts",
"src/credentials/credentials.controller.ts",
"src/credentials/credentials.service.ee.ts",
"src/credentials/credentials.service.ts",
"src/databases/repositories/__tests__/credentials.repository.test.ts",
"src/evaluation.ee/evaluation-collection.service.ts",
"src/evaluation.ee/test-runner/test-runner.service.ee.ts",
"src/eventbus/message-event-bus/message-event-bus.ts",
"src/events/relays/telemetry.event-relay.ts",
"src/executions/execution-data/db-store.ts",
"src/executions/execution-persistence.ts",
"src/executions/execution-recovery.service.ts",
"src/executions/execution.service.ts",
"src/instance-settings-loader/loaders/log-streaming.instance-settings-loader.ts",
"src/license.ts",
"src/metrics/prometheus/instance-role-metrics.service.ts",
"src/modules/agent-evals/agent-evals.module.ts",
"src/modules/agents/agent-knowledge.service.ts",
"src/modules/agents/agent-publish.service.ts",
"src/modules/agents/agent-task.service.ts",
"src/modules/agents/agents.service.ts",
"src/modules/agents/builder/agents-builder.service.ts",
"src/modules/agents/entities/agent-background-job.entity.ts",
"src/modules/agents/instance-ai-builder-delegate.adapter.ts",
"src/modules/agents/integrations/agent-channel-reconciler.service.ts",
"src/modules/agents/integrations/leader-channel-relay.service.ts",
"src/modules/agents/integrations/n8n-memory.ts",
"src/modules/agents/integrations/platforms/discord-integration.ts",
"src/modules/agents/repositories/agent-background-job.repository.ts",
"src/modules/agents/tools/workflow-tool-workflow-resolver.ts",
"src/modules/breaking-changes/breaking-changes.service.ts",
"src/modules/chat-hub/chat-hub-agent.service.ts",
"src/modules/chat-hub/chat-hub-credentials.service.ts",
"src/modules/chat-hub/chat-hub-title.service.ts",
"src/modules/chat-hub/chat-hub-tool.service.ts",
"src/modules/chat-hub/chat-hub-workflow.service.ts",
"src/modules/chat-hub/chat-hub.attachment.service.ts",
"src/modules/chat-hub/chat-hub.models.service.ts",
"src/modules/chat-hub/chat-hub.service.ts",
"src/modules/chat-hub/chat-hub.settings.service.ts",
"src/modules/data-table/data-table-ddl.service.ts",
"src/modules/data-table/data-table.service.ts",
"src/modules/data-table/utils/sql-utils.ts",
"src/modules/dynamic-credentials.ee/database/entities/credential-resolver.ts",
"src/modules/dynamic-credentials.ee/database/repositories/credential-resolver.repository.ts",
"src/modules/dynamic-credentials.ee/services/credential-connection-status.service.ts",
"src/modules/dynamic-credentials.ee/services/credential-resolver-workflow.service.ts",
"src/modules/dynamic-credentials.ee/services/credential-resolver.service.ts",
"src/modules/external-secrets.ee/secrets-providers-connections.service.ee.ts",
"src/modules/favorites/favorites.service.ts",
"src/modules/insights/insights-collection.service.ts",
"src/modules/instance-ai/__tests__/agent-preview-handoff.test.ts",
"src/modules/instance-ai/instance-ai.adapter.service.ts",
"src/modules/instance-ai/mcp/instance-ai-mcp-registry.service.ts",
"src/modules/instance-ai/storage/typeorm-agent-checkpoint-store.ts",
"src/modules/instance-ai/storage/typeorm-agent-memory.ts",
"src/modules/instance-ai/storage/typeorm-observation-log-store.ts",
"src/modules/instance-ai/suspended-thread-persistence.service.ts",
"src/modules/instance-reporting.ee/instance-reporting-scheduler.service.ts",
"src/modules/log-streaming.ee/log-streaming-destination.service.ts",
"src/modules/mcp/mcp-api-key.service.ts",
"src/modules/mcp/mcp.settings.service.ts",
"src/modules/oauth-jwe/oauth-jwe-key.service.ts",
"src/modules/provisioning.ee/provisioning.service.ee.ts",
"src/modules/provisioning.ee/role-mapping-rule.service.ee.ts",
"src/modules/provisioning.ee/role-resolver.service.ee.ts",
"src/modules/source-control.ee/source-control-context.factory.ts",
"src/modules/source-control.ee/source-control-export.service.ee.ts",
"src/modules/source-control.ee/source-control-import.service.ee.ts",
"src/modules/source-control.ee/source-control-scoped.service.ts",
"src/modules/source-control.ee/source-control-status.service.ee.ts",
"src/modules/token-exchange/services/trusted-key.service.ts",
"src/modules/workflow-index/workflow-dependency-query.service.ts",
"src/permissions.ee/check-access.ts",
"src/policy/policy-enforcement.service.ts",
"src/public-api/v1/controllers/__tests__/workflows.public.controller.test.ts",
"src/public-api/v1/handlers/audit/audit.handler.ts",
"src/public-api/v1/handlers/community-packages/community-packages.handler.ts",
"src/public-api/v1/handlers/credentials/credentials.handler.ts",
"src/public-api/v1/handlers/data-tables/data-tables.columns.handler.ts",
"src/public-api/v1/handlers/data-tables/data-tables.handler.ts",
"src/public-api/v1/handlers/data-tables/data-tables.rows.handler.ts",
"src/public-api/v1/handlers/discover/discover.handler.ts",
"src/public-api/v1/handlers/evaluations/evaluations.handler.ts",
"src/public-api/v1/handlers/folders/folders.handler.ts",
"src/public-api/v1/handlers/insights/insights.handler.ts",
"src/public-api/v1/handlers/ldap/ldap.handler.ts",
"src/public-api/v1/handlers/log-streaming/log-streaming.handler.ts",
"src/public-api/v1/handlers/n8n-packages/n8n-packages.handler.ts",
"src/public-api/v1/handlers/otel/otel.handler.ts",
"src/public-api/v1/handlers/projects/projects.handler.ts",
"src/public-api/v1/handlers/security-policy/security-policy.handler.ts",
"src/public-api/v1/handlers/sso-oidc/sso-oidc.handler.ts",
"src/public-api/v1/handlers/sso-saml/sso-saml.handler.ts",
"src/public-api/v1/handlers/tags/tags.handler.ts",
"src/public-api/v1/handlers/users/users.handler.ee.ts",
"src/public-api/v1/handlers/variables/variables.handler.ts",
"src/public-api/v1/handlers/workflows/workflows.handler.ts",
"src/scaling/scaling.service.ts",
"src/scheduling/durable-job-provisioner.ts",
"src/scheduling/durable-scheduler.ts",
"src/scheduling/poll-trigger-node/poll-trigger-job-registrar.ts",
"src/scheduling/schedule-trigger-node/schedule-trigger-job-registrar.ts",
"src/scheduling/system-tasks/system-task-runner.ts",
"src/security-audit/risk-reporters/credentials-risk-reporter.ts",
"src/security-audit/security-audit.repository.ts",
"src/services/export.service.ts",
"src/services/folder-finder.service.ts",
"src/services/folder.service.ts",
"src/services/hooks.service.ts",
"src/services/import.service.ts",
"src/services/ownership-transfer/ownership-transfer-handler.registry.ts",
"src/services/ownership.service.ts",
"src/services/project.service.ee.ts",
"src/services/pruning/executions-pruning.service.ts",
"src/services/pruning/workflow-history-compaction.service.ts",
"src/services/public-api-key.service.ts",
"src/services/role-cache.service.ts",
"src/services/role.service.ts",
"src/services/tag.service.ts",
"src/services/user.service.ts",
"src/services/workflow-statistics-rollup.service.ts",
"src/wait-tracker.ts",
"src/workflows/publication/workflow-publication-outbox-consumer.ts",
"src/workflows/publication/workflow-publication-reconciler.service.ts",
"src/workflows/workflow-creation.service.ts",
"src/workflows/workflow-finder.service.ts",
"src/workflows/workflow-history/workflow-history.service.ts",
"src/workflows/workflow-sharing.service.ts",
"src/workflows/workflow-validation.service.ts",
"src/workflows/workflow.service.ee.ts",
"src/workflows/workflow.service.ts",
"src/workflows/workflows.controller.ts",
"test/extend-expect.ts"
],
"packages/core": [
"src/__tests__/credentials.test.ts",
"src/binary-data/binary-data.config.ts",
"test/helpers/constants.ts"
],
"packages/extensions/insights": [
"src/backend/index.ts",
"src/frontend/InsightsDashboard.vue",
"src/frontend/index.ts"
],
"packages/frontend/@n8n/chat": [
"src/App.vue",
"src/__stories__/App.stories.ts",
"src/__tests__/Chat.spec.ts",
"src/__tests__/plugins/chat.test.ts"
],
"packages/frontend/@n8n/composables": [
"src/__tests__/packageBoundary.test.ts",
"src/injectionKeys.ts"
],
"packages/frontend/@n8n/design-system": [
"src/__tests__/render.ts",
"src/components/AskAssistantAvatar/AskAssistantAvatar.test.ts",
"src/components/AskAssistantAvatar/AssistantAvatar.stories.ts",
"src/components/AskAssistantAvatar/AssistantAvatar.vue",
"src/components/N8nFormInput/validators.ts"
],
"packages/frontend/@n8n/eslint-plugin-design-system": [
"src/plugin.ts",
"src/rules/require-teleported-tooltip-in-dropdown.test.ts"
],
"packages/frontend/@n8n/frontend-constants": ["src/urls.ts", "src/views.test.ts", "src/views.ts"],
"packages/frontend/@n8n/frontend-module-sdk": [
"src/__tests__/setup.ts",
"src/index.ts",
"src/registries/commandRegistry.test.ts"
],
"packages/frontend/@n8n/frontend-test-utils": ["src/__tests__/render.test.ts", "src/defaults.ts"],
"packages/frontend/@n8n/frontend-utils": [
"src/__tests__/setup.ts",
"src/constants/sanitization.ts",
"src/htmlUtils.test.ts"
],
"packages/frontend/@n8n/i18n": ["src/__tests__/setup.ts", "src/index.test.ts", "src/index.ts"],
"packages/frontend/@n8n/rest-api-client": [
"src/__tests__/setup.ts",
"src/api/__tests__/api-keys.test.ts",
"src/api/ai-usage.ts"
],
"packages/frontend/@n8n/stores": [
"src/__tests__/metaTagConfig.test.ts",
"src/cloudPlan.store.ts"
],
"packages/frontend/@n8n/storybook": [".storybook/ThemedDocsContainer.ts"],
"packages/frontend/editor-ui": [
"src/Interface.ts",
"src/__tests__/data/users.ts",
"src/app/App.vue",
"src/app/__tests__/modal-definition.test.ts",
"src/app/constants/modals.ts",
"src/app/stores/defaults/modals.ts",
"src/app/stores/workflowDocument/types.ts",
"src/app/stores/workflows.store.ts",
"src/features/agents/__tests__/AgentAdvancedPanel.test.ts",
"src/features/agents/channels/AgentChannelStandardEditView.vue",
"src/features/ai/chatHub/__test__/data.ts",
"src/features/settings/environments.ee/completions/variables.completions.test.ts",
"src/features/shared/editors/components/CodeNodeEditor/CodeNodeEditor.vue",
"src/features/shared/editors/plugins/codemirror/completions/__tests__/mock.ts",
"src/features/shared/toolsConnection/ToolsConnectionModal.stories.ts"
],
"packages/modules/insights/frontend": [
"src/__tests__/setup.ts",
"src/chartjs.utils.ts",
"src/components/InsightsDashboard.test.ts",
"src/components/InsightsDashboard.vue"
],
"packages/modules/instance-registry/frontend": [
"src/__tests__/setup.ts",
"src/index.ts",
"src/instance-registry.store.test.ts"
],
"packages/modules/otel/frontend": [
"src/OtelSettingsRow.vue",
"src/SettingsOpenTelemetryView.test.ts",
"src/__tests__/setup.ts",
"src/index.ts"
],
"packages/node-dev": ["src/Build.ts"],
"packages/nodes-base": [
"credentials/ActionNetworkApi.credentials.ts",
"credentials/common/aws/s3-path-signing.test.ts",
"credentials/test/AtlassianOAuth2Api.credentials.test.ts",
"nodes/ActionNetwork/ActionNetwork.node.ts",
"nodes/Aws/__tests__/AwsLambda.node.test.ts"
],
"packages/testing/code-health": ["src/cli.ts", "src/cli/arg-parser.test.ts"],
"packages/testing/janitor": ["src/cli.test.ts", "src/cli.ts"],
"packages/testing/playwright": ["coverage-options.test.ts"],
"packages/testing/rules-engine": ["src/ast/ast-rule.ts", "src/ast/imports.test.ts"],
"packages/testing/test-impact": ["src/changes.test.ts", "src/changes.ts"],
"packages/workflow": [
"src/augment-object.ts",
"test/ExpressionExtensions/array-extensions.test.ts"
]
}