@vzn/vx-otel 0.0.0 → 0.0.484
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/LICENSE +21 -0
- package/README.md +146 -0
- package/index.ts +4 -0
- package/package.json +39 -6
- package/src/index.ts +14 -0
- package/src/otlp.ts +482 -0
- package/src/plugin.ts +401 -0
- package/src/sink.ts +541 -0
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
|
+
}
|