@vzn/vx-otel 0.0.646 → 0.0.649
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/README.md +40 -33
- package/package.json +3 -3
- package/src/index.ts +0 -2
- package/src/otlp.ts +14 -1
- package/src/plugin.ts +136 -61
- package/src/sink.ts +90 -41
package/README.md
CHANGED
|
@@ -29,25 +29,25 @@ export default defineWorkspace({
|
|
|
29
29
|
**declines safely** (exports nothing) when no endpoint is set — so it is safe
|
|
30
30
|
to declare in every environment:
|
|
31
31
|
|
|
32
|
-
| Variable | Purpose
|
|
33
|
-
| ------------------------------------------------------- |
|
|
34
|
-
| `OTEL_EXPORTER_OTLP_ENDPOINT` | base collector URL (e.g. `http://localhost:4318`); one
|
|
35
|
-
| `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | full traces URL override
|
|
36
|
-
| `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` | full metrics URL override
|
|
37
|
-
| `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT` | full logs URL override
|
|
38
|
-
| `OTEL_LOGS_EXPORTER=none` | export traces + metrics only
|
|
39
|
-
| `OTEL_METRICS_EXPORTER=none` | no metrics
|
|
40
|
-
| `OTEL_TRACES_EXPORTER=none` | no traces
|
|
41
|
-
| `OTEL_SDK_DISABLED=true` | export nothing (the plugin declines)
|
|
42
|
-
| `OTEL_SERVICE_NAME` | service name (default `vx`)
|
|
43
|
-
| `OTEL_EXPORTER_OTLP_HEADERS` | `k=v,k=v` headers (e.g. auth), percent-encoded
|
|
44
|
-
| `OTEL_EXPORTER_OTLP_<SIGNAL>_HEADERS` | one signal's headers, over the shared ones
|
|
45
|
-
| `OTEL_RESOURCE_ATTRIBUTES` | `k=v,k=v` resource attributes, percent-encoded (a malformed one is dropped whole and warned); its `service.name` names the service when `OTEL_SERVICE_NAME` is unset
|
|
46
|
-
| `OTEL_EXPORTER_OTLP_TIMEOUT` | per-request timeout in ms (default 15000, at most 2³¹−1); `OTEL_EXPORTER_OTLP_<SIGNAL>_TIMEOUT` for one signal. The `timeoutMs` option must be a positive number, or it is refused |
|
|
47
|
-
| `OTEL_EXPORTER_OTLP_COMPRESSION` | `gzip` or `none` (default); `OTEL_EXPORTER_OTLP_<SIGNAL>_COMPRESSION` for one signal, the `compression` option over both
|
|
48
|
-
| `OTEL_EXPORTER_OTLP_CERTIFICATE` | a PEM file of the CA that signed the collector's certificate (`_<SIGNAL>_` for one signal)
|
|
49
|
-
| `OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE` / `_CLIENT_KEY` | PEM files of a client certificate and key, for a collector that asks for mutual TLS
|
|
50
|
-
| `OTEL_EXPORTER_OTLP_PROTOCOL` | vx sends OTLP/HTTP JSON only; under `grpc` a failed export says so (`OTEL_EXPORTER_OTLP_<SIGNAL>_PROTOCOL` per signal)
|
|
32
|
+
| Variable | Purpose |
|
|
33
|
+
| ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
34
|
+
| `OTEL_EXPORTER_OTLP_ENDPOINT` | base collector URL (e.g. `http://localhost:4318`); one not `https://` (`http://` only to 127.0.0.1, ::1 or localhost: a header is often an API key) is refused with a warning |
|
|
35
|
+
| `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | full traces URL override |
|
|
36
|
+
| `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` | full metrics URL override |
|
|
37
|
+
| `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT` | full logs URL override |
|
|
38
|
+
| `OTEL_LOGS_EXPORTER=none` | export traces + metrics only |
|
|
39
|
+
| `OTEL_METRICS_EXPORTER=none` | no metrics |
|
|
40
|
+
| `OTEL_TRACES_EXPORTER=none` | no traces |
|
|
41
|
+
| `OTEL_SDK_DISABLED=true` | export nothing (the plugin declines) |
|
|
42
|
+
| `OTEL_SERVICE_NAME` | service name (default `vx`) |
|
|
43
|
+
| `OTEL_EXPORTER_OTLP_HEADERS` | `k=v,k=v` headers (e.g. auth), percent-encoded |
|
|
44
|
+
| `OTEL_EXPORTER_OTLP_<SIGNAL>_HEADERS` | one signal's headers, over the shared ones |
|
|
45
|
+
| `OTEL_RESOURCE_ATTRIBUTES` | `k=v,k=v` resource attributes, percent-encoded (a malformed one is dropped whole and warned); its `service.name` names the service when `OTEL_SERVICE_NAME` is unset |
|
|
46
|
+
| `OTEL_EXPORTER_OTLP_TIMEOUT` | per-request timeout in ms (default 15000, at most 2³¹−1); `OTEL_EXPORTER_OTLP_<SIGNAL>_TIMEOUT` for one signal; a value that is not a positive number warns and is not used. The `timeoutMs` option must be a positive number up to 2³¹−1, or it is refused |
|
|
47
|
+
| `OTEL_EXPORTER_OTLP_COMPRESSION` | `gzip` or `none` (default), in any case; `OTEL_EXPORTER_OTLP_<SIGNAL>_COMPRESSION` for one signal, the `compression` option over both, refused if it is neither |
|
|
48
|
+
| `OTEL_EXPORTER_OTLP_CERTIFICATE` | a PEM file of the CA that signed the collector's certificate (`_<SIGNAL>_` for one signal) |
|
|
49
|
+
| `OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE` / `_CLIENT_KEY` | PEM files of a client certificate and key, for a collector that asks for mutual TLS |
|
|
50
|
+
| `OTEL_EXPORTER_OTLP_PROTOCOL` | vx sends OTLP/HTTP JSON only; under `grpc` a failed export says so (`OTEL_EXPORTER_OTLP_<SIGNAL>_PROTOCOL` per signal) |
|
|
51
51
|
|
|
52
52
|
Each signal ships only to its own URL: the base endpoint's `/v1/<signal>`
|
|
53
53
|
(appended to its path, before any query), or its override. A signal's own endpoint alone is enough: with only a metrics or logs
|
|
@@ -56,27 +56,29 @@ request, so a large run stays under a collector's body limit. Header names are c
|
|
|
56
56
|
`Authorization` replaces the shared `authorization`); a name no header can
|
|
57
57
|
carry (`Authorization: Basic …` written curl-style, with a colon) is not
|
|
58
58
|
sent, and the warning prints neither its name nor its value. A `headers`
|
|
59
|
-
option value that is
|
|
60
|
-
|
|
59
|
+
option value that is unset (`process.env.X`, X unset) is left out, as
|
|
60
|
+
`vxCache({ headers })` leaves it; any other value that is not a string is
|
|
61
|
+
not sent either, with a warning naming the header. A `content-type` header is
|
|
62
|
+
not sent: the body is OTLP/HTTP JSON, `application/json`.
|
|
61
63
|
|
|
62
64
|
With only a traces URL set, metrics and logs are not
|
|
63
65
|
exported (they used to be POSTed to the traces URL, which a collector
|
|
64
66
|
refuses), and `metrics: true` or `logs: true` without a URL says so once.
|
|
67
|
+
With no signal left to export (each one `none`, `false` or without a
|
|
68
|
+
URL), the plugin declines, as with no endpoint.
|
|
65
69
|
Both are booleans: a string (`'false'` is truthy) is refused.
|
|
66
70
|
|
|
67
|
-
The package exports `otel
|
|
68
|
-
types of the `post` option (a `PostFn`: the transport, fetch unless given,
|
|
69
|
-
handed each request's URL, body, headers, abort signal and `OtlpTls`). The
|
|
71
|
+
The package exports `otel` and its options type `OtelPluginOptions`. The
|
|
70
72
|
OTLP builders are internal (the wire they send is the contract,
|
|
71
73
|
`tests/contract/otlp.txt`).
|
|
72
74
|
|
|
73
|
-
Options override env:
|
|
75
|
+
Options override env, the `endpoint` option included over a signal's own variable:
|
|
74
76
|
|
|
75
77
|
```ts
|
|
76
78
|
otel({
|
|
77
79
|
endpoint: 'https://collector.example.com',
|
|
78
80
|
serviceName: 'my-monorepo',
|
|
79
|
-
headers: { authorization:
|
|
81
|
+
headers: { authorization: `Bearer ${process.env.OTLP_TOKEN}` },
|
|
80
82
|
metrics: true, // default
|
|
81
83
|
logs: true, // default: each executed task's output tail
|
|
82
84
|
timeoutMs: 15_000, // default: per request
|
|
@@ -98,11 +100,13 @@ otel({
|
|
|
98
100
|
span status `ERROR`;
|
|
99
101
|
- a child `vx.task` span per task — `cicd.pipeline.task.name`,
|
|
100
102
|
`cicd.pipeline.task.run.result` (the convention's enum: `success` for a
|
|
101
|
-
run or a hit, `failure`, `timeout`, `skip`, `cancellation`), vx's own
|
|
103
|
+
run or a hit, `failure`, `timeout` (vx's own `timeout`, or a server's readiness wait), `skip`, `cancellation`), vx's own
|
|
102
104
|
status as `vx.task.status`, `vx.cache.source` (miss/local/remote, or none for a task that never ran),
|
|
103
105
|
on a hit `vx.cache.restored` (`true` when it restored outputs, `false` when they were already up to date),
|
|
104
106
|
`vx.task.hash`, duration, CPU ms, peak RSS, and on a skipped task its root
|
|
105
|
-
blocker (`vx.task.blocked_by`),
|
|
107
|
+
blocker (`vx.task.blocked_by`) or, skipped for its `exec.platforms`, what it
|
|
108
|
+
needs and this machine's (`vx.task.platforms_needed`, `vx.task.platform_host`),
|
|
109
|
+
on a server each dependant's worker ran `vx.task.carried`, on a timed-out one `vx.task.timed_out`, on
|
|
106
110
|
a sandboxed one its violation count (`vx.task.sandbox_violations`), on a
|
|
107
111
|
persistent one that never became ready why (`vx.task.not_ready`), its
|
|
108
112
|
command (`vx.task.command`, secrets masked), on a flaky one its record
|
|
@@ -132,8 +136,9 @@ otel({
|
|
|
132
136
|
`load configs`, `git enumeration`, `classify + probe`, `run graph`,
|
|
133
137
|
`record history`, …, the stages `VX_DEBUG_TIMING` prints), with
|
|
134
138
|
`vx.stage.name`: where the time before the first task and after the
|
|
135
|
-
last went. The
|
|
136
|
-
|
|
139
|
+
last went. The `vx.run` span covers them: it starts where the first
|
|
140
|
+
stage does, before the run lock, and that start is the
|
|
141
|
+
`vx.run.started_at` every task's `vx.task.run_started_at` names.
|
|
137
142
|
|
|
138
143
|
**Metrics per run**: `vx.tasks.total`, `vx.tasks.failed`,
|
|
139
144
|
`vx.tasks.cache_hits{source=local|remote}`, what those hits did to the disk as
|
|
@@ -152,7 +157,7 @@ measured `vx.task.cpu_time` (ms) and `vx.task.peak_memory` (bytes), on a
|
|
|
152
157
|
hit `vx.task.time_saved` (ms), `vx.task.admission_held` (ms) when an
|
|
153
158
|
`admit` policy held it, `vx.task.queued` (ms) when it waited for a
|
|
154
159
|
worker, `vx.task.artifact_size` (bytes), `vx.task.cache_save` and
|
|
155
|
-
`vx.task.cache_fetch` (ms); a skipped task sends none. While a task runs, its process tree is sampled
|
|
160
|
+
`vx.task.cache_fetch` (ms); a skipped task, or one a stop aborted, sends none. While a task runs, its process tree is sampled
|
|
156
161
|
each second: `vx.task.cpu_usage` (cores busy since the last sample, 1 =
|
|
157
162
|
one core) and `vx.task.memory` (resident bytes). Sampling runs only while
|
|
158
163
|
metrics export; a remote task, or a task under 1 s, has no samples.
|
|
@@ -202,14 +207,16 @@ fail a run (every export is buffered, time-bounded, and swallows errors).
|
|
|
202
207
|
|
|
203
208
|
A collector shedding load (`429`, `502`, `503`, `504`, or a dropped
|
|
204
209
|
connection) is retried twice, 200 then 800 ms apart or after the
|
|
205
|
-
`Retry-After` it names (at most 2 s), inside the end-of-run deadline; a
|
|
210
|
+
`Retry-After` it names, in seconds or as a date (at most 2 s), inside the end-of-run deadline; a
|
|
206
211
|
deadline that passes mid-wait ends the retries and warns the last answer.
|
|
207
212
|
|
|
208
213
|
Swallowed, but not silent: an export that does not land warns once per
|
|
209
214
|
signal URL, naming what happened — a collector that cannot be reached, one
|
|
210
215
|
that refuses the request (`HTTP 401`, `404`, `500`, with the collector's own
|
|
211
216
|
message), or one that accepts it and reports part of the data dropped
|
|
212
|
-
(OTLP's `partialSuccess`).
|
|
217
|
+
(OTLP's `partialSuccess`). One that accepts it all with a message (a
|
|
218
|
+
`partialSuccess` that rejected nothing, the spec's warning) is said once
|
|
219
|
+
per signal as that warning, not as a failure. A collector that answers with a redirect
|
|
213
220
|
is refused too, naming where it points: like the OTel SDK exporters,
|
|
214
221
|
vx-otel follows none, since a header such as `x-honeycomb-team` would
|
|
215
222
|
reach the new origin with it. The URL in that line is printed with any
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx-otel",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.649",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "OpenTelemetry exporter for vx — a telemetry plugin mapping runs to OTLP traces + metrics (CI/CD + VCS semantic conventions).",
|
|
6
6
|
"keywords": [
|
|
@@ -28,13 +28,13 @@
|
|
|
28
28
|
"LICENSE"
|
|
29
29
|
],
|
|
30
30
|
"engines": {
|
|
31
|
-
"bun": ">=1.4"
|
|
31
|
+
"bun": ">=1.4.4"
|
|
32
32
|
},
|
|
33
33
|
"publishConfig": {
|
|
34
34
|
"access": "public"
|
|
35
35
|
},
|
|
36
36
|
"peerDependencies": {
|
|
37
|
-
"@vzn/vx": "^0.0.
|
|
37
|
+
"@vzn/vx": "^0.0.649"
|
|
38
38
|
},
|
|
39
39
|
"repository": {
|
|
40
40
|
"type": "git",
|
package/src/index.ts
CHANGED
|
@@ -10,5 +10,3 @@
|
|
|
10
10
|
// metrics + per-task logs over HTTP/JSON — no OpenTelemetry SDK dependency.
|
|
11
11
|
|
|
12
12
|
export { otel, type OtelPluginOptions } from './plugin.js'
|
|
13
|
-
// The transport the `post` option takes, and the TLS material it is handed.
|
|
14
|
-
export { type OtlpTls, type PostFn } from './sink.js'
|
package/src/otlp.ts
CHANGED
|
@@ -136,6 +136,9 @@ const VX_ATTR = {
|
|
|
136
136
|
cpuMs: 'vx.cpu_ms',
|
|
137
137
|
taskWhere: 'vx.task.where',
|
|
138
138
|
taskBlockedBy: 'vx.task.blocked_by',
|
|
139
|
+
taskCarried: 'vx.task.carried',
|
|
140
|
+
taskPlatformsNeeded: 'vx.task.platforms_needed',
|
|
141
|
+
taskPlatformHost: 'vx.task.platform_host',
|
|
139
142
|
taskTimedOut: 'vx.task.timed_out',
|
|
140
143
|
taskSandboxViolations: 'vx.task.sandbox_violations',
|
|
141
144
|
taskNotReady: 'vx.task.not_ready',
|
|
@@ -457,7 +460,8 @@ interface TaskSpanRunContext {
|
|
|
457
460
|
function runResult(t: TaskTelemetry): string {
|
|
458
461
|
switch (t.status) {
|
|
459
462
|
case 'failed':
|
|
460
|
-
|
|
463
|
+
// A server killed when its readiness wait ran out timed out too.
|
|
464
|
+
return t.timedOut === true || t.notReady === 'timeout' ? 'timeout' : 'failure'
|
|
461
465
|
case 'skipped':
|
|
462
466
|
return 'skip'
|
|
463
467
|
case 'aborted':
|
|
@@ -508,6 +512,15 @@ export function taskSpanAttributes(t: TaskTelemetry, run: TaskSpanRunContext): K
|
|
|
508
512
|
if (t.where !== undefined) attrs.push(strAttr(VX_ATTR.taskWhere, t.where))
|
|
509
513
|
// A skipped task's root blocker (a task id, as the record carries it).
|
|
510
514
|
if (t.blockedBy !== undefined) attrs.push(strAttr(VX_ATTR.taskBlockedBy, t.blockedBy))
|
|
515
|
+
// A server run by each dependant's worker, not an execution of its own.
|
|
516
|
+
if (t.carried === true) attrs.push(boolAttr(VX_ATTR.taskCarried, true))
|
|
517
|
+
// A skip because the task's `exec.platforms` leave out this machine.
|
|
518
|
+
if (t.otherPlatform !== undefined) {
|
|
519
|
+
attrs.push(
|
|
520
|
+
strArrayAttr(VX_ATTR.taskPlatformsNeeded, t.otherPlatform.needs),
|
|
521
|
+
strAttr(VX_ATTR.taskPlatformHost, t.otherPlatform.host),
|
|
522
|
+
)
|
|
523
|
+
}
|
|
511
524
|
// vx's own deadline killed the task: the 143 is a timeout, not a signal.
|
|
512
525
|
if (t.timedOut === true) attrs.push(boolAttr(VX_ATTR.taskTimedOut, true))
|
|
513
526
|
if (t.sandboxViolations !== undefined)
|
package/src/plugin.ts
CHANGED
|
@@ -14,10 +14,11 @@ import {
|
|
|
14
14
|
definePlugin,
|
|
15
15
|
type TelemetryContext,
|
|
16
16
|
type TelemetrySink,
|
|
17
|
+
UserError,
|
|
17
18
|
type VxPlugin,
|
|
18
19
|
} from '@vzn/vx'
|
|
19
20
|
import { readFileSync } from 'node:fs'
|
|
20
|
-
import type { OtelSinkConfig, OtlpTls
|
|
21
|
+
import type { OtelSinkConfig, OtlpTls } from './sink.js'
|
|
21
22
|
|
|
22
23
|
export interface OtelPluginOptions {
|
|
23
24
|
/** OTLP base endpoint. Falls back to `OTEL_EXPORTER_OTLP_ENDPOINT`. */
|
|
@@ -30,8 +31,12 @@ export interface OtelPluginOptions {
|
|
|
30
31
|
logsEndpoint?: string
|
|
31
32
|
/** Service name. Falls back to `OTEL_SERVICE_NAME`, then `OTEL_RESOURCE_ATTRIBUTES`' `service.name`, else `'vx'`. */
|
|
32
33
|
serviceName?: string
|
|
33
|
-
/**
|
|
34
|
-
|
|
34
|
+
/**
|
|
35
|
+
* Extra OTLP headers, merged over `OTEL_EXPORTER_OTLP_HEADERS` and each
|
|
36
|
+
* signal's own. An unset value (`process.env.X`, X unset) is left out,
|
|
37
|
+
* as `vxCache({ headers })` leaves it.
|
|
38
|
+
*/
|
|
39
|
+
headers?: Record<string, string | undefined>
|
|
35
40
|
/** Emit run/task metrics in addition to traces. Default: true. */
|
|
36
41
|
metrics?: boolean
|
|
37
42
|
/**
|
|
@@ -47,7 +52,7 @@ export interface OtelPluginOptions {
|
|
|
47
52
|
* everything at the run's end.
|
|
48
53
|
*/
|
|
49
54
|
live?: boolean
|
|
50
|
-
/** Per-request timeout (ms). Falls back to `OTEL_EXPORTER_OTLP_TIMEOUT`, else 15000. */
|
|
55
|
+
/** Per-request timeout (ms, above 0, up to 2³¹−1). Falls back to `OTEL_EXPORTER_OTLP_TIMEOUT`, else 15000. */
|
|
51
56
|
timeoutMs?: number
|
|
52
57
|
/**
|
|
53
58
|
* `'gzip'` sends every signal gzipped. Falls back to each signal's
|
|
@@ -55,8 +60,6 @@ export interface OtelPluginOptions {
|
|
|
55
60
|
* else `'none'`.
|
|
56
61
|
*/
|
|
57
62
|
compression?: 'gzip' | 'none'
|
|
58
|
-
/** Test seam — inject the POST transport. Defaults to fetch. */
|
|
59
|
-
post?: PostFn
|
|
60
63
|
}
|
|
61
64
|
|
|
62
65
|
/**
|
|
@@ -71,6 +74,7 @@ export function parseOtlpHeaders(raw: string | undefined): Record<string, string
|
|
|
71
74
|
const decode = (s: string): string => {
|
|
72
75
|
try {
|
|
73
76
|
return decodeURIComponent(s)
|
|
77
|
+
// quiet: a value that is not percent-encoded is taken as written
|
|
74
78
|
} catch {
|
|
75
79
|
return s
|
|
76
80
|
}
|
|
@@ -114,13 +118,27 @@ function headerValueFault(value: string): string | null {
|
|
|
114
118
|
return past ? 'a character past Latin-1' : null
|
|
115
119
|
}
|
|
116
120
|
|
|
121
|
+
/** The hosts an http:// endpoint may name: this machine. */
|
|
122
|
+
const LOOPBACK = new Set(['127.0.0.1', '[::1]', 'localhost'])
|
|
123
|
+
|
|
117
124
|
/** An HTTP header name: an RFC 7230 token. */
|
|
118
125
|
const HEADER_NAME = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/
|
|
119
126
|
|
|
120
|
-
/**
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
127
|
+
/**
|
|
128
|
+
* `OTEL_EXPORTER_OTLP_TIMEOUT`: milliseconds; anything but a positive
|
|
129
|
+
* number is unset, and said, as the spec asks of an invalid value.
|
|
130
|
+
*/
|
|
131
|
+
function envTimeout(
|
|
132
|
+
env: Record<string, string | undefined>,
|
|
133
|
+
name: string,
|
|
134
|
+
warn: ((m: string) => void) | undefined,
|
|
135
|
+
): number | undefined {
|
|
136
|
+
const raw = present(env[name])
|
|
137
|
+
if (raw === undefined) return undefined
|
|
138
|
+
const ms = Number(raw)
|
|
139
|
+
if (Number.isFinite(ms) && ms > 0) return ms
|
|
140
|
+
warn?.(`[vx] vx-otel: ${name} ${JSON.stringify(raw)} is not a positive number of ms — not used`)
|
|
141
|
+
return undefined
|
|
124
142
|
}
|
|
125
143
|
|
|
126
144
|
/**
|
|
@@ -157,7 +175,7 @@ function parseResourceAttributes(
|
|
|
157
175
|
out[key] = decodeURIComponent(pair.slice(eq + 1).trim())
|
|
158
176
|
} catch {
|
|
159
177
|
warn?.(
|
|
160
|
-
`[vx-otel
|
|
178
|
+
`[vx] vx-otel: OTEL_RESOURCE_ATTRIBUTES is malformed (${pair.trim()}) — none of it is used`,
|
|
161
179
|
)
|
|
162
180
|
return {}
|
|
163
181
|
}
|
|
@@ -174,22 +192,36 @@ export function resolveOtelConfig(
|
|
|
174
192
|
env: Record<string, string | undefined>,
|
|
175
193
|
warn?: (m: string) => void,
|
|
176
194
|
): OtelSinkConfig | undefined {
|
|
195
|
+
// `OTEL_SDK_DISABLED=true` exports nothing, so nothing below may warn:
|
|
196
|
+
// read after the endpoints, a bad one it would never use was warned about.
|
|
197
|
+
if (env['OTEL_SDK_DISABLED']?.trim().toLowerCase() === 'true') return undefined
|
|
177
198
|
// Each URL with the setting it came from, so a refusal can name it
|
|
178
199
|
// without printing a value that may hold a credential.
|
|
179
|
-
const
|
|
180
|
-
const o = present(opts[
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
200
|
+
const option = (name: keyof OtelPluginOptions): [string, string] | undefined => {
|
|
201
|
+
const o = present(opts[name] as string | undefined)
|
|
202
|
+
return o === undefined ? undefined : [o, `the ${name} option`]
|
|
203
|
+
}
|
|
204
|
+
const fromEnv = (name: string): [string, string] | undefined => {
|
|
205
|
+
const e = present(env[name])
|
|
206
|
+
return e === undefined ? undefined : [e, name]
|
|
184
207
|
}
|
|
185
|
-
const
|
|
208
|
+
const joined = (
|
|
209
|
+
base: [string, string] | undefined,
|
|
210
|
+
signal: string,
|
|
211
|
+
): [string, string] | undefined =>
|
|
212
|
+
base === undefined ? undefined : [joinSignal(base[0], signal), base[1]]
|
|
213
|
+
// Any option beats any env: the `endpoint` option lost to a signal's
|
|
214
|
+
// env endpoint, so a CI env set for another tool took vx's traces
|
|
215
|
+
// while its metrics went where the config said.
|
|
186
216
|
const pick = (
|
|
187
|
-
|
|
217
|
+
name: keyof OtelPluginOptions,
|
|
188
218
|
envName: string,
|
|
189
219
|
signal: string,
|
|
190
220
|
): [string, string] | undefined =>
|
|
191
|
-
|
|
192
|
-
(
|
|
221
|
+
option(name) ??
|
|
222
|
+
joined(option('endpoint'), signal) ??
|
|
223
|
+
fromEnv(envName) ??
|
|
224
|
+
joined(fromEnv('OTEL_EXPORTER_OTLP_ENDPOINT'), signal)
|
|
193
225
|
const picked = {
|
|
194
226
|
traces: pick('tracesEndpoint', 'OTEL_EXPORTER_OTLP_TRACES_ENDPOINT', 'traces'),
|
|
195
227
|
metrics: pick('metricsEndpoint', 'OTEL_EXPORTER_OTLP_METRICS_ENDPOINT', 'metrics'),
|
|
@@ -198,19 +230,25 @@ export function resolveOtelConfig(
|
|
|
198
230
|
// An endpoint with no http(s) scheme (`localhost:4318`) parses as a URL
|
|
199
231
|
// of scheme `localhost:`: every export failed "Unable to connect", once
|
|
200
232
|
// per signal, after a second of retries. It is refused here, once per
|
|
201
|
-
// setting, and its signals are not exported.
|
|
233
|
+
// setting, and its signals are not exported. http:// is taken only to
|
|
234
|
+
// this machine, as vxCache() and vxHistory() take it: an OTLP header is
|
|
235
|
+
// often the vendor's API key, and http:// to another host sent it in
|
|
236
|
+
// clear.
|
|
202
237
|
const refused = new Map<string, string[]>()
|
|
203
238
|
for (const signal of ['traces', 'metrics', 'logs'] as const) {
|
|
204
239
|
const p = picked[signal]
|
|
205
240
|
if (p === undefined) continue
|
|
206
|
-
const
|
|
207
|
-
if (protocol === '
|
|
241
|
+
const u = URL.parse(p[0])
|
|
242
|
+
if (u?.protocol === 'https:') continue
|
|
243
|
+
if (u?.protocol === 'http:' && LOOPBACK.has(u.hostname)) continue
|
|
208
244
|
refused.set(p[1], [...(refused.get(p[1]) ?? []), signal])
|
|
209
245
|
picked[signal] = undefined
|
|
210
246
|
}
|
|
211
247
|
for (const [setting, signals] of refused) {
|
|
212
248
|
const names = signals.length === 3 ? 'traces, metrics and logs' : signals.join(' and ')
|
|
213
|
-
warn?.(
|
|
249
|
+
warn?.(
|
|
250
|
+
`[vx] vx-otel: ${setting} is not an https:// URL (http only to 127.0.0.1, ::1 or localhost) — ${names} are not exported`,
|
|
251
|
+
)
|
|
214
252
|
}
|
|
215
253
|
const tracesUrl = picked.traces?.[0]
|
|
216
254
|
const metricsUrl = picked.metrics?.[0]
|
|
@@ -223,9 +261,8 @@ export function resolveOtelConfig(
|
|
|
223
261
|
}
|
|
224
262
|
// The standard SDK opt-outs, honoured so a pipeline already configured
|
|
225
263
|
// that way does not start receiving vx's telemetry because it upgraded
|
|
226
|
-
// vx. Only the logs one was read;
|
|
227
|
-
//
|
|
228
|
-
if (env['OTEL_SDK_DISABLED']?.trim().toLowerCase() === 'true') return undefined
|
|
264
|
+
// vx. Only the logs one was read; the traces and metrics `=none` were
|
|
265
|
+
// ignored and still exported (item 1056).
|
|
229
266
|
const off = (name: string): boolean => env[name]?.trim().toLowerCase() === 'none'
|
|
230
267
|
const tracesWanted = !off('OTEL_TRACES_EXPORTER')
|
|
231
268
|
const logsWanted = opts.logs ?? !off('OTEL_LOGS_EXPORTER')
|
|
@@ -240,10 +277,16 @@ export function resolveOtelConfig(
|
|
|
240
277
|
] as const) {
|
|
241
278
|
if (asked === true && url === undefined) {
|
|
242
279
|
warn?.(
|
|
243
|
-
`[vx-otel
|
|
280
|
+
`[vx] vx-otel: ${signal}: true but no ${signal} endpoint (OTEL_EXPORTER_OTLP_ENDPOINT or OTEL_EXPORTER_OTLP_${signal.toUpperCase()}_ENDPOINT) — ${signal} are not exported`,
|
|
244
281
|
)
|
|
245
282
|
}
|
|
246
283
|
}
|
|
284
|
+
const tracesOn = tracesWanted && tracesUrl !== undefined
|
|
285
|
+
const metricsOn = metricsWanted && metricsUrl !== undefined
|
|
286
|
+
const logsOn = logsWanted && logsUrl !== undefined
|
|
287
|
+
// Nothing left to export declines as no endpoint does: a sink that sent
|
|
288
|
+
// nothing still loaded the exporter and had core build its records.
|
|
289
|
+
if (!tracesOn && !metricsOn && !logsOn) return undefined
|
|
247
290
|
|
|
248
291
|
// A header value fetch refuses is refused here, by name: fetch's error
|
|
249
292
|
// quotes the whole value, and the export warning printed it — an auth
|
|
@@ -262,12 +305,23 @@ export function resolveOtelConfig(
|
|
|
262
305
|
if (!badName) {
|
|
263
306
|
badName = true
|
|
264
307
|
warn?.(
|
|
265
|
-
'[vx-otel
|
|
308
|
+
'[vx] 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)',
|
|
266
309
|
)
|
|
267
310
|
}
|
|
268
311
|
continue
|
|
269
312
|
}
|
|
270
313
|
const k = raw.toLowerCase()
|
|
314
|
+
// The body is JSON whatever a header says; a `content-type` over it
|
|
315
|
+
// made a collector parse it as protobuf and refuse every export.
|
|
316
|
+
if (k === 'content-type') {
|
|
317
|
+
if (!dropped.has(k)) {
|
|
318
|
+
dropped.add(k)
|
|
319
|
+
warn?.(
|
|
320
|
+
'[vx] vx-otel: header "content-type" names the body\'s type, and vx-otel sends OTLP/HTTP JSON (application/json) — not sent',
|
|
321
|
+
)
|
|
322
|
+
}
|
|
323
|
+
continue
|
|
324
|
+
}
|
|
271
325
|
// An option's value may be a number or an object: refused like a
|
|
272
326
|
// value fetch cannot send, not left to throw `value.trim is not a
|
|
273
327
|
// function` out of the plugin.
|
|
@@ -276,7 +330,7 @@ export function resolveOtelConfig(
|
|
|
276
330
|
else if (!dropped.has(k)) {
|
|
277
331
|
dropped.add(k)
|
|
278
332
|
warn?.(
|
|
279
|
-
`[vx-otel
|
|
333
|
+
`[vx] vx-otel: header ${JSON.stringify(raw)} holds ${fault}, which no HTTP header can carry — not sent (its value is not printed)`,
|
|
280
334
|
)
|
|
281
335
|
}
|
|
282
336
|
}
|
|
@@ -285,6 +339,8 @@ export function resolveOtelConfig(
|
|
|
285
339
|
// `OTEL_RESOURCE_ATTRIBUTES` (deployment.environment, team, …) was not
|
|
286
340
|
// read, so a pipeline's resource identity never reached vx's telemetry.
|
|
287
341
|
const resource = parseResourceAttributes(env['OTEL_RESOURCE_ATTRIBUTES'], warn)
|
|
342
|
+
const optionHeaders: Record<string, string> = {}
|
|
343
|
+
for (const [k, v] of Object.entries(opts.headers ?? {})) if (v !== undefined) optionHeaders[k] = v
|
|
288
344
|
// vx speaks OTLP/HTTP JSON only; a signal the env sends over gRPC most
|
|
289
345
|
// likely points at a gRPC port, and its failure warning says so.
|
|
290
346
|
const grpc = (['traces', 'metrics', 'logs'] as const).filter(
|
|
@@ -298,17 +354,24 @@ export function resolveOtelConfig(
|
|
|
298
354
|
)
|
|
299
355
|
// `OTEL_EXPORTER_OTLP_COMPRESSION=gzip` was not read, and a pipeline
|
|
300
356
|
// configured for it sent every export uncompressed (F-33).
|
|
301
|
-
|
|
357
|
+
// Each bad variable said once, by name: the warning named none of the
|
|
358
|
+
// three a value may come from, and a second bad one went unsaid.
|
|
359
|
+
const badCompression = new Set<string>()
|
|
302
360
|
const gzip = (['traces', 'metrics', 'logs'] as const).filter((signal) => {
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
361
|
+
// Read as otel() checked it, and as the variable is: `'GZIP'` was taken
|
|
362
|
+
// and sent uncompressed.
|
|
363
|
+
if (opts.compression !== undefined) return opts.compression.trim().toLowerCase() === 'gzip'
|
|
364
|
+
const own = `OTEL_EXPORTER_OTLP_${signal.toUpperCase()}_COMPRESSION`
|
|
365
|
+
const [name, raw] =
|
|
366
|
+
present(env[own]) !== undefined
|
|
367
|
+
? [own, present(env[own])]
|
|
368
|
+
: ['OTEL_EXPORTER_OTLP_COMPRESSION', present(env['OTEL_EXPORTER_OTLP_COMPRESSION'])]
|
|
306
369
|
const value = raw?.trim().toLowerCase()
|
|
307
370
|
if (value === undefined || value === 'none') return false
|
|
308
371
|
if (value === 'gzip') return true
|
|
309
|
-
if (!badCompression) {
|
|
310
|
-
badCompression
|
|
311
|
-
warn?.(`[vx-otel
|
|
372
|
+
if (!badCompression.has(name)) {
|
|
373
|
+
badCompression.add(name)
|
|
374
|
+
warn?.(`[vx] vx-otel: ${name} ${JSON.stringify(raw)} is not gzip or none — sent uncompressed`)
|
|
312
375
|
}
|
|
313
376
|
return false
|
|
314
377
|
})
|
|
@@ -323,24 +386,14 @@ export function resolveOtelConfig(
|
|
|
323
386
|
pem.set(file, readFileSync(file, 'utf8'))
|
|
324
387
|
} catch (err) {
|
|
325
388
|
warn?.(
|
|
326
|
-
`[vx-otel
|
|
389
|
+
`[vx] vx-otel: ${name}: cannot read ${file} (${(err as NodeJS.ErrnoException).code ?? String(err)}) — not used`,
|
|
327
390
|
)
|
|
328
391
|
pem.set(file, undefined)
|
|
329
392
|
}
|
|
330
393
|
}
|
|
331
394
|
return pem.get(file)
|
|
332
395
|
}
|
|
333
|
-
|
|
334
|
-
// non-number timeout aborted every export the moment it started.
|
|
335
|
-
if (
|
|
336
|
-
opts.timeoutMs !== undefined &&
|
|
337
|
-
!(typeof opts.timeoutMs === 'number' && Number.isFinite(opts.timeoutMs) && opts.timeoutMs > 0)
|
|
338
|
-
) {
|
|
339
|
-
throw new Error(
|
|
340
|
-
`[vx-otel] timeoutMs must be a positive number of ms, got ${JSON.stringify(opts.timeoutMs)}`,
|
|
341
|
-
)
|
|
342
|
-
}
|
|
343
|
-
const timeoutMs = opts.timeoutMs ?? envTimeout(env['OTEL_EXPORTER_OTLP_TIMEOUT']) ?? 15_000
|
|
396
|
+
const timeoutMs = opts.timeoutMs ?? envTimeout(env, 'OTEL_EXPORTER_OTLP_TIMEOUT', warn) ?? 15_000
|
|
344
397
|
const tls: Partial<Record<'traces' | 'metrics' | 'logs', OtlpTls>> = {}
|
|
345
398
|
for (const signal of ['traces', 'metrics', 'logs'] as const) {
|
|
346
399
|
const one: OtlpTls = {}
|
|
@@ -371,34 +424,38 @@ export function resolveOtelConfig(
|
|
|
371
424
|
grpc,
|
|
372
425
|
gzip,
|
|
373
426
|
...(Object.keys(tls).length > 0 ? { tls } : {}),
|
|
374
|
-
headers: clean({ ...parseOtlpHeaders(env['OTEL_EXPORTER_OTLP_HEADERS']), ...
|
|
427
|
+
headers: clean({ ...parseOtlpHeaders(env['OTEL_EXPORTER_OTLP_HEADERS']), ...optionHeaders }),
|
|
375
428
|
// A signal's own `OTEL_EXPORTER_OTLP_<SIGNAL>_HEADERS` wins over the
|
|
376
429
|
// shared ones, as the spec orders them; they were not read (item 923).
|
|
377
430
|
// The plugin's `headers` option stays on top of both.
|
|
378
431
|
signalHeaders: {
|
|
379
432
|
traces: clean({
|
|
380
433
|
...parseOtlpHeaders(env['OTEL_EXPORTER_OTLP_TRACES_HEADERS']),
|
|
381
|
-
...
|
|
434
|
+
...optionHeaders,
|
|
382
435
|
}),
|
|
383
436
|
metrics: clean({
|
|
384
437
|
...parseOtlpHeaders(env['OTEL_EXPORTER_OTLP_METRICS_HEADERS']),
|
|
385
|
-
...
|
|
438
|
+
...optionHeaders,
|
|
439
|
+
}),
|
|
440
|
+
logs: clean({
|
|
441
|
+
...parseOtlpHeaders(env['OTEL_EXPORTER_OTLP_LOGS_HEADERS']),
|
|
442
|
+
...optionHeaders,
|
|
386
443
|
}),
|
|
387
|
-
logs: clean({ ...parseOtlpHeaders(env['OTEL_EXPORTER_OTLP_LOGS_HEADERS']), ...opts.headers }),
|
|
388
444
|
},
|
|
389
|
-
...(
|
|
390
|
-
metricsEnabled:
|
|
391
|
-
logsEnabled:
|
|
445
|
+
...(tracesOn ? {} : { tracesEnabled: false }),
|
|
446
|
+
metricsEnabled: metricsOn,
|
|
447
|
+
logsEnabled: logsOn,
|
|
392
448
|
live: opts.live !== false,
|
|
393
449
|
timeoutMs,
|
|
394
450
|
// A signal's own `OTEL_EXPORTER_OTLP_<SIGNAL>_TIMEOUT` wins over the
|
|
395
451
|
// shared one; it was not read (F-49). The option tops both.
|
|
396
452
|
signalTimeoutMs: {
|
|
397
|
-
traces:
|
|
398
|
-
|
|
399
|
-
|
|
453
|
+
traces:
|
|
454
|
+
opts.timeoutMs ?? envTimeout(env, 'OTEL_EXPORTER_OTLP_TRACES_TIMEOUT', warn) ?? timeoutMs,
|
|
455
|
+
metrics:
|
|
456
|
+
opts.timeoutMs ?? envTimeout(env, 'OTEL_EXPORTER_OTLP_METRICS_TIMEOUT', warn) ?? timeoutMs,
|
|
457
|
+
logs: opts.timeoutMs ?? envTimeout(env, 'OTEL_EXPORTER_OTLP_LOGS_TIMEOUT', warn) ?? timeoutMs,
|
|
400
458
|
},
|
|
401
|
-
...(opts.post ? { post: opts.post } : {}),
|
|
402
459
|
...(warn ? { warn } : {}),
|
|
403
460
|
}
|
|
404
461
|
}
|
|
@@ -416,9 +473,11 @@ const OTEL_PLUGIN_KEYS: PluginOptionKinds<OtelPluginOptions> = {
|
|
|
416
473
|
live: 'boolean',
|
|
417
474
|
timeoutMs: 'number',
|
|
418
475
|
compression: 'string',
|
|
419
|
-
post: 'function',
|
|
420
476
|
}
|
|
421
477
|
|
|
478
|
+
/** The longest delay a timer holds; not sink.ts's, which loads only once a run exports. */
|
|
479
|
+
const MAX_TIMER_MS = 2 ** 31 - 1
|
|
480
|
+
|
|
422
481
|
/**
|
|
423
482
|
* The OpenTelemetry exporter plugin. Declared in .vx/workspace.ts via
|
|
424
483
|
* `defineWorkspace({ plugins: [otel()] })`. Contributes a telemetry sink that
|
|
@@ -428,6 +487,22 @@ const OTEL_PLUGIN_KEYS: PluginOptionKinds<OtelPluginOptions> = {
|
|
|
428
487
|
*/
|
|
429
488
|
export function otel(opts: OtelPluginOptions = {}): VxPlugin {
|
|
430
489
|
refuseUnknownOptions('otel()', opts, OTEL_PLUGIN_KEYS)
|
|
490
|
+
// At the call, not when an endpoint is set: a laptop passed what failed
|
|
491
|
+
// in CI. A zero, negative, NaN or past-the-longest-timer timeout aborted
|
|
492
|
+
// every export as it started; a compression the option names is never the
|
|
493
|
+
// env's to excuse.
|
|
494
|
+
const ms = opts.timeoutMs
|
|
495
|
+
if (ms !== undefined && !(ms > 0 && ms <= MAX_TIMER_MS)) {
|
|
496
|
+
throw new UserError(
|
|
497
|
+
`otel() option "timeoutMs" must be a positive number of ms up to ${MAX_TIMER_MS}, got ${ms}`,
|
|
498
|
+
)
|
|
499
|
+
}
|
|
500
|
+
const { compression } = opts
|
|
501
|
+
if (compression !== undefined && !/^\s*(gzip|none)\s*$/i.test(compression)) {
|
|
502
|
+
throw new UserError(
|
|
503
|
+
`otel() option "compression" must be "gzip" or "none", got ${JSON.stringify(compression)}`,
|
|
504
|
+
)
|
|
505
|
+
}
|
|
431
506
|
return definePlugin(import.meta, {
|
|
432
507
|
telemetry(ctx: TelemetryContext): Promise<TelemetrySink> | undefined {
|
|
433
508
|
const config = resolveOtelConfig(opts, process.env, (m) => ctx.warn(m))
|
package/src/sink.ts
CHANGED
|
@@ -165,8 +165,14 @@ async function postOnce(
|
|
|
165
165
|
): Promise<void> {
|
|
166
166
|
const controller = new AbortController()
|
|
167
167
|
// A timer past 2^31-1 ms fires after 1 ms: OTEL_EXPORTER_OTLP_TIMEOUT=1e10
|
|
168
|
-
// aborted every export at once (F-56).
|
|
169
|
-
|
|
168
|
+
// aborted every export at once (F-56). The spec defines that value, so it
|
|
169
|
+
// is held to the longest a timer waits; the option is refused at the call.
|
|
170
|
+
// The reason is what fetch rejects with, so the warning names the
|
|
171
|
+
// timeout spent; a bare abort said only "The operation was aborted."
|
|
172
|
+
const timer = setTimeout(
|
|
173
|
+
() => controller.abort(new Error(`no answer within ${timeoutMs} ms`)),
|
|
174
|
+
Math.min(timeoutMs, MAX_TIMER_MS),
|
|
175
|
+
)
|
|
170
176
|
// Core's flush deadline ends the POST too: `timeoutMs` (15 s by
|
|
171
177
|
// default) alone held the process that long after the run (item 1055).
|
|
172
178
|
const onDeadline = (): void => controller.abort()
|
|
@@ -203,6 +209,7 @@ async function postOnce(
|
|
|
203
209
|
// collector each exported nothing and said nothing, for every run
|
|
204
210
|
// (walked the adopter's path, 2026-09-20). The body is the
|
|
205
211
|
// collector's own explanation, so a line of it rides the message.
|
|
212
|
+
// quiet: the status alone still names the refusal
|
|
206
213
|
const text = await res.text().catch(() => '')
|
|
207
214
|
if (res.status >= 300 && res.status < 400) {
|
|
208
215
|
const location = res.headers.get('location')
|
|
@@ -214,36 +221,51 @@ async function postOnce(
|
|
|
214
221
|
if (!res.ok) {
|
|
215
222
|
const refused = new Error(`HTTP ${res.status}${detail(text)}`)
|
|
216
223
|
if (!RETRYABLE_STATUS.has(res.status)) throw refused
|
|
217
|
-
|
|
218
|
-
throw new OtlpRetryable(refused, Number.isFinite(after) && after > 0 ? after * 1000 : 0)
|
|
224
|
+
throw new OtlpRetryable(refused, retryAfterMs(res.headers.get('retry-after')))
|
|
219
225
|
}
|
|
220
226
|
// OTLP's other silent loss: a 200 whose body says part of the export
|
|
221
227
|
// was dropped (over quota, past a limit). Success at the transport,
|
|
222
228
|
// missing data in the collector.
|
|
223
229
|
const rejected = partialSuccess(text)
|
|
224
|
-
if (rejected !== undefined) throw
|
|
230
|
+
if (rejected !== undefined) throw rejected
|
|
225
231
|
} finally {
|
|
226
232
|
clearTimeout(timer)
|
|
227
233
|
deadline?.removeEventListener('abort', onDeadline)
|
|
228
234
|
}
|
|
229
235
|
}
|
|
230
236
|
|
|
237
|
+
/**
|
|
238
|
+
* A `Retry-After` in ms, or 0: seconds, or an HTTP date (RFC 9110), which
|
|
239
|
+
* `Number()` read as NaN and the retry came at the default delay.
|
|
240
|
+
*/
|
|
241
|
+
function retryAfterMs(value: string | null): number {
|
|
242
|
+
if (value === null) return 0
|
|
243
|
+
const seconds = Number(value)
|
|
244
|
+
const ms = Number.isFinite(seconds) ? seconds * 1000 : Date.parse(value) - Date.now()
|
|
245
|
+
return Number.isFinite(ms) && ms > 0 ? ms : 0
|
|
246
|
+
}
|
|
247
|
+
|
|
231
248
|
/** One line of a collector's error body, bounded — it is remote text. */
|
|
232
249
|
function detail(body: string): string {
|
|
233
250
|
const line = body.replace(/\s+/g, ' ').trim()
|
|
234
251
|
return line === '' ? '' : `: ${line.slice(0, 200)}`
|
|
235
252
|
}
|
|
236
253
|
|
|
254
|
+
/** An export the collector accepted whole, with a word about it. */
|
|
255
|
+
class OtlpWarning extends Error {}
|
|
256
|
+
|
|
237
257
|
/**
|
|
238
|
-
* The OTLP `partialSuccess` shape, when it says something
|
|
239
|
-
*
|
|
240
|
-
*
|
|
258
|
+
* The OTLP `partialSuccess` shape, when it says something: the rejected
|
|
259
|
+
* counts are int64-as-string, and a response with the field present but
|
|
260
|
+
* everything zero is a plain success (the spec's own example). Counts of
|
|
261
|
+
* zero with a message is the spec's warning: the export was accepted.
|
|
241
262
|
*/
|
|
242
|
-
function partialSuccess(body: string):
|
|
263
|
+
function partialSuccess(body: string): Error | undefined {
|
|
243
264
|
if (!body.includes('partialSuccess')) return undefined
|
|
244
265
|
let parsed: unknown
|
|
245
266
|
try {
|
|
246
267
|
parsed = JSON.parse(body)
|
|
268
|
+
// quiet: not JSON: the caller says the body as text
|
|
247
269
|
} catch {
|
|
248
270
|
return undefined
|
|
249
271
|
}
|
|
@@ -253,9 +275,10 @@ function partialSuccess(body: string): string | undefined {
|
|
|
253
275
|
.map((k) => [k, Number(ps[k] ?? 0)] as const)
|
|
254
276
|
.filter(([, n]) => Number.isFinite(n) && n > 0)
|
|
255
277
|
const message = typeof ps['errorMessage'] === 'string' ? ps['errorMessage'].trim() : ''
|
|
256
|
-
if (counts.length === 0
|
|
278
|
+
if (counts.length === 0)
|
|
279
|
+
return message === '' ? undefined : new OtlpWarning(detail(message).slice(2))
|
|
257
280
|
const what = counts.map(([k, n]) => `${n} ${k.replace('rejected', '').toLowerCase()}`).join(', ')
|
|
258
|
-
return `the collector dropped part of the export: ${what
|
|
281
|
+
return new Error(`the collector dropped part of the export: ${what}${detail(message)}`)
|
|
259
282
|
}
|
|
260
283
|
|
|
261
284
|
function genId(bytes: number): string {
|
|
@@ -299,6 +322,8 @@ export class OtelSink implements TelemetrySink {
|
|
|
299
322
|
private pumping: Promise<void> | undefined
|
|
300
323
|
private window: ReturnType<typeof setTimeout> | undefined
|
|
301
324
|
private readonly warned = new Set<OtelSignal>()
|
|
325
|
+
/** Signals whose collector warned about an export it accepted (`partialSuccess`). */
|
|
326
|
+
private readonly cautioned = new Set<OtelSignal>()
|
|
302
327
|
private summary: RunSummaryRecord | undefined
|
|
303
328
|
private uploaded = false
|
|
304
329
|
// Core's own bounded capture buffer, so which task's output survives a
|
|
@@ -348,10 +373,12 @@ export class OtelSink implements TelemetrySink {
|
|
|
348
373
|
this.rootSpanId = genId(8)
|
|
349
374
|
this.run = record.run
|
|
350
375
|
this.runId = record.run.runId
|
|
351
|
-
//
|
|
352
|
-
//
|
|
353
|
-
|
|
354
|
-
|
|
376
|
+
// Where the run's stages begin, else its canonical start: never when
|
|
377
|
+
// this record was projected. The root span covers its stages from
|
|
378
|
+
// here, and every task carries it, so a receiver derives one key
|
|
379
|
+
// whichever span it reads.
|
|
380
|
+
this.runStartedAt = record.originAt ?? record.startedAt
|
|
381
|
+
this.rootStartNano = nanos(this.runStartedAt)
|
|
355
382
|
this.resource = { ...runResource(record.run), ...this.cfg.resource }
|
|
356
383
|
this.liveEvent({
|
|
357
384
|
name: 'vx.run.start',
|
|
@@ -443,7 +470,9 @@ export class OtelSink implements TelemetrySink {
|
|
|
443
470
|
...(links.length > 0 ? { links } : {}),
|
|
444
471
|
})
|
|
445
472
|
// A skipped task never ran: a zero duration would read as a fast one.
|
|
446
|
-
|
|
473
|
+
// An aborted one (a stop) never ran or ran in part: a Ctrl-C charted
|
|
474
|
+
// every pending task at 0 ms. Core keeps neither in its history.
|
|
475
|
+
if (record.status !== 'skipped' && record.status !== 'aborted') {
|
|
447
476
|
this.taskPoints.push({
|
|
448
477
|
kind: 'end',
|
|
449
478
|
task: t,
|
|
@@ -489,7 +518,9 @@ export class OtelSink implements TelemetrySink {
|
|
|
489
518
|
}
|
|
490
519
|
|
|
491
520
|
private liveEvent(event: LiveEvent): void {
|
|
492
|
-
if (this.cfg.live
|
|
521
|
+
if (!this.cfg.live || !this.cfg.logsEnabled) return
|
|
522
|
+
this.liveEvents.push(event)
|
|
523
|
+
this.kick()
|
|
493
524
|
}
|
|
494
525
|
|
|
495
526
|
/**
|
|
@@ -579,13 +610,19 @@ export class OtelSink implements TelemetrySink {
|
|
|
579
610
|
}
|
|
580
611
|
// Finalize the root span now that the run is over (run.end set the end).
|
|
581
612
|
if (this.run !== undefined && this.traceId) {
|
|
582
|
-
//
|
|
583
|
-
//
|
|
584
|
-
//
|
|
585
|
-
|
|
586
|
-
const
|
|
613
|
+
// The root covers its stages: they start at the run's origin, before
|
|
614
|
+
// the run lock and the summary's `startedAt`, and the last ends after
|
|
615
|
+
// its `endedAt`. Drawn from those two, each stage sat outside its
|
|
616
|
+
// parent. The attributes name the same window.
|
|
617
|
+
const marked = this.summary?.stages ?? []
|
|
618
|
+
const startMs = Math.min(this.runStartedAt, this.summary?.startedAt ?? Infinity)
|
|
619
|
+
const endMs =
|
|
587
620
|
this.summary !== undefined
|
|
588
|
-
?
|
|
621
|
+
? Math.max(this.summary.endedAt, Math.ceil(marked.at(-1)?.endedAt ?? 0))
|
|
622
|
+
: undefined
|
|
623
|
+
const end =
|
|
624
|
+
endMs !== undefined
|
|
625
|
+
? nanos(endMs)
|
|
589
626
|
: this.rootEndNano !== '0'
|
|
590
627
|
? this.rootEndNano
|
|
591
628
|
: this.rootStartNano
|
|
@@ -594,12 +631,17 @@ export class OtelSink implements TelemetrySink {
|
|
|
594
631
|
spanId: this.rootSpanId,
|
|
595
632
|
name: 'vx.run',
|
|
596
633
|
kind: SPAN_KIND_INTERNAL,
|
|
597
|
-
startTimeUnixNano:
|
|
634
|
+
startTimeUnixNano: nanos(startMs),
|
|
598
635
|
endTimeUnixNano: end,
|
|
599
|
-
attributes: runSpanAttributes(
|
|
636
|
+
attributes: runSpanAttributes(
|
|
637
|
+
this.run,
|
|
638
|
+
this.summary === undefined
|
|
639
|
+
? undefined
|
|
640
|
+
: { ...this.summary, startedAt: startMs, endedAt: endMs! },
|
|
641
|
+
),
|
|
600
642
|
status: { code: runStatusCode(this.summary) },
|
|
601
643
|
})
|
|
602
|
-
const stages =
|
|
644
|
+
const stages = marked.map((stage) =>
|
|
603
645
|
stageSpan(this.traceId, this.rootSpanId, stage, genId(8)),
|
|
604
646
|
)
|
|
605
647
|
this.spans.splice(1, 0, ...stages)
|
|
@@ -706,21 +748,27 @@ export class OtelSink implements TelemetrySink {
|
|
|
706
748
|
...this.cfg.signalHeaders[signal],
|
|
707
749
|
...(gzip ? { 'content-encoding': 'gzip' } : {}),
|
|
708
750
|
}
|
|
709
|
-
const
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
)
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
751
|
+
const answers = await Promise.all(
|
|
752
|
+
bodies.map((body) =>
|
|
753
|
+
// Through a promise, so a transport that throws before it awaits is caught too.
|
|
754
|
+
Promise.resolve()
|
|
755
|
+
.then(() => this.postFor(signal)(url, body, headers, this.deadline, this.cfg.tls[signal]))
|
|
756
|
+
.then(
|
|
757
|
+
() => undefined,
|
|
758
|
+
(err: unknown) => err,
|
|
759
|
+
),
|
|
760
|
+
),
|
|
761
|
+
)
|
|
762
|
+
const failed = answers
|
|
763
|
+
.filter((e) => e !== undefined && !(e instanceof OtlpWarning))
|
|
764
|
+
.map((e) => (e instanceof Error ? e.message : String(e)))
|
|
765
|
+
const said = answers.find((e) => e instanceof OtlpWarning)
|
|
766
|
+
if (failed.length === 0 && said !== undefined && !this.cautioned.has(signal)) {
|
|
767
|
+
this.cautioned.add(signal)
|
|
768
|
+
this.cfg.warn?.(
|
|
769
|
+
`[vx] vx-otel: the collector at ${shownUrl(url)} accepted the export and warned: ${said.message}`,
|
|
722
770
|
)
|
|
723
|
-
|
|
771
|
+
}
|
|
724
772
|
// export is fully optional — a down collector never affects a run
|
|
725
773
|
if (failed.length === 0 || this.warned.has(signal)) return
|
|
726
774
|
this.warned.add(signal)
|
|
@@ -733,7 +781,7 @@ export class OtelSink implements TelemetrySink {
|
|
|
733
781
|
? ` — the env asks for OTLP over gRPC, and vx sends OTLP/HTTP JSON only: point it at the collector's HTTP endpoint (port 4318)`
|
|
734
782
|
: ''
|
|
735
783
|
const share = bodies.length > 1 ? ` (${failed.length} of ${bodies.length} requests)` : ''
|
|
736
|
-
this.cfg.warn?.(`[vx-otel
|
|
784
|
+
this.cfg.warn?.(`[vx] vx-otel: export failed for ${shownUrl(url)}: ${failed[0]}${share}${hint}`)
|
|
737
785
|
}
|
|
738
786
|
}
|
|
739
787
|
|
|
@@ -779,6 +827,7 @@ function shownUrl(url: string): string {
|
|
|
779
827
|
let u: URL
|
|
780
828
|
try {
|
|
781
829
|
u = new URL(url)
|
|
830
|
+
// quiet: not a URL: the caller says it
|
|
782
831
|
} catch {
|
|
783
832
|
return '(an unparsable URL)'
|
|
784
833
|
}
|