@vzn/vx-otel 0.0.517 → 0.0.519

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
@@ -116,6 +116,15 @@ and the `vx.run.duration_ms` gauge. The `vx.run` span carries the same counts
116
116
  The counts are DELTA sums over the run's own interval (start to end), so a
117
117
  backend adds runs rather than reading each as the series' new total.
118
118
 
119
+ **Metrics per task**, as gauges keyed by `cicd.pipeline.task.name`,
120
+ `vx.task.project` and `vx.task.task`: at each task's end
121
+ `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
124
+ each second: `vx.task.cpu_usage` (cores busy since the last sample, 1 =
125
+ one core) and `vx.task.memory` (resident bytes). Sampling runs only while
126
+ metrics export; a remote task, or a task under 1 s, has no samples.
127
+
119
128
  **Logs per run** (on by default): the captured output tail of each executed
120
129
  task, as one log record linked to its task span (unlinked when traces are
121
130
  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.517",
3
+ "version": "0.0.519",
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.517"
37
+ "@vzn/vx": "^0.0.519"
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
@@ -367,6 +369,7 @@ export function buildMetricsRequest(
367
369
  nowUnixNano: string,
368
370
  startUnixNano: string,
369
371
  resource?: Readonly<Record<string, string>>,
372
+ points: readonly TaskMetricPoint[] = [],
370
373
  ): unknown {
371
374
  const point = (value: number, attrs: KeyValue[] = []) => ({
372
375
  asInt: String(value),
@@ -408,6 +411,7 @@ export function buildMetricsRequest(
408
411
  ]),
409
412
  sum('vx.tasks.cache_up_to_date', [point(summary.upToDateCount)]),
410
413
  gauge('vx.run.duration_ms', summary.totalDurationMs),
414
+ ...taskMetrics(points),
411
415
  ],
412
416
  },
413
417
  ],
@@ -416,6 +420,90 @@ export function buildMetricsRequest(
416
420
  }
417
421
  }
418
422
 
423
+ /**
424
+ * A per-task metric point: a task's totals at its end, or one live sample
425
+ * of its process tree while it runs.
426
+ */
427
+ export type TaskMetricPoint =
428
+ | { kind: 'end'; task: TaskTelemetry; startUnixNano: string; endUnixNano: string }
429
+ | {
430
+ kind: 'sample'
431
+ taskId: string
432
+ project: string
433
+ task: string
434
+ timeUnixNano: string
435
+ /** CPU cores busy since the previous sample (1 = one core). */
436
+ cpuUsage: number
437
+ rssBytes: number
438
+ }
439
+
440
+ /**
441
+ * An ExportMetricsServiceRequest of per-task gauges alone: the points past
442
+ * what fit beside the run's own metrics (`buildMetricsRequest`).
443
+ */
444
+ export function buildTaskMetricsRequest(
445
+ serviceName: string,
446
+ vxVersion: string,
447
+ points: readonly TaskMetricPoint[],
448
+ resource?: Readonly<Record<string, string>>,
449
+ ): unknown {
450
+ return {
451
+ resourceMetrics: [
452
+ {
453
+ resource: { attributes: resourceAttributes(serviceName, vxVersion, resource) },
454
+ scopeMetrics: [{ scope: { name: 'vx', version: vxVersion }, metrics: taskMetrics(points) }],
455
+ },
456
+ ],
457
+ }
458
+ }
459
+
460
+ /**
461
+ * Per-task gauges, keyed by task. At each task's end: its duration, CPU time and peak memory (CPU and memory as
462
+ * 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
464
+ * 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.
466
+ */
467
+ function taskMetrics(points: readonly TaskMetricPoint[]): unknown[] {
468
+ const series = new Map<string, { unit: string; dataPoints: unknown[] }>()
469
+ const add = (name: string, unit: string, point: unknown): void => {
470
+ const s = series.get(name)
471
+ if (s === undefined) series.set(name, { unit, dataPoints: [point] })
472
+ else s.dataPoints.push(point)
473
+ }
474
+ const taskAttrs = (taskId: string, project: string, task: string): KeyValue[] => [
475
+ strAttr(SEMCONV.taskName, taskId),
476
+ strAttr(VX_ATTR.taskProject, project),
477
+ strAttr(VX_ATTR.taskTask, task),
478
+ ]
479
+ for (const p of points) {
480
+ if (p.kind === 'sample') {
481
+ const at = (value: number) => ({
482
+ asDouble: value,
483
+ timeUnixNano: p.timeUnixNano,
484
+ attributes: taskAttrs(p.taskId, p.project, p.task),
485
+ })
486
+ add('vx.task.cpu_usage', '1', at(p.cpuUsage))
487
+ add('vx.task.memory', 'By', at(p.rssBytes))
488
+ continue
489
+ }
490
+ const t = p.task
491
+ const at = (value: number) => ({
492
+ asDouble: value,
493
+ startTimeUnixNano: p.startUnixNano,
494
+ timeUnixNano: p.endUnixNano,
495
+ attributes: [
496
+ ...taskAttrs(t.taskId, t.project, t.task),
497
+ strAttr(VX_ATTR.cacheSource, t.cacheSource),
498
+ ],
499
+ })
500
+ add('vx.task.duration', 'ms', at(t.durationMs))
501
+ if (t.cpuMs !== undefined) add('vx.task.cpu_time', 'ms', at(t.cpuMs))
502
+ if (t.peakRssBytes !== undefined) add('vx.task.peak_memory', 'By', at(t.peakRssBytes))
503
+ }
504
+ return [...series].map(([name, { unit, dataPoints }]) => ({ name, unit, gauge: { dataPoints } }))
505
+ }
506
+
419
507
  // --- logs ---------------------------------------------------------------
420
508
 
421
509
  // OTLP severity numbers: 9 INFO, 17 ERROR.
package/src/sink.ts CHANGED
@@ -16,8 +16,10 @@ 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,
23
25
  runStatusCode,
@@ -269,6 +271,9 @@ export class OtelSink implements TelemetrySink {
269
271
  private rootStartNano = '0'
270
272
  private rootEndNano = '0'
271
273
  private readonly spans: OtlpSpan[] = []
274
+ private readonly taskPoints: TaskMetricPoint[] = []
275
+ private readonly taskNames = new Map<string, { project: string; task: string; startMs: number }>()
276
+ private readonly lastSample = new Map<string, { ts: number; cpuMs: number }>()
272
277
  private readonly taskSpanId = new Map<string, string>()
273
278
  private readonly taskStartNano = new Map<string, string>()
274
279
  private summary: RunSummaryRecord | undefined
@@ -301,9 +306,14 @@ export class OtelSink implements TelemetrySink {
301
306
  post: config.post ?? defaultPost(config.timeoutMs),
302
307
  ...(config.warn ? { warn: config.warn } : {}),
303
308
  }
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']
309
+ this.wants = [
310
+ 'run.start',
311
+ 'task.start',
312
+ ...(config.logsEnabled ? (['task.log'] as const) : []),
313
+ ...(config.metricsEnabled ? (['task.sample'] as const) : []),
314
+ 'task.end',
315
+ 'run.end',
316
+ ]
307
317
  }
308
318
 
309
319
  onRecord(record: TelemetryRecord): void {
@@ -321,7 +331,33 @@ export class OtelSink implements TelemetrySink {
321
331
  case 'task.start':
322
332
  this.taskSpanId.set(record.taskId, genId(8))
323
333
  this.taskStartNano.set(record.taskId, nanos(record.ts))
334
+ this.taskNames.set(record.taskId, {
335
+ project: record.project,
336
+ task: record.task,
337
+ startMs: record.ts,
338
+ })
339
+ return
340
+ case 'task.sample': {
341
+ // Usage is the CPU the tree spent since the last look, over the
342
+ // wall time between: the first look counts from the task's start.
343
+ const names = this.taskNames.get(record.taskId)
344
+ if (names === undefined) return
345
+ const prev = this.lastSample.get(record.taskId) ?? { ts: names.startMs, cpuMs: 0 }
346
+ const wallMs = record.ts - prev.ts
347
+ // A descendant that exited takes its CPU out of the sum: no negative usage.
348
+ const cpuUsage = wallMs > 0 ? Math.max(0, record.cpuMs - prev.cpuMs) / wallMs : 0
349
+ this.lastSample.set(record.taskId, { ts: record.ts, cpuMs: record.cpuMs })
350
+ this.taskPoints.push({
351
+ kind: 'sample',
352
+ taskId: record.taskId,
353
+ project: names.project,
354
+ task: names.task,
355
+ timeUnixNano: nanos(record.ts),
356
+ cpuUsage,
357
+ rssBytes: record.rssBytes,
358
+ })
324
359
  return
360
+ }
325
361
  case 'task.log':
326
362
  this.logs.append(record.taskId, record.chunk)
327
363
  return
@@ -345,6 +381,16 @@ export class OtelSink implements TelemetrySink {
345
381
  }),
346
382
  status: { code: taskStatusCode(t) },
347
383
  })
384
+ // A skipped task never ran: a zero duration would read as a fast one.
385
+ if (record.status !== 'skipped') {
386
+ this.taskPoints.push({
387
+ kind: 'end',
388
+ task: t,
389
+ startUnixNano: startNano,
390
+ endUnixNano: nanos(record.ts),
391
+ })
392
+ }
393
+ this.lastSample.delete(record.taskId)
348
394
  // Decides retention: a cache hit's bytes belong to the run that
349
395
  // executed them, so only an executed success/failure keeps a tail.
350
396
  if (this.cfg.logsEnabled) {
@@ -406,16 +452,39 @@ export class OtelSink implements TelemetrySink {
406
452
 
407
453
  private async shipMetrics(): Promise<void> {
408
454
  if (!this.cfg.metricsEnabled || this.summary === undefined) return
409
- const body = JSON.stringify(
455
+ const summary = this.summary
456
+ const vxVersion = this.run?.vxVersion ?? '0.0.0'
457
+ const first = this.taskPoints[0]
458
+ // The run's own metrics ride with the first batch of per-task points.
459
+ const bodies =
460
+ first === undefined
461
+ ? [this.runMetricsBody(summary, [])]
462
+ : requestBodies(this.taskPoints, (points) =>
463
+ points[0] === first
464
+ ? this.runMetricsBody(summary, points)
465
+ : JSON.stringify(
466
+ buildTaskMetricsRequest(
467
+ this.cfg.serviceName,
468
+ vxVersion,
469
+ points,
470
+ this.cfg.resource,
471
+ ),
472
+ ),
473
+ )
474
+ await this.send('metrics', this.cfg.metricsUrl, bodies)
475
+ }
476
+
477
+ private runMetricsBody(summary: RunSummaryRecord, points: TaskMetricPoint[]): string {
478
+ return JSON.stringify(
410
479
  buildMetricsRequest(
411
480
  this.cfg.serviceName,
412
- this.summary,
413
- nanos(this.summary.endedAt),
414
- nanos(this.summary.startedAt),
481
+ summary,
482
+ nanos(summary.endedAt),
483
+ nanos(summary.startedAt),
415
484
  this.cfg.resource,
485
+ points,
416
486
  ),
417
487
  )
418
- await this.send('metrics', this.cfg.metricsUrl, [body])
419
488
  }
420
489
 
421
490
  private async shipLogs(vxVersion: string): Promise<void> {