@vzn/vx-otel 0.0.519 → 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,14 +103,32 @@ 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
- `ERROR`.
113
+ `ERROR`. A task span links to the spans of the tasks it waited on (a
114
+ group seen through to the tasks behind it), and carries an event per
115
+ attempt that failed and was run again (`vx.task.retry`, with
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);
120
+ - a child span per stage of the run, named by the stage (`startup`,
121
+ `load configs`, `git enumeration`, `classify + probe`, `run graph`,
122
+ `record history`, …, the stages `VX_TIMING` prints), with
123
+ `vx.stage.name`: where the time before the first task and after the
124
+ last went. The stages ahead of the run lock end before the `vx.run`
125
+ span starts.
109
126
 
110
127
  **Metrics per run**: `vx.tasks.total`, `vx.tasks.failed`,
111
128
  `vx.tasks.cache_hits{source=local|remote}`, what those hits did to the disk as
112
129
  `vx.tasks.cache_restored{source=local|remote}` and `vx.tasks.cache_up_to_date`,
113
- 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
114
132
  (`vx.run.hit_count`, `vx.run.up_to_date_count`, `vx.run.restored_local_count`,
115
133
  `vx.run.restored_remote_count`).
116
134
  The counts are DELTA sums over the run's own interval (start to end), so a
@@ -119,18 +137,42 @@ backend adds runs rather than reading each as the series' new total.
119
137
  **Metrics per task**, as gauges keyed by `cicd.pipeline.task.name`,
120
138
  `vx.task.project` and `vx.task.task`: at each task's end
121
139
  `vx.task.duration` (ms, with `vx.cache.source`), and for a task the runner
122
- measured `vx.task.cpu_time` (ms) and `vx.task.peak_memory` (bytes); a
123
- 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
124
143
  each second: `vx.task.cpu_usage` (cores busy since the last sample, 1 =
125
144
  one core) and `vx.task.memory` (resident bytes). Sampling runs only while
126
145
  metrics export; a remote task, or a task under 1 s, has no samples.
127
146
 
128
147
  **Logs per run** (on by default): the captured output tail of each executed
