Skip to content

feat(core): Add Instance reporting module - #36796

Open
konstantintieber wants to merge 13 commits into
masterfrom
api-171-make-n8n-instance-configurable-to-report-to-usage-monitoring
Open

feat(core): Add Instance reporting module#36796
konstantintieber wants to merge 13 commits into
masterfrom
api-171-make-n8n-instance-configurable-to-report-to-usage-monitoring

Conversation

@konstantintieber

@konstantintieber konstantintieber commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds an opt-in instance-reporting module that reports this instance's billable execution numbers to a central usage-monitoring receiver, once a day. Full design and behavior (scheduling, catch-up, retry, report-time selection) are documented in packages/cli/src/modules/instance-reporting/README.md
This PR description covers only the externally-visible surface: the payload, the new env vars, and the schema change.

Payload

Once a day, a POST is sent to <N8N_INSTANCE_REPORTING_BASE_URL>/api/v1/instance-reports:

{
  "instanceId": "",
  "batchId": "",
  "label": "", // optional
  "n8nVersion": "",
  "dataPoints": [
    { "kind": "cumulative", "name": "billableExecutions", "value": 42 },
    { "kind": "daily", "name": "billableExecutions", "value": 5, "date": "2026-09-05" }
  ]
}
  • cumulative is the instance's lifetime production root-execution count (same source as the license productionRootExecutions metric).
  • daily is yesterday's (UTC) billable-execution count from insights, since only a finished day has a final number.
  • The row is persisted before the request goes out, so a retry resends the exact same batchId and values instead of re-measuring — see the README for why that matters for the cumulative series.

New environment variables

Env var Default Notes
N8N_INSTANCE_REPORTING_BASE_URL '' Base URL of the receiver. Unset, the module loads but never sends.
N8N_INSTANCE_REPORTING_LABEL '' Sent as label, when set.
N8N_INSTANCE_REPORTING_AUTH_TOKEN '' Sent as Authorization: Bearer …, when set.

Opt-in via N8N_ENABLED_MODULES=instance-reporting; requires the insights module.

DB schema change

New table central_instance_monitoring_report (migration 1788445119184): one row per day, id (nanoid, doubles as batchId), dataPoints (JSON, exactly as sent), deliveredAt (null while undelivered), attempts, lastError. No new columns on existing tables.

Not yet on the durable scheduler

Scheduling currently uses a plain leader-gated in-process timer (same pattern as execution pruning/history compaction), not the durable scheduler — that framework has no first-class support yet for system-owned jobs. This is a known gap, and the plan is to move this job onto the durable scheduler as a system task in a follow-up.

How to test

See README.md for the module design. I additionally verified this locally end-to-end (real receiver, real workflow executions, forced report timing, retry/dedup, and auth failure handling) — see LOCAL_VERIFICATION.md.

Related Linear tickets, Github issues, and Community forum posts

https://linear.app/n8n/issue/API-171/

Review / Merge checklist

  • I have seen this code, I have run this code, and I take responsibility for this code.
  • PR title and summary are descriptive. (conventions)
  • Docs updated or follow-up ticket created.
  • Tests included.
  • PR Labeled with Backport to Beta, Backport to Stable, or Backport to v1 (if the PR is an urgent fix that needs to be backported)

@n8n-assistant

n8n-assistant Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

PR review overview

Based on ownership of the 24 changed files in this PR:

Ownership Files owned Share Source code Test files Misc
@n8n-io/catalysts 22 92% +734 / -0 +931 / -1 +418 / -0
@n8n-io/migrations-review 1 4% +32 / -0 +0 / -0 +0 / -0
@n8n-io/ligo 1 4% +1 / -0 +0 / -0 +0 / -0
Total 24 100% +767 / -0 +931 / -1 +418 / -0

Required reviews

Some changed files have a required owner in OWNERS. A member of each of these teams must approve this PR before it can merge:

Team Files
@n8n-io/migrations-review 1

Request a review from the team — GitHub assigns reviewers according to the team's review settings. The Auto-assign reviewers label does this for all owning teams.

@codecov

codecov Bot commented Aug 21, 2026

Copy link
Copy Markdown

