@shipfox/api-runners 12.4.0 → 12.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/.turbo/turbo-build.log +4 -4
  2. package/CHANGELOG.md +21 -0
  3. package/dist/core/errors.d.ts +5 -1
  4. package/dist/core/errors.d.ts.map +1 -1
  5. package/dist/core/errors.js +9 -2
  6. package/dist/core/errors.js.map +1 -1
  7. package/dist/core/runner-control-sessions.d.ts.map +1 -1
  8. package/dist/core/runner-control-sessions.js +62 -12
  9. package/dist/core/runner-control-sessions.js.map +1 -1
  10. package/dist/db/job-executions.d.ts.map +1 -1
  11. package/dist/db/job-executions.js +35 -4
  12. package/dist/db/job-executions.js.map +1 -1
  13. package/dist/db/runner-assignments.d.ts +8 -1
  14. package/dist/db/runner-assignments.d.ts.map +1 -1
  15. package/dist/db/runner-assignments.js +57 -10
  16. package/dist/db/runner-assignments.js.map +1 -1
  17. package/dist/db/runner-instances.d.ts +9 -0
  18. package/dist/db/runner-instances.d.ts.map +1 -1
  19. package/dist/db/runner-instances.js +43 -0
  20. package/dist/db/runner-instances.js.map +1 -1
  21. package/dist/db/runner-sessions.d.ts.map +1 -1
  22. package/dist/db/runner-sessions.js +14 -1
  23. package/dist/db/runner-sessions.js.map +1 -1
  24. package/dist/metrics/index.d.ts +2 -2
  25. package/dist/metrics/index.d.ts.map +1 -1
  26. package/dist/metrics/index.js +1 -1
  27. package/dist/metrics/index.js.map +1 -1
  28. package/dist/metrics/instance.d.ts +48 -0
  29. package/dist/metrics/instance.d.ts.map +1 -1
  30. package/dist/metrics/instance.js +138 -1
  31. package/dist/metrics/instance.js.map +1 -1
  32. package/dist/metrics/service.d.ts.map +1 -1
  33. package/dist/metrics/service.js +25 -4
  34. package/dist/metrics/service.js.map +1 -1
  35. package/dist/presentation/routes/assign-runner-instances.d.ts.map +1 -1
  36. package/dist/presentation/routes/assign-runner-instances.js +7 -1
  37. package/dist/presentation/routes/assign-runner-instances.js.map +1 -1
  38. package/dist/tsconfig.test.tsbuildinfo +1 -1
  39. package/package.json +2 -2
  40. package/src/core/errors.ts +30 -1
  41. package/src/core/runner-control-sessions.test.ts +71 -0
  42. package/src/core/runner-control-sessions.ts +79 -10
  43. package/src/db/job-executions.test.ts +98 -12
  44. package/src/db/job-executions.ts +52 -4
  45. package/src/db/runner-assignments.ts +67 -16
  46. package/src/db/runner-instances.test.ts +92 -0
  47. package/src/db/runner-instances.ts +74 -0
  48. package/src/db/runner-sessions.ts +19 -1
  49. package/src/metrics/index.ts +14 -0
  50. package/src/metrics/instance.test.ts +118 -0
  51. package/src/metrics/instance.ts +188 -1
  52. package/src/metrics/service.test.ts +38 -0
  53. package/src/metrics/service.ts +55 -8
  54. package/src/presentation/routes/assign-runner-instances.ts +8 -0
  55. package/tsconfig.build.tsbuildinfo +1 -1
@@ -5,6 +5,11 @@ import {
5
5
  RunnerInstanceAlreadyAssignedError,
6
6
  RunnerInstanceNotAssignableError,
7
7
  } from '#core/errors.js';
8
+ import {
9
+ type ProviderRunnerAssignmentObservation,
10
+ type RunnerAssignmentSurface,
11
+ recordProviderRunnerControlSessionToAssignment,
12
+ } from '#metrics/instance.js';
8
13
  import type {Tx} from './db.js';
9
14
  import {db} from './db.js';
10
15
  import {lockRunnerReservationAdvisoryKeysTx} from './reservation-locks.js';