129
- task, as one log record linked to its task span (unlinked when traces are
130
- 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
131
150
  secrets, and this sends it to the collector; `logs: false` or
132
151
  `OTEL_LOGS_EXPORTER=none` turns it off.
133
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
+
134
176
  ## Behavior note
135
177
 
136
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.519",
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.519"
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
  /**
@@ -126,6 +131,17 @@ const VX_ATTR = {
126
131
  taskOutputs: 'vx.task.outputs',
127
132
  peakRssBytes: 'vx.peak_rss_bytes',
128
133
  taskAttempts: 'vx.task.attempts',
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',
144
+ stageName: 'vx.stage.name',
129
145
  wallclockStartNs: 'vx.task.wallclock_start_ns',
130
146
  wallclockEndNs: 'vx.task.wallclock_end_ns',
131
147
  // task log records (the OTel Logs signal)
@@ -151,6 +167,95 @@ export interface OtlpSpan {
151
167
  endTimeUnixNano: string
152
168
  attributes: KeyValue[]
153
169
  status: { code: number }
170
+ events?: OtlpSpanEvent[]
171
+ links?: OtlpSpanLink[]
172
+ }
173
+
174
+ export interface OtlpSpanEvent {
175
+ timeUnixNano: string
176
+ name: string
177
+ attributes: KeyValue[]
178
+ }
179
+
180
+ export interface OtlpSpanLink {
181
+ traceId: string
182
+ spanId: string
183
+ attributes: KeyValue[]
184
+ }
185
+
186
+ /**
187
+ * A task span's events: each attempt that failed and was run again
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).
191
+ */
192
+ export function taskSpanEvents(t: TaskTelemetry, endUnixNano: string): OtlpSpanEvent[] {
193
+ const events: OtlpSpanEvent[] = (t.failedAttempts ?? []).map((a, i) => ({
194
+ timeUnixNano: String(Math.trunc(a.endedAt) * 1_000_000),
195
+ name: 'vx.task.retry',
196
+ attributes: [
197
+ intAttr(VX_ATTR.taskAttempt, i + 1),
198
+ intAttr(VX_ATTR.taskExitCode, a.exitCode),
199
+ ...(a.timedOut === true ? [boolAttr(VX_ATTR.taskTimedOut, true)] : []),
200
+ ],
201
+ }))
202
+ if (t.timedOut === true) {
203
+ events.push({ timeUnixNano: endUnixNano, name: 'vx.task.timeout', attributes: [] })
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
+ }
220
+ return events
221
+ }
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
+
226
+ /**
227
+ * A child span of the run for one of its stages (`VX_TIMING`'s marks):
228
+ * where the time before the first task and after the last one went. Named
229
+ * by the stage, a fixed set. The stages ahead of the run lock end before
230
+ * the run span starts.
231
+ */
232
+ export function stageSpan(
233
+ traceId: string,
234
+ parentSpanId: string,
235
+ stage: { name: string; startedAt: number; endedAt: number },
236
+ spanId: string,
237
+ ): OtlpSpan {
238
+ return {
239
+ traceId,
240
+ spanId,
241
+ parentSpanId,
242
+ name: stage.name,
243
+ kind: SPAN_KIND_INTERNAL,
244
+ startTimeUnixNano: exactNanos(stage.startedAt),
245
+ endTimeUnixNano: exactNanos(stage.endedAt),
246
+ attributes: [strAttr(VX_ATTR.stageName, stage.name)],
247
+ status: { code: STATUS_UNSET },
248
+ }
249
+ }
250
+
251
+ /** Epoch ms with a fraction, as OTLP's int64 nanoseconds: a stage can be under 1 ms. */
252
+ function exactNanos(ms: number): string {
253
+ return String(BigInt(Math.round(ms * 1000)) * 1000n)
254
+ }
255
+
256
+ /** A link from a task span to the span of a task it waited on. */
257
+ export function dependencyLink(traceId: string, spanId: string, taskId: string): OtlpSpanLink {
258
+ return { traceId, spanId, attributes: [strAttr(SEMCONV.taskName, taskId)] }
154
259
  }
155
260
 
156
261
  // --- attribute mapping (pure) ------------------------------------------
@@ -175,6 +280,24 @@ function resourceAttributes(
175
280
  ]
176
281
  }
177
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
+
178
301
  /**
179
302
  * Attributes for the root `vx.run` span.
180
303
  *
@@ -253,6 +376,8 @@ interface TaskSpanRunContext {
253
376
  workspaceId: string
254
377
  /** The run's canonical start (epoch ms) — the storage key's base. */
255
378
  startedAt: number
379
+ /** The task's command, from its start record; a task that never started has none. */
380
+ command?: string
256
381
  }
257
382
 
258
383
  /**
@@ -283,8 +408,8 @@ function runResult(t: TaskTelemetry): string {
283
408
  * `started_at` from them is computing a dedup key that must match the value
284
409
  * the native ingest path derives, to the millisecond.
285
410
  *
286
- * Nothing here is unbounded: every value is an id, an enum or a number, so a
287
- * 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
288
413
  * exporter ships is a task's captured tail, which travels as a LOG record
289
414
  * (`vx.log.*`) carrying its own full length, so a cut there is visible.
290
415
  */
@@ -325,6 +450,20 @@ export function taskSpanAttributes(t: TaskTelemetry, run: TaskSpanRunContext): K
325
450
  if (t.outputs !== undefined) attrs.push(strAttr(VX_ATTR.taskOutputs, t.outputs))
326
451
  if (t.peakRssBytes !== undefined) attrs.push(intAttr(VX_ATTR.peakRssBytes, t.peakRssBytes))
327
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))
328
467
  if (t.wallclockStartNs !== undefined)
329
468
  attrs.push(int64Attr(VX_ATTR.wallclockStartNs, t.wallclockStartNs))
330
469
  if (t.wallclockEndNs !== undefined)
@@ -411,6 +550,11 @@ export function buildMetricsRequest(
411
550
  ]),
412
551
  sum('vx.tasks.cache_up_to_date', [point(summary.upToDateCount)]),
413
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
+ ),
414
558
  ...taskMetrics(points),