@n8n-assistant n8n-assistant Bot added core Enhancement outside /nodes-base and /editor-ui n8n team Authored by the n8n team labels Aug 21, 2026
getInstanceOwner() filtered users by role but did not load the relation, so
the returned User had no role and permission resolution on it threw
"AuthPrincipal does not have a role defined". Load the relation, matching the
sibling lookups in the same service. Role scopes come along, since Role.scopes
is an eager relation.
@konstantintieber
konstantintieber force-pushed the api-171-make-n8n-instance-configurable-to-report-to-usage-monitoring branch from 78c29b1 to ec4e4c0 Compare August 26, 2026 20:21
…report-to-usage-monitoring

# Conflicts:
#	packages/@n8n/backend-common/src/modules/modules.config.ts
Comment thread packages/cli/src/scheduling/durable-job-provisioner.ts Outdated
@codecov

codecov Bot commented Aug 26, 2026

Copy link
Copy Markdown

Bundle Report

Bundle size has no change ✅

@konstantintieber konstantintieber changed the title feat(core): Add Instance reporting module (no-changelog) feat(core): Add Instance reporting module Aug 27, 2026
@konstantintieber

konstantintieber commented Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

TODO on this PR: undo any changes to scheduler code and no longer rely on it.
Build it as a "regular system task" instead until system-level tasks have first-level support on durable scheduler

EDIT: a vibe-coded attempt was committed here (not manual tested yet): 3c1f4a6

cubic-dev-ai[bot]

This comment was marked as outdated.

cubic-dev-ai[bot]

This comment was marked as outdated.

cubic-dev-ai[bot]

This comment was marked as outdated.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0 issues found across 4 files (changes from recent commits).

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.

Shadow auto-approve: would not auto-approve. Auto-approval blocked by 7 unresolved issues from previous reviews.

Re-trigger cubic

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0 issues found across 1 file (changes from recent commits).

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.

Shadow auto-approve: would not auto-approve. Auto-approval blocked by 7 unresolved issues from previous reviews.

Re-trigger cubic

@konstantintieber

Copy link
Copy Markdown
Contributor Author

Here is a report from letting claude run the local verification steps documented in f426844

Click to show manual test report

Local verification: instance reporting → 127.0.0.1:3456 receiver

Ran the local verification plan end-to-end against a live receiver (not a mock), using a fresh ~/.n8n.

Result: the module works correctly. Confirmed a real end-to-end delivery with matching data, no swapped fields, correct dedup, and correct failure handling.

What was verified, with real numbers

  1. Cold boot — module enabled, N8N_INSTANCE_REPORTING_AUTH_TOKEN=testing, N8N_INSIGHTS_COMPACTION_INTERVAL_MINUTES=1. On first boot the randomly-generated report time (03:18 UTC) was already in the past, so the scheduler's catch-up logic fired immediately — no manual clock-forcing needed on a fresh instance.
  2. Zero-data report — first report correctly sent:
    { "cumulative": 0, "daily": 0, "date": "2026-09-05" }
    with Authorization: Bearer testing, label: "local-dev", and the DB row id as batchId. Confirmed via the receiver's own GET /api/v1/report (bearer testing).
  3. Real data, no swap — created a workflow via the REST API, activated it, fired 5 production webhook executions. Bumped workflow_statistics.rootCount to 42 (vs. daily count of 5) specifically to catch a cumulative/daily swap. After backdating the compacted insights_by_period rows by one day and forcing a re-report, the receiver got:
    { "cumulative": 42, "daily": 5, "date": "2026-09-05" }
    Correctly attributed — not swapped.
  4. No duplicate same-day report — restarting without touching the DB produced Started the instance reporting timer but no second Sent instance report and no second row. Dedup via hasDeliveredToday works.
  5. Failure path — set a wrong bearer token, deleted today's row, restarted:
    • Got Failed to deliver the instance reportInstance report was rejected with status 401
    • Row left with deliveredAt: NULL, attempts: 1, lastError set
    • n8n itself stayed healthy (/healthz → 200) — a failed delivery doesn't affect the instance.

No code defects found in instance-reporting.service.ts, the scheduler, or the settings service — behavior matched the README and code comments exactly.

@konstantintieber
konstantintieber marked this pull request as ready for review September 6, 2026 07:59
@konstantintieber
konstantintieber requested a review from a team as a code owner September 6, 2026 07:59
@konstantintieber

