Skip to content

Workflow health: bucketed run counts behind the window - #5162

Open
midigofrank wants to merge 3 commits into
frank/con-106from
frank/con-182
Open

Workflow health: bucketed run counts behind the window#5162
midigofrank wants to merge 3 commits into
frank/con-106from
frank/con-182

Conversation

@midigofrank

@midigofrank midigofrank commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Description

The outcomes and failures panels both aggregate over the whole window, so neither can answer when the traffic arrived. Add Stats.runs/2 and GET /health/runs: final run counts per state, bucketed over time — 2-hourly over a day, AM/PM over a week, daily over a month.

The bucketing is done in Postgres, not the browser. The alternative is shipping every run in the window — six figures of rows on a busy workflow, cached whole and JSON-encoded — to draw thirty bars.

Runs are counted on inserted_at rather than the state transition, so a run stays in the bar its attempt started in. The redundant last_activity condition on the work order is there for the planner: it lets the existing work_orders(workflow_id, last_activity) index narrow the work orders before the nested loop into runs(work_order_id, inserted_at).

Response

GET /api/projects/:project_id/workflows/:workflow_id/health/runs?days=1
{
  "window": {
    "from": "2026-09-08T12:00:00Z",
    "to": "2026-09-09T13:47:09.512345Z"
  },
  "buckets": [
    { "at": "2026-09-08T12:00:00Z", "success": 41, "failed": 2, "crashed": 0,
      "cancelled": 0, "killed": 0, "exception": 0, "lost": 0 },
    { "at": "2026-09-08T14:00:00Z", "success": 38, "failed": 0, "crashed": 1,
      "cancelled": 0, "killed": 0, "exception": 0, "lost": 0 },
    "… 11 more, through 2026-09-09T12:00:00Z"
  ]
}

Three things about that shape, all of them for the chart:

A bucket is flat, not nested. { at, success, failed, … } is the row shape Recharts takes as data, so the list goes to the chart untouched and each Bar names the state it draws:

<ResponsiveContainer width="100%" height={220}>
  <BarChart data={data.buckets} accessibilityLayer>
    <XAxis dataKey="at" tickFormatter={tick} />
    <YAxis allowDecimals={false} />
    <Tooltip labelFormatter={tick} />
    {RUN_STATES.map(state => (
      <Bar key={state} dataKey={state} stackId="runs" fill={COLORS[state]} />
    ))}
  </BarChart>
</ResponsiveContainer>

No reduce to pivot counts into rows, no groupBy in the client, and a state added to Run.final_states/0 shows up as a key rather than as a silently dropped row.

Every bucket carries every state, zero-filled, so the chart draws a flat window without reasoning about which bars are missing — an idle day is 13 bars of zero, not an empty box. (This is the case the donut needed an explicit emptyMessage branch for; a bar chart doesn't.)

Boundaries sit on the clock, so at can be labelled honestly and the labels don't shift with whatever minute the request landed on:

const tick = (at: string) => {
  const d = new Date(at);

  switch (days) {
    case 1:  return d.toLocaleTimeString([], { hour: 'numeric' });   // "2 PM"
    case 7:  return `${d.toLocaleDateString([], { weekday: 'short' })} ` +
                    `${d.getHours() < 12 ? 'AM' : 'PM'}`;            // "Tue AM"
    case 30: return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
  }
};

Every bucket width divides a day evenly, and window.from is aligned to the width, so integer division by the width is the whole of the bucketing in SQL — no date_trunc special case per range.

Two details worth a look in review

extract(epoch from …) yields numeric, and numeric::bigint rounds. Without the floor a run at 01:59:59.7 is counted in the 02:00 bar; there's a test pinned a hair under a boundary for exactly this.

Each window carries one bucket more than its width divides into (13 / 15 / 31, not 12 / 14 / 30). now sits mid-bucket, so a grid of exactly days_back / width bars would start after now - days_back and leave the oldest hours of the window undrawn while the donuts beside it counted them. The oldest bar reaches back past from instead, and window reports the range the bars actually cover — the last bar is the one now falls in, so it's still filling.

Closes CON-182

AI Usage

Please disclose whether you've used AI anywhere in this PR (it's cool, we just
want to know!):

  • I have used Claude Code
  • I have used another model
  • I have not used AI

You can read more details in our
Responsible AI Policy