415
559
  ],
416
560
  },
@@ -424,7 +568,7 @@ export function buildMetricsRequest(
424
568
  * A per-task metric point: a task's totals at its end, or one live sample
425
569
  * of its process tree while it runs.
426
570
  */
427
- export type TaskMetricPoint =
571
+ export type TaskMetricPoint = (
428
572
  | { kind: 'end'; task: TaskTelemetry; startUnixNano: string; endUnixNano: string }
429
573
  | {
430
574
  kind: 'sample'
@@ -436,6 +580,10 @@ export type TaskMetricPoint =
436
580
  cpuUsage: number
437
581
  rssBytes: number
438
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
+ }
439
587
 
440
588
  /**
441
589
  * An ExportMetricsServiceRequest of per-task gauges alone: the points past
@@ -460,9 +608,11 @@ export function buildTaskMetricsRequest(
460
608
  /**
461
609
  * Per-task gauges, keyed by task. At each task's end: its duration, CPU time and peak memory (CPU and memory as
462
610
  * the runner measured them at exit; a task it did not measure, a cache hit or
463
- * 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
464
613
  * tree's CPU usage and memory, once per sample. A task's numbers otherwise
465
- * 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.
466
616
  */
467
617
  function taskMetrics(points: readonly TaskMetricPoint[]): unknown[] {
468
618
  const series = new Map<string, { unit: string; dataPoints: unknown[] }>()
@@ -476,12 +626,17 @@ function taskMetrics(points: readonly TaskMetricPoint[]): unknown[] {
476
626
  strAttr(VX_ATTR.taskProject, project),
477
627
  strAttr(VX_ATTR.taskTask, task),
478
628
  ]
629
+ const exemplars = (p: TaskMetricPoint, value: number, timeUnixNano: string) =>
630
+ p.span === undefined
631
+ ? {}
632
+ : { exemplars: [{ timeUnixNano, asDouble: value, filteredAttributes: [], ...p.span }] }
479
633
  for (const p of points) {
480
634
  if (p.kind === 'sample') {
481
635
  const at = (value: number) => ({
482
636
  asDouble: value,
483
637
  timeUnixNano: p.timeUnixNano,
484
638
  attributes: taskAttrs(p.taskId, p.project, p.task),
639
+ ...exemplars(p, value, p.timeUnixNano),
485
640
  })
486
641
  add('vx.task.cpu_usage', '1', at(p.cpuUsage))
487
642
  add('vx.task.memory', 'By', at(p.rssBytes))
@@ -496,10 +651,13 @@ function taskMetrics(points: readonly TaskMetricPoint[]): unknown[] {
496
651
  ...taskAttrs(t.taskId, t.project, t.task),
497
652
  strAttr(VX_ATTR.cacheSource, t.cacheSource),
498
653
  ],
654
+ ...exemplars(p, value, p.endUnixNano),
499
655
  })
500
656
  add('vx.task.duration', 'ms', at(t.durationMs))
501
657
  if (t.cpuMs !== undefined) add('vx.task.cpu_time', 'ms', at(t.cpuMs))
502
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))
503
661
  }
504
662
  return [...series].map(([name, { unit, dataPoints }]) => ({ name, unit, gauge: { dataPoints } }))
505
663
  }
