@vzn/vx-otel 0.0.520 → 0.0.521

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
@@ -103,13 +103,20 @@ otel({
103
103
  `vx.task.hash`, duration, CPU ms, peak RSS, and on a skipped task its root
104
104
  blocker (`vx.task.blocked_by`), on a timed-out one `vx.task.timed_out`, on
105
105
  a sandboxed one its violation count (`vx.task.sandbox_violations`), on a
106
- persistent one that never became ready why (`vx.task.not_ready`). A
106
+ persistent one that never became ready why (`vx.task.not_ready`), its
107
+ command (`vx.task.command`, secrets masked), on a flaky one its record
108
+ (`vx.task.flaky.passes`, `vx.task.flaky.failures`), on a hit what the
109
+ stored run took (`vx.cache.stored_duration_ms`, `vx.cache.stored_cpu_ms`,
110
+ `vx.cache.stored_peak_rss_bytes`), and how long an `admit` policy held it
111
+ (`vx.task.admission_held_ms`). A
107
112
  failed task sets span status
108
113
  `ERROR`. A task span links to the spans of the tasks it waited on (a
109
114
  group seen through to the tasks behind it), and carries an event per
110
115
  attempt that failed and was run again (`vx.task.retry`, with
111
- `vx.task.attempt` and `vx.task.exit_code`) and one when vx's own
112
- timeout killed it (`vx.task.timeout`);
116
+ `vx.task.attempt` and `vx.task.exit_code`), one when vx's own
117
+ timeout killed it (`vx.task.timeout`), and one per sandbox violation
118
+ (`vx.sandbox.violation`, its line; past 100, `vx.sandbox.violations_dropped`
119
+ says how many more);
113
120
  - a child span per stage of the run, named by the stage (`startup`,
114
121
  `load configs`, `git enumeration`, `classify + probe`, `run graph`,
115
122
  `record history`, …, the stages `VX_TIMING` prints), with
@@ -120,7 +127,8 @@ otel({
120
127
  **Metrics per run**: `vx.tasks.total`, `vx.tasks.failed`,
121
128
  `vx.tasks.cache_hits{source=local|remote}`, what those hits did to the disk as
122
129
  `vx.tasks.cache_restored{source=local|remote}` and `vx.tasks.cache_up_to_date`,
123
- and the `vx.run.duration_ms` gauge. The `vx.run` span carries the same counts
130
+ and the `vx.run.duration_ms` and `vx.run.time_saved_ms` (what the hits'
131
+ stored runs took) gauges. The `vx.run` span carries the same counts
124
132
  (`vx.run.hit_count`, `vx.run.up_to_date_count`, `vx.run.restored_local_count`,
125
133
  `vx.run.restored_remote_count`).
126
134
  The counts are DELTA sums over the run's own interval (start to end), so a
@@ -129,18 +137,42 @@ backend adds runs rather than reading each as the series' new total.
129
137
  **Metrics per task**, as gauges keyed by `cicd.pipeline.task.name`,
130
138
  `vx.task.project` and `vx.task.task`: at each task's end
131
139
  `vx.task.duration` (ms, with `vx.cache.source`), and for a task the runner
132
- measured `vx.task.cpu_time` (ms) and `vx.task.peak_memory` (bytes); a
133
- skipped task sends none. While a task runs, its process tree is sampled
140
+ measured `vx.task.cpu_time` (ms) and `vx.task.peak_memory` (bytes), on a
141
+ hit `vx.task.time_saved` (ms), and `vx.task.admission_held` (ms) when an
142
+ `admit` policy held it; a skipped task sends none. While a task runs, its process tree is sampled
134
143
  each second: `vx.task.cpu_usage` (cores busy since the last sample, 1 =
135
144
  one core) and `vx.task.memory` (resident bytes). Sampling runs only while
136
145
  metrics export; a remote task, or a task under 1 s, has no samples.
137
146
 
138
147
  **Logs per run** (on by default): the captured output tail of each executed
139
- task, as one log record linked to its task span (unlinked when traces are
140
- off: the span is never exported). Build output can hold
148
+ task, as one log record at the task's end, linked to its task span (unlinked
149
+ when traces are off: the span is never exported). Build output can hold
141
150
  secrets, and this sends it to the collector; `logs: false` or
142
151
  `OTEL_LOGS_EXPORTER=none` turns it off.
143
152
 
153
+ **One run, three signals, linked.** Every request's resource names the run
154
+ (`service.instance.id` and `cicd.pipeline.run.id`, its id), the host
155
+ (`host.name`, `host.arch`, `os.type`) and the commit (`vcs.ref.head.*`),
156
+ under `OTEL_RESOURCE_ATTRIBUTES`, which wins. Each task metric point carries
157
+ its task's span as an exemplar, and each log record its span, so a chart
158
+ opens the trace and a span opens its output.
159
+
160
+ ## Live export
161
+
162
+ ```ts
163
+ otel({ live: process.env.CI === 'true' })
164
+ ```
165
+
166
+ With `live: true` each task's span, metrics and output tail are sent as the
167
+ task ends, its process samples as they are taken, and a log record
168
+ (`event.name` `vx.run.start` or `vx.task.start`, linked to the trace) as the
169
+ run and each task start; the `vx.run` span, the stage spans and the run's
170
+ metrics follow at the end. A dashboard follows a CI run while it runs. One
171
+ send is in flight at a time and takes everything that ended meanwhile; a
172
+ refused send warns once per signal for the run. Cost on 60 tasks against a
173
+ local collector: within the noise of run-to-run wall time (min of 6: 1,101 ms
174
+ off, 1,108 ms on). Off by default.
175
+
144
176
  ## Behavior note
145
177
 
146
178
  This replaces core's previous hardcoded OTel emit, which fired automatically
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx-otel",
3
- "version": "0.0.520",
3
+ "version": "0.0.521",
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": [
@@ -34,7 +34,7 @@
34
34
  "access": "public"
35
35
  },
36
36
  "peerDependencies": {
37
- "@vzn/vx": "^0.0.520"
37
+ "@vzn/vx": "^0.0.521"
38
38
  },
39
39
  "repository": {
40
40
  "type": "git",
package/src/otlp.ts CHANGED
@@ -57,6 +57,11 @@ const SEMCONV = {
57
57
  vcsHeadName: 'vcs.ref.head.name',
58
58
  serviceName: 'service.name',
59
59
  serviceVersion: 'service.version',
60
+ serviceInstanceId: 'service.instance.id',
61
+ hostName: 'host.name',
62
+ hostArch: 'host.arch',
63
+ osType: 'os.type',
64
+ eventName: 'event.name',
60
65
  } as const
61
66
 
62
67
  /**
@@ -127,6 +132,15 @@ const VX_ATTR = {
127
132
  peakRssBytes: 'vx.peak_rss_bytes',
128
133
  taskAttempts: 'vx.task.attempts',
129
134
  taskAttempt: 'vx.task.attempt',
135
+ taskCommand: 'vx.task.command',
136
+ taskFlakyPasses: 'vx.task.flaky.passes',
137
+ taskFlakyFailures: 'vx.task.flaky.failures',
138
+ taskAdmissionHeldMs: 'vx.task.admission_held_ms',
139
+ storedDurationMs: 'vx.cache.stored_duration_ms',
140
+ storedCpuMs: 'vx.cache.stored_cpu_ms',
141
+ storedPeakRssBytes: 'vx.cache.stored_peak_rss_bytes',
142
+ sandboxViolation: 'vx.sandbox.violation',
143
+ sandboxViolationsDropped: 'vx.sandbox.violations_dropped',
130
144
  stageName: 'vx.stage.name',
131
145
  wallclockStartNs: 'vx.task.wallclock_start_ns',
132
146
  wallclockEndNs: 'vx.task.wallclock_end_ns',
@@ -171,8 +185,9 @@ export interface OtlpSpanLink {
171
185
 
172
186
  /**
173
187
  * A task span's events: each attempt that failed and was run again
174
- * (`vx.task.retry`, at its end), and vx's own deadline killing the task
175
- * (`vx.task.timeout`, at the span's end).
188
+ * (`vx.task.retry`, at its end), vx's own deadline killing the task
189
+ * (`vx.task.timeout`, at the span's end), and each sandbox violation
190
+ * (`vx.sandbox.violation`, its line).
176
191
  */
177
192
  export function taskSpanEvents(t: TaskTelemetry, endUnixNano: string): OtlpSpanEvent[] {
178
193
  const events: OtlpSpanEvent[] = (t.failedAttempts ?? []).map((a, i) => ({
@@ -187,9 +202,27 @@ export function taskSpanEvents(t: TaskTelemetry, endUnixNano: string): OtlpSpanE
187
202
  if (t.timedOut === true) {
188
203
  events.push({ timeUnixNano: endUnixNano, name: 'vx.task.timeout', attributes: [] })
189
204
  }
205
+ const lines = t.sandboxViolationLines ?? []
206
+ for (const line of lines.slice(0, MAX_VIOLATION_EVENTS)) {
207
+ events.push({
208
+ timeUnixNano: endUnixNano,
209
+ name: VX_ATTR.sandboxViolation,
210
+ attributes: [strAttr(VX_ATTR.sandboxViolation, line)],
211
+ })
212
+ }
213
+ if (lines.length > MAX_VIOLATION_EVENTS) {
214
+ events.push({
215
+ timeUnixNano: endUnixNano,
216
+ name: VX_ATTR.sandboxViolationsDropped,
217
+ attributes: [intAttr(VX_ATTR.sandboxViolationsDropped, lines.length - MAX_VIOLATION_EVENTS)],
218
+ })
219
+ }
190
220
  return events
191
221
  }
192
222
 
223
+ /** A collector keeps 128 events per span by default; the rest say how many they were. */
224
+ const MAX_VIOLATION_EVENTS = 100
225
+
193
226
  /**
194
227
  * A child span of the run for one of its stages (`VX_TIMING`'s marks):
195
228
  * where the time before the first task and after the last one went. Named
@@ -247,6 +280,24 @@ function resourceAttributes(
247
280
  ]
248
281
  }
249
282
 
283
+ /**
284
+ * The run as resource attributes, so every signal it sends (spans, metric
285
+ * points, log records) names the same run and host: a backend joins them on
286
+ * `service.instance.id`. Under `OTEL_RESOURCE_ATTRIBUTES`, which wins.
287
+ */
288
+ export function runResource(run: RunContextRecord): Record<string, string> {
289
+ const r: Record<string, string> = {
290
+ [SEMCONV.serviceInstanceId]: run.runId,
291
+ [SEMCONV.pipelineRunId]: run.runId,
292
+ [SEMCONV.osType]: run.os === 'win32' ? 'windows' : run.os,
293
+ [SEMCONV.hostArch]: run.arch === 'x64' ? 'amd64' : run.arch,
294
+ }
295
+ if (run.host !== null) r[SEMCONV.hostName] = run.host
296
+ if (run.commitSha !== null) r[SEMCONV.vcsHeadRevision] = run.commitSha
297
+ if (run.branch !== null) r[SEMCONV.vcsHeadName] = run.branch
298
+ return r
299
+ }
300
+
250
301
  /**
251
302
  * Attributes for the root `vx.run` span.
252
303
  *
@@ -325,6 +376,8 @@ interface TaskSpanRunContext {
325
376
  workspaceId: string
326
377
  /** The run's canonical start (epoch ms) — the storage key's base. */
327
378
  startedAt: number
379
+ /** The task's command, from its start record; a task that never started has none. */
380
+ command?: string
328
381
  }
329
382
 
330
383
  /**
@@ -355,8 +408,8 @@ function runResult(t: TaskTelemetry): string {
355
408
  * `started_at` from them is computing a dedup key that must match the value
356
409
  * the native ingest path derives, to the millisecond.
357
410
  *
358
- * Nothing here is unbounded: every value is an id, an enum or a number, so a
359
- * collector's attribute-value limit cannot cut one. The largest thing this
411
+ * Nothing here is unbounded: every value is an id, an enum, a number or the
412
+ * task's command line, so a collector's attribute-value limit cannot cut one. The largest thing this
360
413
  * exporter ships is a task's captured tail, which travels as a LOG record
361
414
  * (`vx.log.*`) carrying its own full length, so a cut there is visible.
362
415
  */
@@ -397,6 +450,20 @@ export function taskSpanAttributes(t: TaskTelemetry, run: TaskSpanRunContext): K
397
450
  if (t.outputs !== undefined) attrs.push(strAttr(VX_ATTR.taskOutputs, t.outputs))
398
451
  if (t.peakRssBytes !== undefined) attrs.push(intAttr(VX_ATTR.peakRssBytes, t.peakRssBytes))
399
452
  if (t.attempts !== undefined) attrs.push(intAttr(VX_ATTR.taskAttempts, t.attempts))
453
+ if (run.command !== undefined) attrs.push(strAttr(VX_ATTR.taskCommand, run.command))
454
+ if (t.flaky !== undefined) {
455
+ attrs.push(
456
+ intAttr(VX_ATTR.taskFlakyPasses, t.flaky.passes),
457
+ intAttr(VX_ATTR.taskFlakyFailures, t.flaky.failures),
458
+ )
459
+ }
460
+ if (t.storedDurationMs !== undefined)
461
+ attrs.push(intAttr(VX_ATTR.storedDurationMs, t.storedDurationMs))
462
+ if (t.storedCpuMs !== undefined) attrs.push(intAttr(VX_ATTR.storedCpuMs, t.storedCpuMs))
463
+ if (t.storedPeakRssBytes !== undefined)
464
+ attrs.push(intAttr(VX_ATTR.storedPeakRssBytes, t.storedPeakRssBytes))
465
+ if (t.admissionHeldMs !== undefined)
466
+ attrs.push(intAttr(VX_ATTR.taskAdmissionHeldMs, t.admissionHeldMs))
400
467
  if (t.wallclockStartNs !== undefined)
401
468
  attrs.push(int64Attr(VX_ATTR.wallclockStartNs, t.wallclockStartNs))
402
469
  if (t.wallclockEndNs !== undefined)
@@ -483,6 +550,11 @@ export function buildMetricsRequest(
483
550
  ]),
484
551
  sum('vx.tasks.cache_up_to_date', [point(summary.upToDateCount)]),
485
552
  gauge('vx.run.duration_ms', summary.totalDurationMs),
553
+ // What the run's cache hits skipped: the stored runs' time.
554
+ gauge(
555
+ 'vx.run.time_saved_ms',
556
+ summary.tasks.reduce((n, t) => n + (t.storedDurationMs ?? 0), 0),
557
+ ),
486
558
  ...taskMetrics(points),
487
559
  ],
488
560
  },
@@ -496,7 +568,7 @@ export function buildMetricsRequest(
496
568
  * A per-task metric point: a task's totals at its end, or one live sample
497
569
  * of its process tree while it runs.
498
570
  */
499
- export type TaskMetricPoint =
571
+ export type TaskMetricPoint = (
500
572
  | { kind: 'end'; task: TaskTelemetry; startUnixNano: string; endUnixNano: string }
501
573
  | {
502
574
  kind: 'sample'
@@ -508,6 +580,10 @@ export type TaskMetricPoint =
508
580
  cpuUsage: number
509
581
  rssBytes: number
510
582
  }
583
+ ) & {
584
+ /** The task's span: each point carries it as an exemplar, a link from the chart to the trace. */
585
+ span?: { traceId: string; spanId: string }
586
+ }
511
587
 
512
588
  /**
513
589
  * An ExportMetricsServiceRequest of per-task gauges alone: the points past
@@ -532,9 +608,11 @@ export function buildTaskMetricsRequest(
532
608
  /**
533
609
  * Per-task gauges, keyed by task. At each task's end: its duration, CPU time and peak memory (CPU and memory as
534
610
  * the runner measured them at exit; a task it did not measure, a cache hit or
535
- * a remote run, sends no point for them). While a task runs: its process
611
+ * a remote run, sends no point for them), on a hit the time it saved, and
612
+ * how long an `admit` policy held it. While a task runs: its process
536
613
  * tree's CPU usage and memory, once per sample. A task's numbers otherwise
537
- * live only on its span, which no metrics backend charts.
614
+ * live only on its span, which no metrics backend charts. Each point names
615
+ * the task's span as an exemplar.
538
616
  */
539
617
  function taskMetrics(points: readonly TaskMetricPoint[]): unknown[] {
540
618
  const series = new Map<string, { unit: string; dataPoints: unknown[] }>()
@@ -548,12 +626,17 @@ function taskMetrics(points: readonly TaskMetricPoint[]): unknown[] {
548
626
  strAttr(VX_ATTR.taskProject, project),
549
627
  strAttr(VX_ATTR.taskTask, task),
550
628
  ]
629
+ const exemplars = (p: TaskMetricPoint, value: number, timeUnixNano: string) =>
630
+ p.span === undefined
631
+ ? {}
632
+ : { exemplars: [{ timeUnixNano, asDouble: value, filteredAttributes: [], ...p.span }] }
551
633
  for (const p of points) {
552
634
  if (p.kind === 'sample') {
553
635
  const at = (value: number) => ({
554
636
  asDouble: value,
555
637
  timeUnixNano: p.timeUnixNano,
556
638
  attributes: taskAttrs(p.taskId, p.project, p.task),
639
+ ...exemplars(p, value, p.timeUnixNano),
557
640
  })
558
641
  add('vx.task.cpu_usage', '1', at(p.cpuUsage))
559
642
  add('vx.task.memory', 'By', at(p.rssBytes))
@@ -568,10 +651,13 @@ function taskMetrics(points: readonly TaskMetricPoint[]): unknown[] {
568
651
  ...taskAttrs(t.taskId, t.project, t.task),
569
652
  strAttr(VX_ATTR.cacheSource, t.cacheSource),
570
653
  ],
654
+ ...exemplars(p, value, p.endUnixNano),
571
655
  })
572
656
  add('vx.task.duration', 'ms', at(t.durationMs))
573
657
  if (t.cpuMs !== undefined) add('vx.task.cpu_time', 'ms', at(t.cpuMs))
574
658
  if (t.peakRssBytes !== undefined) add('vx.task.peak_memory', 'By', at(t.peakRssBytes))
659
+ if (t.storedDurationMs !== undefined) add('vx.task.time_saved', 'ms', at(t.storedDurationMs))
660
+ if (t.admissionHeldMs !== undefined) add('vx.task.admission_held', 'ms', at(t.admissionHeldMs))
575
661
  }
576
662
  return [...series].map(([name, { unit, dataPoints }]) => ({ name, unit, gauge: { dataPoints } }))
577
663
  }
@@ -617,6 +703,8 @@ export function buildLogsRequest(args: {
617
703
  workspaceId: string
618
704
  entries: readonly TaskLogEntry[]
619
705
  timeUnixNano: string
706
+ /** When the task ended; `timeUnixNano` for one with no known end. */
707
+ timeFor?: (taskId: string) => string | undefined
620
708
  traceId?: string
621
709
  spanIdFor?: (taskId: string) => string | undefined
622
710
  resource?: Readonly<Record<string, string>>
@@ -633,8 +721,9 @@ export function buildLogsRequest(args: {
633
721
  ]
634
722
  if (e.hash !== undefined) attrs.push(strAttr(VX_ATTR.taskHash, e.hash))
635
723
  const spanId = args.spanIdFor?.(e.taskId)
724
+ const time = args.timeFor?.(e.taskId) ?? args.timeUnixNano
636
725
  return {
637
- timeUnixNano: args.timeUnixNano,
726
+ timeUnixNano: time,
638
727
  observedTimeUnixNano: args.timeUnixNano,
639
728
  severityNumber: failed ? SEVERITY_ERROR : SEVERITY_INFO,
640
729
  severityText: failed ? 'ERROR' : 'INFO',
@@ -655,3 +744,53 @@ export function buildLogsRequest(args: {
655
744
  ],
656
745
  }
657
746
  }
747
+
748
+ /** A lifecycle moment, sent as it happens in live mode: a run or a task started. */
749
+ export interface LiveEvent {
750
+ name: 'vx.run.start' | 'vx.task.start'
751
+ timeUnixNano: string
752
+ body: string
753
+ taskId?: string
754
+ spanId?: string
755
+ }
756
+
757
+ /**
758
+ * Live mode's lifecycle records, one log record each, linked to the run's
759
+ * trace (and the task's span): what a dashboard shows as running before any
760
+ * span, which is sent only once it ends, exists.
761
+ */
762
+ export function buildEventLogsRequest(args: {
763
+ serviceName: string
764
+ vxVersion: string
765
+ runId: string
766
+ workspaceId: string
767
+ events: readonly LiveEvent[]
768
+ traceId?: string
769
+ resource?: Readonly<Record<string, string>>
770
+ }): unknown {
771
+ const logRecords: OtlpLogRecord[] = args.events.map((e) => ({
772
+ timeUnixNano: e.timeUnixNano,
773
+ observedTimeUnixNano: e.timeUnixNano,
774
+ severityNumber: SEVERITY_INFO,
775
+ severityText: 'INFO',
776
+ body: { stringValue: e.body },
777
+ attributes: [
778
+ strAttr(SEMCONV.eventName, e.name),
779
+ strAttr(SEMCONV.pipelineRunId, args.runId),
780
+ strAttr(VX_ATTR.workspaceId, args.workspaceId),
781
+ ...(e.taskId !== undefined ? [strAttr(SEMCONV.taskName, e.taskId)] : []),
782
+ ],
783
+ ...(args.traceId ? { traceId: args.traceId } : {}),
784
+ ...(args.traceId && e.spanId ? { spanId: e.spanId } : {}),
785
+ }))
786
+ return {
787
+ resourceLogs: [
788
+ {
789
+ resource: {
790
+ attributes: resourceAttributes(args.serviceName, args.vxVersion, args.resource),
791
+ },
792
+ scopeLogs: [{ scope: { name: 'vx', version: args.vxVersion }, logRecords }],
793
+ },
794
+ ],
795
+ }
796
+ }
package/src/plugin.ts CHANGED
@@ -17,7 +17,7 @@ import {
17
17
  type VxPlugin,
18
18
  } from '@vzn/vx'
19
19
  import { readFileSync } from 'node:fs'
20
- import { OtelSink, type OtlpTls, type PostFn } from './sink.js'
20
+ import type { OtelSinkConfig, OtlpTls, PostFn } from './sink.js'
21
21
 
22
22
  export interface OtelPluginOptions {
23
23
  /** OTLP base endpoint. Falls back to `OTEL_EXPORTER_OTLP_ENDPOINT`. */
@@ -40,6 +40,12 @@ export interface OtelPluginOptions {
40
40
  * Set false (or `OTEL_LOGS_EXPORTER=none`) to export traces + metrics only.
41
41
  */
42
42
  logs?: boolean
43
+ /**
44
+ * Send each task's span, metrics and log as it ends, and a log record as
45
+ * the run and each task start, instead of everything at the run's end: a
46
+ * dashboard follows the run while it runs. Default: false.
47
+ */
48
+ live?: boolean
43
49
  /** Per-request timeout (ms). Falls back to `OTEL_EXPORTER_OTLP_TIMEOUT`, else 15000. */
44
50
  timeoutMs?: number
45
51
  /**
@@ -166,7 +172,7 @@ export function resolveOtelConfig(
166
172
  opts: OtelPluginOptions,
167
173
  env: Record<string, string | undefined>,
168
174
  warn?: (m: string) => void,
169
- ): ConstructorParameters<typeof OtelSink>[0] | undefined {
175
+ ): OtelSinkConfig | undefined {
170
176
  const base = present(opts.endpoint) ?? present(env['OTEL_EXPORTER_OTLP_ENDPOINT'])
171
177
  const tracesUrl =
172
178
  present(opts.tracesEndpoint) ??
@@ -354,6 +360,7 @@ export function resolveOtelConfig(
354
360
  ...(tracesWanted && tracesUrl !== undefined ? {} : { tracesEnabled: false }),
355
361
  metricsEnabled: metricsWanted && metricsUrl !== undefined,
356
362
  logsEnabled: logsWanted && logsUrl !== undefined,
363
+ ...(opts.live === true ? { live: true } : {}),
357
364
  timeoutMs,
358
365
  // A signal's own `OTEL_EXPORTER_OTLP_<SIGNAL>_TIMEOUT` wins over the
359
366
  // shared one; it was not read (F-49). The option tops both.
@@ -377,6 +384,7 @@ const OTEL_PLUGIN_KEYS: PluginOptionKinds<OtelPluginOptions> = {
377
384
  headers: 'object',
378
385
  metrics: 'boolean',
379
386
  logs: 'boolean',
387
+ live: 'boolean',
380
388
  timeoutMs: 'number',
381
389
  compression: 'string',
382
390
  post: 'function',
@@ -392,10 +400,12 @@ const OTEL_PLUGIN_KEYS: PluginOptionKinds<OtelPluginOptions> = {
392
400
  export function otel(opts: OtelPluginOptions = {}): VxPlugin {
393
401
  refuseUnknownOptions('otel()', opts, OTEL_PLUGIN_KEYS)
394
402
  return definePlugin(import.meta, {
395
- telemetry(ctx: TelemetryContext): TelemetrySink | undefined {
403
+ telemetry(ctx: TelemetryContext): Promise<TelemetrySink> | undefined {
396
404
  const config = resolveOtelConfig(opts, process.env, (m) => ctx.warn(m))
397
405
  if (config === undefined) return undefined
398
- return new OtelSink(config)
406
+ // The exporter loads only when it will export: a declined otel() is
407
+ // read by every run, and the sink was most of its import cost.
408
+ return import('./sink.js').then(({ OtelSink }) => new OtelSink(config))
399
409
  },
400
410
  })
401
411
  }
package/src/sink.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  // The OTLP telemetry sink — accumulates a run's records into OTLP trace +
2
- // metric payloads and ships them over HTTP/JSON. Observe-only: it implements
2
+ // metric payloads and ships them over HTTP/JSON, at the run's end or, live,
3
+ // as each task ends. Observe-only: it implements
3
4
  // core's TelemetrySink (records in, nothing out). Never-fail: every network
4
5
  // error is swallowed, the POST is time-bounded, and the upload is idempotent —
5
6
  // a down collector can never affect a run.
6
7
 
7
- import { randomBytes } from 'node:crypto'
8
8
  import { TaskLogBuffer } from '@vzn/vx'
9
9
  import type {
10
10
  RunContextRecord,
@@ -13,13 +13,17 @@ import type {
13
13
  TelemetryRecord,
14
14
  TelemetrySink,
15
15
  } from '@vzn/vx'
16
+ import type { TaskLogEntry } from '@vzn/vx'
16
17
  import {
18
+ buildEventLogsRequest,
17
19
  buildLogsRequest,
18
20
  buildMetricsRequest,
19
21
  buildTaskMetricsRequest,
20
22
  buildTraceRequest,
23
+ type LiveEvent,
21
24
  type OtlpSpan,
22
25
  type TaskMetricPoint,
26
+ runResource,
23
27
  runSpanAttributes,
24
28
  SPAN_KIND_INTERNAL,
25
29
  dependencyLink,
@@ -65,6 +69,8 @@ export interface OtelSinkConfig {
65
69
  gzip?: readonly OtelSignal[]
66
70
  /** Per signal: a collector's CA, and a client certificate for mutual TLS. */
67
71
  tls?: Partial<Record<OtelSignal, OtlpTls>>
72
+ /** Send each task's span, metrics and log as it ends, not all at the run's end. */
73
+ live?: boolean
68
74
  /** False under `OTEL_TRACES_EXPORTER=none`; absent is true. */
69
75
  tracesEnabled?: boolean
70
76
  metricsEnabled: boolean
@@ -250,7 +256,7 @@ function partialSuccess(body: string): string | undefined {
250
256
  }
251
257
 
252
258
  function genId(bytes: number): string {
253
- return randomBytes(bytes).toString('hex')
259
+ return Buffer.from(crypto.getRandomValues(new Uint8Array(bytes))).toString('hex')
254
260
  }
255
261
 
256
262
  /** ms → unix-nano decimal string (OTLP int64-as-string). */
@@ -280,6 +286,15 @@ export class OtelSink implements TelemetrySink {
280
286
  private readonly taskSpanId = new Map<string, string>()
281
287
  private readonly taskStartNano = new Map<string, string>()
282
288
  private readonly taskDeps = new Map<string, readonly string[]>()
289
+ private readonly taskCommand = new Map<string, string>()
290
+ private readonly taskEndNano = new Map<string, string>()
291
+ /** Resource attributes: the run's, under `OTEL_RESOURCE_ATTRIBUTES`. */
292
+ private resource: Readonly<Record<string, string>> = {}
293
+ // Live mode: what ended since the last send; one send in flight at a time.
294
+ private readonly liveLogs: TaskLogEntry[] = []
295
+ private readonly liveEvents: LiveEvent[] = []
296
+ private pumping: Promise<void> | undefined
297
+ private readonly warnedLive = new Set<OtelSignal>()
283
298
  private summary: RunSummaryRecord | undefined
284
299
  private uploaded = false
285
300
  // Core's own bounded capture buffer, so which task's output survives a
@@ -302,6 +317,7 @@ export class OtelSink implements TelemetrySink {
302
317
  tls: config.tls ?? {},
303
318
  headers: config.headers,
304
319
  signalHeaders: config.signalHeaders ?? {},
320
+ live: config.live === true,
305
321
  tracesEnabled: config.tracesEnabled !== false,
306
322
  metricsEnabled: config.metricsEnabled,
307
323
  logsEnabled: config.logsEnabled,
@@ -331,17 +347,34 @@ export class OtelSink implements TelemetrySink {
331
347
  // it is what the summary reports and what a receiver stores.
332
348
  this.rootStartNano = nanos(record.startedAt)
333
349
  this.runStartedAt = record.startedAt
350
+ this.resource = { ...runResource(record.run), ...this.cfg.resource }
351
+ this.liveEvent({
352
+ name: 'vx.run.start',
353
+ timeUnixNano: this.rootStartNano,
354
+ body: `${record.run.command}: ${record.total} tasks`,
355
+ })
334
356
  return
335
- case 'task.start':
336
- this.taskSpanId.set(record.taskId, genId(8))
357
+ case 'task.start': {
358
+ const spanId = genId(8)
359
+ this.taskSpanId.set(record.taskId, spanId)
337
360
  this.taskStartNano.set(record.taskId, nanos(record.ts))
361
+ if (record.command !== undefined) this.taskCommand.set(record.taskId, record.command)
338
362
  if (record.dependsOn !== undefined) this.taskDeps.set(record.taskId, record.dependsOn)
363
+ this.liveEvent({
364
+ name: 'vx.task.start',
365
+ timeUnixNano: nanos(record.ts),
366
+ body:
367
+ record.command === undefined ? record.taskId : `${record.taskId}: ${record.command}`,
368
+ taskId: record.taskId,
369
+ spanId,
370
+ })
339
371
  this.taskNames.set(record.taskId, {
340
372
  project: record.project,
341
373
  task: record.task,
342
374
  startMs: record.ts,
343
375
  })
344
376
  return
377
+ }
345
378
  case 'task.sample': {
346
379
  // Usage is the CPU the tree spent since the last look, over the
347
380
  // wall time between: the first look counts from the task's start.
@@ -360,7 +393,9 @@ export class OtelSink implements TelemetrySink {
360
393
  timeUnixNano: nanos(record.ts),
361
394
  cpuUsage,
362
395
  rssBytes: record.rssBytes,
396
+ ...this.spanOf(record.taskId),
363
397
  })
398
+ this.kick()
364
399
  return
365
400
  }
366
401
  case 'task.log':
@@ -394,6 +429,9 @@ export class OtelSink implements TelemetrySink {
394
429
  runId: this.runId,
395
430
  workspaceId: this.run?.workspaceId ?? '',
396
431
  startedAt: this.runStartedAt,
432
+ ...(this.taskCommand.has(record.taskId)
433
+ ? { command: this.taskCommand.get(record.taskId)! }
434
+ : {}),
397
435
  }),
398
436
  status: { code: taskStatusCode(t) },
399
437
  ...(events.length > 0 ? { events } : {}),
@@ -406,14 +444,21 @@ export class OtelSink implements TelemetrySink {
406
444
  task: t,
407
445
  startUnixNano: startNano,
408
446
  endUnixNano: nanos(record.ts),
447
+ ...this.spanOf(record.taskId),
409
448
  })
410
449
  }
411
450
  this.lastSample.delete(record.taskId)
451
+ this.taskEndNano.set(record.taskId, nanos(record.ts))
412
452
  // Decides retention: a cache hit's bytes belong to the run that
413
453
  // executed them, so only an executed success/failure keeps a tail.
414
454
  if (this.cfg.logsEnabled) {
415
455
  this.logs.finish(record.taskId, record.status, record.cacheSource, record.hash)
456
+ if (this.cfg.live) {
457
+ const entry = this.logs.takeEntry(record.taskId)
458
+ if (entry !== undefined) this.liveLogs.push(entry)
459
+ }
416
460
  }
461
+ this.kick()
417
462
  return
418
463
  }
419
464
  case 'run.end':
@@ -425,6 +470,79 @@ export class OtelSink implements TelemetrySink {
425
470
  /** Core's flush deadline, passed to every POST. */
426
471
  private deadline: AbortSignal | undefined
427
472
 
473
+ /** The task's span, for its metric points' exemplars; none with traces off. */
474
+ private spanOf(taskId: string): { span?: { traceId: string; spanId: string } } {
475
+ const spanId = this.taskSpanId.get(taskId)
476
+ return this.cfg.tracesEnabled && this.traceId && spanId !== undefined
477
+ ? { span: { traceId: this.traceId, spanId } }
478
+ : {}
479
+ }
480
+
481
+ private liveEvent(event: LiveEvent): void {
482
+ if (this.cfg.live && this.cfg.logsEnabled) this.liveEvents.push(event)
483
+ }
484
+
485
+ /** Live mode: send what is waiting, unless a send is already on its way (it takes it next). */
486
+ private kick(): void {
487
+ if (!this.cfg.live || this.pumping !== undefined || this.uploaded) return
488
+ this.pumping = Promise.resolve()
489
+ .then(() => this.pump())
490
+ .finally(() => {
491
+ this.pumping = undefined
492
+ })
493
+ }
494
+
495
+ private async pump(): Promise<void> {
496
+ while (
497
+ this.spans.length + this.taskPoints.length + this.liveLogs.length + this.liveEvents.length >
498
+ 0
499
+ ) {
500
+ const spans = this.spans.splice(0)
501
+ const points = this.taskPoints.splice(0)
502
+ const logs = this.liveLogs.splice(0)
503
+ const events = this.liveEvents.splice(0)
504
+ await Promise.all([
505
+ this.shipSpans(spans, true),
506
+ this.shipPoints(points),
507
+ this.shipLiveLogs(logs, events),
508
+ ])
509
+ }
510
+ }
511
+
512
+ private async shipPoints(points: TaskMetricPoint[]): Promise<void> {
513
+ if (!this.cfg.metricsEnabled || points.length === 0) return
514
+ const vxVersion = this.run?.vxVersion ?? '0.0.0'
515
+ const bodies = requestBodies(points, (group) =>
516
+ JSON.stringify(
517
+ buildTaskMetricsRequest(this.cfg.serviceName, vxVersion, group, this.resource),
518
+ ),
519
+ )
520
+ await this.send('metrics', this.cfg.metricsUrl, bodies, true)
521
+ }
522
+
523
+ private async shipLiveLogs(logs: TaskLogEntry[], events: LiveEvent[]): Promise<void> {
524
+ if (!this.cfg.logsEnabled) return
525
+ const bodies = [
526
+ ...(logs.length > 0 ? this.logBodies(logs) : []),
527
+ ...(events.length > 0
528
+ ? requestBodies(events, (group) =>
529
+ JSON.stringify(
530
+ buildEventLogsRequest({
531
+ serviceName: this.cfg.serviceName,
532
+ vxVersion: this.run?.vxVersion ?? '0.0.0',
533
+ runId: this.runId,
534
+ workspaceId: this.run?.workspaceId ?? '',
535
+ events: group,
536
+ resource: this.resource,
537
+ ...(this.cfg.tracesEnabled && this.traceId ? { traceId: this.traceId } : {}),
538
+ }),
539
+ ),
540
+ )
541
+ : []),
542
+ ]
543
+ if (bodies.length > 0) await this.send('logs', this.cfg.logsUrl, bodies, true)
544
+ }
545
+
428
546
  onRunSummary(summary: RunSummaryRecord): void {
429
547
  this.summary = summary
430
548
  }
@@ -433,6 +551,11 @@ export class OtelSink implements TelemetrySink {
433
551
  if (this.uploaded) return
434
552
  this.uploaded = true
435
553
  this.deadline = signal
554
+ // Live: what is on its way lands before the rest, which the run's end sends.
555
+ if (this.cfg.live) {
556
+ await this.pumping
557
+ await this.pump()
558
+ }
436
559
  // Finalize the root span now that the run is over (run.end set the end).
437
560
  if (this.run !== undefined && this.traceId) {
438
561
  // Prefer the summary's own start/end: they are the run's canonical
@@ -460,16 +583,20 @@ export class OtelSink implements TelemetrySink {
460
583
  )
461
584
  this.spans.splice(1, 0, ...stages)
462
585
  }
463
- const vxVersion = this.run?.vxVersion ?? '0.0.0'
464
- await Promise.all([this.shipTraces(vxVersion), this.shipMetrics(), this.shipLogs(vxVersion)])
586
+ await Promise.all([this.shipTraces(), this.shipMetrics(), this.shipLogs()])
587
+ }
588
+
589
+ private async shipTraces(): Promise<void> {
590
+ await this.shipSpans(this.spans, false)
465
591
  }
466
592
 
467
- private async shipTraces(vxVersion: string): Promise<void> {
468
- if (this.cfg.tracesEnabled === false || this.spans.length === 0) return
469
- const bodies = requestBodies(this.spans, (spans) =>
470
- JSON.stringify(buildTraceRequest(this.cfg.serviceName, vxVersion, spans, this.cfg.resource)),
593
+ private async shipSpans(spans: OtlpSpan[], live: boolean): Promise<void> {
594
+ if (this.cfg.tracesEnabled === false || spans.length === 0) return
595
+ const vxVersion = this.run?.vxVersion ?? '0.0.0'
596
+ const bodies = requestBodies(spans, (group) =>
597
+ JSON.stringify(buildTraceRequest(this.cfg.serviceName, vxVersion, group, this.resource)),
471
598
  )
472
- await this.send('traces', this.cfg.tracesUrl, bodies)
599
+ await this.send('traces', this.cfg.tracesUrl, bodies, live)
473
600
  }
474
601
 
475
602
  private async shipMetrics(): Promise<void> {
@@ -485,12 +612,7 @@ export class OtelSink implements TelemetrySink {
485
612
  points[0] === first
486
613
  ? this.runMetricsBody(summary, points)
487
614
  : JSON.stringify(
488
- buildTaskMetricsRequest(
489
- this.cfg.serviceName,
490
- vxVersion,
491
- points,
492
- this.cfg.resource,
493
- ),
615
+ buildTaskMetricsRequest(this.cfg.serviceName, vxVersion, points, this.resource),
494
616
  ),
495
617
  )
496
618
  await this.send('metrics', this.cfg.metricsUrl, bodies)
@@ -503,27 +625,32 @@ export class OtelSink implements TelemetrySink {
503
625
  summary,
504
626
  nanos(summary.endedAt),
505
627
  nanos(summary.startedAt),
506
- this.cfg.resource,
628
+ this.resource,
507
629
  points,
508
630
  ),
509
631
  )
510
632
  }
511
633
 
512
- private async shipLogs(vxVersion: string): Promise<void> {
634
+ private async shipLogs(): Promise<void> {
513
635
  if (!this.cfg.logsEnabled) return
514
- const workspaceId = this.run?.workspaceId ?? ''
515
- const bundle = this.logs.drain(this.runId, workspaceId)
636
+ const bundle = this.logs.drain(this.runId, this.run?.workspaceId ?? '')
516
637
  if (bundle.tasks.length === 0) return
517
- const bodies = requestBodies(bundle.tasks, (entries) =>
638
+ await this.send('logs', this.cfg.logsUrl, this.logBodies(bundle.tasks))
639
+ }
640
+
641
+ private logBodies(tasks: readonly TaskLogEntry[]): string[] {
642
+ const now = nanos(this.summary?.endedAt ?? Date.now())
643
+ return requestBodies(tasks, (entries) =>
518
644
  JSON.stringify(
519
645
  buildLogsRequest({
520
646
  serviceName: this.cfg.serviceName,
521
- resource: this.cfg.resource,
522
- vxVersion,
647
+ resource: this.resource,
648
+ vxVersion: this.run?.vxVersion ?? '0.0.0',
523
649
  runId: this.runId,
524
- workspaceId,
650
+ workspaceId: this.run?.workspaceId ?? '',
525
651
  entries,
526
- timeUnixNano: nanos(this.summary?.endedAt ?? Date.now()),
652
+ timeUnixNano: now,
653
+ timeFor: (taskId) => this.taskEndNano.get(taskId),
527
654
  // Traces off: the trace and its spans are never exported, and a
528
655
  // record naming them links nowhere (F-49).
529
656
  ...(this.cfg.tracesEnabled && this.traceId
@@ -532,7 +659,6 @@ export class OtelSink implements TelemetrySink {
532
659
  }),
533
660
  ),
534
661
  )
535
- await this.send('logs', this.cfg.logsUrl, bodies)
536
662
  }
537
663
 
538
664
  /** The transport, under the signal's own timeout when it has one (F-49). */
@@ -541,8 +667,16 @@ export class OtelSink implements TelemetrySink {
541
667
  return own === undefined || this.injected ? this.cfg.post : defaultPost(own)
542
668
  }
543
669
 
544
- /** POSTs every body at once; one warning per signal, however many fail. */
545
- private async send(signal: OtelSignal, url: string, bodies: readonly string[]): Promise<void> {
670
+ /**
671
+ * POSTs every body at once; one warning per signal, however many fail, and
672
+ * live sends (`once`) one per signal for the whole run.
673
+ */
674
+ private async send(
675
+ signal: OtelSignal,
676
+ url: string,
677
+ bodies: readonly string[],
678
+ once = false,
679
+ ): Promise<void> {
546
680
  const gzip = this.cfg.gzip.includes(signal)
547
681
  const headers: Record<string, string> = {
548
682
  'content-type': 'application/json',
@@ -567,6 +701,10 @@ export class OtelSink implements TelemetrySink {
567
701
  ).filter((m) => m !== undefined)
568
702
  // export is fully optional — a down collector never affects a run
569
703
  if (failed.length === 0) return
704
+ if (once) {
705
+ if (this.warnedLive.has(signal)) return
706
+ this.warnedLive.add(signal)
707
+ }
570
708
  // Name the URL: three signals ship concurrently and each is caught
571
709
  // here on its own, so a bare "export failed" cannot tell a down
572
710
  // collector from one misconfigured signal endpoint.