@@ -19,7 +24,17 @@ export async function assignRunnerInstances(params: {
19
24
  reservationId: string;
20
25
  runnerInstanceIds: string[];
21
26
  }): Promise<string[]> {
22
- return await db().transaction(async (tx) => assignRunnerInstancesTx(tx, params));
27
+ const result = await db().transaction(async (tx) =>
28
+ assignRunnerInstancesTx(tx, {...params, surface: 'provisioner'}),
29
+ );
30
+ for (const observation of result.controlSessionToAssignment)
31
+ recordProviderRunnerControlSessionToAssignment(observation);
32
+ return result.runnerInstanceIds;
33
+ }
34
+
35
+ interface AssignRunnerInstancesTxResult {
36
+ runnerInstanceIds: string[];
37
+ controlSessionToAssignment: ProviderRunnerAssignmentObservation[];
23
38
  }
24
39
 
25
40
  export async function assignRunnerInstancesTx(
@@ -28,8 +43,9 @@ export async function assignRunnerInstancesTx(
28
43
  provisionerId: string;
29
44
  reservationId: string;
30
45
  runnerInstanceIds: string[];
46
+ surface: RunnerAssignmentSurface;
31
47
  },
32
- ): Promise<string[]> {
48
+ ): Promise<AssignRunnerInstancesTxResult> {
33
49
  const runnerInstanceIds = [...params.runnerInstanceIds].sort();
34
50
  await lockRunnerReservationAdvisoryKeysTx(tx, {
35
51
  provisionerId: params.provisionerId,
@@ -43,6 +59,8 @@ export async function assignRunnerInstancesTx(
43
59
  workspaceId: providerRunners.workspaceId,
44
60
  assignedAt: providerRunners.assignedAt,
45
61
  providerRunnerId: providerRunners.providerRunnerId,
62
+ provider: providerRunners.providerKind,
63
+ launchKind: providerRunners.launchKind,
46
64
  labels: providerRunners.labels,
47
65
  state: providerRunners.state,
48
66
  })
@@ -76,7 +94,10 @@ export async function assignRunnerInstancesTx(
76
94
  inArray(providerRunners.id, runnerInstanceIds),
77
95
  ),
78
96
  );
79
- return params.runnerInstanceIds;
97
+ return {
98
+ runnerInstanceIds: params.runnerInstanceIds,
99
+ controlSessionToAssignment: [],
100
+ };
80
101
  }
81
102
 
82
103
  const [reservation] = await tx
