@shipfox/api-runners 12.3.0 → 12.4.1

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 (153) hide show
  1. package/.turbo/turbo-build.log +4 -4
  2. package/CHANGELOG.md +36 -0
  3. package/dist/config.d.ts +1 -0
  4. package/dist/config.d.ts.map +1 -1
  5. package/dist/config.js +9 -2
  6. package/dist/config.js.map +1 -1
  7. package/dist/core/demand.d.ts +4 -2
  8. package/dist/core/demand.d.ts.map +1 -1
  9. package/dist/core/demand.js +22 -6
  10. package/dist/core/demand.js.map +1 -1
  11. package/dist/core/entities/reservation.d.ts +1 -0
  12. package/dist/core/entities/reservation.d.ts.map +1 -1
  13. package/dist/core/entities/reservation.js.map +1 -1
  14. package/dist/core/entities/runner-instance.d.ts +3 -0
  15. package/dist/core/entities/runner-instance.d.ts.map +1 -1
  16. package/dist/core/entities/runner-instance.js.map +1 -1
  17. package/dist/core/errors.d.ts +5 -1
  18. package/dist/core/errors.d.ts.map +1 -1
  19. package/dist/core/errors.js +9 -2
  20. package/dist/core/errors.js.map +1 -1
  21. package/dist/core/runner-activation.d.ts +4 -1
  22. package/dist/core/runner-activation.d.ts.map +1 -1
  23. package/dist/core/runner-activation.js +32 -3
  24. package/dist/core/runner-activation.js.map +1 -1
  25. package/dist/core/runner-control-sessions.d.ts +8 -4
  26. package/dist/core/runner-control-sessions.d.ts.map +1 -1
  27. package/dist/core/runner-control-sessions.js +116 -20
  28. package/dist/core/runner-control-sessions.js.map +1 -1
  29. package/dist/core/runner-instances.d.ts +1 -0
  30. package/dist/core/runner-instances.d.ts.map +1 -1
  31. package/dist/core/runner-instances.js +1 -0
  32. package/dist/core/runner-instances.js.map +1 -1
  33. package/dist/db/db.d.ts +68 -0
  34. package/dist/db/db.d.ts.map +1 -1
  35. package/dist/db/ephemeral-registration-tokens.d.ts.map +1 -1
  36. package/dist/db/ephemeral-registration-tokens.js +1 -1
  37. package/dist/db/ephemeral-registration-tokens.js.map +1 -1
  38. package/dist/db/job-executions.d.ts.map +1 -1
  39. package/dist/db/job-executions.js +35 -4
  40. package/dist/db/job-executions.js.map +1 -1
  41. package/dist/db/reservation-locks.d.ts +6 -0
  42. package/dist/db/reservation-locks.d.ts.map +1 -0
  43. package/dist/db/reservation-locks.js +15 -0
  44. package/dist/db/reservation-locks.js.map +1 -0
  45. package/dist/db/reservations.d.ts +17 -3
  46. package/dist/db/reservations.d.ts.map +1 -1
  47. package/dist/db/reservations.js +246 -51
  48. package/dist/db/reservations.js.map +1 -1
  49. package/dist/db/runner-assignments.d.ts +25 -1
  50. package/dist/db/runner-assignments.d.ts.map +1 -1
  51. package/dist/db/runner-assignments.js +160 -18
  52. package/dist/db/runner-assignments.js.map +1 -1
  53. package/dist/db/runner-instances.d.ts +11 -2
  54. package/dist/db/runner-instances.d.ts.map +1 -1
  55. package/dist/db/runner-instances.js +167 -24
  56. package/dist/db/runner-instances.js.map +1 -1
  57. package/dist/db/runner-sessions.d.ts.map +1 -1
  58. package/dist/db/runner-sessions.js +14 -1
  59. package/dist/db/runner-sessions.js.map +1 -1
  60. package/dist/db/runner-states.d.ts +2 -0
  61. package/dist/db/runner-states.d.ts.map +1 -0
  62. package/dist/db/runner-states.js +7 -0
  63. package/dist/db/runner-states.js.map +1 -0
  64. package/dist/db/schema/reservations.d.ts +18 -0
  65. package/dist/db/schema/reservations.d.ts.map +1 -1
  66. package/dist/db/schema/reservations.js +7 -1
  67. package/dist/db/schema/reservations.js.map +1 -1
  68. package/dist/db/schema/runner-instances.d.ts +18 -0
  69. package/dist/db/schema/runner-instances.d.ts.map +1 -1
  70. package/dist/db/schema/runner-instances.js +8 -0
  71. package/dist/db/schema/runner-instances.js.map +1 -1
  72. package/dist/metrics/index.d.ts +2 -2
  73. package/dist/metrics/index.d.ts.map +1 -1
  74. package/dist/metrics/index.js +1 -1
  75. package/dist/metrics/index.js.map +1 -1
  76. package/dist/metrics/instance.d.ts +72 -2
  77. package/dist/metrics/instance.d.ts.map +1 -1
  78. package/dist/metrics/instance.js +161 -1
  79. package/dist/metrics/instance.js.map +1 -1
  80. package/dist/metrics/service.d.ts.map +1 -1
  81. package/dist/metrics/service.js +25 -4
  82. package/dist/metrics/service.js.map +1 -1
  83. package/dist/presentation/dto/poll-demand.d.ts.map +1 -1
  84. package/dist/presentation/dto/poll-demand.js +3 -0
  85. package/dist/presentation/dto/poll-demand.js.map +1 -1
  86. package/dist/presentation/dto/runner-instances.d.ts +4 -1
  87. package/dist/presentation/dto/runner-instances.d.ts.map +1 -1
  88. package/dist/presentation/dto/runner-instances.js +4 -1
  89. package/dist/presentation/dto/runner-instances.js.map +1 -1
  90. package/dist/presentation/routes/assign-runner-instances.d.ts.map +1 -1
  91. package/dist/presentation/routes/assign-runner-instances.js +10 -4
  92. package/dist/presentation/routes/assign-runner-instances.js.map +1 -1
  93. package/dist/presentation/routes/poll-demand.d.ts.map +1 -1
  94. package/dist/presentation/routes/poll-demand.js +1 -0
  95. package/dist/presentation/routes/poll-demand.js.map +1 -1
  96. package/dist/presentation/routes/reconcile-runner-instances.d.ts.map +1 -1
  97. package/dist/presentation/routes/reconcile-runner-instances.js +4 -2
  98. package/dist/presentation/routes/reconcile-runner-instances.js.map +1 -1
  99. package/dist/presentation/routes/runner-enrollment.d.ts +4 -1
  100. package/dist/presentation/routes/runner-enrollment.d.ts.map +1 -1
  101. package/dist/presentation/routes/runner-enrollment.js +11 -6
  102. package/dist/presentation/routes/runner-enrollment.js.map +1 -1
  103. package/dist/tsconfig.test.tsbuildinfo +1 -1
  104. package/drizzle/0005_previous_lenny_balinger.sql +5 -0
  105. package/drizzle/meta/0005_snapshot.json +2220 -0
  106. package/drizzle/meta/_journal.json +7 -0
  107. package/package.json +6 -6
  108. package/src/config.test.ts +22 -0
  109. package/src/config.ts +15 -2
  110. package/src/core/demand.test.ts +192 -3
  111. package/src/core/demand.ts +44 -4
  112. package/src/core/entities/reservation.ts +1 -0
  113. package/src/core/entities/runner-instance.ts +4 -0
  114. package/src/core/errors.ts +30 -1
  115. package/src/core/runner-activation.ts +48 -2
  116. package/src/core/runner-control-sessions.test.ts +135 -3
  117. package/src/core/runner-control-sessions.ts +141 -19
  118. package/src/core/runner-instances.test.ts +19 -0
  119. package/src/core/runner-instances.ts +2 -0
  120. package/src/core/runner-sessions.test.ts +121 -1
  121. package/src/db/ephemeral-registration-tokens.test.ts +20 -1
  122. package/src/db/ephemeral-registration-tokens.ts +1 -0
  123. package/src/db/job-executions.test.ts +98 -12
  124. package/src/db/job-executions.ts +52 -4
  125. package/src/db/reservation-locks.ts +22 -0
  126. package/src/db/reservations.test.ts +733 -22
  127. package/src/db/reservations.ts +408 -144
  128. package/src/db/runner-assignments.test.ts +105 -1
  129. package/src/db/runner-assignments.ts +245 -25
  130. package/src/db/runner-instances.test.ts +357 -2
  131. package/src/db/runner-instances.ts +289 -55
  132. package/src/db/runner-sessions.ts +19 -1
  133. package/src/db/runner-states.ts +7 -0
  134. package/src/db/schema/reservations.ts +5 -1
  135. package/src/db/schema/runner-instances.ts +9 -0
  136. package/src/metrics/index.ts +24 -1
  137. package/src/metrics/instance.test.ts +118 -0
  138. package/src/metrics/instance.ts +247 -3
  139. package/src/metrics/service.test.ts +46 -1
  140. package/src/metrics/service.ts +55 -8
  141. package/src/presentation/dto/poll-demand.ts +3 -0
  142. package/src/presentation/dto/runner-instances.ts +8 -1
  143. package/src/presentation/routes/assign-runner-instances.ts +14 -2
  144. package/src/presentation/routes/late-runner-enrollment.test.ts +219 -2
  145. package/src/presentation/routes/poll-demand.test.ts +56 -0
  146. package/src/presentation/routes/poll-demand.ts +1 -0
  147. package/src/presentation/routes/reconcile-runner-instances.test.ts +34 -2
  148. package/src/presentation/routes/reconcile-runner-instances.ts +7 -1
  149. package/src/presentation/routes/runner-enrollment.test.ts +316 -3
  150. package/src/presentation/routes/runner-enrollment.ts +8 -5
  151. package/test/factories/reservation.ts +2 -0
  152. package/test/factories/runner-instance.ts +6 -0
  153. package/tsconfig.build.tsbuildinfo +1 -1
@@ -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
+ });
@@ -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
  {
@@ -31,6 +140,21 @@ export const runnerBootstrapExchangeCount = meter.createCounter<{
31
140
  description: 'Runner bootstrap-token exchanges by outcome',
32
141
  });
33
142
 
143
+ export type RunnerActivationTokenNotIssuedReason =
144
+ | 'runner-not-found'
145
+ | 'missing-workspace'
146
+ | 'existing-session'
147
+ | 'not-running';
148
+
149
+ export type RunnerActivationTokenNotIssuedSurface = 'enrollment' | 'poll';
150
+
151
+ export const runnerActivationTokenNotIssuedCount = meter.createCounter<{
152
+ reason: RunnerActivationTokenNotIssuedReason;
153
+ surface: RunnerActivationTokenNotIssuedSurface;
154
+ }>('runners_runner_activation_token_not_issued', {
155
+ description: 'Runner activation token issuance skips by reason and surface',
156
+ });
157
+
34
158
  export const runnerControlHeartbeatCount = meter.createCounter<Record<string, never>>(
35
159
  'runners_runner_control_heartbeat',
36
160
  {description: 'Pre-workspace runner-control heartbeats accepted'},
@@ -67,17 +191,23 @@ export const providerRunnerAbsentTerminatedCount = meter.createCounter<Record<st
67
191
 
68
192
  export const providerRunnerTerminateIntentIssuedCount = meter.createCounter<{
69
193
  surface: 'poll-demand' | 'reconcile';
70
- reason: 'job-cancelled' | 'terminal-state';
194
+ reason: 'activation-timeout' | 'job-cancelled' | 'terminal-state';
71
195
  }>('runners_provider_runner_terminate_intent_issued', {
72
196
  description: 'Provisioned runner terminate intents returned to provisioners',
73
197
  });
74
198
 
75
199
  export const providerRunnerTerminateIntentHonoredCount = meter.createCounter<{
76
- reason: 'job-cancelled';
200
+ reason: 'activation-timeout' | 'job-cancelled';
77
201
  }>('runners_provider_runner_terminate_intent_honored', {
78
202
  description: 'Provisioned runner terminate intents honored by first transition to terminated',
79
203
  });
80
204
 
205
+ export const providerRunnerActivationOutcomeCount = meter.createCounter<{
206
+ outcome: 'reaped' | 'rebound';
207
+ }>('runners_provider_runner_activation_outcome', {
208
+ description: 'Demand-backed runner activation outcomes by recovery action',
209
+ });
210
+
81
211
  export const reservationReleasedCount = meter.createCounter<Record<string, never>>(
82
212
  'runners_reservation_released',
83
213
  {description: 'Reservation units released from terminal provisioned runner reports'},
@@ -95,6 +225,18 @@ export const runnerReservationPromotionFailureCount = meter.createCounter<{
95
225
  description: 'Runner reservation promotion failures during enrollment by reason',
96
226
  });
97
227
 
228
+ export type RunnerReservationCapacityFailureReason =
229
+ | 'reservation-not-found'
230
+ | 'reservation-kind-mismatch'
231
+ | 'reservation-expired'
232
+ | 'capacity-exhausted';
233
+
234
+ export const runnerReservationCapacityFailureCount = meter.createCounter<{
235
+ reason: RunnerReservationCapacityFailureReason;
236
+ }>('runners_reservation_capacity_failures', {
237
+ description: 'Runner reservation admission shortfalls by reason',
238
+ });
239
+
98
240
  export type RunnersRateLimitAction = 'provisioner-mint' | 'ephemeral-register';
99
241
  export type RunnersRateLimitScope = 'provisioner' | 'ephemeral-token';
100
242
  export type RunnersRateLimitOutcome = 'allowed' | 'blocked' | 'unavailable';
@@ -119,12 +261,105 @@ function recordMetric(record: () => void): void {
119
261
  }
120
262
  }
121
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
+
122
342
  export function recordRunnerReservationPromotionFailure(
123
343
  reason: RunnerReservationPromotionFailureReason,
124
344
  ): void {
125
345
  recordMetric(() => runnerReservationPromotionFailureCount.add(1, {reason}));
126
346
  }
127
347
 
348
+ export function recordRunnerActivationTokenNotIssued(params: {
349
+ reason: RunnerActivationTokenNotIssuedReason;
350
+ surface: RunnerActivationTokenNotIssuedSurface;
351
+ }): void {
352
+ recordMetric(() => runnerActivationTokenNotIssuedCount.add(1, params));
353
+ }
354
+
355
+ export function recordRunnerReservationCapacityFailure(
356
+ reason: RunnerReservationCapacityFailureReason,
357
+ count: number,
358
+ ): void {
359
+ if (count <= 0) return;
360
+ recordMetric(() => runnerReservationCapacityFailureCount.add(count, {reason}));
361
+ }
362
+
128
363
  export function recordRunnersRateLimitCheck(params: {
129
364
  action: RunnersRateLimitAction;
130
365
  scope: RunnersRateLimitScope;
@@ -142,3 +377,12 @@ export function recordRunnersRateLimitCheck(params: {
142
377
  export function recordRunnersRateLimitPruneFailure(): void {
143
378
  recordMetric(() => rateLimitPruneFailureCount.add(1));
144
379
  }
380
+
381
+ export function recordProviderRunnerActivationOutcome(params: {
382
+ outcome: 'reaped' | 'rebound';
383
+ count?: number;
384
+ }): void {
385
+ recordMetric(() =>
386
+ providerRunnerActivationOutcomeCount.add(params.count ?? 1, {outcome: params.outcome}),
387
+ );
388
+ }
@@ -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,9 +36,17 @@ 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
- import {registerRunnersServiceMetrics} from './service.js';
42
+ let registerRunnersServiceMetrics: typeof import('./service.js').registerRunnersServiceMetrics;
43
+
44
+ beforeAll(async () => {
45
+ // This package intentionally runs with isolate: false. Reset the shared module cache before
46
+ // importing the service so a previous file's real OpenTelemetry module cannot bypass this mock.
47
+ vi.resetModules();
48
+ ({registerRunnersServiceMetrics} = await import('./service.js'));
49
+ });
37
50
 
38
51
  describe('registerRunnersServiceMetrics', () => {
39
52
  beforeEach(() => {
@@ -41,12 +54,14 @@ describe('registerRunnersServiceMetrics', () => {
41
54
  mocks.countStaleEnrolledRunnerInstances.mockReset();
42
55
  mocks.createObservableGauge.mockClear();
43
56
  mocks.getJobExecutionQueueDepth.mockReset();
57
+ mocks.listProviderRunnerByPhaseMetrics.mockReset();
44
58
  mocks.getMeter.mockReset();
45
59
  mocks.getServiceMetricsProvider.mockReset();
46
60
  mocks.getJobExecutionQueueDepth.mockResolvedValue({
47
61
  pendingJobExecutions: 0,
48
62
  runningJobExecutions: 0,
49
63
  });
64
+ mocks.listProviderRunnerByPhaseMetrics.mockResolvedValue([]);
50
65
  mocks.getMeter.mockReturnValue({
51
66
  createObservableGauge: mocks.createObservableGauge,
52
67
  addBatchObservableCallback: mocks.addBatchObservableCallback,
@@ -96,4 +111,34 @@ describe('registerRunnersServiceMetrics', () => {
96
111
  expect(observer.observe).toHaveBeenCalledWith(mocks.gauges.runningJobExecutions, 4);
97
112
  expect(observer.observe).toHaveBeenCalledTimes(2);
98
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
+ });
99
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
  }
@@ -17,6 +17,9 @@ export function toPollDemandResponseDto(result: PollDemandResult): PollDemandRes
17
17
  count: reservation.count,
18
18
  expires_at: reservation.expiresAt.toISOString(),
19
19
  })),
20
+ ...(result.newlyReservedCount !== undefined
21
+ ? {newly_reserved_count: result.newlyReservedCount}
22
+ : {}),
20
23
  terminate_provider_runner_ids: result.terminateRunnerInstanceIds,
21
24
  };
22
25
  }
@@ -14,10 +14,14 @@ export function toReportRunnerInstancesResponseDto(result: ReportRunnerInstances
14
14
  };
15
15
  }
16
16
 
17
- export function toReconcileRunnerInstancesResponseDto(result: ReconcileRunnerInstancesResult): {
17
+ export function toReconcileRunnerInstancesResponseDto(
18
+ result: ReconcileRunnerInstancesResult,
19
+ options: {includeIntendedReservationId: boolean},
20
+ ): {
18
21
  runners: Array<{
19
22
  provider_runner_id: string;
20
23
  state: ReconcileRunnerInstancesResult['runners'][number]['state'];
24
+ intended_reservation_id?: string | null;
21
25
  reservation_id: string | null;
22
26
  runner_session_id: string | null;
23
27
  bound_job: {
@@ -34,6 +38,9 @@ export function toReconcileRunnerInstancesResponseDto(result: ReconcileRunnerIns
34
38
  runners: result.runners.map((runner) => ({
35
39
  provider_runner_id: runner.providerRunnerId,
36
40
  state: runner.state,
41
+ ...(options.includeIntendedReservationId
42
+ ? {intended_reservation_id: runner.intendedReservationId}
43
+ : {}),
37
44
  reservation_id: runner.reservationId,
38
45
  runner_session_id: runner.runnerSessionId,
39
46
  bound_job: runner.boundJobExecution
@@ -2,15 +2,19 @@ import {requireProvisionerContext} from '@shipfox/api-auth-context';
2
2
  import {
3
3
  assignRunnerInstancesBodySchema,
4
4
  assignRunnerInstancesResponseSchema,
5
+ RESERVATION_EXPIRED_ERROR_CODE,
6
+ RUNNER_INSTANCE_NOT_ASSIGNABLE_ERROR_CODE,
5
7
  } from '@shipfox/api-runners-dto';
6
8
  import {ClientError, defineRoute} from '@shipfox/node-fastify';
7
9
  import {
10
+ getRunnerAssignmentRejectionReason,
8
11
  ReservationExpiredError,
9
12
  ReservationNotFoundError,
10
13
  RunnerInstanceAlreadyAssignedError,
11
14
  RunnerInstanceNotAssignableError,
12
15
  } from '#core/errors.js';
13
16
  import {assignRunnerInstances} from '#core/runner-assignments.js';
17
+ import {recordProviderRunnerAssignmentRejected} from '#metrics/instance.js';
14
18
 
15
19
  export const assignRunnerInstancesRoute = defineRoute({
16
20
  method: 'POST',
@@ -21,17 +25,25 @@ export const assignRunnerInstancesRoute = defineRoute({
21
25
  response: {200: assignRunnerInstancesResponseSchema},
22
26
  },
23
27
  errorHandler: (error) => {
28
+ const rejectionReason = getRunnerAssignmentRejectionReason(error);
29
+ if (rejectionReason)
30
+ recordProviderRunnerAssignmentRejected({
31
+ reason: rejectionReason,
32
+ surface: 'provisioner',
33
+ });
24
34
  if (error instanceof ReservationNotFoundError)
25
35
  throw new ClientError('Reservation not found', 'reservation-not-found', {status: 404});
26
36
  if (error instanceof ReservationExpiredError)
27
- throw new ClientError('Reservation has expired', 'reservation-expired', {status: 409});
37
+ throw new ClientError('Reservation has expired', RESERVATION_EXPIRED_ERROR_CODE, {
38
+ status: 409,
39
+ });
28
40
  if (
29
41
  error instanceof RunnerInstanceAlreadyAssignedError ||
30
42
  error instanceof RunnerInstanceNotAssignableError
31
43
  )
32
44
  throw new ClientError(
33
45
  'Runner instance cannot be assigned',
34
- 'runner-instance-not-assignable',
46
+ RUNNER_INSTANCE_NOT_ASSIGNABLE_ERROR_CODE,
35
47
  {status: 409},
36
48
  );
37
49
  throw error;