@vzn/vx-otel 0.0.646 → 0.0.648

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 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 with no `http://` or `https://` 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. 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 not a string is not sent either, with a warning
60
- naming the header.
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`, its options type `OtelPluginOptions`, and the
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: 'Bearer …' },
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`), on a timed-out one `vx.task.timed_out`, on
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 stages ahead of the run lock end before the `vx.run`
136
- span starts.
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`). A collector that answers with a redirect
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.646",
3
+ "version": "0.0.648",
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.646"
37
+ "@vzn/vx": "^0.0.648"
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
- return t.timedOut === true ? 'timeout' : 'failure'
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, PostFn } from './sink.js'
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
- /** Extra OTLP headers, merged over `OTEL_EXPORTER_OTLP_HEADERS` and each signal's own. */
34
- headers?: Record<string, string>
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
- /** `OTEL_EXPORTER_OTLP_TIMEOUT`: milliseconds; anything but a positive number is unset. */
121
- function envTimeout(raw: string | undefined): number | undefined {
122
- const ms = Number(present(raw))
123
- return Number.isFinite(ms) && ms > 0 ? ms : undefined
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] OTEL_RESOURCE_ATTRIBUTES is malformed (${pair.trim()}) — none of it is used`,
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 from = (option: keyof OtelPluginOptions, envName: string): [string, string] | undefined => {
180
- const o = present(opts[option] as string | undefined)
181
- if (o !== undefined) return [o, `the ${option} option`]
182
- const e = present(env[envName])
183
- return e === undefined ? undefined : [e, envName]
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 base = from('endpoint', 'OTEL_EXPORTER_OTLP_ENDPOINT')
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
- option: keyof OtelPluginOptions,
217
+ name: keyof OtelPluginOptions,
188
218
  envName: string,
189
219
  signal: string,
190
220
  ): [string, string] | undefined =>
191
- from(option, envName) ??
192
- (base === undefined ? undefined : [joinSignal(base[0], signal), base[1]])
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 protocol = URL.parse(p[0])?.protocol
207
- if (protocol === 'http:' || protocol === 'https:') continue
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?.(`[vx-otel] ${setting} is not an http:// or https:// URL — ${names} are not exported`)
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; `OTEL_SDK_DISABLED=true` and the traces
227
- // and metrics `=none` were ignored and still exported (item 1056).
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] ${signal}: true but no ${signal} endpoint (OTEL_EXPORTER_OTLP_ENDPOINT or OTEL_EXPORTER_OTLP_${signal.toUpperCase()}_ENDPOINT) — ${signal} are not exported`,
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] 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)',
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] header ${JSON.stringify(raw)} holds ${fault}, which no HTTP header can carry — not sent (its value is not printed)`,
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
- let badCompression = false
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
- const name = `OTEL_EXPORTER_OTLP_${signal.toUpperCase()}_COMPRESSION`
304
- const raw =
305
- opts.compression ?? present(env[name]) ?? present(env['OTEL_EXPORTER_OTLP_COMPRESSION'])
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 = true
311
- warn?.(`[vx-otel] compression ${JSON.stringify(raw)} is not gzip or none — sent uncompressed`)
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] ${name}: cannot read ${file} (${(err as NodeJS.ErrnoException).code ?? String(err)}) — not used`,
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
- // The option is held to the variable's rule: a zero, negative or
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']), ...opts.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
- ...opts.headers,
434
+ ...optionHeaders,
382
435
  }),
383
436
  metrics: clean({
384
437
  ...parseOtlpHeaders(env['OTEL_EXPORTER_OTLP_METRICS_HEADERS']),
385
- ...opts.headers,
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
- ...(tracesWanted && tracesUrl !== undefined ? {} : { tracesEnabled: false }),
390
- metricsEnabled: metricsWanted && metricsUrl !== undefined,
391
- logsEnabled: logsWanted && logsUrl !== undefined,
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: opts.timeoutMs ?? envTimeout(env['OTEL_EXPORTER_OTLP_TRACES_TIMEOUT']) ?? timeoutMs,
398
- metrics: opts.timeoutMs ?? envTimeout(env['OTEL_EXPORTER_OTLP_METRICS_TIMEOUT']) ?? timeoutMs,
399
- logs: opts.timeoutMs ?? envTimeout(env['OTEL_EXPORTER_OTLP_LOGS_TIMEOUT']) ?? timeoutMs,
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). Held to the longest a timer waits.
169
- const timer = setTimeout(() => controller.abort(), Math.min(timeoutMs, MAX_TIMER_MS))
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
- const after = Number(res.headers.get('retry-after'))
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 new Error(rejected)
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 was dropped: the
239
- * rejected counts are int64-as-string, and a response with the field present
240
- * but everything zero is a plain success (the spec's own example).
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): string | undefined {
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 && message === '') return undefined
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 === '' ? 'some of it' : what}${detail(message)}`
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
- // The run's OWN canonical start, not when this record was projected —
352
- // it is what the summary reports and what a receiver stores.
353
- this.rootStartNano = nanos(record.startedAt)
354
- this.runStartedAt = record.startedAt
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
- if (record.status !== 'skipped') {
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 && this.cfg.logsEnabled) this.liveEvents.push(event)
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
- // Prefer the summary's own start/end: they are the run's canonical
583
- // timing, and a receiver rebuilding the invocation header must agree
584
- // with the native ingest path to the millisecond.
585
- const start = this.summary !== undefined ? nanos(this.summary.startedAt) : this.rootStartNano
586
- const end =
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
- ? nanos(this.summary.endedAt)
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: start,
634
+ startTimeUnixNano: nanos(startMs),
598
635
  endTimeUnixNano: end,
599
- attributes: runSpanAttributes(this.run, this.summary),
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 = (this.summary?.stages ?? []).map((stage) =>
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 failed = (
710
- await Promise.all(
711
- bodies.map((body) =>
712
- // Through a promise, so a transport that throws before it awaits is caught too.
713
- Promise.resolve()
714
- .then(() =>
715
- this.postFor(signal)(url, body, headers, this.deadline, this.cfg.tls[signal]),
716
- )
717
- .then(
718
- () => undefined,
719
- (err: unknown) => (err instanceof Error ? err.message : String(err)),
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
- ).filter((m) => m !== undefined)
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] export failed for ${shownUrl(url)}: ${failed[0]}${share}${hint}`)
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
  }