@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
@@ -1,7 +1,116 @@
1
- import {instanceMetrics} from '@shipfox/node-opentelemetry';
1
+ import {instanceMetrics, logger} from '@shipfox/node-opentelemetry';
2
+ import type {RunnerAssignmentRejectionReason} from '#core/errors.js';
2
3
 
3
4
  const meter = instanceMetrics.getMeter('runners');
4
5
 
6
+ export type RunnerLaunchKind = 'demand' | 'warm' | 'manual';
7
+ export type RunnerAssignmentSurface = 'provisioner' | 'enrollment';
8
+
9
+ type ProviderRunnerLifecycleLabels = {
10
+ provider: string;
11
+ launch_kind: RunnerLaunchKind;
12
+ };
13
+
14
+ type ProviderRunnerAssignmentLifecycleLabels = ProviderRunnerLifecycleLabels & {
15
+ surface: RunnerAssignmentSurface;
16
+ };
17
+
18
+ type JobExecutionQueueTimeLabels = {
19
+ provider: string;
20
+ launch_kind: RunnerLaunchKind | 'unknown';
21
+ };
22
+
23
+ export interface JobExecutionQueueTimeObservation {
24
+ durationMilliseconds: number;
25
+ provider: string | null;
26
+ launchKind: RunnerLaunchKind | 'unknown';
27
+ }
28
+
29
+ export interface ProviderRunnerLifecycleObservation {
30
+ durationMilliseconds: number;
31
+ provider: string | null;
32
+ launchKind: RunnerLaunchKind;
33
+ runnerInstanceId?: string;
34
+ }
35
+
36
+ export interface ProviderRunnerAssignmentObservation extends ProviderRunnerLifecycleObservation {
37
+ surface: RunnerAssignmentSurface;
38
+ }
39
+
40
+ export const UNKNOWN_PROVIDER_KIND = 'unknown';
41
+
42
+ const lifecycleDurationBuckets = {
43
+ long: [
44
+ 100, 500, 1_000, 5_000, 10_000, 15_000, 20_000, 30_000, 45_000, 60_000, 90_000, 120_000,
45
+ 300_000, 600_000,
46
+ ],
47
+ short: [10, 25, 50, 100, 250, 500, 1_000, 5_000, 10_000],
48
+ };
49
+
50
+ const queueTimeBucketsMilliseconds = [
51
+ 100, 500, 1_000, 5_000, 10_000, 15_000, 20_000, 30_000, 45_000, 60_000, 90_000, 120_000, 300_000,
52
+ 600_000, 900_000, 1_800_000, 3_600_000, 7_200_000, 14_400_000,
53
+ ];
54
+
55
+ // Keep the ordered lifecycle phases as separate histograms so long boot times do not flatten
56
+ // short handoffs. Assignment is the only phase with a surface dimension.
57
+ export const providerRunnerCreatedToControlSessionDuration =
58
+ meter.createHistogram<ProviderRunnerLifecycleLabels>(
59
+ 'runners_provider_runner_created_to_control_session',
60
+ {
61
+ description: 'Provider runner row creation to control-session creation duration',
62
+ unit: 'ms',
63
+ advice: {explicitBucketBoundaries: lifecycleDurationBuckets.long},
64
+ },
65
+ );
66
+
67
+ export const providerRunnerControlSessionToAssignmentDuration =
68
+ meter.createHistogram<ProviderRunnerAssignmentLifecycleLabels>(
69
+ 'runners_provider_runner_control_session_to_assignment',
70
+ {
71
+ description: 'Provider runner control-session creation to reservation assignment duration',
72
+ unit: 'ms',
73
+ advice: {explicitBucketBoundaries: lifecycleDurationBuckets.long},
74
+ },
75
+ );
76
+
77
+ export const providerRunnerAssignmentToActivationDuration =
78
+ meter.createHistogram<ProviderRunnerLifecycleLabels>(
79
+ 'runners_provider_runner_assignment_to_activation',
80
+ {
81
+ description:
82
+ 'Provider runner reservation assignment to workspace runner-session creation duration',
83
+ unit: 'ms',
84
+ advice: {explicitBucketBoundaries: lifecycleDurationBuckets.short},
85
+ },
86
+ );
87
+
88
+ export const providerRunnerActivationToFirstClaimDuration =
89
+ meter.createHistogram<ProviderRunnerLifecycleLabels>(
90
+ 'runners_provider_runner_activation_to_first_claim',
91
+ {
92
+ description: 'Provider runner workspace runner-session creation to first job claim duration',
93
+ unit: 'ms',
94
+ advice: {explicitBucketBoundaries: lifecycleDurationBuckets.short},
95
+ },
96
+ );
97
+
98
+ export const jobExecutionQueueTimeDuration = meter.createHistogram<JobExecutionQueueTimeLabels>(
99
+ 'runners_job_execution_queue_time',
100
+ {
101
+ description: 'Job execution pending queue duration from enqueue to runner claim',
102
+ unit: 'ms',
103
+ advice: {explicitBucketBoundaries: queueTimeBucketsMilliseconds},
104
+ },
105
+ );
106
+
107
+ export const providerRunnerAssignmentRejectedCount = meter.createCounter<{
108
+ reason: RunnerAssignmentRejectionReason;
109
+ surface: RunnerAssignmentSurface;
110
+ }>('runners_provider_runner_assignment_rejected', {
111
+ description: 'Provider runner assignment operations rejected by bounded reason and surface',
112
+ });
113
+
5
114
  export const jobExecutionEnqueuedCount = meter.createCounter<Record<string, never>>(
6
115
  'runners_job_execution_enqueued',
7
116
  {
@@ -152,6 +261,84 @@ function recordMetric(record: () => void): void {
152
261
  }
153
262
  }
154
263
 
264
+ function resolveProviderRunnerMetricProvider(params: {
265
+ provider: string | null;
266
+ runnerInstanceId?: string;
267
+ }): string {
268
+ if (params.provider) return params.provider;
269
+ logger().debug(
270
+ {runnerInstanceId: params.runnerInstanceId},
271
+ 'Provider runner metric missing provider kind',
272
+ );
273
+ return UNKNOWN_PROVIDER_KIND;
274
+ }
275
+
276
+ export function recordProviderRunnerCreatedToControlSession(
277
+ params: ProviderRunnerLifecycleObservation,
278
+ ): void {
279
+ if (params.durationMilliseconds < 0) return;
280
+ recordMetric(() =>
281
+ providerRunnerCreatedToControlSessionDuration.record(params.durationMilliseconds, {
282
+ provider: resolveProviderRunnerMetricProvider(params),
283
+ launch_kind: params.launchKind,
284
+ }),
285
+ );
286
+ }
287
+
288
+ export function recordProviderRunnerControlSessionToAssignment(
289
+ params: ProviderRunnerAssignmentObservation,
290
+ ): void {
291
+ if (params.durationMilliseconds < 0) return;
292
+ recordMetric(() =>
293
+ providerRunnerControlSessionToAssignmentDuration.record(params.durationMilliseconds, {
294
+ provider: resolveProviderRunnerMetricProvider(params),
295
+ launch_kind: params.launchKind,
296
+ surface: params.surface,
297
+ }),
298
+ );
299
+ }
300
+
301
+ export function recordProviderRunnerAssignmentToActivation(
302
+ params: ProviderRunnerLifecycleObservation,
303
+ ): void {
304
+ if (params.durationMilliseconds < 0) return;
305
+ recordMetric(() =>
306
+ providerRunnerAssignmentToActivationDuration.record(params.durationMilliseconds, {
307
+ provider: resolveProviderRunnerMetricProvider(params),
308
+ launch_kind: params.launchKind,
309
+ }),
310
+ );
311
+ }
312
+
313
+ export function recordProviderRunnerActivationToFirstClaim(
314
+ params: ProviderRunnerLifecycleObservation,
315
+ ): void {
316
+ if (params.durationMilliseconds < 0) return;
317
+ recordMetric(() =>
318
+ providerRunnerActivationToFirstClaimDuration.record(params.durationMilliseconds, {
319
+ provider: resolveProviderRunnerMetricProvider(params),
320
+ launch_kind: params.launchKind,
321
+ }),
322
+ );
323
+ }
324
+
325
+ export function recordJobExecutionQueueTime(params: JobExecutionQueueTimeObservation): void {
326
+ if (params.durationMilliseconds < 0) return;
327
+ recordMetric(() =>
328
+ jobExecutionQueueTimeDuration.record(params.durationMilliseconds, {
329
+ provider: params.provider ?? UNKNOWN_PROVIDER_KIND,
330
+ launch_kind: params.launchKind,
331
+ }),
332
+ );
333
+ }
334
+
335
+ export function recordProviderRunnerAssignmentRejected(params: {
336
+ reason: RunnerAssignmentRejectionReason;
337
+ surface: RunnerAssignmentSurface;
338
+ }): void {
339
+ recordMetric(() => providerRunnerAssignmentRejectedCount.add(1, params));
340
+ }
341
+
155
342
  export function recordRunnerReservationPromotionFailure(
156
343
  reason: RunnerReservationPromotionFailureReason,
157
344
  ): void {
@@ -2,11 +2,15 @@ const mocks = vi.hoisted(() => {
2
2
  const gauges = {
3
3
  enrolledRunnersWithoutRecentReport: {},
4
4
  pendingJobExecutions: {},
5
+ providerRunnersByPhase: {},
6
+ providerRunnersByPhaseOldestAge: {},
5
7
  runningJobExecutions: {},
6
8
  };
7
9
  const gaugeByName = {
8
10
  runners_enrolled_without_recent_report: gauges.enrolledRunnersWithoutRecentReport,
9
11
  runners_pending_job_executions: gauges.pendingJobExecutions,
12
+ runners_provider_runner_by_phase: gauges.providerRunnersByPhase,
13
+ runners_provider_runner_by_phase_oldest_age: gauges.providerRunnersByPhaseOldestAge,
10
14
  runners_running_job_executions: gauges.runningJobExecutions,
11
15
  };
12
16
  return {
@@ -16,6 +20,7 @@ const mocks = vi.hoisted(() => {
16
20
  gauges,
17
21
  getMeter: vi.fn(),
18
22
  getJobExecutionQueueDepth: vi.fn(),
23
+ listProviderRunnerByPhaseMetrics: vi.fn(),
19
24
  getServiceMetricsProvider: vi.fn(),
20
25
  };
21
26
  });
@@ -31,6 +36,7 @@ vi.mock('#db/job-executions.js', () => ({
31
36
  }));
32
37
  vi.mock('#db/runner-instances.js', () => ({
33
38
  countStaleEnrolledRunnerInstances: mocks.countStaleEnrolledRunnerInstances,
39
+ listProviderRunnerByPhaseMetrics: mocks.listProviderRunnerByPhaseMetrics,
34
40
  }));
35
41
 
36
42
  let registerRunnersServiceMetrics: typeof import('./service.js').registerRunnersServiceMetrics;
@@ -48,12 +54,14 @@ describe('registerRunnersServiceMetrics', () => {
48
54
  mocks.countStaleEnrolledRunnerInstances.mockReset();
49
55
  mocks.createObservableGauge.mockClear();
50
56
  mocks.getJobExecutionQueueDepth.mockReset();
57
+ mocks.listProviderRunnerByPhaseMetrics.mockReset();
51
58
  mocks.getMeter.mockReset();
52
59
  mocks.getServiceMetricsProvider.mockReset();
53
60
  mocks.getJobExecutionQueueDepth.mockResolvedValue({
54
61
  pendingJobExecutions: 0,
55
62
  runningJobExecutions: 0,
56
63
  });
64
+ mocks.listProviderRunnerByPhaseMetrics.mockResolvedValue([]);
57
65
  mocks.getMeter.mockReturnValue({
58
66
  createObservableGauge: mocks.createObservableGauge,
59
67
  addBatchObservableCallback: mocks.addBatchObservableCallback,
@@ -103,4 +111,34 @@ describe('registerRunnersServiceMetrics', () => {
103
111
  expect(observer.observe).toHaveBeenCalledWith(mocks.gauges.runningJobExecutions, 4);
104
112
  expect(observer.observe).toHaveBeenCalledTimes(2);
105
113
  });
114
+
115
+ it('observes provider runners by lifecycle phase', async () => {
116
+ mocks.listProviderRunnerByPhaseMetrics.mockResolvedValue([
117
+ {
118
+ phase: 'assignment',
119
+ provider: 'ec2',
120
+ launchKind: 'demand',
121
+ count: 3,
122
+ oldestAgeMilliseconds: 42,
123
+ },
124
+ ]);
125
+
126
+ registerRunnersServiceMetrics();
127
+ const callback = mocks.addBatchObservableCallback.mock.calls[0]?.[0];
128
+ if (typeof callback !== 'function') throw new Error('Expected metrics callback');
129
+ const observer = {observe: vi.fn()};
130
+
131
+ await callback(observer);
132
+
133
+ expect(observer.observe).toHaveBeenCalledWith(mocks.gauges.providerRunnersByPhase, 3, {
134
+ phase: 'assignment',
135
+ provider: 'ec2',
136
+ launch_kind: 'demand',
137
+ });
138
+ expect(observer.observe).toHaveBeenCalledWith(
139
+ mocks.gauges.providerRunnersByPhaseOldestAge,
140
+ 42,
141
+ {phase: 'assignment', provider: 'ec2', launch_kind: 'demand'},
142
+ );
143
+ });
106
144
  });
@@ -1,7 +1,17 @@
1
1
  import {getServiceMetricsProvider} from '@shipfox/node-opentelemetry';
2
2
  import {config} from '#config.js';
3
3
  import {getJobExecutionQueueDepth} from '#db/job-executions.js';
4
- import {countStaleEnrolledRunnerInstances} from '#db/runner-instances.js';
4
+ import {
5
+ countStaleEnrolledRunnerInstances,
6
+ listProviderRunnerByPhaseMetrics,
7
+ type ProviderRunnerPhaseMetric,
8
+ } from '#db/runner-instances.js';
9
+
10
+ type ProviderRunnerPhaseLabels = {
11
+ phase: ProviderRunnerPhaseMetric['phase'];
12
+ provider: string;
13
+ launch_kind: ProviderRunnerPhaseMetric['launchKind'];
14
+ };
5
15
 
6
16
  export function registerRunnersServiceMetrics(): void {
7
17
  const meter = getServiceMetricsProvider().getMeter('runners');
@@ -19,15 +29,31 @@ export function registerRunnersServiceMetrics(): void {
19
29
  'Running enrolled runners with a live control session, no workspace or runner session, and no recent provisioner report after the stale-runner grace window',
20
30
  },
21
31
  );
32
+ const providerRunnersByPhase = meter.createObservableGauge<ProviderRunnerPhaseLabels>(
33
+ 'runners_provider_runner_by_phase',
34
+ {
35
+ description:
36
+ 'Provider runners by lifecycle phase, including idle runners without an active assignment',
37
+ },
38
+ );
39
+ const providerRunnersByPhaseOldestAge = meter.createObservableGauge<ProviderRunnerPhaseLabels>(
40
+ 'runners_provider_runner_by_phase_oldest_age',
41
+ {
42
+ description: 'Oldest provider runner age by lifecycle phase',
43
+ unit: 'ms',
44
+ },
45
+ );
22
46
 
23
47
  meter.addBatchObservableCallback(
24
48
  async (observer) => {
25
- const [depthResult, staleEnrolledRunnerCountResult] = await Promise.allSettled([
26
- getJobExecutionQueueDepth(),
27
- countStaleEnrolledRunnerInstances({
28
- graceSeconds: config.RUNNER_STALE_PROVISIONED_RUNNER_THRESHOLD_SECONDS,
29
- }),
30
- ]);
49
+ const [depthResult, staleEnrolledRunnerCountResult, providerRunnersByPhaseResult] =
50
+ await Promise.allSettled([
51
+ getJobExecutionQueueDepth(),
52
+ countStaleEnrolledRunnerInstances({
53
+ graceSeconds: config.RUNNER_STALE_PROVISIONED_RUNNER_THRESHOLD_SECONDS,
54
+ }),
55
+ listProviderRunnerByPhaseMetrics(),
56
+ ]);
31
57
 
32
58
  if (depthResult.status === 'fulfilled') {
33
59
  observer.observe(pendingJobExecutions, depthResult.value.pendingJobExecutions);
@@ -36,7 +62,28 @@ export function registerRunnersServiceMetrics(): void {
36
62
  if (staleEnrolledRunnerCountResult.status === 'fulfilled') {
37
63
  observer.observe(enrolledRunnersWithoutRecentReport, staleEnrolledRunnerCountResult.value);
38
64
  }
65
+ if (providerRunnersByPhaseResult.status === 'fulfilled') {
66
+ for (const metric of providerRunnersByPhaseResult.value) {
67
+ const attributes: ProviderRunnerPhaseLabels = {
68
+ phase: metric.phase,
69
+ provider: metric.provider,
70
+ launch_kind: metric.launchKind,
71
+ };
72
+ observer.observe(providerRunnersByPhase, metric.count, attributes);
73
+ observer.observe(
74
+ providerRunnersByPhaseOldestAge,
75
+ metric.oldestAgeMilliseconds,
76
+ attributes,
77
+ );
78
+ }
79
+ }
39
80
  },
40
- [pendingJobExecutions, runningJobExecutions, enrolledRunnersWithoutRecentReport],
81
+ [
82
+ pendingJobExecutions,
83
+ runningJobExecutions,
84
+ enrolledRunnersWithoutRecentReport,
85
+ providerRunnersByPhase,
86
+ providerRunnersByPhaseOldestAge,
87
+ ],
41
88
  );
42
89
  }
@@ -7,12 +7,14 @@ import {
7
7
  } from '@shipfox/api-runners-dto';
8
8
  import {ClientError, defineRoute} from '@shipfox/node-fastify';
9
9
  import {
10
+ getRunnerAssignmentRejectionReason,
10
11
  ReservationExpiredError,
11
12
  ReservationNotFoundError,
12
13
  RunnerInstanceAlreadyAssignedError,
13
14
  RunnerInstanceNotAssignableError,
14
15
  } from '#core/errors.js';
15
16
  import {assignRunnerInstances} from '#core/runner-assignments.js';
17
+ import {recordProviderRunnerAssignmentRejected} from '#metrics/instance.js';
16
18
 
17
19
  export const assignRunnerInstancesRoute = defineRoute({
18
20
  method: 'POST',
@@ -23,6 +25,12 @@ export const assignRunnerInstancesRoute = defineRoute({
23
25
  response: {200: assignRunnerInstancesResponseSchema},
24
26
  },
25
27
  errorHandler: (error) => {
28
+ const rejectionReason = getRunnerAssignmentRejectionReason(error);
29
+ if (rejectionReason)
30
+ recordProviderRunnerAssignmentRejected({
31
+ reason: rejectionReason,
32
+ surface: 'provisioner',
33
+ });
26
34
  if (error instanceof ReservationNotFoundError)
27
35
  throw new ClientError('Reservation not found', 'reservation-not-found', {status: 404});
28
36
  if (error instanceof ReservationExpiredError)