# Reporting

Reporting tells you how your workflows are doing, in two ways:

- **Alerts.** A **rule** watches your deployed workflows for an event — a run
  failed, a run completed, or a workflow recovered — and when it fires, it calls
  a **reporting workflow** with the details. That workflow posts to Slack, to
  Teams or to any webhook.
- **Digests.** A scheduled workflow fetches a daily or weekly summary of your
  runs and mails or posts it. No rule is involved.

Both are ordinary workflows made from starter templates, so you can open and
change them like any other workflow. It is the same model n8n calls an "error
workflow".

```mermaid
flowchart LR
  W[Run of a watched workflow] -->|failed · completed · recovered| Rule[Reporting rule]
  Rule -->|POST with the run details| T[Reporting workflow]
  T --> Slack
  T --> Teams
  T --> Webhook
```

```mermaid
flowchart LR
  Cron[Cron trigger] --> Digest[dcupl API: digest]
  Digest --> Script[Format digest]
  Script --> Mail[dcupl API: email]
  Script --> Slack
```

Runners report every workflow run to dcupl, but only its metadata: execution id,
status, timing, trigger info and, for a failed run, the error message and node
id. **Item data is never sent.**

## Before you start

- You need to be an **owner** or **editor** of the project: only they see the
  **Reporting** tab under Project Settings.
- The project needs two features, both on by default for new projects:
  **Workflows** and **Workflow reporting**. If either is off, the Reporting tab is
  hidden and the reporting API answers `400`; ask your dcupl contact to switch
  them on. A project can have up to 20 rules.