Copy link
Copy Markdown
Contributor Author
Local review results by claude

Code Review — 2026-09-06
PR #36796 · feat(core): Add Instance reporting module · +2116 / −1, 24 files

Principle-by-Principle
#1 Think Before Coding — WARN
The two data points named billableExecutions count different things, and nothing says so.

Cumulative comes from license-metrics.repository.ts:64 — SUM(rootCount) over workflow_statistics rows production_success/production_error, i.e. root executions only.
Daily comes from insightsService.getInsightsSummary(...).total at instance-reporting.service.ts:146. Insights' collector at insights-collection.service.ts:152-182 has no root filter — every non-skipped production execution counts, including sub-workflow executions — and additionally drops source === 'instance_ai'.
So on any instance that uses sub-workflows, daily > the cumulative series' day-over-day delta, permanently. Both go out under the same name, so the receiver cannot tell them apart. README.md:11-14 asserts the cumulative diff is the meaningful figure without noting it isn't the same quantity as daily. The comment at instance-reporting.service.ts:139-140 explains why the license source was picked but not that it disagrees with the other point.

Sampling window mismatch, also unstated. Cumulative is sampled at report time T (say 07:42); daily covers the previous calendar UTC day 00:00–24:00. The cumulative delta is therefore a rolling 24h window offset ~7.7h from the calendar day the daily point names. The README's "fixed 24 hours apart" invariant is about consistency between consecutive cumulative samples — true, but it does not make the two points in one report comparable. The alternative (sample cumulative as of the reported day's boundary) is never mentioned.

Multi-day downtime silently drops days. previousUtcDate(now) at instance-reporting.service.ts:152 only ever reports yesterday. A three-day outage reports one day; the receiver gets no gap signal. This is acknowledged only in a passing clause in an entity doc comment — "A future backfill reads the gap since the last delivered row", entity:18 — not in the README, not in the PR description, no follow-up ticket referenced. That's a data-completeness limitation buried where nobody reviewing the contract will see it.

Near-midnight report times never recover from a failure. With a report time of, say, 23:58, the 5-minute retry lands after midnight, where scheduler:170 computes the new day's slot as still ahead and returns 'skipped'. The day is lost and the pending row lingers. Report times are random, so this hits a small slice of the fleet on any failing day. Undocumented.

Broken link on merge. README.md:65 points at .agents/specs/central-instance-monitoring.md. That file is untracked locally (git status shows ?? .agents/specs/) and is not in the PR's 24 files. The README's "full design" pointer dead-ends.

#2 Simplicity First — WARN
The compaction-floor healing machinery is ~50 lines for a scenario that requires deliberate misconfiguration. instance-reporting-settings.service.ts:92-141 derives a floor, preserves the instance's intra-day offset through a modulo, persists the corrected time, and warns. It only ever fires when an operator raises N8N_INSIGHTS_COMPACTION_INTERVAL_MINUTES above 90 (default is 60, so the floor stays pinned at 03:00). And by its own admission at :97-99, the report generated before healing under-counts anyway. A read-time Math.max(stored, floor) with no persistence and no offset-preserving modulo gives the same practical outcome in three lines. The offset preservation in particular (:109) solves fleet-thundering-herd for a fleet that has all simultaneously raised one env var past 90 minutes.

Three names for one concept. Module instance-reporting, table and entity central_instance_monitoring_report, settings key features.centralInstanceMonitoring. Someone grepping the module name will not find its table.

Redundant guard. scheduler:93 checks instanceType === 'main', already guaranteed by @BackendModule({ instanceTypes: ['main'] }) at module:18. isEnabled is also public with only internal callers.

Two round trips where one would do. repository:59-67 — markDelivered and recordFailure each call increment then update, non-atomically, on a row they already own by id.

Property stuttering. config.instanceReportingBaseUrl on InstanceReportingConfig (config.ts:18). The neighbouring convention is InsightsConfig.compactionIntervalMinutes — the class already carries the prefix.

#3 Surgical Changes — WARN
ownership.service.ts:233 — relations: ['role'] added to a shared method with six production callers. execution-recovery.service.ts:329, commands/execute.ts:84, commands/execute-batch.ts:296, source-control-git.service.ee.ts:106, workflow-statistics.service.ts:285, ai-gateway.service.ts:136 all now pay an extra JOIN for this module's need.