@@ -94,10 +115,13 @@ export async function assignRunnerInstancesTx(
94
115
  if (!reservation) throw new ReservationNotFoundError(params.reservationId);
95
116
  if (reservation.expiresAt <= new Date()) throw new ReservationExpiredError(params.reservationId);
96
117
  if (runnerRows.length !== runnerInstanceIds.length)
97
- throw new RunnerInstanceNotAssignableError(runnerInstanceIds[0] ?? '');
118
+ throw new RunnerInstanceNotAssignableError(runnerInstanceIds[0] ?? '', 'runner-not-found');
98
119
 
99
120
  const activeControlSessions = await tx
100
- .select({runnerInstanceId: runnerControlSessions.runnerInstanceId})
121
+ .select({
122
+ runnerInstanceId: runnerControlSessions.runnerInstanceId,
123
+ createdAt: runnerControlSessions.createdAt,
124
+ })
101
125
  .from(runnerControlSessions)
102
126
  .where(
103
127
  and(
@@ -111,9 +135,13 @@ export async function assignRunnerInstancesTx(
111
135
  const runnerInstanceIdsWithControlSession = new Set(
112
136
  activeControlSessions.map((session) => session.runnerInstanceId),
113
137
  );
138
+ const controlSessionCreatedAtByRunner = new Map(
139
+ activeControlSessions.map((session) => [session.runnerInstanceId, session.createdAt]),
140
+ );
114
141
  const runners = runnerRows.map((runner) => ({
115
142
  ...runner,
116
143
  controlSessionId: runnerInstanceIdsWithControlSession.has(runner.id) ? runner.id : null,
144
+ controlSessionCreatedAt: controlSessionCreatedAtByRunner.get(runner.id) ?? null,
117
145
  }));
118
146
 
119
147
  const alreadyAssigned = runners.filter((runner) => runner.reservationId !== null);
@@ -147,18 +175,19 @@ export async function assignRunnerInstancesTx(
147
175
  ),
148
176
  );
149
177
  if ((assignedCount[0]?.count ?? 0) + newRunners.length > reservation.count)
150
- throw new RunnerInstanceNotAssignableError(newRunners[0]?.id ?? '');
178
+ throw new RunnerInstanceNotAssignableError(newRunners[0]?.id ?? '', 'capacity-exhausted');
151
179
  for (const runner of newRunners) {
152
- if (
153
- runner.state !== 'running' ||
154
- !runner.providerRunnerId ||
155
- !runner.controlSessionId ||
156
- !reservation.requiredLabels.every((label) => runner.labels.includes(label))
157
- )
158
- throw new RunnerInstanceNotAssignableError(runner.id);
180
+ if (runner.state !== 'running')
181
+ throw new RunnerInstanceNotAssignableError(runner.id, 'runner-not-running');
182
+ if (!runner.providerRunnerId)
183
+ throw new RunnerInstanceNotAssignableError(runner.id, 'provider-identity-missing');
184
+ if (!runner.controlSessionId)
185
+ throw new RunnerInstanceNotAssignableError(runner.id, 'control-session-not-active');
186
+ if (!reservation.requiredLabels.every((label) => runner.labels.includes(label)))
187
+ throw new RunnerInstanceNotAssignableError(runner.id, 'labels-mismatch');
159
188
  }
160
189
  if (newRunners.length > 0) {
161
- await tx
190
+ const assignedRows = await tx
162
191
  .update(providerRunners)
163
192
  .set({
164
193
  workspaceId: reservation.workspaceId,
@@ -172,9 +201,31 @@ export async function assignRunnerInstancesTx(
172
201
  providerRunners.id,
173
202
  newRunners.map((runner) => runner.id),
174
203
  ),
175
- );
204
+ )
205
+ .returning({id: providerRunners.id, assignedAt: providerRunners.assignedAt});
206
+ const runnersById = new Map(newRunners.map((runner) => [runner.id, runner]));
207
+ const controlSessionToAssignment: ProviderRunnerAssignmentObservation[] = [];
208
+ for (const assigned of assignedRows) {
209
+ const runner = runnersById.get(assigned.id);
210
+ const controlSessionCreatedAt = runner?.controlSessionCreatedAt;
211
+ if (!runner || !assigned.assignedAt || !controlSessionCreatedAt) continue;
212
+ controlSessionToAssignment.push({
213
+ durationMilliseconds: assigned.assignedAt.getTime() - controlSessionCreatedAt.getTime(),
214
+ provider: runner.provider,
215
+ launchKind: runner.launchKind,
216
+ surface: params.surface,
217
+ runnerInstanceId: runner.id,
218
+ });
219
+ }
220
+ return {
221
+ runnerInstanceIds: params.runnerInstanceIds,
222
+ controlSessionToAssignment,
223
+ };
176
224
  }
177
- return params.runnerInstanceIds;
225
+ return {
226
+ runnerInstanceIds: params.runnerInstanceIds,
227
+ controlSessionToAssignment: [],
228
+ };
178
229
  }
179
230
 
180
231
  export type RunnerReservationCapacityFailureReason =
@@ -7,6 +7,7 @@ import {
7
7
  countStaleEnrolledRunnerInstances,
8
8
  listActiveRunnerInstanceCountsByTemplateTx,
9
9
  listActiveRunnerInstances,
10
+ listProviderRunnerByPhaseMetrics,
10
11
  listProvisionerTerminateIntentRowsTx,
11
12
  listProvisionerTerminateIntents,
12
13
  reapStaleRunnerInstances,
@@ -2540,6 +2541,97 @@ describe('countStaleEnrolledRunnerInstances', () => {
2540
2541
  }
2541
2542
  });
2542
2543
 
2544
+ describe('listProviderRunnerByPhaseMetrics', () => {
2545
+ it('groups runners by the lifecycle phase that is currently blocking activation', async () => {
2546
+ const provisionerId = crypto.randomUUID();
2547
+ const provider = `metrics-test-${crypto.randomUUID()}`;
2548
+ const reservationId = crypto.randomUUID();
2549
+ const workspaceId = crypto.randomUUID();
2550
+ const old = new Date(Date.now() - 120_000);
2551
+
2552
+ const createRunner = async (params: {
2553
+ state: 'starting' | 'running';
2554
+ launchKind: 'demand' | 'warm' | 'manual';
2555
+ intendedReservationId?: string | null;
2556
+ workspaceId?: string | null;
2557
+ reservationId?: string | null;
2558
+ assignedAt?: Date | null;
2559
+ createdAt?: Date;
2560
+ }) => {
2561
+ const [runner] = await db()
2562
+ .insert(providerRunners)
2563
+ .values({
2564
+ provisionerId,
2565
+ providerRunnerId: crypto.randomUUID(),
2566
+ providerKind: provider,
2567
+ launchKind: params.launchKind,
2568
+ intendedReservationId: params.intendedReservationId ?? null,
2569
+ workspaceId: params.workspaceId ?? null,
2570
+ reservationId: params.reservationId ?? null,
2571
+ assignedAt: params.assignedAt ?? null,
2572
+ state: params.state,
2573
+ labels: ['linux'],
2574
+ reportedAt: new Date(),
2575
+ createdAt: params.createdAt ?? old,
2576
+ })
2577
+ .returning({id: providerRunners.id});
2578
+ if (!runner) throw new Error('Expected runner instance');
2579
+ return runner.id;
2580
+ };
2581
+
2582
+ await createRunner({state: 'starting', launchKind: 'demand'});
2583
+ const enrollmentRunnerId = await createRunner({state: 'starting', launchKind: 'warm'});
2584
+ const assignmentRunnerId = await createRunner({
2585
+ state: 'running',
2586
+ launchKind: 'demand',
2587
+ intendedReservationId: reservationId,
2588
+ });
2589
+ const activationRunnerId = await createRunner({
2590
+ state: 'running',
2591
+ launchKind: 'manual',
2592
+ workspaceId,
2593
+ reservationId,
2594
+ assignedAt: old,
2595
+ });
2596
+ const idleRunnerId = await createRunner({state: 'running', launchKind: 'warm'});
2597
+ const demandIdleRunnerId = await createRunner({state: 'running', launchKind: 'demand'});
2598
+
2599
+ await db()
2600
+ .insert(runnerControlSessions)
2601
+ .values(
2602
+ [
2603
+ enrollmentRunnerId,
2604
+ assignmentRunnerId,
2605
+ activationRunnerId,
2606
+ idleRunnerId,
2607
+ demandIdleRunnerId,
2608
+ ].map((runnerInstanceId) => ({
2609
+ runnerInstanceId,
2610
+ provisionerId,
2611
+ hashedToken: crypto.randomUUID(),
2612
+ prefix: 'metrics-test',
2613
+ expiresAt: new Date(Date.now() + 60_000),
2614
+ createdAt: old,
2615
+ })),
2616
+ );
2617
+
2618
+ const metrics = await listProviderRunnerByPhaseMetrics();
2619
+ const ownMetrics = metrics.filter((metric) => metric.provider === provider);
2620
+
2621
+ expect(ownMetrics).toEqual(
2622
+ expect.arrayContaining([
2623
+ expect.objectContaining({phase: 'control_session', launchKind: 'demand', count: 1}),
2624
+ expect.objectContaining({phase: 'enrollment', launchKind: 'warm', count: 1}),
2625
+ expect.objectContaining({phase: 'assignment', launchKind: 'demand', count: 1}),
2626
+ expect.objectContaining({phase: 'activation', launchKind: 'manual', count: 1}),
2627
+ expect.objectContaining({phase: 'idle', launchKind: 'warm', count: 1}),
2628
+ expect.objectContaining({phase: 'idle', launchKind: 'demand', count: 1}),
2629
+ ]),
2630
+ );
2631
+ expect(ownMetrics).toHaveLength(6);
2632
+ });
2633
+ });
2634
+
2543
2635
  describe('runner instance provider attachment', () => {
2544
2636
  it('updates the pre-created runner instance before its first lifecycle report', async () => {
2545
2637
  const provisionerId = crypto.randomUUID();
@@ -375,6 +375,80 @@ export async function countStaleEnrolledRunnerInstances(params: {
375
375
  return row?.count ?? 0;
376
376
  }
377
377
 
378
+ export type ProviderRunnerPhase =
379
+ | 'control_session'
380
+ | 'enrollment'
381
+ | 'assignment'
382
+ | 'activation'
383
+ | 'idle';
384
+
385
+ export interface ProviderRunnerPhaseMetric {
386
+ phase: ProviderRunnerPhase;
387
+ provider: string;
388
+ launchKind: 'demand' | 'warm' | 'manual';
389
+ count: number;
390
+ oldestAgeMilliseconds: number;
391
+ }
392
+
393
+ export async function listProviderRunnerByPhaseMetrics(): Promise<ProviderRunnerPhaseMetric[]> {
394
+ const phase = sql<ProviderRunnerPhase>`case
395
+ when ${runnerControlSessions.id} is null then 'control_session'
396
+ when ${providerRunners.state} <> 'running' then 'enrollment'
397
+ when ${providerRunners.intendedReservationId} is not null
398
+ and ${providerRunners.workspaceId} is null then 'assignment'
399
+ when ${providerRunners.workspaceId} is not null then 'activation'
400
+ else 'idle'
401
+ end`;
402
+ const startedAt = sql<Date | null>`case
403
+ when ${runnerControlSessions.id} is null then ${providerRunners.createdAt}
404
+ when ${providerRunners.state} <> 'running' then ${runnerControlSessions.createdAt}
405
+ when ${providerRunners.intendedReservationId} is not null
406
+ and ${providerRunners.workspaceId} is null then ${runnerControlSessions.createdAt}
407
+ when ${providerRunners.workspaceId} is not null
408
+ then coalesce(${providerRunners.assignedAt}, ${runnerControlSessions.createdAt})
409
+ else coalesce(
410
+ ${runnerControlSessions.createdAt},
411
+ ${providerRunners.assignedAt},
412
+ ${providerRunners.createdAt}
413
+ )
414
+ end`;
415
+ const rows = await db()
416
+ .select({
417
+ phase,
418
+ provider: sql<string>`coalesce(${providerRunners.providerKind}, 'unknown')`,
419
+ launchKind: providerRunners.launchKind,
420
+ count: sql<number>`count(*)::int`,
421
+ oldestAgeMilliseconds: sql<number>`coalesce(
422
+ max(extract(epoch from (now() - (${startedAt}))) * 1000),
423
+ 0
424
+ )::double precision`,
425
+ })
426
+ .from(providerRunners)
427
+ .leftJoin(
428
+ runnerControlSessions,
429
+ and(
430
+ eq(runnerControlSessions.runnerInstanceId, providerRunners.id),
431
+ isNull(runnerControlSessions.closedAt),
432
+ gt(runnerControlSessions.expiresAt, sql`now()`),
433
+ ),
434
+ )
435
+ .where(
436
+ and(
437
+ inArray(providerRunners.state, ['starting', 'running']),
438
+ isNull(providerRunners.runnerSessionId),
439
+ ),
440
+ )
441
+ .groupBy(phase, providerRunners.providerKind, providerRunners.launchKind);
442
+
443
+ return rows.map((row) => ({
444
+ phase: row.phase,
445
+ provider: row.provider,
446
+ launchKind: row.launchKind,
447
+ count: row.count,
448
+ oldestAgeMilliseconds: Math.max(0, row.oldestAgeMilliseconds),
449
+ }));
450
+ }
451
+
378
452
  export async function listActiveRunnerInstances(params: {
379
453
  workspaceId: string;
380
454
  windowSeconds: number;
@@ -3,6 +3,10 @@ import {and, asc, eq, gt, inArray, isNull, lt, notExists, or, sql} from 'drizzle
3
3
  import type {RunnerSession} from '#core/entities/runner-session.js';
4
4
  import {EmptyRunnerLabelsError} from '#core/errors.js';
5
5
  import {sanitizeRunnerLabelsOrThrow} from '#core/runner-labels.js';
6
+ import {
7
+ type ProviderRunnerLifecycleObservation,
8
+ recordProviderRunnerAssignmentToActivation,
9
+ } from '#metrics/instance.js';
6
10
  import {db} from './db.js';
7
11
  import {provisionerTokens} from './schema/provisioner-tokens.js';
8
12
  import {runnerActivationTokens} from './schema/runner-activation-tokens.js';
@@ -58,7 +62,8 @@ export async function createRunnerSessionConsumingActivationToken(params: {
58
62
  labels: string[];
59
63
  toolCapabilities?: RunnerToolCapabilitiesDto | null;
60
64
  }) {
61
- return await db().transaction(async (tx) => {
65
+ let assignmentToActivationObservation: ProviderRunnerLifecycleObservation | null = null;
66
+ const session = await db().transaction(async (tx) => {
62
67
  const [runner] = await tx
63
68
  .select({
64
69
  activationTokenId: runnerActivationTokens.id,
@@ -66,6 +71,9 @@ export async function createRunnerSessionConsumingActivationToken(params: {
66
71
  workspaceId: providerRunners.workspaceId,
67
72
  provisionerId: providerRunners.provisionerId,
68
73
  providerRunnerId: providerRunners.providerRunnerId,
74
+ assignedAt: providerRunners.assignedAt,
75
+ provider: providerRunners.providerKind,
76
+ launchKind: providerRunners.launchKind,
69
77
  runnerSessionId: providerRunners.runnerSessionId,
70
78
  })
71
79
  .from(runnerActivationTokens)
@@ -143,8 +151,18 @@ export async function createRunnerSessionConsumingActivationToken(params: {
143
151
  isNull(runnerControlSessions.closedAt),
144
152
  ),
145
153
  );
154
+ if (runner.assignedAt)
155
+ assignmentToActivationObservation = {
156
+ durationMilliseconds: session.createdAt.getTime() - runner.assignedAt.getTime(),
157
+ provider: runner.provider,
158
+ launchKind: runner.launchKind,
159
+ runnerInstanceId: runner.runnerInstanceId,
160
+ };
146
161
  return toRunnerSession(session);
147
162
  });
163
+ if (assignmentToActivationObservation)
164
+ recordProviderRunnerAssignmentToActivation(assignmentToActivationObservation);
165
+ return session;
148
166
  }
149
167
 
150
168
  export interface DeleteExpiredRunnerSessionsParams {
@@ -1,6 +1,8 @@
1
1
  export type {
2
2
  RunnerActivationTokenNotIssuedReason,
3
3
  RunnerActivationTokenNotIssuedSurface,
4
+ RunnerAssignmentSurface,
5
+ RunnerLaunchKind,
4
6
  RunnerReservationCapacityFailureReason,
5
7
  RunnerReservationPromotionFailureReason,
6
8
  } from './instance.js';
@@ -8,13 +10,25 @@ export {
8
10
  jobExecutionClaimedCount,
9
11
  jobExecutionEnqueuedCount,
10
12
  jobExecutionLeaseExpiredCount,
13
+ jobExecutionQueueTimeDuration,
11
14
  providerRunnerAbsentTerminatedCount,
12
15
  providerRunnerActivationOutcomeCount,
16
+ providerRunnerActivationToFirstClaimDuration,
17
+ providerRunnerAssignmentRejectedCount,
18
+ providerRunnerAssignmentToActivationDuration,
19
+ providerRunnerControlSessionToAssignmentDuration,
13
20
  providerRunnerCountDivergenceCount,
21
+ providerRunnerCreatedToControlSessionDuration,
14
22
  providerRunnerReconcileCallCount,
15
23
  providerRunnerTerminateIntentHonoredCount,
16
24
  providerRunnerTerminateIntentIssuedCount,
25
+ recordJobExecutionQueueTime,
17
26
  recordProviderRunnerActivationOutcome,
27
+ recordProviderRunnerActivationToFirstClaim,
28
+ recordProviderRunnerAssignmentRejected,
29
+ recordProviderRunnerAssignmentToActivation,
30
+ recordProviderRunnerControlSessionToAssignment,
31
+ recordProviderRunnerCreatedToControlSession,
18
32
  recordRunnerActivationTokenNotIssued,
19
33
  recordRunnerReservationCapacityFailure,
20
34
  recordRunnerReservationPromotionFailure,
@@ -0,0 +1,118 @@
1
+ const metricMocks = vi.hoisted(() => {
2
+ const histograms = new Map<string, {record: ReturnType<typeof vi.fn>}>();
3
+ const counters = new Map<string, {add: ReturnType<typeof vi.fn>}>();
4
+ const createHistogram = vi.fn((name: string) => {
5
+ const histogram = {record: vi.fn()};
6
+ histograms.set(name, histogram);
7
+ return histogram;
8
+ });
9
+ const createCounter = vi.fn((name: string) => {
10
+ const counter = {add: vi.fn()};
11
+ counters.set(name, counter);
12
+ return counter;
13
+ });
14
+
15
+ return {counters, createCounter, createHistogram, histograms};
16
+ });
17
+
18
+ vi.mock('@shipfox/node-opentelemetry', () => ({
19
+ instanceMetrics: {
20
+ getMeter: () => ({
21
+ createCounter: metricMocks.createCounter,
22
+ createHistogram: metricMocks.createHistogram,
23
+ }),
24
+ },
25
+ logger: () => ({debug: vi.fn()}),
26
+ }));
27
+
28
+ let metrics: typeof import('./instance.js');
29
+
30
+ beforeAll(async () => {
31
+ vi.resetModules();
32
+ metricMocks.counters.clear();
33
+ metricMocks.createCounter.mockClear();
34
+ metricMocks.createHistogram.mockClear();
35
+ metricMocks.histograms.clear();
36
+ metrics = await import('./instance.js');
37
+ });
38
+
39
+ describe('runner lifecycle metrics', () => {
40
+ it('defines provider-runner histograms with millisecond units and buckets', () => {
41
+ const calls = metricMocks.createHistogram.mock.calls as unknown as Array<
42
+ [string, {unit?: string; advice?: {explicitBucketBoundaries?: number[]}}]
43
+ >;
44
+
45
+ expect(
46
+ calls
47
+ .filter(([name]) => name.startsWith('runners_provider_runner_'))
48
+ .map(([name, options]) => ({
49
+ name,
50
+ unit: options.unit,
51
+ buckets: options.advice?.explicitBucketBoundaries,
52
+ })),
53
+ ).toEqual([
54
+ {
55
+ name: 'runners_provider_runner_created_to_control_session',
56
+ unit: 'ms',
57
+ buckets: [
58
+ 100, 500, 1_000, 5_000, 10_000, 15_000, 20_000, 30_000, 45_000, 60_000, 90_000, 120_000,
59
+ 300_000, 600_000,
60
+ ],
61
+ },
62
+ {
63
+ name: 'runners_provider_runner_control_session_to_assignment',
64
+ unit: 'ms',
65
+ buckets: [
66
+ 100, 500, 1_000, 5_000, 10_000, 15_000, 20_000, 30_000, 45_000, 60_000, 90_000, 120_000,
67
+ 300_000, 600_000,
68
+ ],
69
+ },
70
+ {
71
+ name: 'runners_provider_runner_assignment_to_activation',
72
+ unit: 'ms',
73
+ buckets: [10, 25, 50, 100, 250, 500, 1_000, 5_000, 10_000],
74
+ },
75
+ {
76
+ name: 'runners_provider_runner_activation_to_first_claim',
77
+ unit: 'ms',
78
+ buckets: [10, 25, 50, 100, 250, 500, 1_000, 5_000, 10_000],
79
+ },
80
+ ]);
81
+ });
82
+
83
+ it('records lifecycle durations without converting milliseconds', () => {
84
+ metrics.recordProviderRunnerCreatedToControlSession({
85
+ durationMilliseconds: 1_234,
86
+ provider: 'ec2',
87
+ launchKind: 'demand',
88
+ });
89
+
90
+ expect(
91
+ metricMocks.histograms.get('runners_provider_runner_created_to_control_session')?.record,
92
+ ).toHaveBeenCalledWith(1_234, {provider: 'ec2', launch_kind: 'demand'});
93
+ });
94
+ });
95
+
96
+ describe('job execution queue time metrics', () => {
97
+ it('records milliseconds with bounded labels and ignores negative durations', () => {
98
+ const record = metricMocks.histograms.get('runners_job_execution_queue_time')?.record;
99
+ expect(record).toBeDefined();
100
+
101
+ metrics.recordJobExecutionQueueTime({
102
+ durationMilliseconds: 1_234,
103
+ provider: null,
104
+ launchKind: 'manual',
105
+ });
106
+ metrics.recordJobExecutionQueueTime({
107
+ durationMilliseconds: -1,
108
+ provider: 'ec2',
109
+ launchKind: 'demand',
110
+ });
111
+
112
+ expect(record).toHaveBeenCalledTimes(1);
113
+ expect(record).toHaveBeenCalledWith(1_234, {
114
+ provider: 'unknown',
115
+ launch_kind: 'manual',
116
+ });
117
+ });
118
+ });