@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 +49 -7
- package/package.json +2 -2
- package/src/otlp.ts +217 -6
- package/src/plugin.ts +14 -4
- package/src/sink.ts +191 -31
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`)
|
|
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`
|
|
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)
|
|
123
|
-
|
|
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
|
|
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.
|
|
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
|
/**
|
|
@@ -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
|
|
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)
|
|
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:
|
|
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 {
|
|
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,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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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.
|
|
628
|
+
this.resource,
|
|
485
629
|
points,
|
|
486
630
|
),
|
|
487
631
|
)
|
|
488
632
|
}
|
|
489
633
|
|
|
490
|
-
private async shipLogs(
|
|
634
|
+
private async shipLogs(): Promise<void> {
|
|
491
635
|
if (!this.cfg.logsEnabled) return
|
|
492
|
-
const
|
|
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
|
-
|
|
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.
|
|
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:
|
|
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
|
-
/**
|
|
523
|
-
|
|
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.
|