Worse, it's load-bearing and unmarked. getInsightsSummary → resolveAccessFilter → rolesGrantingScope reads user.role (insights.service.ts:84-99). Without the relation the owner looks role-less, the access filter narrows, and the daily data point silently under-counts — no exception, no log, just a wrong number shipped to a billing-adjacent receiver. There is no comment at instance-reporting.service.ts:130 or in ownership.service.ts recording the dependency. A future "why are we joining role here?" cleanup breaks this module invisibly.

instance-reporting.service.ts:19-24 — orphaned class doc. The block "Measures and delivers one instance report…" sits immediately above const REQUEST_TIMEOUT_MS, so the class at :27 is documented by the timeout's one-liner and the real doc comment describes a constant.

migration:9-11 — await separated from its expression by a three-line comment. Valid JS, reads as truncated code. Move the comment above the await.

LOCAL_VERIFICATION.md — 223 lines of a personal verification log checked into packages/cli/src/. export N8N_DB=…, sqlite3 snippets, "Keep the receiver's request log visible". That's PR-description or .agents/ material, not module documentation; the README already covers the design. It will rot with the first refactor and nobody will notice.

#4 Goal-Driven Execution — PASS
Strongest part of the PR. ~930 test lines across four files, and they test behaviour rather than shape:

Scheduler: catch-up on a passed slot, no re-fire after catch-up, leader takeover/stepdown/shutdown, retry bounded at exactly 3, settings-read failure re-arming, and a backward clock jump firing nothing (scheduler test:258-269).
Service: 200, 301 and 500 all treated as rejections; batchId reuse across a retry; resend-as-measured asserting getInsightsSummary is not called again (service test:321-337).
Settings: ten cases including claim-loss adoption, idempotent shifting, and a compaction interval covering the whole day.
A real-DB repository integration test, plus documented manual E2E.
Remaining gaps, all tied to findings above: nothing pins the role-loaded-owner requirement from the consuming side; no case for the midnight-crossing retry; and no test asserts that the two data points are commensurable — because they aren't.

Verdict: PASS WITH WARNINGS
The engineering is careful and unusually well tested; the scheduling design and its documented rationale are solid. The warnings cluster in one place: the semantics of the payload. Two differently-defined counters ship under one name, over mismatched windows, with silent gaps on downtime — and the reviewer-facing docs assert an invariant that doesn't hold between them. That's the part to settle before this feeds anything that looks like billing.

Specific fixes
instance-reporting.service.ts:141 + :146 — reconcile the two counters or stop implying they're comparable. Either make daily root-only (matching rootCount), or rename the points so the receiver can't diff one against the other. Whichever way, state the definition of each in README.md and the PR description. This is the blocking item.
instance-reporting.service.ts:132-142 — decide and document whether cumulative should be sampled at report time or as of the reported day's boundary. If report-time sampling is intentional, say in the README that the cumulative delta is a rolling 24h window offset from the daily point's calendar day.
ownership.service.ts:233 — either add a comment naming resolveAccessFilter/rolesGrantingScope as the reason the relation must stay, or leave the shared method alone and load the owner-with-role inside this module. Add a module-side test that fails if the owner arrives without a role.
README.md:65 — commit .agents/specs/central-instance-monitoring.md or drop the link.
instance-reporting-scheduler.service.ts:168-184 — handle or document the midnight-crossing retry: a report time near 23:59 that fails once loses the day.
README.md / PR description — state the multi-day-downtime gap and link a backfill ticket, rather than leaving it in entity:18.
instance-reporting-settings.service.ts:92-141 — consider collapsing the healing path to a read-time clamp unless a real operator has raised the compaction interval past 90 minutes.
instance-reporting.service.ts:19-24 — move the class doc above @service().
migration:9-11 — move the comment above await.
LOCAL_VERIFICATION.md — relocate out of packages/cli/src/.
repository.ts:59-67 — collapse increment + update into one statement each.
scheduler:93 — drop the instanceType === 'main' check; make isEnabled private.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cla-signed core Enhancement outside /nodes-base and /editor-ui n8n team Authored by the n8n team

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant