@a4anthony/proctorkit-sdk 0.1.1 → 0.2.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.
@@ -1,18 +1,40 @@
1
1
  import { DeliveryReadinessError } from "./delivery-readiness.js";
2
2
 
3
3
  const RECORDING_READINESS_TIMEOUT_MS = 10_000;
4
- const CANARY_BYTES = 1_024;
4
+ const DEFAULT_CANARY_BYTES = 1_024;
5
+ const MAX_CANARY_BYTES = 10_000_000;
6
+ const DEFAULT_MEASUREMENT_SAMPLES = 3;
5
7
 
6
- interface RecordingReadinessConfig {
8
+ export interface RecordingReadinessConfig {
7
9
  sessionId: string;
8
10
  ingestUrl: string;
9
11
  appId?: string;
10
12
  }
11
13
 
14
+ export interface RecordingDeliveryMeasurementConfig extends RecordingReadinessConfig {
15
+ /** Representative bytes produced by the enabled recorders in one workload window. */
16
+ payloadBytes: number;
17
+ /** Maximum acceptable upload completion time for the representative payload. */
18
+ completionWindowMs: number;
19
+ /** Number of scored uploads. Default: 3; bounded to 1..5. */
20
+ samples?: number;
21
+ /** Injectable monotonic clock for deterministic tests. */
22
+ now?: () => number;
23
+ }
24
+
25
+ export interface RecordingDeliveryMeasurement {
26
+ mode: "direct" | "segments" | "post";
27
+ payloadBytes: number;
28
+ completionWindowMs: number;
29
+ sampleDurationsMs: number[];
30
+ medianCompletionMs: number;
31
+ }
32
+
12
33
  interface DirectCanary {
13
34
  canaryId: string;
14
35
  uploadId: string;
15
36
  url: string;
37
+ expectedBytes: number;
16
38
  }
17
39
 
18
40
  /** Verify the server-selected recording path. No host-facing controls exist. */
@@ -40,9 +62,9 @@ export async function verifyRecordingDelivery(
40
62
  const mode = await resolveUploadMode(baseUrl, controller.signal);
41
63
  phase = "media";
42
64
  if (mode === "direct" || mode === "segments") {
43
- await verifyDirectPath(baseUrl, config, controller.signal);
65
+ await verifyDirectPath(baseUrl, config, DEFAULT_CANARY_BYTES, controller.signal, defaultNow);
44
66
  } else {
45
- await verifyPostPath(baseUrl, config, controller.signal);
67
+ await verifyPostPath(baseUrl, config, DEFAULT_CANARY_BYTES, controller.signal, defaultNow);
46
68
  }
47
69
  return mode;
48
70
  } catch (error) {
@@ -68,6 +90,91 @@ export async function verifyRecordingDelivery(
68
90
  }
69
91
  }
70
92
 
93
+ /**
94
+ * Measure whether the real recording destination can absorb the configured
95
+ * media workload. A disposable warm-up is excluded, then the median of the
96
+ * scored uploads is compared with the workload completion window.
97
+ */
98
+ export async function measureRecordingDeliveryReadiness(
99
+ config: RecordingDeliveryMeasurementConfig,
100
+ ): Promise<RecordingDeliveryMeasurement> {
101
+ const payloadBytes = normaliseCanaryBytes(config.payloadBytes);
102
+ const completionWindowMs = normaliseCompletionWindow(config.completionWindowMs);
103
+ const sampleCount = Math.min(
104
+ 5,
105
+ Math.max(1, Math.floor(config.samples ?? DEFAULT_MEASUREMENT_SAMPLES)),
106
+ );
107
+ const now = config.now ?? defaultNow;
108
+ const baseUrl = originOf(config.ingestUrl);
109
+ if (!baseUrl) {
110
+ throw new DeliveryReadinessError(
111
+ "upload-config-invalid",
112
+ "The ingest URL does not provide a valid recording upload origin.",
113
+ );
114
+ }
115
+
116
+ const mode = await withReadinessTimeout(RECORDING_READINESS_TIMEOUT_MS, "config", (signal) =>
117
+ resolveUploadMode(baseUrl, signal),
118
+ );
119
+
120
+ // Warm the exact storage/CORS path, but never use a cold request to decide
121
+ // candidate eligibility. A failed warm-up is also excluded; the scored
122
+ // attempts below provide the repeated evidence used for the decision.
123
+ await runPathProbe(baseUrl, config, mode, DEFAULT_CANARY_BYTES, now).catch(() => undefined);
124
+
125
+ const durations: number[] = [];
126
+ let lastError: unknown;
127
+ for (let attempt = 0; attempt < sampleCount; attempt += 1) {
128
+ try {
129
+ durations.push(await runPathProbe(baseUrl, config, mode, payloadBytes, now));
130
+ } catch (error) {
131
+ lastError = error;
132
+ }
133
+ }
134
+
135
+ const requiredSuccesses = Math.ceil(sampleCount / 2);
136
+ if (durations.length < requiredSuccesses) {
137
+ const details = measurementDetails(mode, payloadBytes, completionWindowMs, durations.length);
138
+ if (lastError instanceof DeliveryReadinessError) {
139
+ throw new DeliveryReadinessError(lastError.code, lastError.message, {
140
+ cause: lastError,
141
+ ...(lastError.status !== undefined ? { status: lastError.status } : {}),
142
+ details,
143
+ });
144
+ }
145
+ throw new DeliveryReadinessError(
146
+ "media-storage-unreachable",
147
+ "The recording storage path could not complete enough readiness uploads.",
148
+ { cause: lastError, details },
149
+ );
150
+ }
151
+
152
+ const medianCompletionMs = median(durations);
153
+ if (medianCompletionMs > completionWindowMs) {
154
+ throw new DeliveryReadinessError(
155
+ "media-storage-too-slow",
156
+ `The representative recording payload took ${Math.round(medianCompletionMs)} ms; the workload window is ${Math.round(completionWindowMs)} ms.`,
157
+ {
158
+ details: measurementDetails(
159
+ mode,
160
+ payloadBytes,
161
+ completionWindowMs,
162
+ durations.length,
163
+ medianCompletionMs,
164
+ ),
165
+ },
166
+ );
167
+ }
168
+
169
+ return {
170
+ mode,
171
+ payloadBytes,
172
+ completionWindowMs,
173
+ sampleDurationsMs: durations.map((duration) => Math.round(duration)),
174
+ medianCompletionMs: Math.round(medianCompletionMs),
175
+ };
176
+ }
177
+
71
178
  async function resolveUploadMode(
72
179
  baseUrl: string,
73
180
  signal: AbortSignal,
@@ -111,31 +218,40 @@ async function resolveUploadMode(
111
218
  async function verifyDirectPath(
112
219
  baseUrl: string,
113
220
  config: RecordingReadinessConfig,
221
+ canaryBytes: number,
114
222
  signal: AbortSignal,
115
- ): Promise<void> {
223
+ now: () => number,
224
+ ): Promise<number> {
116
225
  let canary: DirectCanary | null = null;
117
226
  try {
118
227
  canary = await postJson<DirectCanary>(
119
228
  `${baseUrl}/uploads/readiness/direct/start`,
120
- { sessionId: config.sessionId },
229
+ { sessionId: config.sessionId, bytes: canaryBytes },
121
230
  config.appId,
122
231
  signal,
123
232
  );
124
- if (!canary.canaryId || !canary.uploadId || !canary.url) {
233
+ if (
234
+ !canary.canaryId ||
235
+ !canary.uploadId ||
236
+ !canary.url ||
237
+ canary.expectedBytes !== canaryBytes
238
+ ) {
125
239
  throw new DeliveryReadinessError(
126
240
  "media-storage-invalid-response",
127
241
  "The recording storage canary did not return an upload target.",
128
242
  );
129
243
  }
130
244
 
245
+ const uploadStartedAt = now();
131
246
  const put = await fetch(canary.url, {
132
247
  method: "PUT",
133
- body: new Blob([new Uint8Array(CANARY_BYTES)], {
248
+ body: new Blob([new Uint8Array(canaryBytes)], {
134
249
  type: "application/octet-stream",
135
250
  }),
136
251
  credentials: "omit",
137
252
  signal,
138
253
  });
254
+ const uploadDurationMs = Math.max(0.1, now() - uploadStartedAt);
139
255
  if (!put.ok) {
140
256
  throw mediaHttpError(put.status, "The recording storage rejected the canary upload.");
141
257
  }
@@ -146,17 +262,19 @@ async function verifyDirectPath(
146
262
  sessionId: config.sessionId,
147
263
  canaryId: canary.canaryId,
148
264
  uploadId: canary.uploadId,
265
+ expectedBytes: canaryBytes,
149
266
  },
150
267
  config.appId,
151
268
  signal,
152
269
  );
153
- if (completed.ok !== true || Number(completed.receivedBytes) !== CANARY_BYTES) {
270
+ if (completed.ok !== true || Number(completed.receivedBytes) !== canaryBytes) {
154
271
  throw new DeliveryReadinessError(
155
272
  "media-storage-invalid-response",
156
273
  "The recording storage did not acknowledge the complete canary.",
157
274
  );
158
275
  }
159
276
  canary = null;
277
+ return uploadDurationMs;
160
278
  } finally {
161
279
  if (canary) void abortDirectCanary(baseUrl, config, canary);
162
280
  }
@@ -165,24 +283,28 @@ async function verifyDirectPath(
165
283
  async function verifyPostPath(
166
284
  baseUrl: string,
167
285
  config: RecordingReadinessConfig,
286
+ canaryBytes: number,
168
287
  signal: AbortSignal,
169
- ): Promise<void> {
288
+ now: () => number,
289
+ ): Promise<number> {
170
290
  const headers: Record<string, string> = {
171
291
  "content-type": "application/octet-stream",
172
292
  };
173
293
  if (config.appId) headers["x-app-id"] = config.appId;
294
+ const uploadStartedAt = now();
174
295
  const response = await fetch(
175
296
  `${baseUrl}/uploads/readiness/post/${encodeURIComponent(config.sessionId)}`,
176
297
  {
177
298
  method: "POST",
178
299
  headers,
179
- body: new Blob([new Uint8Array(CANARY_BYTES)], {
300
+ body: new Blob([new Uint8Array(canaryBytes)], {
180
301
  type: "application/octet-stream",
181
302
  }),
182
303
  credentials: "omit",
183
304
  signal,
184
305
  },
185
306
  );
307
+ const uploadDurationMs = Math.max(0.1, now() - uploadStartedAt);
186
308
  if (!response.ok) {
187
309
  throw mediaHttpError(response.status, "The recording relay rejected the canary upload.");
188
310
  }
@@ -196,12 +318,13 @@ async function verifyPostPath(
196
318
  { cause: error },
197
319
  );
198
320
  }
199
- if (body.ok !== true || Number(body.receivedBytes) !== CANARY_BYTES) {
321
+ if (body.ok !== true || Number(body.receivedBytes) !== canaryBytes) {
200
322
  throw new DeliveryReadinessError(
201
323
  "media-storage-invalid-response",
202
324
  "The recording relay did not acknowledge the complete canary.",
203
325
  );
204
326
  }
327
+ return uploadDurationMs;
205
328
  }
206
329
 
207
330
  async function postJson<T>(
@@ -247,6 +370,7 @@ async function abortDirectCanary(
247
370
  sessionId: config.sessionId,
248
371
  canaryId: canary.canaryId,
249
372
  uploadId: canary.uploadId,
373
+ expectedBytes: canary.expectedBytes,
250
374
  },
251
375
  config.appId,
252
376
  controller.signal,
@@ -258,6 +382,101 @@ async function abortDirectCanary(
258
382
  }
259
383
  }
260
384
 
385
+ async function runPathProbe(
386
+ baseUrl: string,
387
+ config: RecordingReadinessConfig,
388
+ mode: "direct" | "segments" | "post",
389
+ bytes: number,
390
+ now: () => number,
391
+ ): Promise<number> {
392
+ return withReadinessTimeout(RECORDING_READINESS_TIMEOUT_MS, "media", (signal) =>
393
+ mode === "direct" || mode === "segments"
394
+ ? verifyDirectPath(baseUrl, config, bytes, signal, now)
395
+ : verifyPostPath(baseUrl, config, bytes, signal, now),
396
+ );
397
+ }
398
+
399
+ async function withReadinessTimeout<T>(
400
+ timeoutMs: number,
401
+ phase: "config" | "media",
402
+ run: (signal: AbortSignal) => Promise<T>,
403
+ ): Promise<T> {
404
+ const controller = new AbortController();
405
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
406
+ try {
407
+ return await run(controller.signal);
408
+ } catch (error) {
409
+ if (error instanceof DeliveryReadinessError) throw error;
410
+ if (controller.signal.aborted) {
411
+ throw new DeliveryReadinessError(
412
+ phase === "config" ? "upload-config-timeout" : "media-storage-timeout",
413
+ phase === "config"
414
+ ? "Timed out while resolving the recording upload path."
415
+ : "Timed out while verifying recording storage.",
416
+ { cause: error },
417
+ );
418
+ }
419
+ throw new DeliveryReadinessError(
420
+ phase === "config" ? "upload-config-unreachable" : "media-storage-unreachable",
421
+ phase === "config"
422
+ ? "The recording upload configuration could not be reached."
423
+ : "The recording storage path could not be reached.",
424
+ { cause: error },
425
+ );
426
+ } finally {
427
+ clearTimeout(timeout);
428
+ }
429
+ }
430
+
431
+ function normaliseCanaryBytes(value: number): number {
432
+ if (!Number.isFinite(value) || value < 1) {
433
+ throw new DeliveryReadinessError(
434
+ "media-storage-invalid-response",
435
+ "The recording readiness payload size must be a positive number.",
436
+ );
437
+ }
438
+ return Math.min(MAX_CANARY_BYTES, Math.max(1, Math.round(value)));
439
+ }
440
+
441
+ function normaliseCompletionWindow(value: number): number {
442
+ if (!Number.isFinite(value) || value <= 0) {
443
+ throw new DeliveryReadinessError(
444
+ "media-storage-invalid-response",
445
+ "The recording readiness completion window must be positive.",
446
+ );
447
+ }
448
+ return Math.max(100, Math.round(value));
449
+ }
450
+
451
+ function median(values: number[]): number {
452
+ const sorted = [...values].sort((a, b) => a - b);
453
+ const middle = Math.floor(sorted.length / 2);
454
+ if (sorted.length % 2 === 1) return sorted[middle]!;
455
+ return (sorted[middle - 1]! + sorted[middle]!) / 2;
456
+ }
457
+
458
+ function measurementDetails(
459
+ mode: "direct" | "segments" | "post",
460
+ payloadBytes: number,
461
+ completionWindowMs: number,
462
+ successfulSamples: number,
463
+ medianCompletionMs?: number,
464
+ ): Readonly<Record<string, string | number>> {
465
+ return {
466
+ recordingUploadMode: mode,
467
+ readinessPayloadBytes: payloadBytes,
468
+ readinessWindowMs: Math.round(completionWindowMs),
469
+ readinessSampleCount: successfulSamples,
470
+ ...(medianCompletionMs !== undefined
471
+ ? { readinessMedianMs: Math.round(medianCompletionMs) }
472
+ : {}),
473
+ };
474
+ }
475
+
476
+ function defaultNow(): number {
477
+ return typeof performance !== "undefined" ? performance.now() : Date.now();
478
+ }
479
+
261
480
  function mediaHttpError(status: number, message: string): DeliveryReadinessError {
262
481
  return new DeliveryReadinessError(
263
482
  status >= 400 && status < 500 ? "media-storage-rejected" : "media-storage-unreachable",