@vzn/vx-otel 0.0.0 → 0.0.485
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/LICENSE +21 -0
- package/README.md +146 -0
- package/index.ts +4 -0
- package/package.json +39 -6
- package/src/index.ts +14 -0
- package/src/otlp.ts +482 -0
- package/src/plugin.ts +401 -0
- package/src/sink.ts +541 -0
package/src/otlp.ts
ADDED
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
// OTLP/HTTP JSON payload builders — pure functions, no SDK.
|
|
2
|
+
//
|
|
3
|
+
// vx-otel speaks the OTLP/HTTP JSON protocol directly rather than pulling the
|
|
4
|
+
// OpenTelemetry SDK closure. The protocol is a stable wire spec; building the
|
|
5
|
+
// payloads by hand keeps the package zero-dependency, fully testable, and free
|
|
6
|
+
// of SDK-version drift. Maps a vx run to:
|
|
7
|
+
// - a TRACE: one root `vx.run` span + one child `vx.task` span per task,
|
|
8
|
+
// with CI/CD + VCS semantic-convention attributes;
|
|
9
|
+
// - METRICS: task/run counters + a run-duration gauge;
|
|
10
|
+
// - LOGS: one record per executed task carrying its captured output tail.
|
|
11
|
+
//
|
|
12
|
+
// References: OpenTelemetry CI/CD + VCS semantic conventions; OTLP/JSON
|
|
13
|
+
// protobuf-JSON mapping (int64 fields are decimal STRINGS).
|
|
14
|
+
|
|
15
|
+
import { TELEMETRY_SCHEMA_VERSION } from '@vzn/vx'
|
|
16
|
+
import type { RunContextRecord, RunSummaryRecord, TaskLogEntry, TaskTelemetry } from '@vzn/vx'
|
|
17
|
+
|
|
18
|
+
// --- OTLP value + attribute primitives ---------------------------------
|
|
19
|
+
|
|
20
|
+
type AnyValue =
|
|
21
|
+
| { stringValue: string }
|
|
22
|
+
| { intValue: string }
|
|
23
|
+
| { boolValue: boolean }
|
|
24
|
+
| { doubleValue: number }
|
|
25
|
+
|
|
26
|
+
export interface KeyValue {
|
|
27
|
+
key: string
|
|
28
|
+
value: AnyValue
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function strAttr(key: string, v: string): KeyValue {
|
|
32
|
+
return { key, value: { stringValue: v } }
|
|
33
|
+
}
|
|
34
|
+
function intAttr(key: string, v: number): KeyValue {
|
|
35
|
+
return { key, value: { intValue: String(Math.trunc(v)) } }
|
|
36
|
+
}
|
|
37
|
+
function boolAttr(key: string, v: boolean): KeyValue {
|
|
38
|
+
return { key, value: { boolValue: v } }
|
|
39
|
+
}
|
|
40
|
+
/** An int64 that is ALREADY a decimal string — OTLP's own encoding for the
|
|
41
|
+
* type. Passing it through untouched is the point: routing a nanosecond
|
|
42
|
+
* count through a JS number would round it. */
|
|
43
|
+
function int64Attr(key: string, v: string): KeyValue {
|
|
44
|
+
return { key, value: { intValue: v } }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// --- semantic-convention keys ------------------------------------------
|
|
48
|
+
|
|
49
|
+
const SEMCONV = {
|
|
50
|
+
pipelineRunId: 'cicd.pipeline.run.id',
|
|
51
|
+
pipelineResult: 'cicd.pipeline.result',
|
|
52
|
+
taskName: 'cicd.pipeline.task.name',
|
|
53
|
+
taskRunResult: 'cicd.pipeline.task.run.result',
|
|
54
|
+
vcsHeadRevision: 'vcs.ref.head.revision',
|
|
55
|
+
vcsHeadName: 'vcs.ref.head.name',
|
|
56
|
+
serviceName: 'service.name',
|
|
57
|
+
serviceVersion: 'service.version',
|
|
58
|
+
} as const
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Every vx-specific attribute key, in ONE place.
|
|
62
|
+
*
|
|
63
|
+
* These keys are the wire: a trace is only a lossless description of a run if
|
|
64
|
+
* a reader can find every field again, and the only thing standing between an
|
|
65
|
+
* encoder and a decoder is agreement on these strings. Naming them once means
|
|
66
|
+
* a rename is a compile error here rather than a field that silently stops
|
|
67
|
+
* arriving. Anything with a real OTel semantic convention uses it instead (see
|
|
68
|
+
* SEMCONV above) — these cover what the conventions have no word for.
|
|
69
|
+
*
|
|
70
|
+
* `vx.default_branch` deliberately does NOT reuse `vcs.ref.base.name`: that
|
|
71
|
+
* convention means the base ref of a specific change, which is not the same
|
|
72
|
+
* question as "what is this repo's trunk", and a decoder reading the wrong
|
|
73
|
+
* one would quietly mis-classify every run's trust scope.
|
|
74
|
+
*/
|
|
75
|
+
const VX_ATTR = {
|
|
76
|
+
// run
|
|
77
|
+
schema: 'vx.telemetry.schema',
|
|
78
|
+
workspaceId: 'vx.workspace.id',
|
|
79
|
+
workspaceName: 'vx.workspace.name',
|
|
80
|
+
command: 'vx.command',
|
|
81
|
+
requestedTasks: 'vx.requested_tasks',
|
|
82
|
+
cachePolicy: 'vx.cache_policy',
|
|
83
|
+
concurrency: 'vx.concurrency',
|
|
84
|
+
flow: 'vx.flow',
|
|
85
|
+
ci: 'vx.ci',
|
|
86
|
+
ciProvider: 'vx.ci.provider',
|
|
87
|
+
host: 'vx.host',
|
|
88
|
+
os: 'vx.os',
|
|
89
|
+
arch: 'vx.arch',
|
|
90
|
+
version: 'vx.version',
|
|
91
|
+
dirty: 'vx.dirty',
|
|
92
|
+
defaultBranch: 'vx.default_branch',
|
|
93
|
+
tagPrefix: 'vx.tag.',
|
|
94
|
+
// run summary (root span only)
|
|
95
|
+
runStartedAt: 'vx.run.started_at',
|
|
96
|
+
runEndedAt: 'vx.run.ended_at',
|
|
97
|
+
runDurationMs: 'vx.run.duration_ms',
|
|
98
|
+
runTaskCount: 'vx.run.task_count',
|
|
99
|
+
runFailedCount: 'vx.run.failed_count',
|
|
100
|
+
runAbortedCount: 'vx.run.aborted_count',
|
|
101
|
+
runHitCount: 'vx.run.hit_count',
|
|
102
|
+
runHitLocalCount: 'vx.run.hit_local_count',
|
|
103
|
+
runHitRemoteCount: 'vx.run.hit_remote_count',
|
|
104
|
+
runExitOk: 'vx.run.exit_ok',
|
|
105
|
+
// task
|
|
106
|
+
taskProject: 'vx.task.project',
|
|
107
|
+
taskRunStartedAt: 'vx.task.run_started_at',
|
|
108
|
+
taskTask: 'vx.task.task',
|
|
109
|
+
cacheSource: 'vx.cache.source',
|
|
110
|
+
taskExitCode: 'vx.task.exit_code',
|
|
111
|
+
taskStatus: 'vx.task.status',
|
|
112
|
+
taskDurationMs: 'vx.task.duration_ms',
|
|
113
|
+
taskHash: 'vx.task.hash',
|
|
114
|
+
cpuMs: 'vx.cpu_ms',
|
|
115
|
+
taskWhere: 'vx.task.where',
|
|
116
|
+
taskBlockedBy: 'vx.task.blocked_by',
|
|
117
|
+
taskTimedOut: 'vx.task.timed_out',
|
|
118
|
+
taskSandboxViolations: 'vx.task.sandbox_violations',
|
|
119
|
+
taskNotReady: 'vx.task.not_ready',
|
|
120
|
+
taskOutputs: 'vx.task.outputs',
|
|
121
|
+
peakRssBytes: 'vx.peak_rss_bytes',
|
|
122
|
+
taskAttempts: 'vx.task.attempts',
|
|
123
|
+
wallclockStartNs: 'vx.task.wallclock_start_ns',
|
|
124
|
+
wallclockEndNs: 'vx.task.wallclock_end_ns',
|
|
125
|
+
// task log records (the OTel Logs signal)
|
|
126
|
+
logCharsFull: 'vx.log.chars_full',
|
|
127
|
+
logTruncatedHead: 'vx.log.truncated_head',
|
|
128
|
+
logStatus: 'vx.log.status',
|
|
129
|
+
} as const
|
|
130
|
+
|
|
131
|
+
// OTLP status codes: 0 UNSET, 1 OK, 2 ERROR. Span kind: 1 INTERNAL.
|
|
132
|
+
const STATUS_UNSET = 0
|
|
133
|
+
const STATUS_ERROR = 2
|
|
134
|
+
export const SPAN_KIND_INTERNAL = 1
|
|
135
|
+
// Metric aggregation temporality: 1 = DELTA.
|
|
136
|
+
const AGG_DELTA = 1
|
|
137
|
+
|
|
138
|
+
export interface OtlpSpan {
|
|
139
|
+
traceId: string
|
|
140
|
+
spanId: string
|
|
141
|
+
parentSpanId?: string
|
|
142
|
+
name: string
|
|
143
|
+
kind: number
|
|
144
|
+
startTimeUnixNano: string
|
|
145
|
+
endTimeUnixNano: string
|
|
146
|
+
attributes: KeyValue[]
|
|
147
|
+
status: { code: number }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// --- attribute mapping (pure) ------------------------------------------
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Resource attributes — service identity, after `OTEL_RESOURCE_ATTRIBUTES`'
|
|
154
|
+
* own (`extra`), whose `service.name` / `service.version` vx's replace.
|
|
155
|
+
* Run/VCS context rides span attrs.
|
|
156
|
+
*/
|
|
157
|
+
function resourceAttributes(
|
|
158
|
+
serviceName: string,
|
|
159
|
+
vxVersion: string,
|
|
160
|
+
extra: Readonly<Record<string, string>> = {},
|
|
161
|
+
): KeyValue[] {
|
|
162
|
+
const own = new Set<string>([SEMCONV.serviceName, SEMCONV.serviceVersion])
|
|
163
|
+
return [
|
|
164
|
+
...Object.entries(extra)
|
|
165
|
+
.filter(([k]) => !own.has(k))
|
|
166
|
+
.map(([k, v]) => strAttr(k, v)),
|
|
167
|
+
strAttr(SEMCONV.serviceName, serviceName),
|
|
168
|
+
strAttr(SEMCONV.serviceVersion, vxVersion),
|
|
169
|
+
]
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Attributes for the root `vx.run` span.
|
|
174
|
+
*
|
|
175
|
+
* With `summary` supplied this is a COMPLETE description of the run — every
|
|
176
|
+
* `RunContextRecord` field plus the run-level tallies — so a receiver can
|
|
177
|
+
* rebuild the invocation header from the trace alone. The summary is absent
|
|
178
|
+
* only when a run died before it was assembled; the context half still ships.
|
|
179
|
+
*
|
|
180
|
+
* `started_at` / `ended_at` ride as millisecond attributes even though the
|
|
181
|
+
* span already carries times, because the span's are unix-NANOS: decoding
|
|
182
|
+
* them costs a BigInt (epoch-ms × 1e6 is past Number.MAX_SAFE_INTEGER), and
|
|
183
|
+
* these two values are a storage partition key — silently losing precision on
|
|
184
|
+
* them is not a rounding error, it is a row in the wrong partition.
|
|
185
|
+
*/
|
|
186
|
+
export function runSpanAttributes(run: RunContextRecord, summary?: RunSummaryRecord): KeyValue[] {
|
|
187
|
+
const attrs: KeyValue[] = [
|
|
188
|
+
strAttr(SEMCONV.pipelineRunId, run.runId),
|
|
189
|
+
intAttr(VX_ATTR.schema, TELEMETRY_SCHEMA_VERSION),
|
|
190
|
+
strAttr(VX_ATTR.workspaceId, run.workspaceId),
|
|
191
|
+
strAttr(VX_ATTR.workspaceName, run.workspaceName),
|
|
192
|
+
strAttr(VX_ATTR.command, run.command),
|
|
193
|
+
strAttr(VX_ATTR.requestedTasks, run.requestedTasks.join(',')),
|
|
194
|
+
strAttr(VX_ATTR.cachePolicy, run.cachePolicy),
|
|
195
|
+
intAttr(VX_ATTR.concurrency, run.concurrency),
|
|
196
|
+
boolAttr(VX_ATTR.ci, run.ci),
|
|
197
|
+
strAttr(VX_ATTR.os, run.os),
|
|
198
|
+
strAttr(VX_ATTR.arch, run.arch),
|
|
199
|
+
strAttr(VX_ATTR.version, run.vxVersion),
|
|
200
|
+
]
|
|
201
|
+
if (run.flow !== null) attrs.push(strAttr(VX_ATTR.flow, run.flow))
|
|
202
|
+
if (run.commitSha !== null) attrs.push(strAttr(SEMCONV.vcsHeadRevision, run.commitSha))
|
|
203
|
+
if (run.branch !== null) attrs.push(strAttr(SEMCONV.vcsHeadName, run.branch))
|
|
204
|
+
if (run.defaultBranch !== null) attrs.push(strAttr(VX_ATTR.defaultBranch, run.defaultBranch))
|
|
205
|
+
if (run.dirty !== null) attrs.push(boolAttr(VX_ATTR.dirty, run.dirty))
|
|
206
|
+
if (run.ciProvider !== null) attrs.push(strAttr(VX_ATTR.ciProvider, run.ciProvider))
|
|
207
|
+
if (run.host !== null) attrs.push(strAttr(VX_ATTR.host, run.host))
|
|
208
|
+
for (const [k, v] of Object.entries(run.tags)) attrs.push(strAttr(`${VX_ATTR.tagPrefix}${k}`, v))
|
|
209
|
+
if (summary !== undefined) {
|
|
210
|
+
attrs.push(
|
|
211
|
+
intAttr(VX_ATTR.runStartedAt, summary.startedAt),
|
|
212
|
+
intAttr(VX_ATTR.runEndedAt, summary.endedAt),
|
|
213
|
+
intAttr(VX_ATTR.runDurationMs, summary.totalDurationMs),
|
|
214
|
+
intAttr(VX_ATTR.runTaskCount, summary.taskCount),
|
|
215
|
+
intAttr(VX_ATTR.runFailedCount, summary.failedCount),
|
|
216
|
+
intAttr(VX_ATTR.runAbortedCount, summary.abortedCount),
|
|
217
|
+
intAttr(VX_ATTR.runHitCount, summary.hitCount),
|
|
218
|
+
intAttr(VX_ATTR.runHitLocalCount, summary.hitLocalCount),
|
|
219
|
+
intAttr(VX_ATTR.runHitRemoteCount, summary.hitRemoteCount),
|
|
220
|
+
boolAttr(VX_ATTR.runExitOk, summary.exitOk),
|
|
221
|
+
strAttr(SEMCONV.pipelineResult, pipelineResult(summary)),
|
|
222
|
+
)
|
|
223
|
+
}
|
|
224
|
+
return attrs
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* `cicd.pipeline.result`, the task enum's run-level twin. A red run with
|
|
229
|
+
* nothing failed and something aborted is a stopped one.
|
|
230
|
+
*/
|
|
231
|
+
function pipelineResult(s: RunSummaryRecord): string {
|
|
232
|
+
if (s.exitOk) return 'success'
|
|
233
|
+
return s.failedCount === 0 && s.abortedCount > 0 ? 'cancellation' : 'failure'
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** The root span is ERROR when the run is red: UNSET read as a clean run. */
|
|
237
|
+
export function runStatusCode(summary?: RunSummaryRecord): number {
|
|
238
|
+
return summary?.exitOk === false ? STATUS_ERROR : STATUS_UNSET
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** What a task span needs to identify its run without its root span. */
|
|
242
|
+
interface TaskSpanRunContext {
|
|
243
|
+
runId: string
|
|
244
|
+
workspaceId: string
|
|
245
|
+
/** The run's canonical start (epoch ms) — the storage key's base. */
|
|
246
|
+
startedAt: number
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* `cicd.pipeline.task.run.result` is an enum (success, failure, error,
|
|
251
|
+
* timeout, cancellation, skip). vx's own status went out verbatim, so
|
|
252
|
+
* `failed`, `skipped`, `aborted` and the two hit statuses matched none of it
|
|
253
|
+
* and a conventions-aware backend counted no task as failed (F-12). The
|
|
254
|
+
* exact status still rides `vx.task.status`.
|
|
255
|
+
*/
|
|
256
|
+
function runResult(t: TaskTelemetry): string {
|
|
257
|
+
switch (t.status) {
|
|
258
|
+
case 'failed':
|
|
259
|
+
return t.timedOut === true ? 'timeout' : 'failure'
|
|
260
|
+
case 'skipped':
|
|
261
|
+
return 'skip'
|
|
262
|
+
case 'aborted':
|
|
263
|
+
return 'cancellation'
|
|
264
|
+
default:
|
|
265
|
+
return 'success'
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Attributes for a child `vx.task` span — every `TaskTelemetry` field.
|
|
271
|
+
*
|
|
272
|
+
* The wallclock offsets ride as int64 STRINGS (OTLP's own int64 encoding), not
|
|
273
|
+
* numbers: they are nanoseconds, and a receiver reconstructing a task's
|
|
274
|
+
* `started_at` from them is computing a dedup key that must match the value
|
|
275
|
+
* the native ingest path derives, to the millisecond.
|
|
276
|
+
*
|
|
277
|
+
* Nothing here is unbounded: every value is an id, an enum or a number, so a
|
|
278
|
+
* collector's attribute-value limit cannot cut one. The largest thing this
|
|
279
|
+
* exporter ships is a task's captured tail, which travels as a LOG record
|
|
280
|
+
* (`vx.log.*`) carrying its own full length, so a cut there is visible.
|
|
281
|
+
*/
|
|
282
|
+
export function taskSpanAttributes(t: TaskTelemetry, run: TaskSpanRunContext): KeyValue[] {
|
|
283
|
+
const attrs: KeyValue[] = [
|
|
284
|
+
strAttr(SEMCONV.taskName, t.taskId),
|
|
285
|
+
strAttr(SEMCONV.taskRunResult, runResult(t)),
|
|
286
|
+
strAttr(VX_ATTR.taskStatus, t.status),
|
|
287
|
+
// Which run, which workspace, and when that run began. Required, not
|
|
288
|
+
// optional: OTLP is re-batched in transit, so a task span can arrive in a
|
|
289
|
+
// payload its root span is not in, and a span that can only be read
|
|
290
|
+
// alongside its parent is a span a collector can silently strand. These
|
|
291
|
+
// three make it attributable on its own — and `run_started_at` is what
|
|
292
|
+
// lets a receiver derive the SAME storage key it would have derived from
|
|
293
|
+
// the complete trace, so the two arrival orders converge instead of
|
|
294
|
+
// duplicating.
|
|
295
|
+
strAttr(SEMCONV.pipelineRunId, run.runId),
|
|
296
|
+
strAttr(VX_ATTR.workspaceId, run.workspaceId),
|
|
297
|
+
intAttr(VX_ATTR.taskRunStartedAt, run.startedAt),
|
|
298
|
+
strAttr(VX_ATTR.taskProject, t.project),
|
|
299
|
+
strAttr(VX_ATTR.taskTask, t.task),
|
|
300
|
+
strAttr(VX_ATTR.cacheSource, t.cacheSource),
|
|
301
|
+
intAttr(VX_ATTR.taskExitCode, t.exitCode),
|
|
302
|
+
intAttr(VX_ATTR.taskDurationMs, t.durationMs),
|
|
303
|
+
]
|
|
304
|
+
if (t.hash !== undefined) attrs.push(strAttr(VX_ATTR.taskHash, t.hash))
|
|
305
|
+
if (t.cpuMs !== undefined) attrs.push(intAttr(VX_ATTR.cpuMs, t.cpuMs))
|
|
306
|
+
if (t.where !== undefined) attrs.push(strAttr(VX_ATTR.taskWhere, t.where))
|
|
307
|
+
// A skipped task's root blocker (a task id, as the record carries it).
|
|
308
|
+
if (t.blockedBy !== undefined) attrs.push(strAttr(VX_ATTR.taskBlockedBy, t.blockedBy))
|
|
309
|
+
// vx's own deadline killed the task: the 143 is a timeout, not a signal.
|
|
310
|
+
if (t.timedOut === true) attrs.push(boolAttr(VX_ATTR.taskTimedOut, true))
|
|
311
|
+
if (t.sandboxViolations !== undefined)
|
|
312
|
+
attrs.push(intAttr(VX_ATTR.taskSandboxViolations, t.sandboxViolations))
|
|
313
|
+
// Why a persistent task never became ready ('timeout' | 'exited' | 'spawn').
|
|
314
|
+
if (t.notReady !== undefined) attrs.push(strAttr(VX_ATTR.taskNotReady, t.notReady))
|
|
315
|
+
if (t.outputs !== undefined) attrs.push(strAttr(VX_ATTR.taskOutputs, t.outputs))
|
|
316
|
+
if (t.peakRssBytes !== undefined) attrs.push(intAttr(VX_ATTR.peakRssBytes, t.peakRssBytes))
|
|
317
|
+
if (t.attempts !== undefined) attrs.push(intAttr(VX_ATTR.taskAttempts, t.attempts))
|
|
318
|
+
if (t.wallclockStartNs !== undefined)
|
|
319
|
+
attrs.push(int64Attr(VX_ATTR.wallclockStartNs, t.wallclockStartNs))
|
|
320
|
+
if (t.wallclockEndNs !== undefined)
|
|
321
|
+
attrs.push(int64Attr(VX_ATTR.wallclockEndNs, t.wallclockEndNs))
|
|
322
|
+
return attrs
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** A failed task maps to span status ERROR. Everything else stays UNSET. */
|
|
326
|
+
export function taskStatusCode(t: TaskTelemetry): number {
|
|
327
|
+
if (t.status === 'failed') return STATUS_ERROR
|
|
328
|
+
return STATUS_UNSET
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// --- envelope builders -------------------------------------------------
|
|
332
|
+
|
|
333
|
+
/** Wrap spans in an ExportTraceServiceRequest. */
|
|
334
|
+
export function buildTraceRequest(
|
|
335
|
+
serviceName: string,
|
|
336
|
+
vxVersion: string,
|
|
337
|
+
spans: OtlpSpan[],
|
|
338
|
+
resource?: Readonly<Record<string, string>>,
|
|
339
|
+
): unknown {
|
|
340
|
+
return {
|
|
341
|
+
resourceSpans: [
|
|
342
|
+
{
|
|
343
|
+
resource: { attributes: resourceAttributes(serviceName, vxVersion, resource) },
|
|
344
|
+
scopeSpans: [{ scope: { name: 'vx', version: vxVersion }, spans }],
|
|
345
|
+
},
|
|
346
|
+
],
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Build an ExportMetricsServiceRequest from a run summary. Each count is the
|
|
352
|
+
* RUN's, so it is a DELTA over the run's own interval: sent CUMULATIVE with
|
|
353
|
+
* no start time, two runs of 10 tasks read as a series that never rose, and
|
|
354
|
+
* parallel jobs interleaved on one series (item 927).
|
|
355
|
+
*/
|
|
356
|
+
export function buildMetricsRequest(
|
|
357
|
+
serviceName: string,
|
|
358
|
+
summary: RunSummaryRecord,
|
|
359
|
+
nowUnixNano: string,
|
|
360
|
+
startUnixNano: string,
|
|
361
|
+
resource?: Readonly<Record<string, string>>,
|
|
362
|
+
): unknown {
|
|
363
|
+
const point = (value: number, attrs: KeyValue[] = []) => ({
|
|
364
|
+
asInt: String(value),
|
|
365
|
+
startTimeUnixNano: startUnixNano,
|
|
366
|
+
timeUnixNano: nowUnixNano,
|
|
367
|
+
attributes: attrs,
|
|
368
|
+
})
|
|
369
|
+
const sum = (name: string, dataPoints: ReturnType<typeof point>[]) => ({
|
|
370
|
+
name,
|
|
371
|
+
sum: { dataPoints, aggregationTemporality: AGG_DELTA, isMonotonic: true },
|
|
372
|
+
})
|
|
373
|
+
const gauge = (name: string, value: number) => ({
|
|
374
|
+
name,
|
|
375
|
+
gauge: {
|
|
376
|
+
dataPoints: [{ asDouble: value, timeUnixNano: nowUnixNano, attributes: [] }],
|
|
377
|
+
},
|
|
378
|
+
})
|
|
379
|
+
return {
|
|
380
|
+
resourceMetrics: [
|
|
381
|
+
{
|
|
382
|
+
resource: { attributes: resourceAttributes(serviceName, summary.run.vxVersion, resource) },
|
|
383
|
+
scopeMetrics: [
|
|
384
|
+
{
|
|
385
|
+
scope: { name: 'vx', version: summary.run.vxVersion },
|
|
386
|
+
metrics: [
|
|
387
|
+
sum('vx.tasks.total', [point(summary.taskCount)]),
|
|
388
|
+
sum('vx.tasks.failed', [point(summary.failedCount)]),
|
|
389
|
+
// One metric, a point per source: two entries of one name
|
|
390
|
+
// are two metrics a backend may keep only one of.
|
|
391
|
+
sum('vx.tasks.cache_hits', [
|
|
392
|
+
point(summary.hitLocalCount, [strAttr('source', 'local')]),
|
|
393
|
+
point(summary.hitRemoteCount, [strAttr('source', 'remote')]),
|
|
394
|
+
]),
|
|
395
|
+
gauge('vx.run.duration_ms', summary.totalDurationMs),
|
|
396
|
+
],
|
|
397
|
+
},
|
|
398
|
+
],
|
|
399
|
+
},
|
|
400
|
+
],
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// --- logs ---------------------------------------------------------------
|
|
405
|
+
|
|
406
|
+
// OTLP severity numbers: 9 INFO, 17 ERROR.
|
|
407
|
+
const SEVERITY_INFO = 9
|
|
408
|
+
const SEVERITY_ERROR = 17
|
|
409
|
+
|
|
410
|
+
export interface OtlpLogRecord {
|
|
411
|
+
timeUnixNano: string
|
|
412
|
+
observedTimeUnixNano: string
|
|
413
|
+
severityNumber: number
|
|
414
|
+
severityText: string
|
|
415
|
+
body: { stringValue: string }
|
|
416
|
+
attributes: KeyValue[]
|
|
417
|
+
traceId?: string
|
|
418
|
+
spanId?: string
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* One log record per EXECUTED task, carrying its captured output tail.
|
|
423
|
+
*
|
|
424
|
+
* Per task, not per chunk. A chunk-level stream is the conventional shape for
|
|
425
|
+
* application logs, but a build task's output arrives as thousands of tiny
|
|
426
|
+
* writes and the thing anyone reads is the tail — so a record per chunk would
|
|
427
|
+
* multiply the payload by orders of magnitude to deliver the same bytes, and
|
|
428
|
+
* force every receiver to reassemble them in order before it could show
|
|
429
|
+
* anything. The capture buffer already bounds and orders the tail; this ships
|
|
430
|
+
* what it drained.
|
|
431
|
+
*
|
|
432
|
+
* `traceId`/`spanId` link each record to its task span, so a viewer opens the
|
|
433
|
+
* output from the span rather than by correlating ids by hand. The truncation
|
|
434
|
+
* counters ride along because a capped tail that reads as complete is worse
|
|
435
|
+
* than one that says what it lost.
|
|
436
|
+
*/
|
|
437
|
+
export function buildLogsRequest(args: {
|
|
438
|
+
serviceName: string
|
|
439
|
+
vxVersion: string
|
|
440
|
+
runId: string
|
|
441
|
+
/** The run's workspace — a receiver has no other way to route the record. */
|
|
442
|
+
workspaceId: string
|
|
443
|
+
entries: readonly TaskLogEntry[]
|
|
444
|
+
timeUnixNano: string
|
|
445
|
+
traceId?: string
|
|
446
|
+
spanIdFor?: (taskId: string) => string | undefined
|
|
447
|
+
resource?: Readonly<Record<string, string>>
|
|
448
|
+
}): unknown {
|
|
449
|
+
const logRecords: OtlpLogRecord[] = args.entries.map((e) => {
|
|
450
|
+
const failed = e.status === 'failed'
|
|
451
|
+
const attrs: KeyValue[] = [
|
|
452
|
+
strAttr(SEMCONV.pipelineRunId, args.runId),
|
|
453
|
+
strAttr(VX_ATTR.workspaceId, args.workspaceId),
|
|
454
|
+
strAttr(SEMCONV.taskName, e.taskId),
|
|
455
|
+
strAttr(VX_ATTR.logStatus, e.status),
|
|
456
|
+
intAttr(VX_ATTR.logCharsFull, e.charsFull),
|
|
457
|
+
intAttr(VX_ATTR.logTruncatedHead, e.truncatedHeadChars),
|
|
458
|
+
]
|
|
459
|
+
if (e.hash !== undefined) attrs.push(strAttr(VX_ATTR.taskHash, e.hash))
|
|
460
|
+
const spanId = args.spanIdFor?.(e.taskId)
|
|
461
|
+
return {
|
|
462
|
+
timeUnixNano: args.timeUnixNano,
|
|
463
|
+
observedTimeUnixNano: args.timeUnixNano,
|
|
464
|
+
severityNumber: failed ? SEVERITY_ERROR : SEVERITY_INFO,
|
|
465
|
+
severityText: failed ? 'ERROR' : 'INFO',
|
|
466
|
+
body: { stringValue: e.content },
|
|
467
|
+
attributes: attrs,
|
|
468
|
+
...(args.traceId ? { traceId: args.traceId } : {}),
|
|
469
|
+
...(spanId ? { spanId } : {}),
|
|
470
|
+
}
|
|
471
|
+
})
|
|
472
|
+
return {
|
|
473
|
+
resourceLogs: [
|
|
474
|
+
{
|
|
475
|
+
resource: {
|
|
476
|
+
attributes: resourceAttributes(args.serviceName, args.vxVersion, args.resource),
|
|
477
|
+
},
|
|
478
|
+
scopeLogs: [{ scope: { name: 'vx', version: args.vxVersion }, logRecords }],
|
|
479
|
+
},
|
|
480
|
+
],
|
|
481
|
+
}
|
|
482
|
+
}
|