@vzn/vx-otel 0.0.0 → 0.0.485

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.
package/src/sink.ts ADDED
@@ -0,0 +1,541 @@
1
+ // The OTLP telemetry sink — accumulates a run's records into OTLP trace +
2
+ // metric payloads and ships them over HTTP/JSON. Observe-only: it implements
3
+ // core's TelemetrySink (records in, nothing out). Never-fail: every network
4
+ // error is swallowed, the POST is time-bounded, and the upload is idempotent —
5
+ // a down collector can never affect a run.
6
+
7
+ import { randomBytes } from 'node:crypto'
8
+ import { TaskLogBuffer } from '@vzn/vx'
9
+ import type {
10
+ RunContextRecord,
11
+ RunSummaryRecord,
12
+ TaskTelemetry,
13
+ TelemetryRecord,
14
+ TelemetrySink,
15
+ } from '@vzn/vx'
16
+ import {
17
+ buildLogsRequest,
18
+ buildMetricsRequest,
19
+ buildTraceRequest,
20
+ type OtlpSpan,
21
+ runSpanAttributes,
22
+ SPAN_KIND_INTERNAL,
23
+ runStatusCode,
24
+ taskSpanAttributes,
25
+ taskStatusCode,
26
+ } from './otlp.js'
27
+
28
+ /** A POST function — injected in tests, defaults to fetch. Returns nothing;
29
+ * errors are the sink's to swallow. */
30
+ export type PostFn = (
31
+ url: string,
32
+ body: string,
33
+ headers: Record<string, string>,
34
+ signal?: AbortSignal,
35
+ tls?: OtlpTls,
36
+ ) => Promise<void>
37
+
38
+ /** PEM text from `OTEL_EXPORTER_OTLP_*CERTIFICATE` / `*CLIENT_KEY`, as fetch takes it. */
39
+ export interface OtlpTls {
40
+ ca?: string
41
+ cert?: string
42
+ key?: string
43
+ }
44
+
45
+ export type OtelSignal = 'traces' | 'metrics' | 'logs'
46
+
47
+ export interface OtelSinkConfig {
48
+ tracesUrl: string
49
+ metricsUrl: string
50
+ logsUrl: string
51
+ serviceName: string
52
+ /** `OTEL_RESOURCE_ATTRIBUTES`, under vx's own service identity. */
53
+ resource?: Readonly<Record<string, string>>
54
+ /** Signals the env asks to export over gRPC, which vx does not speak. */
55
+ grpc?: readonly OtelSignal[]
56
+ headers: Record<string, string>
57
+ /** Per signal, over `headers`. */
58
+ signalHeaders?: Partial<Record<OtelSignal, Record<string, string>>>
59
+ /** Signals sent gzipped (`OTEL_EXPORTER_OTLP_COMPRESSION=gzip`). */
60
+ gzip?: readonly OtelSignal[]
61
+ /** Per signal: a collector's CA, and a client certificate for mutual TLS. */
62
+ tls?: Partial<Record<OtelSignal, OtlpTls>>
63
+ /** False under `OTEL_TRACES_EXPORTER=none`; absent is true. */
64
+ tracesEnabled?: boolean
65
+ metricsEnabled: boolean
66
+ logsEnabled: boolean
67
+ timeoutMs: number
68
+ /** Per signal, over `timeoutMs` (`OTEL_EXPORTER_OTLP_<SIGNAL>_TIMEOUT`). */
69
+ signalTimeoutMs?: Partial<Record<OtelSignal, number>>
70
+ post?: PostFn
71
+ warn?: (message: string) => void
72
+ }
73
+
74
+ const defaultPost =
75
+ (timeoutMs: number): PostFn =>
76
+ async (url, json, headers, deadline, tls) => {
77
+ // Compressed once, not per attempt; the header says the sink asked.
78
+ const body = headers['content-encoding'] === 'gzip' ? Bun.gzipSync(json) : json
79
+ // A collector that sheds load (429, 502, 503, 504, or a reset
80
+ // connection) is retried twice, as the OTLP spec asks of exporters: the
81
+ // export was dropped whole on the first 503 (F-28). The flush deadline
82
+ // still bounds it all.
83
+ for (let attempt = 0; ; attempt++) {
84
+ try {
85
+ return await postOnce(url, body, headers, timeoutMs, deadline, tls)
86
+ } catch (err) {
87
+ const retry = err instanceof OtlpRetryable ? err.afterMs : undefined
88
+ const delay = RETRY_DELAYS_MS[attempt]
89
+ if (retry === undefined || delay === undefined || deadline?.aborted === true) {
90
+ throw err instanceof OtlpRetryable ? err.refused : err
91
+ }
92
+ // The deadline ended the wait: core has given up on the flush, and
93
+ // the refusal is what to tell (F-46).
94
+ if (await sleepUnless(retry > 0 ? Math.min(retry, MAX_RETRY_AFTER_MS) : delay, deadline)) {
95
+ throw err instanceof OtlpRetryable ? err.refused : err
96
+ }
97
+ }
98
+ }
99
+ }
100
+
101
+ /** fetch refused the server's certificate (untrusted, expired, wrong name). */
102
+ function isCertificateRefusal(err: unknown): boolean {
103
+ const code = (err as { code?: unknown } | null)?.code
104
+ return (
105
+ typeof code === 'string' &&
106
+ (code.includes('CERT') || code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE')
107
+ )
108
+ }
109
+
110
+ const RETRY_DELAYS_MS = [200, 800] as const
111
+ const MAX_TIMER_MS = 2 ** 31 - 1
112
+ const MAX_RETRY_AFTER_MS = 2000
113
+ const RETRYABLE_STATUS = new Set([429, 502, 503, 504])
114
+
115
+ /** A failure worth another attempt; `afterMs` is the collector's Retry-After, or 0. */
116
+ class OtlpRetryable extends Error {
117
+ constructor(
118
+ readonly refused: Error,
119
+ readonly afterMs: number,
120
+ ) {
121
+ super(refused.message)
122
+ }
123
+ }
124
+
125
+ /** Resolves true when `deadline` ended the wait. */
126
+ function sleepUnless(ms: number, deadline: AbortSignal | undefined): Promise<boolean> {
127
+ return new Promise((resolve) => {
128
+ const done = (): void => {
129
+ clearTimeout(timer)
130
+ deadline?.removeEventListener('abort', done)
131
+ resolve(deadline?.aborted === true)
132
+ }
133
+ const timer = setTimeout(done, ms)
134
+ deadline?.addEventListener('abort', done, { once: true })
135
+ })
136
+ }
137
+
138
+ // Takes the CONFIGURED timeout. It used to abort on a literal 15 s while
139
+ // `timeoutMs` was resolved, defaulted and stored and then read by nobody, so
140
+ // `otel({ timeoutMs: 1000 })` waited fifteen seconds on a hanging collector
141
+ // (2026-09-19). Clearable timer, not AbortSignal.timeout: the latter's
142
+ // internal timer is not unref'd and would keep a CLI process alive until it
143
+ // fires, well after the POST resolved.
144
+ async function postOnce(
145
+ url: string,
146
+ body: string | Uint8Array,
147
+ headers: Record<string, string>,
148
+ timeoutMs: number,
149
+ deadline: AbortSignal | undefined,
150
+ tls: OtlpTls | undefined,
151
+ ): Promise<void> {
152
+ const controller = new AbortController()
153
+ // A timer past 2^31-1 ms fires after 1 ms: OTEL_EXPORTER_OTLP_TIMEOUT=1e10
154
+ // aborted every export at once (F-56). Held to the longest a timer waits.
155
+ const timer = setTimeout(() => controller.abort(), Math.min(timeoutMs, MAX_TIMER_MS))
156
+ // Core's flush deadline ends the POST too: `timeoutMs` (15 s by
157
+ // default) alone held the process that long after the run (item 1055).
158
+ const onDeadline = (): void => controller.abort()
159
+ deadline?.addEventListener('abort', onDeadline, { once: true })
160
+ try {
161
+ let res: Response
162
+ try {
163
+ res = await fetch(url, {
164
+ method: 'POST',
165
+ body,
166
+ headers,
167
+ signal: controller.signal,
168
+ // An OTLP header is often the vendor's API key, and fetch drops only
169
+ // `Authorization` on a redirect to another origin: a collector's 307
170
+ // sent the key there (L-43). The OTel SDK exporters follow none.
171
+ redirect: 'manual',
172
+ ...(tls === undefined ? {} : { tls }),
173
+ })
174
+ } catch (err) {
175
+ // A connection that failed is worth a retry; one this side aborted is
176
+ // not, nor a certificate this side refused: it fails the same way each
177
+ // time, and the retries spent a second of the flush on it (F-40).
178
+ if (controller.signal.aborted) throw err
179
+ if (isCertificateRefusal(err)) {
180
+ throw new Error(
181
+ `${err instanceof Error ? err.message : String(err)} — for a collector behind a private CA, set OTEL_EXPORTER_OTLP_CERTIFICATE to its CA's PEM file`,
182
+ )
183
+ }
184
+ throw new OtlpRetryable(err instanceof Error ? err : new Error(String(err)), 0)
185
+ }
186
+ // A collector that REFUSES the export still ANSWERS: only one that
187
+ // cannot be reached throws. Until this read the status, a 401 from a
188
+ // wrong token, a 404 from a wrong path and a 500 from a wedged
189
+ // collector each exported nothing and said nothing, for every run
190
+ // (walked the adopter's path, 2026-09-20). The body is the
191
+ // collector's own explanation, so a line of it rides the message.
192
+ const text = await res.text().catch(() => '')
193
+ if (res.status >= 300 && res.status < 400) {
194
+ const location = res.headers.get('location')
195
+ const to = location === null ? null : URL.parse(location, url)
196
+ throw new Error(
197
+ `HTTP ${res.status}: the collector redirected${to === null ? '' : ` to ${shownUrl(to.href)}`}; OTLP exporters follow no redirect — set the endpoint to where it points`,
198
+ )
199
+ }
200
+ if (!res.ok) {
201
+ const refused = new Error(`HTTP ${res.status}${detail(text)}`)
202
+ if (!RETRYABLE_STATUS.has(res.status)) throw refused
203
+ const after = Number(res.headers.get('retry-after'))
204
+ throw new OtlpRetryable(refused, Number.isFinite(after) && after > 0 ? after * 1000 : 0)
205
+ }
206
+ // OTLP's other silent loss: a 200 whose body says part of the export
207
+ // was dropped (over quota, past a limit). Success at the transport,
208
+ // missing data in the collector.
209
+ const rejected = partialSuccess(text)
210
+ if (rejected !== undefined) throw new Error(rejected)
211
+ } finally {
212
+ clearTimeout(timer)
213
+ deadline?.removeEventListener('abort', onDeadline)
214
+ }
215
+ }
216
+
217
+ /** One line of a collector's error body, bounded — it is remote text. */
218
+ function detail(body: string): string {
219
+ const line = body.replace(/\s+/g, ' ').trim()
220
+ return line === '' ? '' : `: ${line.slice(0, 200)}`
221
+ }
222
+
223
+ /**
224
+ * The OTLP `partialSuccess` shape, when it says something was dropped: the
225
+ * rejected counts are int64-as-string, and a response with the field present
226
+ * but everything zero is a plain success (the spec's own example).
227
+ */
228
+ function partialSuccess(body: string): string | undefined {
229
+ if (!body.includes('partialSuccess')) return undefined
230
+ let parsed: unknown
231
+ try {
232
+ parsed = JSON.parse(body)
233
+ } catch {
234
+ return undefined
235
+ }
236
+ const ps = (parsed as { partialSuccess?: Record<string, unknown> })?.partialSuccess
237
+ if (ps === undefined || ps === null) return undefined
238
+ const counts = (['rejectedSpans', 'rejectedDataPoints', 'rejectedLogRecords'] as const)
239
+ .map((k) => [k, Number(ps[k] ?? 0)] as const)
240
+ .filter(([, n]) => Number.isFinite(n) && n > 0)
241
+ const message = typeof ps['errorMessage'] === 'string' ? ps['errorMessage'].trim() : ''
242
+ if (counts.length === 0 && message === '') return undefined
243
+ const what = counts.map(([k, n]) => `${n} ${k.replace('rejected', '').toLowerCase()}`).join(', ')
244
+ return `the collector dropped part of the export: ${what === '' ? 'some of it' : what}${detail(message)}`
245
+ }
246
+
247
+ function genId(bytes: number): string {
248
+ return randomBytes(bytes).toString('hex')
249
+ }
250
+
251
+ /** ms → unix-nano decimal string (OTLP int64-as-string). */
252
+ function nanos(ms: number): string {
253
+ return String(Math.trunc(ms) * 1_000_000)
254
+ }
255
+
256
+ export class OtelSink implements TelemetrySink {
257
+ readonly name = '@vzn/vx-otel'
258
+ /**
259
+ * Which record kinds this sink takes. `task.log` is included ONLY when the
260
+ * logs signal is on: core checks this before it projects a chunk at all, so
261
+ * a logs-off exporter costs a run exactly nothing on the output path.
262
+ */
263
+ readonly wants: ReadonlyArray<TelemetryRecord['kind']>
264
+
265
+ private readonly cfg: Required<Omit<OtelSinkConfig, 'warn'>> & { warn?: (m: string) => void }
266
+ private traceId = ''
267
+ private rootSpanId = ''
268
+ private run: RunContextRecord | undefined
269
+ private rootStartNano = '0'
270
+ private rootEndNano = '0'
271
+ private readonly spans: OtlpSpan[] = []
272
+ private readonly taskSpanId = new Map<string, string>()
273
+ private readonly taskStartNano = new Map<string, string>()
274
+ private summary: RunSummaryRecord | undefined
275
+ private uploaded = false
276
+ // Core's own bounded capture buffer, so which task's output survives a
277
+ // chatty run is core's rule, not this sink's.
278
+ private readonly logs = new TaskLogBuffer()
279
+ private runId = ''
280
+ private runStartedAt = 0
281
+ private readonly injected: boolean
282
+
283
+ constructor(config: OtelSinkConfig) {
284
+ this.injected = config.post !== undefined
285
+ this.cfg = {
286
+ tracesUrl: config.tracesUrl,
287
+ metricsUrl: config.metricsUrl,
288
+ logsUrl: config.logsUrl,
289
+ serviceName: config.serviceName,
290
+ resource: config.resource ?? {},
291
+ grpc: config.grpc ?? [],
292
+ gzip: config.gzip ?? [],
293
+ tls: config.tls ?? {},
294
+ headers: config.headers,
295
+ signalHeaders: config.signalHeaders ?? {},
296
+ tracesEnabled: config.tracesEnabled !== false,
297
+ metricsEnabled: config.metricsEnabled,
298
+ logsEnabled: config.logsEnabled,
299
+ timeoutMs: config.timeoutMs,
300
+ signalTimeoutMs: config.signalTimeoutMs ?? {},
301
+ post: config.post ?? defaultPost(config.timeoutMs),
302
+ ...(config.warn ? { warn: config.warn } : {}),
303
+ }
304
+ this.wants = config.logsEnabled
305
+ ? ['run.start', 'task.start', 'task.log', 'task.end', 'run.end']
306
+ : ['run.start', 'task.start', 'task.end', 'run.end']
307
+ }
308
+
309
+ onRecord(record: TelemetryRecord): void {
310
+ switch (record.kind) {
311
+ case 'run.start':
312
+ this.traceId = genId(16)
313
+ this.rootSpanId = genId(8)
314
+ this.run = record.run
315
+ this.runId = record.run.runId
316
+ // The run's OWN canonical start, not when this record was projected —
317
+ // it is what the summary reports and what a receiver stores.
318
+ this.rootStartNano = nanos(record.startedAt)
319
+ this.runStartedAt = record.startedAt
320
+ return
321
+ case 'task.start':
322
+ this.taskSpanId.set(record.taskId, genId(8))
323
+ this.taskStartNano.set(record.taskId, nanos(record.ts))
324
+ return
325
+ case 'task.log':
326
+ this.logs.append(record.taskId, record.chunk)
327
+ return
328
+ case 'task.end': {
329
+ const spanId = this.taskSpanId.get(record.taskId) ?? genId(8)
330
+ const startNano =
331
+ this.taskStartNano.get(record.taskId) ?? nanos(record.ts - record.durationMs)
332
+ const t: TaskTelemetry = record
333
+ this.spans.push({
334
+ traceId: this.traceId,
335
+ spanId,
336
+ ...(this.rootSpanId ? { parentSpanId: this.rootSpanId } : {}),
337
+ name: 'vx.task',
338
+ kind: SPAN_KIND_INTERNAL,
339
+ startTimeUnixNano: startNano,
340
+ endTimeUnixNano: nanos(record.ts),
341
+ attributes: taskSpanAttributes(t, {
342
+ runId: this.runId,
343
+ workspaceId: this.run?.workspaceId ?? '',
344
+ startedAt: this.runStartedAt,
345
+ }),
346
+ status: { code: taskStatusCode(t) },
347
+ })
348
+ // Decides retention: a cache hit's bytes belong to the run that
349
+ // executed them, so only an executed success/failure keeps a tail.
350
+ if (this.cfg.logsEnabled) {
351
+ this.logs.finish(record.taskId, record.status, record.cacheSource, record.hash)
352
+ }
353
+ return
354
+ }
355
+ case 'run.end':
356
+ this.rootEndNano = nanos(record.ts)
357
+ return
358
+ }
359
+ }
360
+
361
+ /** Core's flush deadline, passed to every POST. */
362
+ private deadline: AbortSignal | undefined
363
+
364
+ onRunSummary(summary: RunSummaryRecord): void {
365
+ this.summary = summary
366
+ }
367
+
368
+ async flush(signal?: AbortSignal): Promise<void> {
369
+ if (this.uploaded) return
370
+ this.uploaded = true
371
+ this.deadline = signal
372
+ // Finalize the root span now that the run is over (run.end set the end).
373
+ if (this.run !== undefined && this.traceId) {
374
+ // Prefer the summary's own start/end: they are the run's canonical
375
+ // timing, and a receiver rebuilding the invocation header must agree
376
+ // with the native ingest path to the millisecond.
377
+ const start = this.summary !== undefined ? nanos(this.summary.startedAt) : this.rootStartNano
378
+ const end =
379
+ this.summary !== undefined
380
+ ? nanos(this.summary.endedAt)
381
+ : this.rootEndNano !== '0'
382
+ ? this.rootEndNano
383
+ : this.rootStartNano
384
+ this.spans.unshift({
385
+ traceId: this.traceId,
386
+ spanId: this.rootSpanId,
387
+ name: 'vx.run',
388
+ kind: SPAN_KIND_INTERNAL,
389
+ startTimeUnixNano: start,
390
+ endTimeUnixNano: end,
391
+ attributes: runSpanAttributes(this.run, this.summary),
392
+ status: { code: runStatusCode(this.summary) },
393
+ })
394
+ }
395
+ const vxVersion = this.run?.vxVersion ?? '0.0.0'
396
+ await Promise.all([this.shipTraces(vxVersion), this.shipMetrics(), this.shipLogs(vxVersion)])
397
+ }
398
+
399
+ private async shipTraces(vxVersion: string): Promise<void> {
400
+ if (this.cfg.tracesEnabled === false || this.spans.length === 0) return
401
+ const bodies = requestBodies(this.spans, (spans) =>
402
+ JSON.stringify(buildTraceRequest(this.cfg.serviceName, vxVersion, spans, this.cfg.resource)),
403
+ )
404
+ await this.send('traces', this.cfg.tracesUrl, bodies)
405
+ }
406
+
407
+ private async shipMetrics(): Promise<void> {
408
+ if (!this.cfg.metricsEnabled || this.summary === undefined) return
409
+ const body = JSON.stringify(
410
+ buildMetricsRequest(
411
+ this.cfg.serviceName,
412
+ this.summary,
413
+ nanos(this.summary.endedAt),
414
+ nanos(this.summary.startedAt),
415
+ this.cfg.resource,
416
+ ),
417
+ )
418
+ await this.send('metrics', this.cfg.metricsUrl, [body])
419
+ }
420
+
421
+ private async shipLogs(vxVersion: string): Promise<void> {
422
+ if (!this.cfg.logsEnabled) return
423
+ const workspaceId = this.run?.workspaceId ?? ''
424
+ const bundle = this.logs.drain(this.runId, workspaceId)
425
+ if (bundle.tasks.length === 0) return
426
+ const bodies = requestBodies(bundle.tasks, (entries) =>
427
+ JSON.stringify(
428
+ buildLogsRequest({
429
+ serviceName: this.cfg.serviceName,
430
+ resource: this.cfg.resource,
431
+ vxVersion,
432
+ runId: this.runId,
433
+ workspaceId,
434
+ entries,
435
+ timeUnixNano: nanos(this.summary?.endedAt ?? Date.now()),
436
+ // Traces off: the trace and its spans are never exported, and a
437
+ // record naming them links nowhere (F-49).
438
+ ...(this.cfg.tracesEnabled && this.traceId
439
+ ? { traceId: this.traceId, spanIdFor: (taskId) => this.taskSpanId.get(taskId) }
440
+ : {}),
441
+ }),
442
+ ),
443
+ )
444
+ await this.send('logs', this.cfg.logsUrl, bodies)
445
+ }
446
+
447
+ /** The transport, under the signal's own timeout when it has one (F-49). */
448
+ private postFor(signal: OtelSignal): PostFn {
449
+ const own = this.cfg.signalTimeoutMs[signal]
450
+ return own === undefined || this.injected ? this.cfg.post : defaultPost(own)
451
+ }
452
+
453
+ /** POSTs every body at once; one warning per signal, however many fail. */
454
+ private async send(signal: OtelSignal, url: string, bodies: readonly string[]): Promise<void> {
455
+ const gzip = this.cfg.gzip.includes(signal)
456
+ const headers: Record<string, string> = {
457
+ 'content-type': 'application/json',
458
+ ...this.cfg.headers,
459
+ ...this.cfg.signalHeaders[signal],
460
+ ...(gzip ? { 'content-encoding': 'gzip' } : {}),
461
+ }
462
+ const failed = (
463
+ await Promise.all(
464
+ bodies.map((body) =>
465
+ // Through a promise, so a transport that throws before it awaits is caught too.
466
+ Promise.resolve()
467
+ .then(() =>
468
+ this.postFor(signal)(url, body, headers, this.deadline, this.cfg.tls[signal]),
469
+ )
470
+ .then(
471
+ () => undefined,
472
+ (err: unknown) => (err instanceof Error ? err.message : String(err)),
473
+ ),
474
+ ),
475
+ )
476
+ ).filter((m) => m !== undefined)
477
+ // export is fully optional — a down collector never affects a run
478
+ if (failed.length === 0) return
479
+ // Name the URL: three signals ship concurrently and each is caught
480
+ // here on its own, so a bare "export failed" cannot tell a down
481
+ // collector from one misconfigured signal endpoint.
482
+ // An HTTP POST to a gRPC port fails with a transport error that
483
+ // names neither; the env that asked for gRPC is the likely cause.
484
+ const hint = this.cfg.grpc.includes(signal)
485
+ ? ` — the env asks for OTLP over gRPC, and vx sends OTLP/HTTP JSON only: point it at the collector's HTTP endpoint (port 4318)`
486
+ : ''
487
+ const share = bodies.length > 1 ? ` (${failed.length} of ${bodies.length} requests)` : ''
488
+ this.cfg.warn?.(`[vx-otel] export failed for ${shownUrl(url)}: ${failed[0]}${share}${hint}`)
489
+ }
490
+ }
491
+
492
+ /**
493
+ * Items per OTLP request. A run's spans went in ONE request, and 20 000 tasks
494
+ * made 23 MiB, past a collector's 20 MiB default: `request body too large`,
495
+ * the whole trace lost (F-22). 1 000 spans is ~1.2 MiB; the SDKs batch 512.
496
+ */
497
+ const ITEMS_PER_REQUEST = 1000
498
+
499
+ /**
500
+ * Bytes per OTLP request. A count bounds spans, not log tails: the run's log
501
+ * budget counts characters, and JSON writes a control character as six bytes
502
+ * (`\u0001`), so 64 tails of control bytes made one 23 MiB logs request
503
+ * (F-23). A body past this is split in half until it fits or is one item.
504
+ */
505
+ const BYTES_PER_REQUEST = 4 * 1024 * 1024
506
+
507
+ /** `items` as request bodies, each at most ITEMS_PER_REQUEST items and BYTES_PER_REQUEST bytes. */
508
+ function requestBodies<T>(items: readonly T[], encode: (group: T[]) => string): string[] {
509
+ const out: string[] = []
510
+ const add = (group: T[]): void => {
511
+ const body = encode(group)
512
+ if (group.length > 1 && Buffer.byteLength(body) > BYTES_PER_REQUEST) {
513
+ const half = Math.ceil(group.length / 2)
514
+ add(group.slice(0, half))
515
+ add(group.slice(half))
516
+ } else out.push(body)
517
+ }
518
+ for (let i = 0; i < items.length; i += ITEMS_PER_REQUEST) {
519
+ add(items.slice(i, i + ITEMS_PER_REQUEST))
520
+ }
521
+ return out
522
+ }
523
+
524
+ /**
525
+ * `url` as a warning may print it: a credential in its userinfo or query
526
+ * (`https://user:token@…`, `…?api-key=…`) is replaced, so a refused export
527
+ * does not put the secret in a CI log. Headers were redacted by item 928;
528
+ * the URL was not.
529
+ */
530
+ function shownUrl(url: string): string {
531
+ let u: URL
532
+ try {
533
+ u = new URL(url)
534
+ } catch {
535
+ return '(an unparsable URL)'
536
+ }
537
+ if (u.username !== '') u.username = '***'
538
+ if (u.password !== '') u.password = '***'
539
+ if (u.search !== '') u.search = '?***'
540
+ return u.href
541
+ }