@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/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/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
|
+
}
|