Pre-submission checklist

  • I have performed an AI review of my code (we recommend using /review
    with Claude Code)
  • I have implemented and tested all related authorization policies.
    (e.g., :owner, :admin, :editor, :viewer)
  • I have updated the changelog.
  • I have ticked a box in "AI usage" in this PR

The outcomes and failures slices both aggregate, so neither can answer
when the traffic arrived. Add Stats.runs/2 and GET /health/runs, which
return every run that reached a final state in the window, oldest first,
so a chart can bucket them over time.

Runs are filtered on inserted_at rather than the state transition, so a
run stays in the bar its attempt started in. The redundant last_activity
condition on the work order is there for the planner: it lets the
existing index narrow the work orders before the nested loop into runs.
@github-project-automation github-project-automation Bot moved this to New Issues in Core Sep 9, 2026
@codecov

codecov Bot commented Sep 9, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.45455% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 90.8%. Comparing base (28ec391) to head (77d6c89).

Files with missing lines Patch % Lines
lib/lightning/workflows/stats.ex 95.0% 1 Missing ⚠️
Additional details and impacted files
@@               Coverage Diff               @@
##           frank/con-106   #5162     +/-   ##
===============================================
- Coverage           90.8%   90.8%   -0.0%     
===============================================
  Files                422     422             
  Lines              20864   20881     +17     
===============================================
+ Hits               18950   18959      +9     
- Misses              1914    1922      +8     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Stats.runs/2 returned every final run in the window and left the browser
to bucket them. On a busy workflow that's six figures of rows, cached
whole and JSON-encoded, to draw thirty bars. Count them in Postgres
instead: 2-hourly over a day, AM/PM over a week, daily over a month.

The grid is aligned to the clock, so integer division by the bucket
width is the whole of the bucketing — no date_trunc case per width — and
a bar labelled "2am" or "Tuesday" is telling the truth. extract(epoch
from ...) yields numeric and the cast to bigint rounds, so the query
floors first; without it a run at 01:59:59.7 counts in the 02:00 bar.

Each window carries one bucket more than its width divides into. `now`
sits mid-bucket, so a grid of exactly days_back / width bars would start
after now - days_back and leave the oldest hours of the window undrawn
while the donuts beside it counted them. The oldest bar reaches back
past `from` instead, and `window` reports the range actually covered.

Buckets come back zero-filled across every final state and flat rather
than nested, which is the row shape Recharts takes as `data`.
@midigofrank midigofrank changed the title Workflow health: serve the individual runs behind the window Workflow health: bucketed run counts behind the window Sep 9, 2026
@midigofrank
midigofrank marked this pull request as ready for review September 9, 2026 17:23
@midigofrank
midigofrank requested a review from lmac-1 September 9, 2026 17:23
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown

Security Review ✅

  • S0 (project scoping): WorkflowHealthController.authorize_workflow/2 (lib/lightning_web/controllers/api/workflow_health_controller.ex:64) fetches through Workflows.get_workflow_for_project/3, which now routes via Query.workflows_for(project) (lib/lightning/workflows.ex:145) filtering both project_id and deleted_at; Workflows.Stats queries then filter by that workflow's id, the LiveView is guarded by the :project_scope / :ensure_workflow_belongs_to_project hooks, the new SearchParams error-signature filter runs inside the already-project-scoped search_workorders_query (lib/lightning/invocation.ex:626), and Jobs.get_job_name/2 (lib/lightning/jobs.ex:172) joins workflow and filters project_id.
  • S1 (authorization): Controller re-invokes Permissions.can(:project_users, :access_project, user, project) inside its plug so cookie auth alone isn't enough (lib/lightning_web/controllers/api/workflow_health_controller.ex:75), returning 404 on any failure to avoid confirming existence; coverage in test/lightning_web/controllers/api/workflow_health_controller_test.exs:45-222 exercises anonymous, non-member, cross-project, support-user opt-in, MFA-blocked, scheduled-deletion project, soft-deleted workflow, and malformed-id cases.
  • S2 (audit trail): N/A — the PR only adds read-only stats endpoints and read-only UI; no new writes to workflows, credentials, project settings, or other config resources.

con-106 replaced the health page's push-based cache invalidation with a
change marker in the cache key; con-182 added the bucketed runs chart
beside it. Kept the runs slice, dropped `Stats.invalidate/1` and its test.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: New Issues

Development

Successfully merging this pull request may close these issues.

2 participants