@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 +40 -8
- package/package.json +2 -2
- package/src/otlp.ts +147 -8
- package/src/plugin.ts +14 -4
- package/src/sink.ts +168 -30
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`)
|
|
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`)
|
|
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`
|
|
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)
|
|
133
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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),
|
|
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
|
|
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)
|
|
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:
|
|
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 {
|
|
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
|
-
):
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
464
|
-
|
|
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
|
|
468
|
-
if (this.cfg.tracesEnabled === false ||
|
|
469
|
-
const
|
|
470
|
-
|
|
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.
|
|
628
|
+
this.resource,
|
|
507
629
|
points,
|
|
508
630
|
),
|
|
509
631
|
)
|
|
510
632
|
}
|
|
511
633
|
|
|
512
|
-
private async shipLogs(
|
|
634
|
+
private async shipLogs(): Promise<void> {
|
|
513
635
|
if (!this.cfg.logsEnabled) return
|
|
514
|
-
const
|
|
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
|
-
|
|
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.
|
|
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:
|
|
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
|
-
/**
|
|
545
|
-
|
|
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.
|