@vzn/vx-otel 0.0.519 → 0.0.520

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -105,7 +105,17 @@ otel({
105
105
  a sandboxed one its violation count (`vx.task.sandbox_violations`), on a
106
106
  persistent one that never became ready why (`vx.task.not_ready`). A
107
107
  failed task sets span status
108
- `ERROR`.
108
+ `ERROR`. A task span links to the spans of the tasks it waited on (a
109
+ group seen through to the tasks behind it), and carries an event per
110
+ attempt that failed and was run again (`vx.task.retry`, with
111
+ `vx.task.attempt` and `vx.task.exit_code`) and one when vx's own
112
+ timeout killed it (`vx.task.timeout`);
113
+ - a child span per stage of the run, named by the stage (`startup`,
114
+ `load configs`, `git enumeration`, `classify + probe`, `run graph`,
115
+ `record history`, …, the stages `VX_TIMING` prints), with
116
+ `vx.stage.name`: where the time before the first task and after the
117
+ last went. The stages ahead of the run lock end before the `vx.run`
118
+ span starts.
109
119
 
110
120
  **Metrics per run**: `vx.tasks.total`, `vx.tasks.failed`,
111
121
  `vx.tasks.cache_hits{source=local|remote}`, what those hits did to the disk as
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx-otel",
3
- "version": "0.0.519",
3
+ "version": "0.0.520",
4
4
  "license": "MIT",
5
5
  "description": "OpenTelemetry exporter for vx — a telemetry plugin mapping runs to OTLP traces + metrics (CI/CD + VCS semantic conventions).",
6
6
  "keywords": [
@@ -34,7 +34,7 @@
34
34
  "access": "public"
35
35
  },
36
36
  "peerDependencies": {
37
- "@vzn/vx": "^0.0.519"
37
+ "@vzn/vx": "^0.0.520"
38
38
  },
39
39
  "repository": {
40
40
  "type": "git",
package/src/otlp.ts CHANGED
@@ -126,6 +126,8 @@ const VX_ATTR = {
126
126
  taskOutputs: 'vx.task.outputs',
127
127
  peakRssBytes: 'vx.peak_rss_bytes',
128
128
  taskAttempts: 'vx.task.attempts',
129
+ taskAttempt: 'vx.task.attempt',
130
+ stageName: 'vx.stage.name',
129
131
  wallclockStartNs: 'vx.task.wallclock_start_ns',
130
132
  wallclockEndNs: 'vx.task.wallclock_end_ns',
131
133
  // task log records (the OTel Logs signal)
@@ -151,6 +153,76 @@ export interface OtlpSpan {
151
153
  endTimeUnixNano: string
152
154
  attributes: KeyValue[]
153
155
  status: { code: number }
156
+ events?: OtlpSpanEvent[]
157
+ links?: OtlpSpanLink[]
158
+ }
159
+
160
+ export interface OtlpSpanEvent {
161
+ timeUnixNano: string
162
+ name: string
163
+ attributes: KeyValue[]
164
+ }
165
+
166
+ export interface OtlpSpanLink {
167
+ traceId: string
168
+ spanId: string
169
+ attributes: KeyValue[]
170
+ }
171
+
172
+ /**
173
+ * A task span's events: each attempt that failed and was run again
174
+ * (`vx.task.retry`, at its end), and vx's own deadline killing the task
175
+ * (`vx.task.timeout`, at the span's end).
176
+ */
177
+ export function taskSpanEvents(t: TaskTelemetry, endUnixNano: string): OtlpSpanEvent[] {
178
+ const events: OtlpSpanEvent[] = (t.failedAttempts ?? []).map((a, i) => ({
179
+ timeUnixNano: String(Math.trunc(a.endedAt) * 1_000_000),
180
+ name: 'vx.task.retry',
181
+ attributes: [
182
+ intAttr(VX_ATTR.taskAttempt, i + 1),
183
+ intAttr(VX_ATTR.taskExitCode, a.exitCode),
184
+ ...(a.timedOut === true ? [boolAttr(VX_ATTR.taskTimedOut, true)] : []),
185
+ ],
186
+ }))
187
+ if (t.timedOut === true) {
188
+ events.push({ timeUnixNano: endUnixNano, name: 'vx.task.timeout', attributes: [] })
189
+ }
190
+ return events
191
+ }
192
+
193
+ /**
194
+ * A child span of the run for one of its stages (`VX_TIMING`'s marks):
195
+ * where the time before the first task and after the last one went. Named
196
+ * by the stage, a fixed set. The stages ahead of the run lock end before
197
+ * the run span starts.
198
+ */
199
+ export function stageSpan(
200
+ traceId: string,
201
+ parentSpanId: string,
202
+ stage: { name: string; startedAt: number; endedAt: number },
203
+ spanId: string,
204
+ ): OtlpSpan {
205
+ return {
206
+ traceId,
207
+ spanId,
208
+ parentSpanId,
209
+ name: stage.name,
210
+ kind: SPAN_KIND_INTERNAL,
211
+ startTimeUnixNano: exactNanos(stage.startedAt),
212
+ endTimeUnixNano: exactNanos(stage.endedAt),
213
+ attributes: [strAttr(VX_ATTR.stageName, stage.name)],
214
+ status: { code: STATUS_UNSET },
215
+ }
216
+ }
217
+
218
+ /** Epoch ms with a fraction, as OTLP's int64 nanoseconds: a stage can be under 1 ms. */
219
+ function exactNanos(ms: number): string {
220
+ return String(BigInt(Math.round(ms * 1000)) * 1000n)
221
+ }
222
+
223
+ /** A link from a task span to the span of a task it waited on. */
224
+ export function dependencyLink(traceId: string, spanId: string, taskId: string): OtlpSpanLink {
225
+ return { traceId, spanId, attributes: [strAttr(SEMCONV.taskName, taskId)] }
154
226
  }
155
227
 
156
228
  // --- attribute mapping (pure) ------------------------------------------
package/src/sink.ts CHANGED
@@ -22,6 +22,9 @@ import {
22
22
  type TaskMetricPoint,
23
23
  runSpanAttributes,
24
24
  SPAN_KIND_INTERNAL,
25
+ dependencyLink,
26
+ stageSpan,
27
+ taskSpanEvents,
25
28
  runStatusCode,
26
29
  taskSpanAttributes,
27
30
  taskStatusCode,
@@ -276,6 +279,7 @@ export class OtelSink implements TelemetrySink {
276
279
  private readonly lastSample = new Map<string, { ts: number; cpuMs: number }>()
277
280
  private readonly taskSpanId = new Map<string, string>()
278
281
  private readonly taskStartNano = new Map<string, string>()
282
+ private readonly taskDeps = new Map<string, readonly string[]>()
279
283
  private summary: RunSummaryRecord | undefined
280
284
  private uploaded = false
281
285
  // Core's own bounded capture buffer, so which task's output survives a
@@ -331,6 +335,7 @@ export class OtelSink implements TelemetrySink {
331
335
  case 'task.start':
332
336
  this.taskSpanId.set(record.taskId, genId(8))
333
337
  this.taskStartNano.set(record.taskId, nanos(record.ts))
338
+ if (record.dependsOn !== undefined) this.taskDeps.set(record.taskId, record.dependsOn)
334
339
  this.taskNames.set(record.taskId, {
335
340
  project: record.project,
336
341
  task: record.task,
@@ -362,7 +367,18 @@ export class OtelSink implements TelemetrySink {
362
367
  this.logs.append(record.taskId, record.chunk)
363
368
  return
364
369
  case 'task.end': {
365
- const spanId = this.taskSpanId.get(record.taskId) ?? genId(8)
370
+ // A task that never started (a skip) still gets a span, and a later
371
+ // task that waited on it links to it.
372
+ let spanId = this.taskSpanId.get(record.taskId)
373
+ if (spanId === undefined) {
374
+ spanId = genId(8)
375
+ this.taskSpanId.set(record.taskId, spanId)
376
+ }
377
+ const links = (this.taskDeps.get(record.taskId) ?? []).flatMap((dep) => {
378
+ const depSpan = this.taskSpanId.get(dep)
379
+ return depSpan === undefined ? [] : [dependencyLink(this.traceId, depSpan, dep)]
380
+ })
381
+ const events = taskSpanEvents(record, nanos(record.ts))
366
382
  const startNano =
367
383
  this.taskStartNano.get(record.taskId) ?? nanos(record.ts - record.durationMs)
368
384
  const t: TaskTelemetry = record
@@ -380,6 +396,8 @@ export class OtelSink implements TelemetrySink {
380
396
  startedAt: this.runStartedAt,
381
397
  }),
382
398
  status: { code: taskStatusCode(t) },
399
+ ...(events.length > 0 ? { events } : {}),
400
+ ...(links.length > 0 ? { links } : {}),
383
401
  })
384
402
  // A skipped task never ran: a zero duration would read as a fast one.
385
403
  if (record.status !== 'skipped') {
@@ -437,6 +455,10 @@ export class OtelSink implements TelemetrySink {
437
455
  attributes: runSpanAttributes(this.run, this.summary),
438
456
  status: { code: runStatusCode(this.summary) },
439
457
  })
458
+ const stages = (this.summary?.stages ?? []).map((stage) =>
459
+ stageSpan(this.traceId, this.rootSpanId, stage, genId(8)),
460
+ )
461
+ this.spans.splice(1, 0, ...stages)
440
462
  }
441
463
  const vxVersion = this.run?.vxVersion ?? '0.0.0'
442
464
  await Promise.all([this.shipTraces(vxVersion), this.shipMetrics(), this.shipLogs(vxVersion)])