@@ -545,6 +703,8 @@ export function buildLogsRequest(args: {
545
703
  workspaceId: string
546
704
  entries: readonly TaskLogEntry[]
547
705
  timeUnixNano: string
706
+ /** When the task ended; `timeUnixNano` for one with no known end. */
707
+ timeFor?: (taskId: string) => string | undefined
548
708
  traceId?: string
549
709
  spanIdFor?: (taskId: string) => string | undefined
550
710
  resource?: Readonly<Record<string, string>>
@@ -561,8 +721,9 @@ export function buildLogsRequest(args: {
561
721
  ]
562
722
  if (e.hash !== undefined) attrs.push(strAttr(VX_ATTR.taskHash, e.hash))
563
723
  const spanId = args.spanIdFor?.(e.taskId)
724
+ const time = args.timeFor?.(e.taskId) ?? args.timeUnixNano
564
725
  return {
565
- timeUnixNano: args.timeUnixNano,
726
+ timeUnixNano: time,
566
727
  observedTimeUnixNano: args.timeUnixNano,
567
728
  severityNumber: failed ? SEVERITY_ERROR : SEVERITY_INFO,
568
729
  severityText: failed ? 'ERROR' : 'INFO',
@@ -583,3 +744,53 @@ export function buildLogsRequest(args: {
583
744
  ],
584
745
  }
585
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,15 +13,22 @@ 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,
29
+ dependencyLink,
30
+ stageSpan,
31
+ taskSpanEvents,
25
32
  runStatusCode,
26
33
  taskSpanAttributes,
27
34
  taskStatusCode,
@@ -62,6 +69,8 @@ export interface OtelSinkConfig {
62
69
  gzip?: readonly OtelSignal[]
63
70
  /** Per signal: a collector's CA, and a client certificate for mutual TLS. */
64
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
65
74
  /** False under `OTEL_TRACES_EXPORTER=none`; absent is true. */
66
75
  tracesEnabled?: boolean
67
76
  metricsEnabled: boolean
@@ -247,7 +256,7 @@ function partialSuccess(body: string): string | undefined {
247
256
  }
248
257
 
249
258
  function genId(bytes: number): string {
250
- return randomBytes(bytes).toString('hex')
259
+ return Buffer.from(crypto.getRandomValues(new Uint8Array(bytes))).toString('hex')
251
260
  }
252
261
 
253
262
  /** ms → unix-nano decimal string (OTLP int64-as-string). */
@@ -276,6 +285,16 @@ export class OtelSink implements TelemetrySink {
276
285
  private readonly lastSample = new Map<string, { ts: number; cpuMs: number }>()
277
286
  private readonly taskSpanId = new Map<string, string>()
278
287
  private readonly taskStartNano = new Map<string, string>()
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>()
279
298
  private summary: RunSummaryRecord | undefined
280
299
  private uploaded = false
281
300
  // Core's own bounded capture buffer, so which task's output survives a
@@ -298,6 +317,7 @@ export class OtelSink implements TelemetrySink {
298
317
  tls: config.tls ?? {},
299
318
  headers: config.headers,
300
319
  signalHeaders: config.signalHeaders ?? {},
320
+ live: config.live === true,
301
321
  tracesEnabled: config.tracesEnabled !== false,
302
322
  metricsEnabled: config.metricsEnabled,
303
323
  logsEnabled: config.logsEnabled,
@@ -327,16 +347,34 @@ export class OtelSink implements TelemetrySink {
327
347
  // it is what the summary reports and what a receiver stores.
328
348
  this.rootStartNano = nanos(record.startedAt)
329
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
+ })
330
356
  return
331
- case 'task.start':
332
- this.taskSpanId.set(record.taskId, genId(8))
357
+ case 'task.start': {
358
+ const spanId = genId(8)
359
+ this.taskSpanId.set(record.taskId, spanId)
333
360
  this.taskStartNano.set(record.taskId, nanos(record.ts))
361
+ if (record.command !== undefined) this.taskCommand.set(record.taskId, record.command)
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
+ })
334
371
  this.taskNames.set(record.taskId, {
335
372
  project: record.project,
336
373
  task: record.task,
337
374
  startMs: record.ts,
338
375
  })
339
376
  return
377
+ }
340
378
  case 'task.sample': {
341
379
  // Usage is the CPU the tree spent since the last look, over the
342
380
  // wall time between: the first look counts from the task's start.
@@ -355,14 +393,27 @@ export class OtelSink implements TelemetrySink {
355
393
  timeUnixNano: nanos(record.ts),
356
394
  cpuUsage,
357
395
  rssBytes: record.rssBytes,
396
+ ...this.spanOf(record.taskId),
358
397
  })
398
+ this.kick()
359
399
  return
360
400
  }
361
401
  case 'task.log':
362
402
  this.logs.append(record.taskId, record.chunk)
363
403
  return
364
404
  case 'task.end': {
365
- const spanId = this.taskSpanId.get(record.taskId) ?? genId(8)
405
+ // A task that never started (a skip) still gets a span, and a later
406
+ // task that waited on it links to it.
407
+ let spanId = this.taskSpanId.get(record.taskId)
408
+ if (spanId === undefined) {
409
+ spanId = genId(8)
410
+ this.taskSpanId.set(record.taskId, spanId)
411
+ }
412
+ const links = (this.taskDeps.get(record.taskId) ?? []).flatMap((dep) => {
413
+ const depSpan = this.taskSpanId.get(dep)
414
+ return depSpan === undefined ? [] : [dependencyLink(this.traceId, depSpan, dep)]
415
+ })
416
+ const events = taskSpanEvents(record, nanos(record.ts))
366
417
  const startNano =
367
418
  this.taskStartNano.get(record.taskId) ?? nanos(record.ts - record.durationMs)
368
419
  const t: TaskTelemetry = record
@@ -378,8 +429,13 @@ export class OtelSink implements TelemetrySink {
378
429
  runId: this.runId,
379
430
  workspaceId: this.run?.workspaceId ?? '',
380
431
  startedAt: this.runStartedAt,
432
+ ...(this.taskCommand.has(record.taskId)
433
+ ? { command: this.taskCommand.get(record.taskId)! }
434
+ : {}),
381
435
  }),
382
436
  status: { code: taskStatusCode(t) },
437
+ ...(events.length > 0 ? { events } : {}),
438
+ ...(links.length > 0 ? { links } : {}),
383
439
  })
384
440
  // A skipped task never ran: a zero duration would read as a fast one.
385
441
  if (record.status !== 'skipped') {
@@ -388,14 +444,21 @@ export class OtelSink implements TelemetrySink {
388
444
  task: t,
389
445
  startUnixNano: startNano,
390
446
  endUnixNano: nanos(record.ts),
447
+ ...this.spanOf(record.taskId),
391
448
  })
392
449
  }
