Skip to content

Proposal: per-step analytics for workflows (sent / delivered / open rate / click rate) #447

Description

@cyrilchandelier

Problem

Campaigns expose delivery and engagement stats, but workflows are a black box. Once a workflow is live there is no way to answer:

  • How many emails did step 3 actually send?
  • Is the open rate on the follow-up email worse than the welcome email?
  • Where in the sequence do contacts drop off?

For anyone running onboarding or lifecycle sequences this is the difference between "the automation runs" and "the automation works". Today the only recourse is querying the database directly, and for Cloud customers, that's not even an option.

Good news: the data already exists

No information is lost today, it just isn't aggregated or exposed.

  • Email.workflowStepExecutionId is populated at send time (WorkflowExecutionService.ts:576), so every workflow email is traceable to the exact step that sent it.
  • Email already carries sentAt, deliveredAt, openedAt, clickedAt, bouncedAt, complainedAt, plus opens / clicks counters.
  • WorkflowStepExecution carries stepId and status, which gives entry/completion counts for every step type, not just email steps.
  • The SES webhook handler in EmailService.handleWebhookEvent (EmailService.ts:552-587) already increments denormalized campaign counters on delivered / opened / clicked / bounced. There is simply no equivalent branch for workflows.

What is missing

  1. No aggregate. Computing stats on the fly means joining emails → workflow_step_executions → stepId across millions of rows on every page view. I'm assuming the performance hit is out of question.
  2. No API. Workflows.ts has no stats endpoint.
  3. No UI. WorkflowBuilder.tsx / WorkflowVisualizer.tsx render step nodes with no metrics, and WorkflowEdge.tsx renders edges with no metrics.

Proposed implementation

1. Schema: denormalized counters, mirroring Campaign

Add counters directly to WorkflowStep rather than introducing a separate stats table, so the shape matches the existing Campaign precedent (totalRecipients / sentCount / deliveredCount / etc.) and reads need no join.

model WorkflowStep {
  // ... existing fields

  // Stats (denormalized counters, incremented on write)
  enteredCount   Int @default(0)  // all step types
  completedCount Int @default(0)  // all step types

  sentCount         Int @default(0)  // SEND_EMAIL only
  deliveredCount    Int @default(0)
  openedCount       Int @default(0)  // unique, matches campaign semantics
  clickedCount      Int @default(0)  // unique
  bouncedCount      Int @default(0)
  complainedCount   Int @default(0)
}
model WorkflowTransition {
  // ... existing fields
  traversedCount Int @default(0)
}
model Workflow {
  // ... existing fields
  statsSince DateTime?  // when counters started being collected; null = since forever (backfilled)
}

On the trade-off of counters-on-the-row: WorkflowStep rows are wider than a counter table and are read on every execution tick, so constant increments do create more MVCC churn than a narrow dedicated table would. In practice the counters are unindexed, so Postgres can apply HOT updates, and fillfactor can be tuned if page pressure ever shows up. Consistency with the campaign pattern and a smaller diff seem worth more than a second table at this stage; splitting stats out later is a mechanical migration if write volume demands it.

2. Write path

Email events. Add a workflow branch alongside the existing campaign branch in EmailService.handleWebhookEvent. stepId is reachable from the already-loaded email via workflowStepExecution (join on primary key, cheap):

if (email.workflowStepExecutionId) {
  // same switch as the campaign branch:
  //   delivered → deliveredCount
  //   opened    → openedCount, only if !email.openedAt  (unique, matches campaign semantics)
  //   clicked   → clickedCount, only if !email.clickedAt
  //   bounced   → bouncedCount
  //   complained → complainedCount
}

Keeping the !email.openedAt / !email.clickedAt guards means workflow open/click rates are unique-based and directly comparable with campaign rates shown elsewhere in the dashboard.

sentCount increments where the email is created in WorkflowExecutionService.ts:576.

Step entry/completion. Increment enteredCount / completedCount where WorkflowStepExecution rows are created and transitioned to COMPLETED in WorkflowExecutionService.

Transitions. Increment traversedCount at the branch-selection sites in WorkflowExecutionService:

  • processNextSteps (~:1113-1138), conditional branch selection
  • the WAIT_FOR_EVENT timeout / fallback path (~:351-375)
  • the first-transition path (~:648-657)

All three should funnel through one private helper (recordTransition(transitionId)) so the behaviour cannot drift between call sites.

3. Read path

GET /workflows/:id/stats returns, in one query with no joins:

