From c70e754d4c0dde37cc5c2a0415ba3e31316c29cd Mon Sep 17 00:00:00 2001 From: Konstantin Tieber <46342664+konstantintieber@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:29:30 +0000 Subject: [PATCH] feat(core): Include reportTime and lastSuccessfulReport of instance-reporting in module-settings endpoint (no-changelog) (#38458) Co-authored-by: Sam Wooler --- .../@n8n/api-types/src/frontend-settings.ts | 11 ++ packages/@n8n/api-types/src/index.ts | 1 + .../@n8n/api-types/src/instance-reporting.ts | 7 ++ packages/cli/eslint.config.mjs | 2 +- .../LOCAL_VERIFICATION.md | 0 .../README.md | 35 +++++- ...stance-reporting-scheduler.service.test.ts | 60 +++------ ...nstance-reporting-settings.service.test.ts | 0 .../instance-reporting.controller.test.ts | 28 +++++ .../instance-reporting.module.test.ts | 115 ++++++++++++++++++ .../instance-reporting.service.test.ts | 0 .../entities/instance-monitoring-report.ts | 0 ...ring-report.repository.integration.test.ts | 37 ++++++ .../instance-monitoring-report.repository.ts | 15 ++- .../instance-reporting-scheduler.service.ts | 37 ++---- .../instance-reporting-settings.service.ts | 0 .../instance-reporting.config.ts | 0 .../instance-reporting.constants.ts | 0 .../instance-reporting.controller.ts | 16 +++ .../instance-reporting.module.ts | 84 +++++++++++++ .../instance-reporting.service.ts | 0 .../instance-reporting.module.ts | 34 ------ .../@n8n/rest-api-client/src/api/index.ts | 1 + .../src/api/instance-reporting.ts | 10 ++ scripts/lint-parity/samples.json | 2 +- 25 files changed, 384 insertions(+), 111 deletions(-) create mode 100644 packages/@n8n/api-types/src/instance-reporting.ts rename packages/cli/src/modules/{instance-reporting => instance-reporting.ee}/LOCAL_VERIFICATION.md (100%) rename packages/cli/src/modules/{instance-reporting => instance-reporting.ee}/README.md (81%) rename packages/cli/src/modules/{instance-reporting => instance-reporting.ee}/__tests__/instance-reporting-scheduler.service.test.ts (88%) rename packages/cli/src/modules/{instance-reporting => instance-reporting.ee}/__tests__/instance-reporting-settings.service.test.ts (100%) create mode 100644 packages/cli/src/modules/instance-reporting.ee/__tests__/instance-reporting.controller.test.ts create mode 100644 packages/cli/src/modules/instance-reporting.ee/__tests__/instance-reporting.module.test.ts rename packages/cli/src/modules/{instance-reporting => instance-reporting.ee}/__tests__/instance-reporting.service.test.ts (100%) rename packages/cli/src/modules/{instance-reporting => instance-reporting.ee}/database/entities/instance-monitoring-report.ts (100%) rename packages/cli/src/modules/{instance-reporting => instance-reporting.ee}/database/repositories/__tests__/instance-monitoring-report.repository.integration.test.ts (83%) rename packages/cli/src/modules/{instance-reporting => instance-reporting.ee}/database/repositories/instance-monitoring-report.repository.ts (91%) rename packages/cli/src/modules/{instance-reporting => instance-reporting.ee}/instance-reporting-scheduler.service.ts (84%) rename packages/cli/src/modules/{instance-reporting => instance-reporting.ee}/instance-reporting-settings.service.ts (100%) rename packages/cli/src/modules/{instance-reporting => instance-reporting.ee}/instance-reporting.config.ts (100%) rename packages/cli/src/modules/{instance-reporting => instance-reporting.ee}/instance-reporting.constants.ts (100%) create mode 100644 packages/cli/src/modules/instance-reporting.ee/instance-reporting.controller.ts create mode 100644 packages/cli/src/modules/instance-reporting.ee/instance-reporting.module.ts rename packages/cli/src/modules/{instance-reporting => instance-reporting.ee}/instance-reporting.service.ts (100%) delete mode 100644 packages/cli/src/modules/instance-reporting/instance-reporting.module.ts create mode 100644 packages/frontend/@n8n/rest-api-client/src/api/instance-reporting.ts diff --git a/packages/@n8n/api-types/src/frontend-settings.ts b/packages/@n8n/api-types/src/frontend-settings.ts index 64ecddb54d4..8169493b6e6 100644 --- a/packages/@n8n/api-types/src/frontend-settings.ts +++ b/packages/@n8n/api-types/src/frontend-settings.ts @@ -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. */ diff --git a/packages/@n8n/api-types/src/index.ts b/packages/@n8n/api-types/src/index.ts index dfc51ef0c02..b8fa87406d2 100644 --- a/packages/@n8n/api-types/src/index.ts +++ b/packages/@n8n/api-types/src/index.ts @@ -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 type * from './node-type-availability'; export * from './redaction-enforcement'; diff --git a/packages/@n8n/api-types/src/instance-reporting.ts b/packages/@n8n/api-types/src/instance-reporting.ts new file mode 100644 index 00000000000..98e9db707a9 --- /dev/null +++ b/packages/@n8n/api-types/src/instance-reporting.ts @@ -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; +}; diff --git a/packages/cli/eslint.config.mjs b/packages/cli/eslint.config.mjs index 1103c630eca..a7e17ed9ac9 100644 --- a/packages/cli/eslint.config.mjs +++ b/packages/cli/eslint.config.mjs @@ -386,7 +386,7 @@ export default defineConfig( // tasks. NEVER add to this list — new periodic leader work must be a // @SystemTask() class. Entries are removed as each migrates on its own ticket. files: [ - './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/workflow-statistics-rollup.service.ts', ], diff --git a/packages/cli/src/modules/instance-reporting/LOCAL_VERIFICATION.md b/packages/cli/src/modules/instance-reporting.ee/LOCAL_VERIFICATION.md similarity index 100% rename from packages/cli/src/modules/instance-reporting/LOCAL_VERIFICATION.md rename to packages/cli/src/modules/instance-reporting.ee/LOCAL_VERIFICATION.md diff --git a/packages/cli/src/modules/instance-reporting/README.md b/packages/cli/src/modules/instance-reporting.ee/README.md similarity index 81% rename from packages/cli/src/modules/instance-reporting/README.md rename to packages/cli/src/modules/instance-reporting.ee/README.md index 514e68e55d3..3450686a8ad 100644 --- a/packages/cli/src/modules/instance-reporting/README.md +++ b/packages/cli/src/modules/instance-reporting.ee/README.md @@ -85,7 +85,7 @@ there. | Env var | Default | Notes | |---|---|---| -| `N8N_INSTANCE_REPORTING_BASE_URL` | `''` | Base URL of the receiver. The report is POSTed to `/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 `/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. diff --git a/packages/cli/src/modules/instance-reporting/__tests__/instance-reporting-scheduler.service.test.ts b/packages/cli/src/modules/instance-reporting.ee/__tests__/instance-reporting-scheduler.service.test.ts similarity index 88% rename from packages/cli/src/modules/instance-reporting/__tests__/instance-reporting-scheduler.service.test.ts rename to packages/cli/src/modules/instance-reporting.ee/__tests__/instance-reporting-scheduler.service.test.ts index 579d184ca9f..2572890f56b 100644 --- a/packages/cli/src/modules/instance-reporting/__tests__/instance-reporting-scheduler.service.test.ts +++ b/packages/cli/src/modules/instance-reporting.ee/__tests__/instance-reporting-scheduler.service.test.ts @@ -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; settingsService: Mocked; instanceSettings: Mocked; - modulesConfig: Mocked; eventService: Mocked; /** 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(); // 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({ disabledModules }); const serverStartedListeners: Array<() => void> = []; const eventService = mock(); @@ -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')); diff --git a/packages/cli/src/modules/instance-reporting/__tests__/instance-reporting-settings.service.test.ts b/packages/cli/src/modules/instance-reporting.ee/__tests__/instance-reporting-settings.service.test.ts similarity index 100% rename from packages/cli/src/modules/instance-reporting/__tests__/instance-reporting-settings.service.test.ts rename to packages/cli/src/modules/instance-reporting.ee/__tests__/instance-reporting-settings.service.test.ts diff --git a/packages/cli/src/modules/instance-reporting.ee/__tests__/instance-reporting.controller.test.ts b/packages/cli/src/modules/instance-reporting.ee/__tests__/instance-reporting.controller.test.ts new file mode 100644 index 00000000000..6f4e873962c --- /dev/null +++ b/packages/cli/src/modules/instance-reporting.ee/__tests__/instance-reporting.controller.test.ts @@ -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(); + 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(); + reportRepository.findLastDeliveryTime.mockResolvedValue(null); + + const status = await new InstanceReportingController(reportRepository).getStatus(); + + expect(status).toEqual({ lastSuccessfulReport: null }); + }); + }); +}); diff --git a/packages/cli/src/modules/instance-reporting.ee/__tests__/instance-reporting.module.test.ts b/packages/cli/src/modules/instance-reporting.ee/__tests__/instance-reporting.module.test.ts new file mode 100644 index 00000000000..5a7e271b536 --- /dev/null +++ b/packages/cli/src/modules/instance-reporting.ee/__tests__/instance-reporting.module.test.ts @@ -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({ disabledModules })); + Container.set(Logger, mockLogger()); + + const settingsService = mock(); + settingsService.getReportTime.mockResolvedValue(REPORT_TIME); + Container.set(InstanceReportingSettingsService, settingsService); + + const reportRepository = mock(); + reportRepository.findLastDeliveryTime.mockResolvedValue(lastDelivery); + Container.set(InstanceMonitoringReportRepository, reportRepository); + + const scheduler = mock(); + 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); + }); +}); diff --git a/packages/cli/src/modules/instance-reporting/__tests__/instance-reporting.service.test.ts b/packages/cli/src/modules/instance-reporting.ee/__tests__/instance-reporting.service.test.ts similarity index 100% rename from packages/cli/src/modules/instance-reporting/__tests__/instance-reporting.service.test.ts rename to packages/cli/src/modules/instance-reporting.ee/__tests__/instance-reporting.service.test.ts diff --git a/packages/cli/src/modules/instance-reporting/database/entities/instance-monitoring-report.ts b/packages/cli/src/modules/instance-reporting.ee/database/entities/instance-monitoring-report.ts similarity index 100% rename from packages/cli/src/modules/instance-reporting/database/entities/instance-monitoring-report.ts rename to packages/cli/src/modules/instance-reporting.ee/database/entities/instance-monitoring-report.ts diff --git a/packages/cli/src/modules/instance-reporting/database/repositories/__tests__/instance-monitoring-report.repository.integration.test.ts b/packages/cli/src/modules/instance-reporting.ee/database/repositories/__tests__/instance-monitoring-report.repository.integration.test.ts similarity index 83% rename from packages/cli/src/modules/instance-reporting/database/repositories/__tests__/instance-monitoring-report.repository.integration.test.ts rename to packages/cli/src/modules/instance-reporting.ee/database/repositories/__tests__/instance-monitoring-report.repository.integration.test.ts index 907c0ef67cf..e60a5fb55a2 100644 --- a/packages/cli/src/modules/instance-reporting/database/repositories/__tests__/instance-monitoring-report.repository.integration.test.ts +++ b/packages/cli/src/modules/instance-reporting.ee/database/repositories/__tests__/instance-monitoring-report.repository.integration.test.ts @@ -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); diff --git a/packages/cli/src/modules/instance-reporting/database/repositories/instance-monitoring-report.repository.ts b/packages/cli/src/modules/instance-reporting.ee/database/repositories/instance-monitoring-report.repository.ts similarity index 91% rename from packages/cli/src/modules/instance-reporting/database/repositories/instance-monitoring-report.repository.ts rename to packages/cli/src/modules/instance-reporting.ee/database/repositories/instance-monitoring-report.repository.ts index 096bcccaebe..3bf6ee8fb21 100644 --- a/packages/cli/src/modules/instance-reporting/database/repositories/instance-monitoring-report.repository.ts +++ b/packages/cli/src/modules/instance-reporting.ee/database/repositories/instance-monitoring-report.repository.ts @@ -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 (a > b ? a : b)) : null; } + /** + * When the receiver last accepted a report, or `null` when it never did. + */ + async findLastDeliveryTime(): Promise { + 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 { await this.increment({ id }, 'attempts', 1); await this.update( diff --git a/packages/cli/src/modules/instance-reporting/instance-reporting-scheduler.service.ts b/packages/cli/src/modules/instance-reporting.ee/instance-reporting-scheduler.service.ts similarity index 84% rename from packages/cli/src/modules/instance-reporting/instance-reporting-scheduler.service.ts rename to packages/cli/src/modules/instance-reporting.ee/instance-reporting-scheduler.service.ts index 53f0e4c5752..3950a08795d 100644 --- a/packages/cli/src/modules/instance-reporting/instance-reporting-scheduler.service.ts +++ b/packages/cli/src/modules/instance-reporting.ee/instance-reporting-scheduler.service.ts @@ -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 { + 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; } /** diff --git a/packages/cli/src/modules/instance-reporting/instance-reporting-settings.service.ts b/packages/cli/src/modules/instance-reporting.ee/instance-reporting-settings.service.ts similarity index 100% rename from packages/cli/src/modules/instance-reporting/instance-reporting-settings.service.ts rename to packages/cli/src/modules/instance-reporting.ee/instance-reporting-settings.service.ts diff --git a/packages/cli/src/modules/instance-reporting/instance-reporting.config.ts b/packages/cli/src/modules/instance-reporting.ee/instance-reporting.config.ts similarity index 100% rename from packages/cli/src/modules/instance-reporting/instance-reporting.config.ts rename to packages/cli/src/modules/instance-reporting.ee/instance-reporting.config.ts diff --git a/packages/cli/src/modules/instance-reporting/instance-reporting.constants.ts b/packages/cli/src/modules/instance-reporting.ee/instance-reporting.constants.ts similarity index 100% rename from packages/cli/src/modules/instance-reporting/instance-reporting.constants.ts rename to packages/cli/src/modules/instance-reporting.ee/instance-reporting.constants.ts diff --git a/packages/cli/src/modules/instance-reporting.ee/instance-reporting.controller.ts b/packages/cli/src/modules/instance-reporting.ee/instance-reporting.controller.ts new file mode 100644 index 00000000000..fc72daed9d3 --- /dev/null +++ b/packages/cli/src/modules/instance-reporting.ee/instance-reporting.controller.ts @@ -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 { + const lastDelivery = await this.reportRepository.findLastDeliveryTime(); + + return { lastSuccessfulReport: lastDelivery?.toISOString() ?? null }; + } +} diff --git a/packages/cli/src/modules/instance-reporting.ee/instance-reporting.module.ts b/packages/cli/src/modules/instance-reporting.ee/instance-reporting.module.ts new file mode 100644 index 00000000000..fe73d03a305 --- /dev/null +++ b/packages/cli/src/modules/instance-reporting.ee/instance-reporting.module.ts @@ -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 { + const { InstanceReportingConfig } = await import('./instance-reporting.config.js'); + + return Container.get(InstanceReportingConfig).instanceReportingBaseUrl !== ''; + } +} diff --git a/packages/cli/src/modules/instance-reporting/instance-reporting.service.ts b/packages/cli/src/modules/instance-reporting.ee/instance-reporting.service.ts similarity index 100% rename from packages/cli/src/modules/instance-reporting/instance-reporting.service.ts rename to packages/cli/src/modules/instance-reporting.ee/instance-reporting.service.ts diff --git a/packages/cli/src/modules/instance-reporting/instance-reporting.module.ts b/packages/cli/src/modules/instance-reporting/instance-reporting.module.ts deleted file mode 100644 index 67a65484ff9..00000000000 --- a/packages/cli/src/modules/instance-reporting/instance-reporting.module.ts +++ /dev/null @@ -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]; - } -} diff --git a/packages/frontend/@n8n/rest-api-client/src/api/index.ts b/packages/frontend/@n8n/rest-api-client/src/api/index.ts index 8dc81622d82..e2e1a160b73 100644 --- a/packages/frontend/@n8n/rest-api-client/src/api/index.ts +++ b/packages/frontend/@n8n/rest-api-client/src/api/index.ts @@ -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'; diff --git a/packages/frontend/@n8n/rest-api-client/src/api/instance-reporting.ts b/packages/frontend/@n8n/rest-api-client/src/api/instance-reporting.ts new file mode 100644 index 00000000000..cc498144625 --- /dev/null +++ b/packages/frontend/@n8n/rest-api-client/src/api/instance-reporting.ts @@ -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 { + return await makeRestApiRequest(context, 'GET', '/instance-reporting/status'); +} diff --git a/scripts/lint-parity/samples.json b/scripts/lint-parity/samples.json index 5b67d2b0397..004b8c9d40f 100644 --- a/scripts/lint-parity/samples.json +++ b/scripts/lint-parity/samples.json @@ -213,7 +213,7 @@ "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/instance-reporting-scheduler.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",