393
450
  this.lastSample.delete(record.taskId)
451
+ this.taskEndNano.set(record.taskId, nanos(record.ts))
394
452
  // Decides retention: a cache hit's bytes belong to the run that
395
453
  // executed them, so only an executed success/failure keeps a tail.
396
454
  if (this.cfg.logsEnabled) {
397
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
+ }
398
460
  }
461
+ this.kick()
399
462
  return
400
463
  }
401
464
  case 'run.end':
@@ -407,6 +470,79 @@ export class OtelSink implements TelemetrySink {
407
470
  /** Core's flush deadline, passed to every POST. */
408
471
  private deadline: AbortSignal | undefined
409
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
+
410
546
  onRunSummary(summary: RunSummaryRecord): void {
411
547
  this.summary = summary
412
548
  }
@@ -415,6 +551,11 @@ export class OtelSink implements TelemetrySink {
415
551
  if (this.uploaded) return
416
552
  this.uploaded = true
417
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
+ }
418
559
  // Finalize the root span now that the run is over (run.end set the end).
419
560
  if (this.run !== undefined && this.traceId) {
420
561
  // Prefer the summary's own start/end: they are the run's canonical
@@ -437,17 +578,25 @@ export class OtelSink implements TelemetrySink {
437
578
  attributes: runSpanAttributes(this.run, this.summary),
438
579
  status: { code: runStatusCode(this.summary) },
439
580
  })
581
+ const stages = (this.summary?.stages ?? []).map((stage) =>
582
+ stageSpan(this.traceId, this.rootSpanId, stage, genId(8)),
583
+ )
584
+ this.spans.splice(1, 0, ...stages)
440
585
  }
441
- const vxVersion = this.run?.vxVersion ?? '0.0.0'
442
- await Promise.all([this.shipTraces(vxVersion), this.shipMetrics(), this.shipLogs(vxVersion)])
586
+ await Promise.all([this.shipTraces(), this.shipMetrics(), this.shipLogs()])
443
587
  }
444
588
 
445
- private async shipTraces(vxVersion: string): Promise<void> {
446
- if (this.cfg.tracesEnabled === false || this.spans.length === 0) return
447
- const bodies = requestBodies(this.spans, (spans) =>
448
- JSON.stringify(buildTraceRequest(this.cfg.serviceName, vxVersion, spans, this.cfg.resource)),
589
+ private async shipTraces(): Promise<void> {
590
+ await this.shipSpans(this.spans, false)
591
+ }
592
+
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)),
449
598
  )