{
  "statsSince": "2026-08-04T00:00:00Z",  // null = complete history
  "steps": {
    "<stepId>": {
      "enteredCount": 1200, "completedCount": 1180,
      "sentCount": 1180, "deliveredCount": 1150,
      "openedCount": 520, "clickedCount": 90,
      "bouncedCount": 30, "complainedCount": 2,
      "openRate": 45.2, "clickRate": 7.8, "bounceRate": 2.5
    }
  },
  "transitions": {
    "<transitionId>": { "traversedCount": 420 }
  }
}

Rates computed server-side over deliveredCount (or sentCount) to match how AnalyticsService derives campaign rates.

4. UI

  • Step nodes (CustomNode in WorkflowBuilder.tsx:277, and WorkflowVisualizer.tsx): compact footer on SEND_EMAIL nodes, Sent 1,180 · Open 45% · Click 8%. Non-email steps show Entered 1,200.
  • Edges (WorkflowEdge.tsx): traversal count and share of the source step's completedCount, e.g. 420 (84%) on the yes branch, 80 (16%) on the no branch. The gap between the sum of outgoing edges and the source's completedCount surfaces drop-off directly.
  • When statsSince is set, the workflow page shows "Stats since Aug 4, 2026" so pre-existing workflows don't look broken with zeroes.

5. Backfill, deliberately not in the migration

The Prisma migration should only do ALTER TABLE ... ADD COLUMN ... INTEGER NOT NULL DEFAULT 0, which is metadata-only on Postgres 11+: instant regardless of table size, and equally safe for Plunk Cloud and for a self-hoster on modest hardware.

Aggregating historical emails inside migrate deploy would be blocking, unbounded, and could wedge a release on a large database. Instead:

  • New/existing workflows get statsSince = now() at deploy. The UI is honest about the window; nothing needs to be computed.
  • A separate, optional script (apps/api/src/scripts/backfill-workflow-step-stats.ts) lets the operator reconstruct history off-peak if they want it, and clears statsSince for workflows it fully backfills.

The script should be --dry-run capable, filterable by --workflow-id, batched per step with a sleep between batches, and idempotent (writing absolute values rather than increments, so a rerun cannot double-count).

The per-step aggregate stays index-friendly using the existing workflow_step_executions(stepId) and emails(workflowStepExecutionId) indexes, one query per step rather than one scan over the whole email table:

SELECT
  count(*) FILTER (WHERE e."sentAt"       IS NOT NULL) AS sent,
  count(*) FILTER (WHERE e."deliveredAt"  IS NOT NULL) AS delivered,
  count(*) FILTER (WHERE e."openedAt"     IS NOT NULL) AS opened,
  count(*) FILTER (WHERE e."clickedAt"    IS NOT NULL) AS clicked,
  count(*) FILTER (WHERE e."bouncedAt"    IS NOT NULL) AS bounced,
  count(*) FILTER (WHERE e."complainedAt" IS NOT NULL) AS complained
FROM emails e
JOIN workflow_step_executions se ON se.id = e."workflowStepExecutionId"
WHERE se."stepId" = $1;

There is precedent for hand-written data migrations in this repo (packages/db/prisma/migrations/20260615120000_normalize_contact_emails/migration.sql), but that one had to run in lockstep with a constraint change. This one does not, which is exactly why it can live outside the deploy path.

Transitions cannot be meaningfully backfilled. Nothing records which edge a past execution traversed; it could only be approximated by counting executions of each destination step, which breaks down when several steps feed the same destination. Transition counters should start at zero and rely on statsSince.

Scope

In scope for a first PR:

  • Counters on WorkflowStep and WorkflowTransition, statsSince on Workflow
  • Write-path increments (email events, step entry/completion, transition traversal)
  • GET /workflows/:id/stats
  • Step node and edge metrics in the workflow builder/visualizer
  • Optional backfill script

Explicitly out of scope (possible follow-ups):

  • Time-windowed stats / trend charts. This needs a per-step-per-day rollup table. Rough sizing: ~60 bytes per row, 20 steps × 365 days ≈ 7.3k rows per workflow per year, so ~7M rows/year at 1,000 active workflows. Manageable, but it needs a retention policy and a rollup job, and it isn't necessary to answer the core question. Lifetime counters first; buckets only if people ask for trends.
  • Per-contact drill-down ("show me the 80 people who didn't open").
  • Exposing workflow step stats in the project-level AnalyticsService aggregates.

Questions for maintainers

  1. Counters on WorkflowStep vs. a dedicated WorkflowStepStats table: is matching the Campaign precedent the preference, or is MVCC churn on a hot-read table a concern worth pre-empting?
  2. Should rates be computed over deliveredCount or sentCount? Whichever matches the campaign dashboard is probably right; happy to follow.
  3. Is apps/api/src/scripts/ an acceptable new location for operator scripts, or is there an existing convention for one-off maintenance tasks?

@driaug I'd be happy to open a PR if this direction sounds right, just let me know.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions