Skip to content

Commit 6abc330

Browse files
authored
improvement(logs): group dashboard stats by workflow id and join names once per group (#8973)
* improvement(logs): group dashboard stats by workflow id and resolve names afterwards * improvement(logs): join workflow names once per group in the same stats statement
1 parent 1971b5f commit 6abc330

2 files changed

Lines changed: 360 additions & 8 deletions

File tree

Lines changed: 336 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,336 @@
1+
/**
2+
* Dashboard stats against real PostgreSQL: grouping by `workflow_id` and
3+
* joining names once per group answers exactly what the read did when it
4+
* grouped by the name joined on every row — for every filter, for
5+
* runs whose workflow was deleted or never recorded, and past the
6+
* `MAX_STATS_WORKFLOWS` cut, whose ties are ordered by name.
7+
*/
8+
import { db } from '@sim/db'
9+
import {
10+
folder,
11+
user,
12+
workflow,
13+
workflowExecutionLogs,
14+
workflowExecutionSnapshots,
15+
workspace,
16+
} from '@sim/db/schema'
17+
import { deleteWorkspaceFixture, insertWorkspaceFixture } from '@sim/db/testing/workspace-fixtures'
18+
import { generateId } from '@sim/utils/id'
19+
import { compareStrings } from '@sim/utils/string'
20+
import { and, eq, inArray, type SQL, sql } from 'drizzle-orm'
21+
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
22+
import { MAX_STATS_WORKFLOWS } from '@/lib/api/contracts/logs'
23+
import { buildFilterConditions, type LogFilterParams } from '@/lib/logs/filters'
24+
import { folderScopeCondition, type LogFolderScope } from '@/lib/logs/folder-scope'
25+
import { handledErrorSpanCondition } from '@/lib/logs/handled-errors'
26+
import { buildLogFilters, type LogFilters } from '@/lib/logs/public-filters'
27+
import { buildDashboardStats, type LogStatsWindow } from '@/lib/logs/stats'
28+
import { type LogStatsSegmentRow, readLogStatsSegments } from '@/lib/logs/stats-queries'
29+
30+
const prefix = generateId()
31+
const ids = {
32+
owner: `log-stats-${prefix}`,
33+
workspace: generateId(),
34+
snapshot: generateId(),
35+
folder: generateId(),
36+
childFolder: generateId(),
37+
deletedWorkflow: generateId(),
38+
}
39+
const FOLDER_NAME = `Stats Folder ${prefix}`
40+
41+
/** More live workflows than the cut keeps, so the deleted series pushes it further over. */
42+
const WORKFLOW_COUNT = MAX_STATS_WORKFLOWS + 5
43+
const workflowIds = Array.from({ length: WORKFLOW_COUNT }, () => generateId())
44+
const TRIGGERS = ['api', 'webhook', 'schedule', 'manual'] as const
45+
46+
const SEGMENT_COUNT = 24
47+
const WINDOW: LogStatsWindow = {
48+
startTime: new Date('2026-01-01T00:00:00.000Z'),
49+
endTime: new Date('2026-01-02T00:00:00.000Z'),
50+
segmentMs: 60 * 60 * 1000,
51+
}
52+
53+
function runAt(hour: number, minute: number) {
54+
return new Date(Date.UTC(2026, 0, 1, hour, minute))
55+
}
56+
57+
/**
58+
* The read as it stood before it stopped grouping by the joined name: the
59+
* oracle each case is compared against.
60+
*/
61+
async function readSegmentsBeforeChange(
62+
where: SQL | undefined,
63+
countHandledErrors: boolean
64+
): Promise<LogStatsSegmentRow[]> {
65+
const startTimeIso = WINDOW.startTime.toISOString()
66+
return db
67+
.select({
68+
handledErrorRuns: (countHandledErrors
69+
? sql<number>`COUNT(*) FILTER (WHERE ${handledErrorSpanCondition()})`
70+
: sql<number>`0`
71+
).as('handled_error_runs'),
72+
workflowId: sql<string>`COALESCE(${workflowExecutionLogs.workflowId}, 'deleted')`,
73+
workflowName: sql<string>`COALESCE(${workflow.name}, 'Deleted Workflow')`,
74+
segmentIndex:
75+
sql<number>`FLOOR(EXTRACT(EPOCH FROM (${workflowExecutionLogs.startedAt} - ${startTimeIso}::timestamp)) * 1000 / ${WINDOW.segmentMs})`.as(
76+
'segment_index'
77+
),
78+
totalExecutions: sql<number>`COUNT(*)`.as('total_executions'),
79+
successfulExecutions:
80+
sql<number>`COUNT(*) FILTER (WHERE ${workflowExecutionLogs.level} != 'error')`.as(
81+
'successful_executions'
82+
),
83+
avgDurationMs:
84+
sql<number>`COALESCE(AVG(${workflowExecutionLogs.totalDurationMs}) FILTER (WHERE ${workflowExecutionLogs.totalDurationMs} > 0), 0)`.as(
85+
'avg_duration_ms'
86+
),
87+
})
88+
.from(workflowExecutionLogs)
89+
.leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
90+
.where(where)
91+
.groupBy(
92+
sql`COALESCE(${workflowExecutionLogs.workflowId}, 'deleted')`,
93+
sql`COALESCE(${workflow.name}, 'Deleted Workflow')`,
94+
sql`segment_index`
95+
)
96+
}
97+
98+
function canonical(rows: LogStatsSegmentRow[]) {
99+
return [...rows].sort(
100+
(a, b) =>
101+
compareStrings(a.workflowId, b.workflowId) || Number(a.segmentIndex) - Number(b.segmentIndex)
102+
)
103+
}
104+
105+
async function expectSameStats(where: SQL | undefined, countHandledErrors = false) {
106+
const [before, after] = await Promise.all([
107+
readSegmentsBeforeChange(where, countHandledErrors),
108+
readLogStatsSegments(where, WINDOW.startTime.toISOString(), WINDOW.segmentMs, {
109+
countHandledErrors,
110+
}),
111+
])
112+
113+
expect(canonical(after)).toEqual(canonical(before))
114+
115+
const options = { maxWorkflows: MAX_STATS_WORKFLOWS, includeHandledErrors: countHandledErrors }
116+
const expected = buildDashboardStats(canonical(before), WINDOW, SEGMENT_COUNT, options)
117+
expect(buildDashboardStats(canonical(after), WINDOW, SEGMENT_COUNT, options)).toEqual(expected)
118+
expect(buildDashboardStats(canonical(after), WINDOW, SEGMENT_COUNT)).toEqual(
119+
buildDashboardStats(canonical(before), WINDOW, SEGMENT_COUNT)
120+
)
121+
return { before, after }
122+
}
123+
124+
function scopedTo(filters: SQL | undefined) {
125+
return and(eq(workflowExecutionLogs.workspaceId, ids.workspace), filters)
126+
}
127+
128+
beforeAll(async () => {
129+
const now = new Date()
130+
await db.insert(user).values({
131+
id: ids.owner,
132+
name: 'Log Stats',
133+
email: `${ids.owner}@log-stats.test`,
134+
emailVerified: true,
135+
createdAt: now,
136+
updatedAt: now,
137+
})
138+
await insertWorkspaceFixture(db, {
139+
id: ids.workspace,
140+
name: 'Log Stats',
141+
ownerId: ids.owner,
142+
billedAccountUserId: ids.owner,
143+
})
144+
await db.insert(folder).values([
145+
{
146+
id: ids.folder,
147+
resourceType: 'workflow',
148+
name: FOLDER_NAME,
149+
userId: ids.owner,
150+
workspaceId: ids.workspace,
151+
},
152+
{
153+
id: ids.childFolder,
154+
resourceType: 'workflow',
155+
name: `Nested ${prefix}`,
156+
userId: ids.owner,
157+
workspaceId: ids.workspace,
158+
parentId: ids.folder,
159+
},
160+
])
161+
162+
const folderFor = (index: number) =>
163+
index % 5 === 0 ? ids.folder : index % 5 === 1 ? ids.childFolder : null
164+
await db.insert(workflow).values([
165+
...workflowIds.map((id, index) => ({
166+
id,
167+
userId: ids.owner,
168+
workspaceId: ids.workspace,
169+
folderId: folderFor(index),
170+
name: `Workflow ${String(index).padStart(3, '0')}`,
171+
lastSynced: now,
172+
createdAt: now,
173+
updatedAt: now,
174+
})),
175+
{
176+
id: ids.deletedWorkflow,
177+
userId: ids.owner,
178+
workspaceId: ids.workspace,
179+
folderId: ids.folder,
180+
name: 'Doomed Workflow',
181+
lastSynced: now,
182+
createdAt: now,
183+
updatedAt: now,
184+
},
185+
])
186+
await db.insert(workflowExecutionSnapshots).values({
187+
id: ids.snapshot,
188+
workflowId: workflowIds[0],
189+
stateHash: ids.snapshot,
190+
stateData: {},
191+
})
192+
193+
const run = (
194+
workflowId: string | null,
195+
n: number,
196+
level: 'info' | 'error',
197+
executionData: Record<string, unknown> = {}
198+
) => ({
199+
id: generateId(),
200+
workflowId,
201+
workspaceId: ids.workspace,
202+
executionId: `${prefix}-exec-${generateId()}`,
203+
stateSnapshotId: ids.snapshot,
204+
level,
205+
status: level === 'error' ? 'failed' : 'completed',
206+
trigger: TRIGGERS[n % TRIGGERS.length],
207+
startedAt: runAt(n % SEGMENT_COUNT, (n * 7) % 60),
208+
totalDurationMs: n % 6 === 0 ? null : n % 6 === 1 ? 0 : 100 + n * 13,
209+
costTotal: String((n % 9) * 0.01),
210+
executionData,
211+
})
212+
213+
/**
214+
* Most workflows never fail, so the cut falls inside a tie on error rate that
215+
* only names can order; a few fail, and one run carries a handled error.
216+
*/
217+
await db
218+
.insert(workflowExecutionLogs)
219+
.values([
220+
...workflowIds.flatMap((id, index) => [
221+
run(id, index, 'info', index === 3 ? { hasHandledErrors: true } : {}),
222+
run(id, index + 11, index % 17 === 0 ? 'error' : 'info'),
223+
...(index % 3 === 0 ? [run(id, index + 5, 'info')] : []),
224+
]),
225+
run(ids.deletedWorkflow, 2, 'info'),
226+
run(ids.deletedWorkflow, 9, 'error'),
227+
run(null, 4, 'info'),
228+
run(null, 30, 'error'),
229+
])
230+
231+
await db.delete(workflow).where(eq(workflow.id, ids.deletedWorkflow))
232+
}, 60_000)
233+
234+
afterAll(async () => {
235+
await db.delete(workflowExecutionLogs).where(eq(workflowExecutionLogs.workspaceId, ids.workspace))
236+
await db.delete(workflowExecutionSnapshots).where(eq(workflowExecutionSnapshots.id, ids.snapshot))
237+
await db.delete(workflow).where(inArray(workflow.id, workflowIds))
238+
await db.delete(folder).where(eq(folder.workspaceId, ids.workspace))
239+
await deleteWorkspaceFixture(db, eq(workspace.id, ids.workspace))
240+
await db.delete(user).where(eq(user.id, ids.owner))
241+
})
242+
243+
describe('dashboard stats segments', () => {
244+
it('keeps runs of deleted and unrecorded workflows in one named series', async () => {
245+
const { after } = await expectSameStats(scopedTo(undefined))
246+
247+
const deleted = after.filter((row) => row.workflowId === 'deleted')
248+
expect(deleted.every((row) => row.workflowName === 'Deleted Workflow')).toBe(true)
249+
expect(deleted.reduce((sum, row) => sum + Number(row.totalExecutions), 0)).toBe(4)
250+
})
251+
252+
it('cuts the per-workflow series at the same workflows, ordered by name inside a tie', async () => {
253+
const { after } = await expectSameStats(scopedTo(undefined))
254+
const { stats, workflowsTruncated } = buildDashboardStats(
255+
canonical(after),
256+
WINDOW,
257+
SEGMENT_COUNT,
258+
{ maxWorkflows: MAX_STATS_WORKFLOWS }
259+
)
260+
261+
expect(workflowsTruncated).toBe(true)
262+
expect(stats.workflows).toHaveLength(MAX_STATS_WORKFLOWS)
263+
expect(stats.workflows.at(-1)?.workflowName).toMatch(/^Workflow \d{3}$/)
264+
})
265+
266+
const dashboardCases: Array<[string, Omit<LogFilterParams, 'workspaceId'>]> = [
267+
['no filter', {}],
268+
['an error level', { level: 'error' }],
269+
['an info level', { level: 'info' }],
270+
['workflow ids', { workflowIds: workflowIds.slice(0, 4).join(',') }],
271+
['folder ids', { folderIds: `${ids.folder},${ids.childFolder}` }],
272+
['a workflow name search', { workflowName: 'Workflow 01' }],
273+
['a folder name search', { folderName: FOLDER_NAME }],
274+
['triggers', { triggers: 'api,webhook' }],
275+
['the all-triggers sentinel', { triggers: 'all' }],
276+
[
277+
'a date range',
278+
{ startDate: runAt(3, 0).toISOString(), endDate: runAt(15, 30).toISOString() },
279+
],
280+
['a cost bound', { costOperator: '>=', costValue: 0.04 }],
281+
['a duration bound', { durationOperator: '>', durationValue: 500 }],
282+
['an execution id search', { search: `${prefix}-exec` }],
283+
[
284+
'folder, trigger, level, and date together',
285+
{
286+
folderIds: ids.folder,
287+
triggers: 'api,schedule,manual',
288+
level: 'info',
289+
startDate: runAt(1, 0).toISOString(),
290+
},
291+
],
292+
]
293+
294+
it.each(dashboardCases)(
295+
'answers the dashboard route the same under %s',
296+
async (_case, params) => {
297+
const filterParams: LogFilterParams = { ...params, workspaceId: ids.workspace }
298+
await expectSameStats(
299+
scopedTo(buildFilterConditions(filterParams, { useSimpleLevelFilter: true }))
300+
)
301+
}
302+
)
303+
304+
const publicCases: Array<[string, Omit<LogFilters, 'workspaceId'>, LogFolderScope | undefined]> =
305+
[
306+
['no filter', {}, undefined],
307+
['workflow ids', { workflowIds: workflowIds.slice(10, 13) }, undefined],
308+
['a workflow name', { workflowName: 'workflow 1' }, undefined],
309+
['an error level', { level: 'error' }, undefined],
310+
['statuses', { statuses: ['failed'] }, undefined],
311+
['a cost range', { minCost: 0.02, maxCost: 0.06 }, undefined],
312+
['the workspace root', {}, { includesRoot: true, folderIds: [] }],
313+
['a folder subtree', {}, { includesRoot: false, folderIds: [ids.folder, ids.childFolder] }],
314+
['a folder that matched nothing', {}, { includesRoot: false, folderIds: [] }],
315+
]
316+
317+
it.each(publicCases)(
318+
'answers the public API the same under %s',
319+
async (_case, filters, scope) => {
320+
await expectSameStats(
321+
and(
322+
buildLogFilters({ ...filters, workspaceId: ids.workspace }),
323+
scope ? folderScopeCondition(scope) : undefined
324+
)
325+
)
326+
}
327+
)
328+
329+
it('counts handled errors the same', async () => {
330+
const { after } = await expectSameStats(
331+
buildLogFilters({ workspaceId: ids.workspace, level: 'error', includeHandledErrors: true }),
332+
true
333+
)
334+
expect(after.reduce((sum, row) => sum + Number(row.handledErrorRuns), 0)).toBe(1)
335+
})
336+
})

‎apps/sim/lib/logs/stats-queries.ts‎

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { dbReplica } from '@sim/db'
22
import { workflow, workflowExecutionLogs } from '@sim/db/schema'
33
import { eq, type SQL, sql } from 'drizzle-orm'
44
import { handledErrorSpanCondition } from '@/lib/logs/handled-errors'
5+
import { DELETED_WORKFLOW_LABEL } from '@/lib/workflows/workflow-labels'
56

67
/** Oldest and newest run start in the filtered set, or nulls when it is empty. */
78
export interface LogStatsBounds {
@@ -58,6 +59,12 @@ export async function readLogStatsBounds(where: SQL | undefined): Promise<LogSta
5859
* of a comparison against a typed column, so Postgres has nothing to infer the
5960
* type from and resolves no overload without it.
6061
*
62+
* Groups by the log's own `workflow_id`, then joins `workflow` once per group
63+
* for its name, in the same statement. A name is a function of the id, so this
64+
* yields the same groups as grouping by both without carrying name text through
65+
* the aggregate. The inner join to `workflow` serves only the filters that
66+
* reference it; Postgres removes it when nothing does.
67+
*
6168
* A run whose workflow has been deleted still counts — it collapses into a
6269
* single `'deleted'` series rather than disappearing, so the workspace totals
6370
* stay reconcilable against the log list.
@@ -68,14 +75,13 @@ export async function readLogStatsSegments(
6875
segmentMs: number,
6976
options: ReadLogStatsSegmentsOptions = {}
7077
): Promise<LogStatsSegmentRow[]> {
71-
return dbReplica
78+
const grouped = dbReplica
7279
.select({
7380
handledErrorRuns: (options.countHandledErrors
7481
? sql<number>`COUNT(*) FILTER (WHERE ${handledErrorSpanCondition()})`
7582
: sql<number>`0`
7683
).as('handled_error_runs'),
77-
workflowId: sql<string>`COALESCE(${workflowExecutionLogs.workflowId}, 'deleted')`,
78-
workflowName: sql<string>`COALESCE(${workflow.name}, 'Deleted Workflow')`,
84+
workflowId: workflowExecutionLogs.workflowId,
7985
segmentIndex:
8086
sql<number>`FLOOR(EXTRACT(EPOCH FROM (${workflowExecutionLogs.startedAt} - ${startTimeIso}::timestamp)) * 1000 / ${segmentMs})`.as(
8187
'segment_index'
@@ -93,9 +99,19 @@ export async function readLogStatsSegments(
9399
.from(workflowExecutionLogs)
94100
.leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
95101
.where(where)
96-
.groupBy(
97-
sql`COALESCE(${workflowExecutionLogs.workflowId}, 'deleted')`,
98-
sql`COALESCE(${workflow.name}, 'Deleted Workflow')`,
99-
sql`segment_index`
100-
)
102+
.groupBy(workflowExecutionLogs.workflowId, sql`segment_index`)
103+
.as('grouped')
104+
105+
return dbReplica
106+
.select({
107+
handledErrorRuns: grouped.handledErrorRuns,
108+
workflowId: sql<string>`COALESCE(${grouped.workflowId}, 'deleted')`,
109+
workflowName: sql<string>`COALESCE(${workflow.name}, ${DELETED_WORKFLOW_LABEL})`,
110+
segmentIndex: grouped.segmentIndex,
111+
totalExecutions: grouped.totalExecutions,
112+
successfulExecutions: grouped.successfulExecutions,
113+
avgDurationMs: grouped.avgDurationMs,
114+
})
115+
.from(grouped)
116+
.leftJoin(workflow, eq(grouped.workflowId, workflow.id))
101117
}

0 commit comments

Comments
 (0)