450
- await this.send('traces', this.cfg.tracesUrl, bodies)
599
+ await this.send('traces', this.cfg.tracesUrl, bodies, live)
451
600
  }
452
601
 
453
602
  private async shipMetrics(): Promise<void> {
@@ -463,12 +612,7 @@ export class OtelSink implements TelemetrySink {
463
612
  points[0] === first
464
613
  ? this.runMetricsBody(summary, points)
465
614
  : JSON.stringify(
466
- buildTaskMetricsRequest(
467
- this.cfg.serviceName,
468
- vxVersion,
469
- points,
470
- this.cfg.resource,
471
- ),
615
+ buildTaskMetricsRequest(this.cfg.serviceName, vxVersion, points, this.resource),
472
616
  ),
473
617
  )
474
618
  await this.send('metrics', this.cfg.metricsUrl, bodies)
@@ -481,27 +625,32 @@ export class OtelSink implements TelemetrySink {
481
625
  summary,
482
626
  nanos(summary.endedAt),
483
627
  nanos(summary.startedAt),
484
- this.cfg.resource,
628
+ this.resource,
485
629
  points,
486
630
  ),
487
631
  )
488
632
  }
489
633
 
490
- private async shipLogs(vxVersion: string): Promise<void> {
634
+ private async shipLogs(): Promise<void> {
491
635
  if (!this.cfg.logsEnabled) return
492
- const workspaceId = this.run?.workspaceId ?? ''
493
- const bundle = this.logs.drain(this.runId, workspaceId)
636
+ const bundle = this.logs.drain(this.runId, this.run?.workspaceId ?? '')
494
637
  if (bundle.tasks.length === 0) return
495
- 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) =>
496
644
  JSON.stringify(
497
645
  buildLogsRequest({
498
646
  serviceName: this.cfg.serviceName,
499
- resource: this.cfg.resource,
500
- vxVersion,
647
+ resource: this.resource,
648
+ vxVersion: this.run?.vxVersion ?? '0.0.0',
501
649
  runId: this.runId,
502
- workspaceId,
650
+ workspaceId: this.run?.workspaceId ?? '',
503
651
  entries,
504
- timeUnixNano: nanos(this.summary?.endedAt ?? Date.now()),
652
+ timeUnixNano: now,
653
+ timeFor: (taskId) => this.taskEndNano.get(taskId),
505
654
  // Traces off: the trace and its spans are never exported, and a
506
655
  // record naming them links nowhere (F-49).
507
656
  ...(this.cfg.tracesEnabled && this.traceId
@@ -510,7 +659,6 @@ export class OtelSink implements TelemetrySink {
510
659
  }),
511
660
  ),
512
661
  )
513
- await this.send('logs', this.cfg.logsUrl, bodies)
514
662
  }
515
663
 
516
664
  /** The transport, under the signal's own timeout when it has one (F-49). */
@@ -519,8 +667,16 @@ export class OtelSink implements TelemetrySink {
519
667
  return own === undefined || this.injected ? this.cfg.post : defaultPost(own)
520
668
  }
521
669
 
522
- /** POSTs every body at once; one warning per signal, however many fail. */
523
- 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> {
524
680
  const gzip = this.cfg.gzip.includes(signal)
525
681
  const headers: Record<string, string> = {
526
682
  'content-type': 'application/json',
@@ -545,6 +701,10 @@ export class OtelSink implements TelemetrySink {
545
701
  ).filter((m) => m !== undefined)
546
702
  // export is fully optional — a down collector never affects a run
547
703
  if (failed.length === 0) return
704
+ if (once) {
705
+ if (this.warnedLive.has(signal)) return
706
+ this.warnedLive.add(signal)
707
+ }
548
708
  // Name the URL: three signals ship concurrently and each is caught
549
709
  // here on its own, so a bare "export failed" cannot tell a down
550
710
  // collector from one misconfigured signal endpoint.