@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/plugin.ts ADDED
@@ -0,0 +1,401 @@
1
+ // The `otel()` telemetry plugin. Contributes one observe-only telemetry sink
2
+ // that exports a vx run as OTLP traces + metrics. Zero-config via the standard
3
+ // OTel env vars; declines (returns undefined) when no endpoint is configured,
4
+ // so declaring `otel()` is safe in every environment.
5
+ //
6
+ // This replaces core's old hardcoded `attachOtelEmit` (logs-only, fired
7
+ // unconditionally when OTEL_EXPORTER_OTLP_ENDPOINT was set). OTel is now a
8
+ // plugin — declare it in vx.workspace.ts. The trade: the env var ALONE no
9
+ // longer auto-exports; you must `defineWorkspace({ plugins: [otel()] })`.
10
+
11
+ import {
12
+ refuseUnknownOptions,
13
+ type PluginOptionKinds,
14
+ definePlugin,
15
+ type TelemetryContext,
16
+ type TelemetrySink,
17
+ type VxPlugin,
18
+ } from '@vzn/vx'
19
+ import { readFileSync } from 'node:fs'
20
+ import { OtelSink, type OtlpTls, type PostFn } from './sink.js'
21
+
22
+ export interface OtelPluginOptions {
23
+ /** OTLP base endpoint. Falls back to `OTEL_EXPORTER_OTLP_ENDPOINT`. */
24
+ endpoint?: string
25
+ /** Full traces URL override. Falls back to `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT`, else `<endpoint>/v1/traces`. */
26
+ tracesEndpoint?: string
27
+ /** Full metrics URL override. Falls back to `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT`, else `<endpoint>/v1/metrics`. */
28
+ metricsEndpoint?: string
29
+ /** Full logs URL override. Falls back to `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT`, else `<endpoint>/v1/logs`. */
30
+ logsEndpoint?: string
31
+ /** Service name. Falls back to `OTEL_SERVICE_NAME`, then `OTEL_RESOURCE_ATTRIBUTES`' `service.name`, else `'vx'`. */
32
+ serviceName?: string
33
+ /** Extra OTLP headers, merged over `OTEL_EXPORTER_OTLP_HEADERS` and each signal's own. */
34
+ headers?: Record<string, string>
35
+ /** Emit run/task metrics in addition to traces. Default: true. */
36
+ metrics?: boolean
37
+ /**
38
+ * Ship each executed task's captured output tail as an OTel log record.
39
+ * Default: true — an endpoint is configured, so the intent is to export.
40
+ * Set false (or `OTEL_LOGS_EXPORTER=none`) to export traces + metrics only.
41
+ */
42
+ logs?: boolean
43
+ /** Per-request timeout (ms). Falls back to `OTEL_EXPORTER_OTLP_TIMEOUT`, else 15000. */
44
+ timeoutMs?: number
45
+ /**
46
+ * `'gzip'` sends every signal gzipped. Falls back to each signal's
47
+ * `OTEL_EXPORTER_OTLP_<SIGNAL>_COMPRESSION`, then `OTEL_EXPORTER_OTLP_COMPRESSION`,
48
+ * else `'none'`.
49
+ */
50
+ compression?: 'gzip' | 'none'
51
+ /** Test seam — inject the POST transport. Defaults to fetch. */
52
+ post?: PostFn
53
+ }
54
+
55
+ /**
56
+ * Parse an `OTEL_EXPORTER_OTLP_HEADERS`-style `k=v,k=v` string. The spec's
57
+ * format is W3C Baggage's, so keys and values are percent-decoded:
58
+ * `Authorization=Basic%20…`, as vendors document it, was sent literally and
59
+ * every export was refused 401 (item 923). A malformed escape is kept as
60
+ * written.
61
+ */
62
+ export function parseOtlpHeaders(raw: string | undefined): Record<string, string> {
63
+ if (!raw) return {}
64
+ const decode = (s: string): string => {
65
+ try {
66
+ return decodeURIComponent(s)
67
+ } catch {
68
+ return s
69
+ }
70
+ }
71
+ const out: Record<string, string> = {}
72
+ for (const pair of raw.split(',')) {
73
+ const eq = pair.indexOf('=')
74
+ if (eq <= 0) continue
75
+ const key = decode(pair.slice(0, eq).trim())
76
+ const value = decode(pair.slice(eq + 1).trim())
77
+ if (key) out[key] = value
78
+ }
79
+ return out
80
+ }
81
+
82
+ /**
83
+ * An EMPTY value is as absent as an unset one. A workflow writing
84
+ * `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: ${{ secrets.X }}` with the secret unset
85
+ * exports an empty string, and `??` does not fall through on it — so an empty
86
+ * signal URL used to survive the `tracesUrl === undefined` guard and produce a
87
+ * sink that POSTed to '' on every run. Whitespace-only counts too (a stray
88
+ * newline from a shell here-doc). Every option and env read goes through this,
89
+ * so they cannot disagree the way they did.
90
+ */
91
+ function present(v: string | undefined): string | undefined {
92
+ return v === undefined || v.trim() === '' ? undefined : v
93
+ }
94
+
95
+ /**
96
+ * Why a string cannot be an HTTP header value, or null. Bun's `fetch`
97
+ * refuses a line break, a NUL or a character past Latin-1 once it has
98
+ * trimmed the ends (measured on 1.4.2), and quotes the value in its error.
99
+ */
100
+ function headerValueFault(value: string): string | null {
101
+ let past = false
102
+ for (const ch of value.trim()) {
103
+ const c = ch.codePointAt(0)!
104
+ if (c === 0x0a || c === 0x0d || c === 0) return 'a line break or NUL'
105
+ if (c > 0xff) past = true
106
+ }
107
+ return past ? 'a character past Latin-1' : null
108
+ }
109
+
110
+ /** An HTTP header name: an RFC 7230 token. */
111
+ const HEADER_NAME = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/
112
+
113
+ /** `OTEL_EXPORTER_OTLP_TIMEOUT`: milliseconds; anything but a positive number is unset. */
114
+ function envTimeout(raw: string | undefined): number | undefined {
115
+ const ms = Number(present(raw))
116
+ return Number.isFinite(ms) && ms > 0 ? ms : undefined
117
+ }
118
+
119
+ /**
120
+ * `v1/<signal>` appended to the base URL's PATH, as the exporter spec says:
121
+ * a base with a query had it glued onto the query's last value (F-49).
122
+ */
123
+ function joinSignal(base: string, signal: string): string {
124
+ if (URL.canParse(base)) {
125
+ const url = new URL(base)
126
+ url.pathname = `${url.pathname.replace(/\/+$/, '')}/v1/${signal}`
127
+ return url.toString()
128
+ }
129
+ return `${base.replace(/\/+$/, '')}/v1/${signal}`
130
+ }
131
+
132
+ /**
133
+ * `OTEL_RESOURCE_ATTRIBUTES`: `k=v,k=v`, percent-encoded. On a decoding
134
+ * error the Resource SDK spec discards the whole variable; a malformed
135
+ * escape was sent as written (F-49).
136
+ */
137
+ function parseResourceAttributes(
138
+ raw: string | undefined,
139
+ warn: ((m: string) => void) | undefined,
140
+ ): Record<string, string> {
141
+ if (present(raw) === undefined) return {}
142
+ const out: Record<string, string> = {}
143
+ for (const pair of raw!.split(',')) {
144
+ if (pair.trim() === '') continue
145
+ const eq = pair.indexOf('=')
146
+ try {
147
+ if (eq <= 0) throw new Error('no key')
148
+ const key = decodeURIComponent(pair.slice(0, eq).trim())
149
+ if (key === '') throw new Error('no key')
150
+ out[key] = decodeURIComponent(pair.slice(eq + 1).trim())
151
+ } catch {
152
+ warn?.(
153
+ `[vx-otel] OTEL_RESOURCE_ATTRIBUTES is malformed (${pair.trim()}) — none of it is used`,
154
+ )
155
+ return {}
156
+ }
157
+ }
158
+ return out
159
+ }
160
+
161
+ /**
162
+ * Resolve the export config from options + env. Returns undefined when no
163
+ * endpoint is configured (the plugin then declines — zero-config safe).
164
+ */
165
+ export function resolveOtelConfig(
166
+ opts: OtelPluginOptions,
167
+ env: Record<string, string | undefined>,
168
+ warn?: (m: string) => void,
169
+ ): ConstructorParameters<typeof OtelSink>[0] | undefined {
170
+ const base = present(opts.endpoint) ?? present(env['OTEL_EXPORTER_OTLP_ENDPOINT'])
171
+ const tracesUrl =
172
+ present(opts.tracesEndpoint) ??
173
+ present(env['OTEL_EXPORTER_OTLP_TRACES_ENDPOINT']) ??
174
+ (base ? joinSignal(base, 'traces') : undefined)
175
+ const metricsUrl =
176
+ present(opts.metricsEndpoint) ??
177
+ present(env['OTEL_EXPORTER_OTLP_METRICS_ENDPOINT']) ??
178
+ (base ? joinSignal(base, 'metrics') : undefined)
179
+ const logsUrl =
180
+ present(opts.logsEndpoint) ??
181
+ present(env['OTEL_EXPORTER_OTLP_LOGS_ENDPOINT']) ??
182
+ (base ? joinSignal(base, 'logs') : undefined)
183
+ // Each signal has its own endpoint, as the spec has them: a pipeline that
184
+ // set only a metrics (or logs) endpoint declined whole and exported
185
+ // nothing (F-45). The plugin declines only when no signal has one.
186
+ if (tracesUrl === undefined && metricsUrl === undefined && logsUrl === undefined) {
187
+ return undefined
188
+ }
189
+ // The standard SDK opt-outs, honoured so a pipeline already configured
190
+ // that way does not start receiving vx's telemetry because it upgraded
191
+ // vx. Only the logs one was read; `OTEL_SDK_DISABLED=true` and the traces
192
+ // and metrics `=none` were ignored and still exported (item 1056).
193
+ if (env['OTEL_SDK_DISABLED']?.trim().toLowerCase() === 'true') return undefined
194
+ const off = (name: string): boolean => env[name]?.trim().toLowerCase() === 'none'
195
+ const tracesWanted = !off('OTEL_TRACES_EXPORTER')
196
+ const logsWanted = opts.logs ?? !off('OTEL_LOGS_EXPORTER')
197
+ const metricsWanted = opts.metrics ?? !off('OTEL_METRICS_EXPORTER')
198
+ // A signal ships only to its OWN url. With only a traces endpoint set, the
199
+ // metrics payload used to go to the traces url — a request the collector
200
+ // refuses on every run (item 807). A signal asked for by name and given no
201
+ // url says so once instead.
202
+ for (const [signal, asked, url] of [
203
+ ['metrics', opts.metrics, metricsUrl],
204
+ ['logs', opts.logs, logsUrl],
205
+ ] as const) {
206
+ if (asked === true && url === undefined) {
207
+ warn?.(
208
+ `[vx-otel] ${signal}: true but no ${signal} endpoint (OTEL_EXPORTER_OTLP_ENDPOINT or OTEL_EXPORTER_OTLP_${signal.toUpperCase()}_ENDPOINT) — ${signal} are not exported`,
209
+ )
210
+ }
211
+ }
212
+
213
+ // A header value fetch refuses is refused here, by name: fetch's error
214
+ // quotes the whole value, and the export warning printed it — an auth
215
+ // header's secret in the log (item 928). A NAME fetch refuses is refused
216
+ // too, and never printed: a curl-style `Authorization: Basic …=` split at
217
+ // its first `=` is a name holding the credential, and every export failed
218
+ // quoting it (item 1056). Names are lower-cased so the later of two
219
+ // spellings replaces the earlier, as the spec orders them: `Authorization`
220
+ // and `authorization` were both sent, joined into one bad credential.
221
+ const dropped = new Set<string>()
222
+ let badName = false
223
+ const clean = (h: Record<string, string>): Record<string, string> => {
224
+ const out: Record<string, string> = {}
225
+ for (const [raw, v] of Object.entries(h)) {
226
+ if (!HEADER_NAME.test(raw)) {
227
+ if (!badName) {
228
+ badName = true
229
+ warn?.(
230
+ '[vx-otel] a header name holds a character no HTTP header name can carry (a space, a colon, …) — not sent (neither its name nor its value is printed)',
231
+ )
232
+ }
233
+ continue
234
+ }
235
+ const k = raw.toLowerCase()
236
+ // An option's value may be a number or an object: refused like a
237
+ // value fetch cannot send, not left to throw `value.trim is not a
238
+ // function` out of the plugin.
239
+ const fault = typeof v === 'string' ? headerValueFault(v) : 'a non-string value'
240
+ if (fault === null) out[k] = v
241
+ else if (!dropped.has(k)) {
242
+ dropped.add(k)
243
+ warn?.(
244
+ `[vx-otel] header ${JSON.stringify(raw)} holds ${fault}, which no HTTP header can carry — not sent (its value is not printed)`,
245
+ )
246
+ }
247
+ }
248
+ return out
249
+ }
250
+ // `OTEL_RESOURCE_ATTRIBUTES` (deployment.environment, team, …) was not
251
+ // read, so a pipeline's resource identity never reached vx's telemetry.
252
+ const resource = parseResourceAttributes(env['OTEL_RESOURCE_ATTRIBUTES'], warn)
253
+ // vx speaks OTLP/HTTP JSON only; a signal the env sends over gRPC most
254
+ // likely points at a gRPC port, and its failure warning says so.
255
+ const grpc = (['traces', 'metrics', 'logs'] as const).filter(
256
+ (signal) =>
257
+ (
258
+ present(env[`OTEL_EXPORTER_OTLP_${signal.toUpperCase()}_PROTOCOL`]) ??
259
+ env['OTEL_EXPORTER_OTLP_PROTOCOL']
260
+ )
261
+ ?.trim()
262
+ .toLowerCase() === 'grpc',
263
+ )
264
+ // `OTEL_EXPORTER_OTLP_COMPRESSION=gzip` was not read, and a pipeline
265
+ // configured for it sent every export uncompressed (F-33).
266
+ let badCompression = false
267
+ const gzip = (['traces', 'metrics', 'logs'] as const).filter((signal) => {
268
+ const name = `OTEL_EXPORTER_OTLP_${signal.toUpperCase()}_COMPRESSION`
269
+ const raw =
270
+ opts.compression ?? present(env[name]) ?? present(env['OTEL_EXPORTER_OTLP_COMPRESSION'])
271
+ const value = raw?.trim().toLowerCase()
272
+ if (value === undefined || value === 'none') return false
273
+ if (value === 'gzip') return true
274
+ if (!badCompression) {
275
+ badCompression = true
276
+ warn?.(`[vx-otel] compression ${JSON.stringify(raw)} is not gzip or none — sent uncompressed`)
277
+ }
278
+ return false
279
+ })
280
+ // The spec's TLS files were not read: a collector behind a private CA
281
+ // failed every export on its certificate, and one that asks for a client
282
+ // certificate refused the connection (F-39). Each is a PEM file path; a
283
+ // signal's own wins. Read once here; one that cannot be read warns once.
284
+ const pem = new Map<string, string | undefined>()
285
+ const read = (name: string, file: string): string | undefined => {
286
+ if (!pem.has(file)) {
287
+ try {
288
+ pem.set(file, readFileSync(file, 'utf8'))
289
+ } catch (err) {
290
+ warn?.(
291
+ `[vx-otel] ${name}: cannot read ${file} (${(err as NodeJS.ErrnoException).code ?? String(err)}) — not used`,
292
+ )
293
+ pem.set(file, undefined)
294
+ }
295
+ }
296
+ return pem.get(file)
297
+ }
298
+ // The option is held to the variable's rule: a zero, negative or
299
+ // non-number timeout aborted every export the moment it started.
300
+ if (
301
+ opts.timeoutMs !== undefined &&
302
+ !(typeof opts.timeoutMs === 'number' && Number.isFinite(opts.timeoutMs) && opts.timeoutMs > 0)
303
+ ) {
304
+ throw new Error(
305
+ `[vx-otel] timeoutMs must be a positive number of ms, got ${JSON.stringify(opts.timeoutMs)}`,
306
+ )
307
+ }
308
+ const timeoutMs = opts.timeoutMs ?? envTimeout(env['OTEL_EXPORTER_OTLP_TIMEOUT']) ?? 15_000
309
+ const tls: Partial<Record<'traces' | 'metrics' | 'logs', OtlpTls>> = {}
310
+ for (const signal of ['traces', 'metrics', 'logs'] as const) {
311
+ const one: OtlpTls = {}
312
+ for (const [field, suffix] of [
313
+ ['ca', 'CERTIFICATE'],
314
+ ['cert', 'CLIENT_CERTIFICATE'],
315
+ ['key', 'CLIENT_KEY'],
316
+ ] as const) {
317
+ const own = `OTEL_EXPORTER_OTLP_${signal.toUpperCase()}_${suffix}`
318
+ const shared = `OTEL_EXPORTER_OTLP_${suffix}`
319
+ const name = present(env[own]) !== undefined ? own : shared
320
+ const file = present(env[name])
321
+ const text = file === undefined ? undefined : read(name, file)
322
+ if (text !== undefined) one[field] = text
323
+ }
324
+ if (Object.keys(one).length > 0) tls[signal] = one
325
+ }
326
+ return {
327
+ tracesUrl: tracesUrl ?? '',
328
+ metricsUrl: metricsUrl ?? '',
329
+ logsUrl: logsUrl ?? '',
330
+ serviceName:
331
+ present(opts.serviceName) ??
332
+ present(env['OTEL_SERVICE_NAME']) ??
333
+ present(resource['service.name']) ??
334
+ 'vx',
335
+ resource,
336
+ grpc,
337
+ gzip,
338
+ ...(Object.keys(tls).length > 0 ? { tls } : {}),
339
+ headers: clean({ ...parseOtlpHeaders(env['OTEL_EXPORTER_OTLP_HEADERS']), ...opts.headers }),
340
+ // A signal's own `OTEL_EXPORTER_OTLP_<SIGNAL>_HEADERS` wins over the
341
+ // shared ones, as the spec orders them; they were not read (item 923).
342
+ // The plugin's `headers` option stays on top of both.
343
+ signalHeaders: {
344
+ traces: clean({
345
+ ...parseOtlpHeaders(env['OTEL_EXPORTER_OTLP_TRACES_HEADERS']),
346
+ ...opts.headers,
347
+ }),
348
+ metrics: clean({
349
+ ...parseOtlpHeaders(env['OTEL_EXPORTER_OTLP_METRICS_HEADERS']),
350
+ ...opts.headers,
351
+ }),
352
+ logs: clean({ ...parseOtlpHeaders(env['OTEL_EXPORTER_OTLP_LOGS_HEADERS']), ...opts.headers }),
353
+ },
354
+ ...(tracesWanted && tracesUrl !== undefined ? {} : { tracesEnabled: false }),
355
+ metricsEnabled: metricsWanted && metricsUrl !== undefined,
356
+ logsEnabled: logsWanted && logsUrl !== undefined,
357
+ timeoutMs,
358
+ // A signal's own `OTEL_EXPORTER_OTLP_<SIGNAL>_TIMEOUT` wins over the
359
+ // shared one; it was not read (F-49). The option tops both.
360
+ signalTimeoutMs: {
361
+ traces: opts.timeoutMs ?? envTimeout(env['OTEL_EXPORTER_OTLP_TRACES_TIMEOUT']) ?? timeoutMs,
362
+ metrics: opts.timeoutMs ?? envTimeout(env['OTEL_EXPORTER_OTLP_METRICS_TIMEOUT']) ?? timeoutMs,
363
+ logs: opts.timeoutMs ?? envTimeout(env['OTEL_EXPORTER_OTLP_LOGS_TIMEOUT']) ?? timeoutMs,
364
+ },
365
+ ...(opts.post ? { post: opts.post } : {}),
366
+ ...(warn ? { warn } : {}),
367
+ }
368
+ }
369
+
370
+ /** Each option `OtelPluginOptions` names, with its kind: derived from the type, so the two cannot drift. */
371
+ const OTEL_PLUGIN_KEYS: PluginOptionKinds<OtelPluginOptions> = {
372
+ endpoint: 'string',
373
+ tracesEndpoint: 'string',
374
+ metricsEndpoint: 'string',
375
+ logsEndpoint: 'string',
376
+ serviceName: 'string',
377
+ headers: 'object',
378
+ metrics: 'boolean',
379
+ logs: 'boolean',
380
+ timeoutMs: 'number',
381
+ compression: 'string',
382
+ post: 'function',
383
+ }
384
+
385
+ /**
386
+ * The OpenTelemetry exporter plugin. Declared in vx.workspace.ts via
387
+ * `defineWorkspace({ plugins: [otel()] })`. Contributes a telemetry sink that
388
+ * maps each run to OTLP traces (a `vx.run` root span + `vx.task` children),
389
+ * metrics, and one log record per executed task, over OTLP/HTTP JSON.
390
+ * Declines when no OTLP endpoint is set.
391
+ */
392
+ export function otel(opts: OtelPluginOptions = {}): VxPlugin {
393
+ refuseUnknownOptions('otel()', opts, OTEL_PLUGIN_KEYS)
394
+ return definePlugin(import.meta, {
395
+ telemetry(ctx: TelemetryContext): TelemetrySink | undefined {
396
+ const config = resolveOtelConfig(opts, process.env, (m) => ctx.warn(m))
397
+ if (config === undefined) return undefined
398
+ return new OtelSink(config)
399
+ },
400
+ })
401
+ }