@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 +9 -0
- package/package.json +2 -2
- package/src/otlp.ts +89 -1
- package/src/sink.ts +77 -8
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.
|
|
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.
|
|
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 =
|
|
305
|
-
|
|
306
|
-
|
|
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
|
|
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
|
-
|
|
413
|
-
nanos(
|
|
414
|
-
nanos(
|
|
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> {
|