- Runners hosted by dcupl report their runs without any setup. For a runner in
  your own infrastructure, see [Limits and notes](#limits-and-notes).

## Send alerts to Slack, Teams or a webhook

1. **Create the reporting workflow.** Project Settings → **Reporting** → **New
   reporting workflow** opens the **Create Workflow** dialog on the Workflows
   page with the **Reporting** shelf of starter templates. (Creating a workflow
   on the Workflows page opens the same dialog.) Pick one:

   | Template               | What it does                                                                                                                 | Variable             |
   | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------- | -------------------- |
   | **Slack on failure**   | Posts a short message to a Slack incoming webhook: the event, the workflow name in bold, and a link to the project           | `slack_webhook_url`  |
   | **Teams on failure**   | Posts a Microsoft Teams Adaptive Card for the event, with an **Open project** button                                         | `teams_webhook_url`  |
   | **Webhook on failure** | Forwards the [alert payload](#the-alert-payload) as JSON to any HTTPS endpoint, with the event in the `x-dcupl-event` header | `report_webhook_url` |

   Despite their names, all three handle all three events (`❌ … failed`,
   `✔ … completed in N ms`, `✅ … recovered`): pair any of them with a **Run
   failed**, **Run completed** or **Workflow recovered** rule.

2. **Set the variable.** The template declares its one variable with an empty
   value, so the deploy dialog lists it as a gap you fill in per deployment. Or
   define a project variable with the same name once and reuse it across
   workflows. None of the templates needs an API key.
   - **Slack:** create (or reuse) a Slack app with an **Incoming Webhook** for
     the channel and copy its `https://hooks.slack.com/services/…` URL.
   - **Teams:** add the **Incoming Webhook** connector to the channel, or
     create a webhook with the Teams **Workflows** app, and copy its URL.
   - **Webhook:** any HTTPS endpoint you control (PagerDuty, Discord via a
     relay, your own service). It has to be public: a workflow's Request node
     cannot reach private or loopback addresses such as `localhost`.

3. **Deploy it** to a runner like any other workflow. Its request trigger is
   open: anyone who knows the runner URL and the workflow key can post into your
   channel. If the runner is reachable from the internet, add an API key to the
   trigger; the rule sends that key when it calls the workflow.

4. **Create a rule** under Project Settings → **Reporting**: the event, the
   workflows to watch, this workflow as the target, and a cooldown. See
   [Rules](#rules).

5. **Test it.** **Execute** in the editor runs the workflow without a reporting
   payload: the Slack and Teams templates post "No reporting payload — trigger
   this workflow from a reporting rule.", and the Webhook template forwards the
   body you sent. To preview a real message, paste a payload like the one in
   [The alert payload](#the-alert-payload) into the test's **Request Body
   (JSON)**, or let a watched workflow fail, complete or recover.

Every template carries a **canvas note** (the yellow sticky note near the
trigger): how it works, the variable to set, and how to test it.

## Send a daily or weekly digest

The two digest templates schedule themselves (cron `0 0 8 * * *`, 08:00 UTC),
so you create one, set its variable and deploy it — no rule needed:

| Template                  | What it does                                                                                                                                          | Variable                                                  |
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- |
| **Daily digest to mail**  | Fetches yesterday's digest (UTC) with the dcupl API node, formats it as an HTML mail with a plain-text fallback, and sends it with the dcupl API node | `reporting_email_to`: up to 10 addresses, comma-separated |
| **Daily digest to Slack** | Fetches the digest the same way and posts it to a Slack incoming webhook                                                                              | `slack_webhook_url`                                       |

For a **weekly** digest, open the deployed workflow, change the cron trigger to
the weekday you want, and change the digest node's **Period** from Daily to
Weekly; there is no separate weekly template. **Execute** in the editor makes a
real call, so a test sends the actual digest.

To build a digest from scratch:

1. A **cron trigger** on your schedule.
2. A **dcupl API** node, operation **Reporting digest**, with `period` (Daily
   or Weekly) and `timezone` (an IANA zone, e.g. `Europe/Vienna`). It fetches
   the most recently closed period: yesterday for daily, the seven days ending
   yesterday for weekly.
3. A **Script** node that turns the [digest payload](#the-digest-payload) into
   a message.
4. A **dcupl API** node (**Send e-mail**) or a **Request** node (Slack, Teams,
   a webhook) that delivers it.

## Rules

Project Settings → **Reporting** lists the project's rules, where you create,
edit, switch on and off, and delete them. Rules react to single runs only;
there is no digest event and no schedule on a rule. A rule has:

- **Name** — a label for the list.
- **Event** — **Run failed**, **Run completed** or **Workflow recovered**.
- **Workflows** — **All workflows**, or a selection of deployed workflows to
  watch.
- **Target** — the reporting workflow to call. It needs a POST request trigger.
- **Cooldown (minutes)** — 0 to 1440 (24 hours), for Run failed and Run
  completed, see below.
- **Enabled** — switch the rule off without deleting it.

What the events fire on:

- **Run failed** — a failed run of a watched workflow.
- **Run completed** — every completed run. With **All workflows** that is every
  completed run of every deployed workflow, and the form warns: "This fires for
  every completed run of every workflow. Select specific workflows to keep the
  target quiet."
- **Workflow recovered** — once per outage: the first completed run directly
  after a failed run of the same workflow. A completed run after a completed
  run, or a workflow's very first run, does not fire it.

A **cancelled** run fires nothing, and a cancelled run between a failure and the
next completed run means that no recovery is reported: only a direct failed →
completed change counts.

**Cooldown.** Once a rule fires for a workflow, further matching runs of that
workflow are counted but not reported until the cooldown has passed; the next
call reports them in `suppressedCount` (`(+N more)` in the messages). A new rule
starts at 15 minutes, and at 0 once you pick Run completed; `0` reports every
run. Workflow recovered has no cooldown setting, since it fires only once per
outage anyway.

**A target that watches itself.** When the target is one of the watched
workflows, its own runs are not reported, while the other watched workflows'
runs still are. The form shows a note but saves the rule. Two rules whose
targets watch each other can call each other in a loop if both have a cooldown
of `0`: keep a cooldown of at least 1 minute on such rules, and don't point a
Workflow recovered rule at a target it also watches.

## Customize the message

The message is built by one script step per template (**Format message**,
**Build Adaptive Card**, **Format digest**). Open it and edit it; it is written
to be changed. Every script reads top to bottom the same way:

1. A **Settings** block of plain constants, the things most people change:

   | Setting             | Templates                | What it does                                                                                                                                            |
   | ------------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
   | `HEADER` / `FOOTER` | both digests             | First and last paragraph (mail) or line (Slack) of the message. Plain text; set to `''` to leave it out.                                                |
   | `CONSOLE_URL`       | all four                 | The Console the link points to. Pre-filled with the Console you created the workflow in; the link is `CONSOLE_URL + '/app/p/' + projectId + LINK_PAGE`. |
   | `LINK_PAGE`         | all four                 | The page the link opens inside the project. Default: the workflows overview, `/draft/workflows/v3/templates/overview`.                                  |
   | `LINK_LABEL`        | both digests             | The text of the project link.                                                                                                                           |
   | `SHOW_PROJECT_LINK` | Slack / Teams on failure | `false` removes the link (Slack) or the **Open project** button (Teams).                                                                                |
   | `MAX_ERRORS`        | both digests             | How many of the most frequent errors to list (the payload carries at most 10).                                                                          |

2. Numbered steps: read the payload, name the values the message is built
   from, build the message. Change the wording where the strings are.
3. One `return` with the fields the next step sends.

The alert templates receive the payload as the request body: a script reads it
as `$json.first().body`. Workflows are called by their **name**
(`workflow.name` / `workflowName`), falling back to the key, since deployed
keys carry the project ID and are hard to read.

**Formatting** depends on where the message goes:

- **Slack** uses [mrkdwn](https://api.slack.com/reference/surfaces/formatting):
  `*bold*`, `_italic_`, `<https://…|link text>`. The scripts escape `&`, `<`
  and `>` in text that comes from a run (names, error messages) with their
  `esc()` helper. Keep using it when you add such text, or an error message
  containing `<!channel>` becomes a real mention.
- **Teams** text blocks take a Markdown subset: `**bold**`, `_italic_`,
  `[link text](https://…)`.
- **E-mail** is sent as HTML with a plain-text fallback; the script returns
  `subject`, `text` and `html`, so edit both bodies if you change the content.
  The HTML is deliberately simple (inline styles, one table), because mail
  clients ignore stylesheets and scripts. Foreign text goes through `esc()` so
  an error message can't inject markup.

The Slack, Teams and webhook request steps send the script's item as their
whole JSON body (`{{$json}}`), so a script never has to escape anything for
JSON: add a field to the returned object and it is sent (e.g. Slack's `blocks`
for [Block Kit](https://api.slack.com/block-kit) layouts).

## The dcupl API node

The **dcupl API** node (palette section **Nodes**, "Reporting mails, digests,
runs") lets a workflow read or send reporting data itself. Digests are made of
it, and it also works on its own, e.g. in a workflow that mails on demand or
lists recent runs.

The node **authenticates as the runner**: you don't create, store or pass an
API key. Every other integration node (Request, dcupl Files, …) carries its own
credentials. The project is the one of the deployed workflow the node runs in.

Pick an **operation**, which decides the rest of the fields:

| Operation            | Fields                                                                                                                                                                | Output (one item unless noted)                                                |
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
| **Send e-mail**      | `to` (comma-separated addresses or a JSON array; templated), `subject` (templated), `text` and/or `html` (templated, at least one required)                           | `{ accepted, to }` — **one item per input item**, `to` normalized to an array |
| **Reporting digest** | `period` (Daily / Weekly), `timezone` (IANA, e.g. `Europe/Vienna`)                                                                                                    | The [digest payload](#the-digest-payload) for the closed period               |
| **Workflow runs**    | `status` (optional multi-select: Completed / Failed / Cancelled — omit for all), `sinceHours` (look-back window, default 24, max 720), `limit` (default 100, max 500) | **One item per run** (none if the window is empty)                            |

The **Send e-mail** fields are templated like the Request node's
(`{{$json.field}}`, `{{variables.name}}`) and filled in per input item, so the
node sends one mail per item. **Reporting digest** and **Workflow runs** make
one call, however many items arrive.

The node has three output ports:

- **Success** — the call went through.
- **Permission Denied** — dcupl answered 401/403: it rejected the runner's own
  credentials (for example, because the runner's key changed).
- **Error** — anything else: a validation error, reporting switched off for the
  project (`400`), a workflow dcupl can't find on this runner (`404`) or can't
  attribute to one project (`409`, see [Limits and notes](#limits-and-notes)),
  a server error, a network or timeout failure, or a runner that isn't connected
  to dcupl at all. The item carries `{ error: { message, statusCode? } }`.

Wire **Permission Denied** and **Error** to their own branch (a Slack alert, a
script that logs and stops, …) as you would for any other integration node, so
a bad runner key or a switched-off feature doesn't fail silently.

## The alert payload

When a rule fires, dcupl POSTs a `ReportingInvocationPayload` as JSON to the
target workflow's request trigger, as an asynchronous run:

```json
{
  "event": "run-failed",
  "projectId": "6DssgYWUvwzpFiWiZRkw",
  "rule": { "uid": "rule_abc123", "name": "Slack on failure" },
  "workflow": { "uid": "wf_def456", "key": "6DssgYWUvwzpFiWiZRkw-always-fails", "name": "Always fails" },
  "entry": {
    "executionId": "exec_789",
    "workflowKey": "6DssgYWUvwzpFiWiZRkw-always-fails",
    "status": "failed",
    "startedAt": 1757404800000,
    "duration": 842,
    "metadata": {
      "nodesExecuted": 3,
      "itemsProcessed": 12,
      "completedNodes": 2,
      "failedNodes": 1
    },
    "error": { "message": "boom", "nodeId": "transform" },
    "triggerType": "request",
    "apiPath": "/webhook",
    "runnerUid": "zaKeUgvEk6jDyk7Jl0je",
    "workflowUid": "wf_def456"
  },
  "suppressedCount": 2,
  "firedAt": 1757404860000
}
```

- `event` is `run-failed`, `run-completed` or `workflow-recovered`.
- `workflow.name` is the workflow's readable name (the key when it has none);
  use it in messages. `workflow.key` is the deployed key with the project ID.
- `entry.error` (`message`, `nodeId`) is present only for a failed run.
- `suppressedCount` is the number of runs the cooldown held back since the last
  call for this rule and workflow; always `0` for `workflow-recovered`.
- `triggerType` is how the run was started: `debug`, `cron` or `request`.
  `apiPath` is set for request-triggered runs.

## The digest payload

The **Reporting digest** operation returns one item:

```json
{
  "projectId": "6DssgYWUvwzpFiWiZRkw",
  "period": {
    "key": "2026-09-08",
    "start": 1788818400000,
    "end": 1788904800000,
    "timezone": "Europe/Vienna",
    "label": "Tue 2026-09-08"
  },
  "totals": { "runs": 40, "completed": 37, "failed": 3, "cancelled": 0, "successRate": 0.925, "avgDuration": 842 },
  "workflows": [
    {
      "workflowUid": "wf_def456",
      "workflowKey": "…-always-fails",
      "workflowName": "Always fails",
      "runs": 5,
      "completed": 2,
      "failed": 3,
      "cancelled": 0,
      "avgDuration": 601
    }
  ],
  "topErrors": [
    { "message": "boom", "count": 3, "workflowKeys": ["…-always-fails"], "workflowNames": ["Always fails"] }
  ],
  "generatedAt": 1788933900000
}
```

- `period.key` is `YYYY-MM-DD` for a daily digest and an ISO week `YYYY-Www` for
  a weekly one; `period.label` is the readable form the messages show.
- `totals.successRate` is `0`–`1` (`0` when `runs` is `0`); `avgDuration` is in
  rounded milliseconds.
- `workflows` has one row per workflow that ran in the period, sorted by
  `failed` descending, then `runs` descending, then `workflowKey`.
- `topErrors` holds up to 10 distinct failure messages, sorted by `count`
  descending, each with the keys (`workflowKeys`) and names (`workflowNames`)
  of the workflows it occurred on.
- `generatedAt` is when the digest was built, not when the period ended.

## Reporting API endpoints

Two endpoints serve callers outside a workflow, such as scripts or CI jobs, that
hold a project **CLI API key** (Project Settings → API Keys → type "CLI"). A
reporting workflow never needs them: its dcupl API node reaches the same
operations without a key. Both live on `https://api.dcupl.com`; send the key in
an `api-key` header.

**`POST /projects/:id/reporting/email`** sends a transactional e-mail:

```json
{
  "to": ["ops@example.com", "eng@example.com"],
  "subject": "dcupl digest: 42 runs, 3 failed",
  "text": "…"
}
```

- `to` — an array of 1–10 addresses (after trimming and removing duplicates),
  or one comma-separated string.
- `subject` — trimmed, up to 200 characters, no line breaks (rejected with
  `400 "subject must not contain line breaks"`).
- `text` and/or `html` — at least one with non-whitespace content, each up to
  100 kB.

It answers `200 { "accepted": <n> }`, `400` on a validation error, and
`502 "Mail delivery failed"` when the mail can't be delivered. Mails come from
`no-reply@dcupl.com`, display name "dcupl Reporting". It needs the `owner` or
`editor` role.

**`GET /projects/:id/reporting/executions`** lists recorded runs. Query
parameters: `workflowUid`, `status` (comma-separated), `since` (epoch ms) and
`limit` (1–500, default 100). dcupl keeps up to 100 runs per workflow for 30
days. It needs the `viewer` role or above.

## Limits and notes

- **Three events only:** `run-failed`, `run-completed`, `workflow-recovered`.
  Digests are scheduled workflows, not rule events.
- **A recovery right after a restart of the dcupl platform may go unreported**,
  because Workflow recovered compares a run with the workflow's previous one.
- **Digest totals are counted per UTC day.** For a timezone away from UTC, the
  window a digest covers can shift by part of a day at its edges, most visibly
  west of UTC around local midnight.
- **The dcupl API node works only on a runner connected to dcupl.** On a local
  or offline runner it fails on its **Error** port with a message saying so.
  That is different from **Permission Denied**, where the runner is connected
  but the call was rejected.
- **A workflow key two projects deploy on the same runner is ambiguous.** dcupl
  finds the calling project from the workflow key alone, so that one project's
  workflow can never read another project's data on a shared runner. If two
  projects deploy a workflow under the same key on the same runner, the dcupl
  API node gets `409` on its **Error** port.
- **Runners in your own infrastructure** send the same run metadata to dcupl
  (never item data). Set `DCUPL_RUN_REPORTING=false` on such a runner to opt it
  out; no runs are reported and its boot log says "Run reporting disabled".

## Next steps

- [Getting started](/console/workflows/getting-started) — build your first workflow step-by-step
- [Workflows Overview](/console/workflows) — nodes, edges, and merge strategies
- [Template reference](/console/workflows/reference) — the template JSON, script globals and limits
