|
| 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 | +}) |
0 commit comments