Nyk commited on
Commit
503957a
·
1 Parent(s): 1bc4581

feat(metrics): add task regression trend endpoint

Browse files
src/app/api/tasks/regression/route.ts ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from 'next/server'
2
+ import { requireRole } from '@/lib/auth'
3
+ import { readLimiter } from '@/lib/rate-limit'
4
+ import { getDatabase } from '@/lib/db'
5
+ import { logger } from '@/lib/logger'
6
+
7
+ interface RegressionTaskRow {
8
+ id: number
9
+ created_at: number
10
+ completed_at: number | null
11
+ retry_count: number | null
12
+ outcome: string | null
13
+ error_message: string | null
14
+ }
15
+
16
+ interface WindowStats {
17
+ label: 'baseline' | 'post'
18
+ start: number
19
+ end: number
20
+ sample_size: number
21
+ latency_seconds: {
22
+ p50: number | null
23
+ p95: number | null
24
+ avg: number | null
25
+ }
26
+ interventions: {
27
+ count: number
28
+ rate: number
29
+ }
30
+ }
31
+
32
+ function parseTimestamp(value: string | null): number | null {
33
+ if (!value) return null
34
+ const trimmed = value.trim()
35
+ if (!trimmed) return null
36
+
37
+ const numeric = Number(trimmed)
38
+ if (Number.isFinite(numeric) && numeric > 0) {
39
+ return Math.floor(numeric)
40
+ }
41
+
42
+ const parsed = Date.parse(trimmed)
43
+ if (!Number.isNaN(parsed)) {
44
+ return Math.floor(parsed / 1000)
45
+ }
46
+
47
+ return null
48
+ }
49
+
50
+ function percentileNearestRank(values: number[], percentile: number): number | null {
51
+ if (values.length === 0) return null
52
+ const sorted = [...values].sort((a, b) => a - b)
53
+ const rank = Math.ceil((percentile / 100) * sorted.length)
54
+ const index = Math.min(sorted.length - 1, Math.max(0, rank - 1))
55
+ return sorted[index]
56
+ }
57
+
58
+ function average(values: number[]): number | null {
59
+ if (values.length === 0) return null
60
+ const sum = values.reduce((acc, value) => acc + value, 0)
61
+ return sum / values.length
62
+ }
63
+
64
+ function isTaskIntervened(row: RegressionTaskRow): boolean {
65
+ const retryCount = Number(row.retry_count || 0)
66
+ const outcome = String(row.outcome || '').toLowerCase()
67
+ const hasErrorMessage = String(row.error_message || '').trim().length > 0
68
+ return retryCount > 0 || hasErrorMessage || outcome === 'failed' || outcome === 'partial' || outcome === 'abandoned'
69
+ }
70
+
71
+ function buildWindowStats(
72
+ label: 'baseline' | 'post',
73
+ start: number,
74
+ end: number,
75
+ tasks: RegressionTaskRow[],
76
+ ): WindowStats {
77
+ const latencySamples: number[] = []
78
+ let interventionCount = 0
79
+
80
+ for (const task of tasks) {
81
+ if (!task.completed_at) continue
82
+ if (task.completed_at < start || task.completed_at >= end) continue
83
+
84
+ if (task.completed_at >= task.created_at) {
85
+ latencySamples.push(task.completed_at - task.created_at)
86
+ }
87
+ if (isTaskIntervened(task)) {
88
+ interventionCount += 1
89
+ }
90
+ }
91
+
92
+ const sampleSize = latencySamples.length
93
+ return {
94
+ label,
95
+ start,
96
+ end,
97
+ sample_size: sampleSize,
98
+ latency_seconds: {
99
+ p50: percentileNearestRank(latencySamples, 50),
100
+ p95: percentileNearestRank(latencySamples, 95),
101
+ avg: average(latencySamples),
102
+ },
103
+ interventions: {
104
+ count: interventionCount,
105
+ rate: sampleSize > 0 ? interventionCount / sampleSize : 0,
106
+ },
107
+ }
108
+ }
109
+
110
+ export async function GET(request: NextRequest) {
111
+ const auth = requireRole(request, 'viewer')
112
+ if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
113
+
114
+ const rateCheck = readLimiter(request)
115
+ if (rateCheck) return rateCheck
116
+
117
+ try {
118
+ const workspaceId = auth.user.workspace_id ?? 1
119
+ const now = Math.floor(Date.now() / 1000)
120
+ const { searchParams } = new URL(request.url)
121
+
122
+ const betaStart = parseTimestamp(searchParams.get('beta_start') || searchParams.get('cutover'))
123
+ if (!betaStart) {
124
+ return NextResponse.json({ error: 'beta_start query parameter is required (unix seconds or ISO timestamp)' }, { status: 400 })
125
+ }
126
+ if (betaStart > now) {
127
+ return NextResponse.json({ error: 'beta_start must not be in the future' }, { status: 400 })
128
+ }
129
+
130
+ const maxLookbackSeconds = 30 * 24 * 60 * 60
131
+ const lookbackSecondsRaw = Number(searchParams.get('lookback_seconds') || 7 * 24 * 60 * 60)
132
+ const lookbackSeconds = Math.min(maxLookbackSeconds, Math.max(60, Math.floor(Number.isFinite(lookbackSecondsRaw) ? lookbackSecondsRaw : 7 * 24 * 60 * 60)))
133
+
134
+ const postStart = betaStart
135
+ // Include tasks completed in the current second.
136
+ const postEnd = now + 1
137
+ const postDuration = Math.max(60, postEnd - postStart)
138
+ const baselineDuration = Math.min(lookbackSeconds, postDuration)
139
+ const baselineEnd = betaStart
140
+ const baselineStart = Math.max(0, baselineEnd - baselineDuration)
141
+
142
+ const db = getDatabase()
143
+ const rows = db.prepare(`
144
+ SELECT
145
+ id,
146
+ created_at,
147
+ completed_at,
148
+ retry_count,
149
+ outcome,
150
+ error_message
151
+ FROM tasks
152
+ WHERE workspace_id = ?
153
+ AND status = 'done'
154
+ AND completed_at IS NOT NULL
155
+ AND completed_at >= ?
156
+ AND completed_at < ?
157
+ `).all(workspaceId, baselineStart, postEnd) as RegressionTaskRow[]
158
+
159
+ const baseline = buildWindowStats('baseline', baselineStart, baselineEnd, rows)
160
+ const post = buildWindowStats('post', postStart, postEnd, rows)
161
+
162
+ const p95Delta = (post.latency_seconds.p95 !== null && baseline.latency_seconds.p95 !== null)
163
+ ? post.latency_seconds.p95 - baseline.latency_seconds.p95
164
+ : null
165
+ const interventionRateDelta = post.interventions.rate - baseline.interventions.rate
166
+
167
+ return NextResponse.json({
168
+ metric_definitions: {
169
+ p95_task_latency_seconds: '95th percentile of (completed_at - created_at) for done tasks in the window',
170
+ intervention_rate: 'intervened_task_count / sample_size where intervened = retry_count>0 OR outcome in {failed,partial,abandoned} OR error_message not empty',
171
+ },
172
+ params: {
173
+ beta_start: betaStart,
174
+ lookback_seconds: lookbackSeconds,
175
+ },
176
+ windows: {
177
+ baseline,
178
+ post,
179
+ },
180
+ deltas: {
181
+ p95_latency_seconds: p95Delta,
182
+ intervention_rate: interventionRateDelta,
183
+ },
184
+ })
185
+ } catch (error) {
186
+ logger.error({ err: error }, 'GET /api/tasks/regression error')
187
+ return NextResponse.json({ error: 'Failed to compute regression metrics' }, { status: 500 })
188
+ }
189
+ }
tests/task-regression.spec.ts ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { expect, test } from '@playwright/test'
2
+ import { API_KEY_HEADER, createTestTask, deleteTestTask } from './helpers'
3
+
4
+ test.describe('Task Regression Metrics API', () => {
5
+ const cleanup: number[] = []
6
+
7
+ test.afterEach(async ({ request }) => {
8
+ for (const id of cleanup) {
9
+ await deleteTestTask(request, id).catch(() => {})
10
+ }
11
+ cleanup.length = 0
12
+ })
13
+
14
+ test('returns baseline vs post p95 latency and intervention trend', async ({ request }) => {
15
+ const baselineTaskA = await createTestTask(request, {
16
+ status: 'done',
17
+ retry_count: 0,
18
+ outcome: 'success',
19
+ })
20
+ expect(baselineTaskA.res.status()).toBe(201)
21
+ cleanup.push(baselineTaskA.id)
22
+
23
+ const baselineTaskB = await createTestTask(request, {
24
+ status: 'done',
25
+ retry_count: 1,
26
+ outcome: 'partial',
27
+ error_message: 'Needs operator check',
28
+ })
29
+ expect(baselineTaskB.res.status()).toBe(201)
30
+ cleanup.push(baselineTaskB.id)
31
+
32
+ await new Promise((resolve) => setTimeout(resolve, 1200))
33
+ const betaStart = Math.floor(Date.now() / 1000)
34
+
35
+ const postTaskA = await createTestTask(request, {
36
+ status: 'done',
37
+ retry_count: 2,
38
+ outcome: 'failed',
39
+ error_message: 'Escalated',
40
+ })
41
+ expect(postTaskA.res.status()).toBe(201)
42
+ cleanup.push(postTaskA.id)
43
+
44
+ const postTaskB = await createTestTask(request, {
45
+ status: 'done',
46
+ retry_count: 1,
47
+ outcome: 'abandoned',
48
+ error_message: 'Manual rollback',
49
+ })
50
+ expect(postTaskB.res.status()).toBe(201)
51
+ cleanup.push(postTaskB.id)
52
+
53
+ const res = await request.get(`/api/tasks/regression?beta_start=${betaStart}&lookback_seconds=3600`, {
54
+ headers: API_KEY_HEADER,
55
+ })
56
+ const responseText = await res.text()
57
+ expect(res.status(), responseText).toBe(200)
58
+
59
+ const body = JSON.parse(responseText)
60
+ expect(body.metric_definitions).toBeTruthy()
61
+ expect(body.windows?.baseline?.sample_size).toBeGreaterThan(0)
62
+ expect(body.windows?.post?.sample_size).toBeGreaterThan(0)
63
+ expect(typeof body.windows?.baseline?.latency_seconds?.p95).toBe('number')
64
+ expect(typeof body.windows?.post?.latency_seconds?.p95).toBe('number')
65
+ expect(body.windows?.post?.interventions?.rate).toBeGreaterThan(body.windows?.baseline?.interventions?.rate)
66
+ })
67
+
68
+ test('requires beta_start and auth', async ({ request }) => {
69
+ const unauth = await request.get('/api/tasks/regression?beta_start=1700000000')
70
+ expect(unauth.status()).toBe(401)
71
+
72
+ const missing = await request.get('/api/tasks/regression', { headers: API_KEY_HEADER })
73
+ expect(missing.status()).toBe(400)
74
+ })
75
+ })