@vzn/vx-otel 0.0.518 → 0.0.520

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
@@ -105,7 +105,17 @@ otel({
105
105
  a sandboxed one its violation count (`vx.task.sandbox_violations`), on a
106
106
  persistent one that never became ready why (`vx.task.not_ready`). A
107
107
  failed task sets span status
108
- `ERROR`.
108
+ `ERROR`. A task span links to the spans of the tasks it waited on (a
109
+ group seen through to the tasks behind it), and carries an event per
110
+ 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`);
113
+ - a child span per stage of the run, named by the stage (`startup`,
114
+ `load configs`, `git enumeration`, `classify + probe`, `run graph`,
115
+ `record history`, …, the stages `VX_TIMING` prints), with
116
+ `vx.stage.name`: where the time before the first task and after the
117
+ last went. The stages ahead of the run lock end before the `vx.run`
118
+ span starts.
109
119
 
110
120
  **Metrics per run**: `vx.tasks.total`, `vx.tasks.failed`,
111
121
  `vx.tasks.cache_hits{source=local|remote}`, what those hits did to the disk as
@@ -116,6 +126,15 @@ and the `vx.run.duration_ms` gauge. The `vx.run` span carries the same counts
116
126
  The counts are DELTA sums over the run's own interval (start to end), so a
117
127
  backend adds runs rather than reading each as the series' new total.
118
128
 
129
+ **Metrics per task**, as gauges keyed by `cicd.pipeline.task.name`,
130
+ `vx.task.project` and `vx.task.task`: at each task's end
131
+ `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
134
+ each second: `vx.task.cpu_usage` (cores busy since the last sample, 1 =
135
+ one core) and `vx.task.memory` (resident bytes). Sampling runs only while
136
+ metrics export; a remote task, or a task under 1 s, has no samples.
137
+
119
138
  **Logs per run** (on by default): the captured output tail of each executed
120
139
  task, as one log record linked to its task span (unlinked when traces are
121
140
  off: the span is never exported). Build output can hold
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx-otel",
3
- "version": "0.0.518",
3
+ "version": "0.0.520",
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.518"
37
+ "@vzn/vx": "^0.0.520"
38
38
  },
39
39
  "repository": {
40
40
  "type": "git",
package/src/otlp.ts CHANGED
@@ -6,7 +6,9 @@
6
6
  // of SDK-version drift. Maps a vx run to:
7
7
  // - a TRACE: one root `vx.run` span + one child `vx.task` span per task,
8
8
  // with CI/CD + VCS semantic-convention attributes;
9
- // - METRICS: task/run counters + a run-duration gauge;
9
+ // - METRICS: task/run counters + a run-duration gauge; per task its
10
+ // duration, CPU time and peak memory, and while it runs its process
11
+ // tree's CPU usage and memory, sampled each second;
10
12
  // - LOGS: one record per executed task carrying its captured output tail.
11
13
  //
12
14
  // References: OpenTelemetry CI/CD + VCS semantic conventions; OTLP/JSON
@@ -124,6 +126,8 @@ const VX_ATTR = {
124
126
  taskOutputs: 'vx.task.outputs',
125
127
  peakRssBytes: 'vx.peak_rss_bytes',
126
128
  taskAttempts: 'vx.task.attempts',
129
+ taskAttempt: 'vx.task.attempt',
130
+ stageName: 'vx.stage.name',
127
131
  wallclockStartNs: 'vx.task.wallclock_start_ns',
128
132
  wallclockEndNs: 'vx.task.wallclock_end_ns',
129
133
  // task log records (the OTel Logs signal)
@@ -149,6 +153,76 @@ export interface OtlpSpan {
149
153
  endTimeUnixNano: string
150
154
  attributes: KeyValue[]
151
155
  status: { code: number }
156
+ events?: OtlpSpanEvent[]
157
+ links?: OtlpSpanLink[]
158
+ }
159
+
160
+ export interface OtlpSpanEvent {
161
+ timeUnixNano: string
162
+ name: string
163
+ attributes: KeyValue[]
164
+ }
165
+
166
+ export interface OtlpSpanLink {
167
+ traceId: string
168
+ spanId: string
169
+ attributes: KeyValue[]
170
+ }
171
+
172
+ /**
173
+ * 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).
176
+ */
177
+ export function taskSpanEvents(t: TaskTelemetry, endUnixNano: string): OtlpSpanEvent[] {
178
+ const events: OtlpSpanEvent[] = (t.failedAttempts ?? []).map((a, i) => ({
179
+ timeUnixNano: String(Math.trunc(a.endedAt) * 1_000_000),
180
+ name: 'vx.task.retry',
181
+ attributes: [
182
+ intAttr(VX_ATTR.taskAttempt, i + 1),
183
+ intAttr(VX_ATTR.taskExitCode, a.exitCode),
184
+ ...(a.timedOut === true ? [boolAttr(VX_ATTR.taskTimedOut, true)] : []),
185
+ ],
186
+ }))
187
+ if (t.timedOut === true) {
188
+ events.push({ timeUnixNano: endUnixNano, name: 'vx.task.timeout', attributes: [] })
189
+ }
190
+ return events
191
+ }
192
+
193
+ /**
194
+ * A child span of the run for one of its stages (`VX_TIMING`'s marks):
195
+ * where the time before the first task and after the last one went. Named
196
+ * by the stage, a fixed set. The stages ahead of the run lock end before
197
+ * the run span starts.
198
+ */
199
+ export function stageSpan(
200
+ traceId: string,
201
+ parentSpanId: string,
202
+ stage: { name: string; startedAt: number; endedAt: number },
203
+ spanId: string,
204
+ ): OtlpSpan {
205
+ return {
206
+ traceId,
207
+ spanId,
208
+ parentSpanId,
209
+ name: stage.name,
210
+ kind: SPAN_KIND_INTERNAL,
211
+ startTimeUnixNano: exactNanos(stage.startedAt),
212
+ endTimeUnixNano: exactNanos(stage.endedAt),
213
+ attributes: [strAttr(VX_ATTR.stageName, stage.name)],
214
+ status: { code: STATUS_UNSET },
215
+ }
216
+ }
217
+
218
+ /** Epoch ms with a fraction, as OTLP's int64 nanoseconds: a stage can be under 1 ms. */
219
+ function exactNanos(ms: number): string {
220
+ return String(BigInt(Math.round(ms * 1000)) * 1000n)
221
+ }
222
+
223
+ /** A link from a task span to the span of a task it waited on. */
224
+ export function dependencyLink(traceId: string, spanId: string, taskId: string): OtlpSpanLink {
225
+ return { traceId, spanId, attributes: [strAttr(SEMCONV.taskName, taskId)] }
152
226
  }
153
227
 
154
228
  // --- attribute mapping (pure) ------------------------------------------
@@ -367,6 +441,7 @@ export function buildMetricsRequest(
367
441
  nowUnixNano: string,
368
442
  startUnixNano: string,
369
443
  resource?: Readonly<Record<string, string>>,
444
+ points: readonly TaskMetricPoint[] = [],
370
445
  ): unknown {
371
446
  const point = (value: number, attrs: KeyValue[] = []) => ({
372
447
  asInt: String(value),
@@ -408,6 +483,7 @@ export function buildMetricsRequest(
408
483
  ]),
409
484
  sum('vx.tasks.cache_up_to_date', [point(summary.upToDateCount)]),
410
485
  gauge('vx.run.duration_ms', summary.totalDurationMs),
486
+ ...taskMetrics(points),
411
487
  ],
412
488
  },
413
489
  ],
@@ -416,6 +492,90 @@ export function buildMetricsRequest(
416
492
  }
417
493
  }
418
494
 
495
+ /**
496
+ * A per-task metric point: a task's totals at its end, or one live sample
497
+ * of its process tree while it runs.
498
+ */
499
+ export type TaskMetricPoint =
500
+ | { kind: 'end'; task: TaskTelemetry; startUnixNano: string; endUnixNano: string }
501
+ | {
502
+ kind: 'sample'
503
+ taskId: string
504
+ project: string
505
+ task: string
506
+ timeUnixNano: string
507
+ /** CPU cores busy since the previous sample (1 = one core). */
508
+ cpuUsage: number
509
+ rssBytes: number
510
+ }
511
+
512
+ /**
513
+ * An ExportMetricsServiceRequest of per-task gauges alone: the points past
514
+ * what fit beside the run's own metrics (`buildMetricsRequest`).
515
+ */
516
+ export function buildTaskMetricsRequest(
517
+ serviceName: string,
518
+ vxVersion: string,
519
+ points: readonly TaskMetricPoint[],
520
+ resource?: Readonly<Record<string, string>>,
521
+ ): unknown {
522
+ return {
523
+ resourceMetrics: [
524
+ {
525
+ resource: { attributes: resourceAttributes(serviceName, vxVersion, resource) },
526
+ scopeMetrics: [{ scope: { name: 'vx', version: vxVersion }, metrics: taskMetrics(points) }],
527
+ },
528
+ ],
529
+ }
530
+ }
531
+
532
+ /**
533
+ * Per-task gauges, keyed by task. At each task's end: its duration, CPU time and peak memory (CPU and memory as
534
+ * 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
536
+ * 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.
538
+ */
539
+ function taskMetrics(points: readonly TaskMetricPoint[]): unknown[] {
540
+ const series = new Map<string, { unit: string; dataPoints: unknown[] }>()
541
+ const add = (name: string, unit: string, point: unknown): void => {
542
+ const s = series.get(name)
543
+ if (s === undefined) series.set(name, { unit, dataPoints: [point] })
544
+ else s.dataPoints.push(point)
545
+ }
546
+ const taskAttrs = (taskId: string, project: string, task: string): KeyValue[] => [
547
+ strAttr(SEMCONV.taskName, taskId),
548
+ strAttr(VX_ATTR.taskProject, project),
549
+ strAttr(VX_ATTR.taskTask, task),
550
+ ]
551
+ for (const p of points) {
552
+ if (p.kind === 'sample') {
553
+ const at = (value: number) => ({
554
+ asDouble: value,
555
+ timeUnixNano: p.timeUnixNano,
556
+ attributes: taskAttrs(p.taskId, p.project, p.task),
557
+ })
558
+ add('vx.task.cpu_usage', '1', at(p.cpuUsage))
559
+ add('vx.task.memory', 'By', at(p.rssBytes))
560
+ continue
561
+ }
562
+ const t = p.task
563
+ const at = (value: number) => ({
564
+ asDouble: value,
565
+ startTimeUnixNano: p.startUnixNano,
566
+ timeUnixNano: p.endUnixNano,
567
+ attributes: [
568
+ ...taskAttrs(t.taskId, t.project, t.task),
569
+ strAttr(VX_ATTR.cacheSource, t.cacheSource),
570
+ ],
571
+ })
572
+ add('vx.task.duration', 'ms', at(t.durationMs))
573
+ if (t.cpuMs !== undefined) add('vx.task.cpu_time', 'ms', at(t.cpuMs))
574
+ if (t.peakRssBytes !== undefined) add('vx.task.peak_memory', 'By', at(t.peakRssBytes))
575
+ }
576
+ return [...series].map(([name, { unit, dataPoints }]) => ({ name, unit, gauge: { dataPoints } }))
577
+ }
578
+
419
579
  // --- logs ---------------------------------------------------------------
420
580
 
421
581
  // OTLP severity numbers: 9 INFO, 17 ERROR.
package/src/sink.ts CHANGED
@@ -16,10 +16,15 @@ import type {
16
16
  import {
17
17
  buildLogsRequest,
18
18
  buildMetricsRequest,
19
+ buildTaskMetricsRequest,
19
20
  buildTraceRequest,
20
21
  type OtlpSpan,
22
+ type TaskMetricPoint,
21
23
  runSpanAttributes,
22
24
  SPAN_KIND_INTERNAL,
25
+ dependencyLink,
26
+ stageSpan,
27
+ taskSpanEvents,
23
28
  runStatusCode,
24
29
  taskSpanAttributes,
25
30
  taskStatusCode,
@@ -269,8 +274,12 @@ export class OtelSink implements TelemetrySink {
269
274
  private rootStartNano = '0'
270
275
  private rootEndNano = '0'
271
276
  private readonly spans: OtlpSpan[] = []
277
+ private readonly taskPoints: TaskMetricPoint[] = []
278
+ private readonly taskNames = new Map<string, { project: string; task: string; startMs: number }>()
279
+ private readonly lastSample = new Map<string, { ts: number; cpuMs: number }>()
272
280
  private readonly taskSpanId = new Map<string, string>()
273
281
  private readonly taskStartNano = new Map<string, string>()
282
+ private readonly taskDeps = new Map<string, readonly string[]>()
274
283
  private summary: RunSummaryRecord | undefined
275
284
  private uploaded = false
276
285
  // Core's own bounded capture buffer, so which task's output survives a
@@ -301,9 +310,14 @@ export class OtelSink implements TelemetrySink {
301
310
  post: config.post ?? defaultPost(config.timeoutMs),
302
311
  ...(config.warn ? { warn: config.warn } : {}),
303
312
  }
304
- this.wants = config.logsEnabled
305
- ? ['run.start', 'task.start', 'task.log', 'task.end', 'run.end']
306
- : ['run.start', 'task.start', 'task.end', 'run.end']
313
+ this.wants = [
314
+ 'run.start',
315
+ 'task.start',
316
+ ...(config.logsEnabled ? (['task.log'] as const) : []),
317
+ ...(config.metricsEnabled ? (['task.sample'] as const) : []),
318
+ 'task.end',
319
+ 'run.end',
320
+ ]
307
321
  }
308
322
 
309
323
  onRecord(record: TelemetryRecord): void {
@@ -321,12 +335,50 @@ export class OtelSink implements TelemetrySink {
321
335
  case 'task.start':
322
336
  this.taskSpanId.set(record.taskId, genId(8))
323
337
  this.taskStartNano.set(record.taskId, nanos(record.ts))
338
+ if (record.dependsOn !== undefined) this.taskDeps.set(record.taskId, record.dependsOn)
339
+ this.taskNames.set(record.taskId, {
340
+ project: record.project,
341
+ task: record.task,
342
+ startMs: record.ts,
343
+ })
344
+ return
345
+ case 'task.sample': {
346
+ // Usage is the CPU the tree spent since the last look, over the
347
+ // wall time between: the first look counts from the task's start.
348
+ const names = this.taskNames.get(record.taskId)
349
+ if (names === undefined) return
350
+ const prev = this.lastSample.get(record.taskId) ?? { ts: names.startMs, cpuMs: 0 }
351
+ const wallMs = record.ts - prev.ts
352
+ // A descendant that exited takes its CPU out of the sum: no negative usage.
353
+ const cpuUsage = wallMs > 0 ? Math.max(0, record.cpuMs - prev.cpuMs) / wallMs : 0
354
+ this.lastSample.set(record.taskId, { ts: record.ts, cpuMs: record.cpuMs })
355
+ this.taskPoints.push({
356
+ kind: 'sample',
357
+ taskId: record.taskId,
358
+ project: names.project,
359
+ task: names.task,
360
+ timeUnixNano: nanos(record.ts),
361
+ cpuUsage,
362
+ rssBytes: record.rssBytes,
363
+ })
324
364
  return
365
+ }
325
366
  case 'task.log':
326
367
  this.logs.append(record.taskId, record.chunk)
327
368
  return
328
369
  case 'task.end': {
329
- const spanId = this.taskSpanId.get(record.taskId) ?? genId(8)
370
+ // A task that never started (a skip) still gets a span, and a later
371
+ // task that waited on it links to it.
372
+ let spanId = this.taskSpanId.get(record.taskId)
373
+ if (spanId === undefined) {
374
+ spanId = genId(8)
375
+ this.taskSpanId.set(record.taskId, spanId)
376
+ }
377
+ const links = (this.taskDeps.get(record.taskId) ?? []).flatMap((dep) => {
378
+ const depSpan = this.taskSpanId.get(dep)
379
+ return depSpan === undefined ? [] : [dependencyLink(this.traceId, depSpan, dep)]
380
+ })
381
+ const events = taskSpanEvents(record, nanos(record.ts))
330
382
  const startNano =
331
383
  this.taskStartNano.get(record.taskId) ?? nanos(record.ts - record.durationMs)
332
384
  const t: TaskTelemetry = record
@@ -344,7 +396,19 @@ export class OtelSink implements TelemetrySink {
344
396
  startedAt: this.runStartedAt,
345
397
  }),
346
398
  status: { code: taskStatusCode(t) },
399
+ ...(events.length > 0 ? { events } : {}),
400
+ ...(links.length > 0 ? { links } : {}),
347
401
  })
402
+ // A skipped task never ran: a zero duration would read as a fast one.
403
+ if (record.status !== 'skipped') {
404
+ this.taskPoints.push({
405
+ kind: 'end',
406
+ task: t,
407
+ startUnixNano: startNano,
408
+ endUnixNano: nanos(record.ts),
409
+ })
410
+ }
411
+ this.lastSample.delete(record.taskId)
348
412
  // Decides retention: a cache hit's bytes belong to the run that
349
413
  // executed them, so only an executed success/failure keeps a tail.
350
414
  if (this.cfg.logsEnabled) {
@@ -391,6 +455,10 @@ export class OtelSink implements TelemetrySink {
391
455
  attributes: runSpanAttributes(this.run, this.summary),
392
456
  status: { code: runStatusCode(this.summary) },
393
457
  })
458
+ const stages = (this.summary?.stages ?? []).map((stage) =>
459
+ stageSpan(this.traceId, this.rootSpanId, stage, genId(8)),
460
+ )
461
+ this.spans.splice(1, 0, ...stages)
394
462
  }
395
463
  const vxVersion = this.run?.vxVersion ?? '0.0.0'
396
464
  await Promise.all([this.shipTraces(vxVersion), this.shipMetrics(), this.shipLogs(vxVersion)])
@@ -406,16 +474,39 @@ export class OtelSink implements TelemetrySink {
406
474
 
407
475
  private async shipMetrics(): Promise<void> {
408
476
  if (!this.cfg.metricsEnabled || this.summary === undefined) return
409
- const body = JSON.stringify(
477
+ const summary = this.summary
478
+ const vxVersion = this.run?.vxVersion ?? '0.0.0'
479
+ const first = this.taskPoints[0]
480
+ // The run's own metrics ride with the first batch of per-task points.
481
+ const bodies =
482
+ first === undefined
483
+ ? [this.runMetricsBody(summary, [])]
484
+ : requestBodies(this.taskPoints, (points) =>
485
+ points[0] === first
486
+ ? this.runMetricsBody(summary, points)
487
+ : JSON.stringify(
488
+ buildTaskMetricsRequest(
489
+ this.cfg.serviceName,
490
+ vxVersion,
491
+ points,
492
+ this.cfg.resource,
493
+ ),
494
+ ),
495
+ )
496
+ await this.send('metrics', this.cfg.metricsUrl, bodies)
497
+ }
498
+
499
+ private runMetricsBody(summary: RunSummaryRecord, points: TaskMetricPoint[]): string {
500
+ return JSON.stringify(
410
501
  buildMetricsRequest(
411
502
  this.cfg.serviceName,
412
- this.summary,
413
- nanos(this.summary.endedAt),
414
- nanos(this.summary.startedAt),
503
+ summary,
504
+ nanos(summary.endedAt),
505
+ nanos(summary.startedAt),
415
506
  this.cfg.resource,
507
+ points,
416
508
  ),
417
509
  )
418
- await this.send('metrics', this.cfg.metricsUrl, [body])
419
510
  }
420
511
 
421
512
  private async shipLogs(vxVersion: string): Promise<void> {