@vzn/vx-otel 0.0.522 → 0.0.524
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 +16 -12
- package/package.json +2 -2
- package/src/otlp.ts +37 -2
- package/src/plugin.ts +3 -2
- package/src/sink.ts +67 -48
package/README.md
CHANGED
|
@@ -152,26 +152,30 @@ secrets, and this sends it to the collector; `logs: false` or
|
|
|
152
152
|
|
|
153
153
|
**One run, three signals, linked.** Every request's resource names the run
|
|
154
154
|
(`service.instance.id` and `cicd.pipeline.run.id`, its id), the host
|
|
155
|
-
(`host.name`, `host.arch`, `os.type`)
|
|
156
|
-
|
|
155
|
+
(`host.name`, `host.arch`, `os.type`), the commit (`vcs.ref.head.*`) and
|
|
156
|
+
the repository (`vcs.repository.url.full`, `vcs.repository.name`,
|
|
157
|
+
`vcs.owner.name`, `vcs.provider.name`, from the `origin` remote) with
|
|
158
|
+
`vx.workspace.path`, the workspace root inside it (`.` at the top), under `OTEL_RESOURCE_ATTRIBUTES`, which wins. Each task metric point carries
|
|
157
159
|
its task's span as an exemplar, and each log record its span, so a chart
|
|
158
160
|
opens the trace and a span opens its output.
|
|
159
161
|
|
|
160
162
|
## Live export
|
|
161
163
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
```
|
|
165
|
-
|
|
166
|
-
With `live: true` each task's span, metrics and output tail are sent as the
|
|
164
|
+
On by default; `otel({ live: false })` sends everything at the end instead.
|
|
165
|
+
Each task's span, metrics and output tail are sent as the
|
|
167
166
|
task ends, its process samples as they are taken, and a log record
|
|
168
167
|
(`event.name` `vx.run.start` or `vx.task.start`, linked to the trace) as the
|
|
169
168
|
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.
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
169
|
+
metrics follow at the end. A dashboard follows a CI run while it runs.
|
|
170
|
+
Sends are batched: one per second at most, one in flight at a time, each
|
|
171
|
+
taking everything that ended meanwhile; a refused send warns once per
|
|
172
|
+
signal for the run. On 60 tasks against a local collector the run burns the
|
|
173
|
+
same CPU live as with one send at the end (median of 10: 815 ms both).
|
|
174
|
+
|
|
175
|
+
No send ever holds a task: a task's end only queues its records. A collector
|
|
176
|
+
that never answers holds nothing either: at the end of the run every send
|
|
177
|
+
still in flight is cut at core's teardown deadline (3 s,
|
|
178
|
+
`VX_TEARDOWN_TIMEOUT_MS`), and the process exits.
|
|
175
179
|
|
|
176
180
|
## Behavior note
|
|
177
181
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx-otel",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.524",
|
|
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.524"
|
|
38
38
|
},
|
|
39
39
|
"repository": {
|
|
40
40
|
"type": "git",
|
package/src/otlp.ts
CHANGED
|
@@ -55,6 +55,10 @@ const SEMCONV = {
|
|
|
55
55
|
taskRunResult: 'cicd.pipeline.task.run.result',
|
|
56
56
|
vcsHeadRevision: 'vcs.ref.head.revision',
|
|
57
57
|
vcsHeadName: 'vcs.ref.head.name',
|
|
58
|
+
vcsRepositoryUrl: 'vcs.repository.url.full',
|
|
59
|
+
vcsRepositoryName: 'vcs.repository.name',
|
|
60
|
+
vcsOwnerName: 'vcs.owner.name',
|
|
61
|
+
vcsProviderName: 'vcs.provider.name',
|
|
58
62
|
serviceName: 'service.name',
|
|
59
63
|
serviceVersion: 'service.version',
|
|
60
64
|
serviceInstanceId: 'service.instance.id',
|
|
@@ -84,6 +88,7 @@ const VX_ATTR = {
|
|
|
84
88
|
schema: 'vx.telemetry.schema',
|
|
85
89
|
workspaceId: 'vx.workspace.id',
|
|
86
90
|
workspaceName: 'vx.workspace.name',
|
|
91
|
+
workspacePath: 'vx.workspace.path',
|
|
87
92
|
command: 'vx.command',
|
|
88
93
|
requestedTasks: 'vx.requested_tasks',
|
|
89
94
|
cachePolicy: 'vx.cache_policy',
|
|
@@ -282,8 +287,9 @@ function resourceAttributes(
|
|
|
282
287
|
|
|
283
288
|
/**
|
|
284
289
|
* The run as resource attributes, so every signal it sends (spans, metric
|
|
285
|
-
* points, log records) names the same run and
|
|
286
|
-
* `service.instance.id`. Under `OTEL_RESOURCE_ATTRIBUTES`,
|
|
290
|
+
* points, log records) names the same run, host, repository and workspace: a
|
|
291
|
+
* backend joins them on `service.instance.id`. Under `OTEL_RESOURCE_ATTRIBUTES`,
|
|
292
|
+
* which wins.
|
|
287
293
|
*/
|
|
288
294
|
export function runResource(run: RunContextRecord): Record<string, string> {
|
|
289
295
|
const r: Record<string, string> = {
|
|
@@ -293,11 +299,39 @@ export function runResource(run: RunContextRecord): Record<string, string> {
|
|
|
293
299
|
[SEMCONV.hostArch]: run.arch === 'x64' ? 'amd64' : run.arch,
|
|
294
300
|
}
|
|
295
301
|
if (run.host !== null) r[SEMCONV.hostName] = run.host
|
|
302
|
+
for (const [k, v] of repositoryFields(run)) r[k] = v
|
|
296
303
|
if (run.commitSha !== null) r[SEMCONV.vcsHeadRevision] = run.commitSha
|
|
297
304
|
if (run.branch !== null) r[SEMCONV.vcsHeadName] = run.branch
|
|
298
305
|
return r
|
|
299
306
|
}
|
|
300
307
|
|
|
308
|
+
const VCS_PROVIDERS: Readonly<Record<string, string>> = {
|
|
309
|
+
'github.com': 'github',
|
|
310
|
+
'gitlab.com': 'gitlab',
|
|
311
|
+
'bitbucket.org': 'bitbucket',
|
|
312
|
+
'codeberg.org': 'gitea',
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* The repository (from the normalized origin remote, `host/owner/name`) as
|
|
317
|
+
* the VCS conventions name it, and where in it the workspace sits.
|
|
318
|
+
*/
|
|
319
|
+
function repositoryFields(run: RunContextRecord): [key: string, value: string][] {
|
|
320
|
+
const fields: [string, string][] = []
|
|
321
|
+
if (run.repository !== undefined) {
|
|
322
|
+
const parts = run.repository.split('/')
|
|
323
|
+
fields.push(
|
|
324
|
+
[SEMCONV.vcsRepositoryUrl, `https://${run.repository}`],
|
|
325
|
+
[SEMCONV.vcsRepositoryName, parts.at(-1)!],
|
|
326
|
+
)
|
|
327
|
+
if (parts.length >= 3) fields.push([SEMCONV.vcsOwnerName, parts.at(-2)!])
|
|
328
|
+
const provider = VCS_PROVIDERS[parts[0]!]
|
|
329
|
+
if (provider !== undefined) fields.push([SEMCONV.vcsProviderName, provider])
|
|
330
|
+
}
|
|
331
|
+
if (run.workspacePath !== undefined) fields.push([VX_ATTR.workspacePath, run.workspacePath])
|
|
332
|
+
return fields
|
|
333
|
+
}
|
|
334
|
+
|
|
301
335
|
/**
|
|
302
336
|
* Attributes for the root `vx.run` span.
|
|
303
337
|
*
|
|
@@ -318,6 +352,7 @@ export function runSpanAttributes(run: RunContextRecord, summary?: RunSummaryRec
|
|
|
318
352
|
intAttr(VX_ATTR.schema, TELEMETRY_SCHEMA_VERSION),
|
|
319
353
|
strAttr(VX_ATTR.workspaceId, run.workspaceId),
|
|
320
354
|
strAttr(VX_ATTR.workspaceName, run.workspaceName),
|
|
355
|
+
...repositoryFields(run).map(([k, v]) => strAttr(k, v)),
|
|
321
356
|
strAttr(VX_ATTR.command, run.command),
|
|
322
357
|
strAttr(VX_ATTR.requestedTasks, run.requestedTasks.join(',')),
|
|
323
358
|
strAttr(VX_ATTR.cachePolicy, run.cachePolicy),
|
package/src/plugin.ts
CHANGED
|
@@ -43,7 +43,8 @@ export interface OtelPluginOptions {
|
|
|
43
43
|
/**
|
|
44
44
|
* Send each task's span, metrics and log as it ends, and a log record as
|
|
45
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
|
|
46
|
+
* dashboard follows the run while it runs. Default: true; `false` sends
|
|
47
|
+
* everything at the run's end.
|
|
47
48
|
*/
|
|
48
49
|
live?: boolean
|
|
49
50
|
/** Per-request timeout (ms). Falls back to `OTEL_EXPORTER_OTLP_TIMEOUT`, else 15000. */
|
|
@@ -360,7 +361,7 @@ export function resolveOtelConfig(
|
|
|
360
361
|
...(tracesWanted && tracesUrl !== undefined ? {} : { tracesEnabled: false }),
|
|
361
362
|
metricsEnabled: metricsWanted && metricsUrl !== undefined,
|
|
362
363
|
logsEnabled: logsWanted && logsUrl !== undefined,
|
|
363
|
-
|
|
364
|
+
live: opts.live !== false,
|
|
364
365
|
timeoutMs,
|
|
365
366
|
// A signal's own `OTEL_EXPORTER_OTLP_<SIGNAL>_TIMEOUT` wins over the
|
|
366
367
|
// shared one; it was not read (F-49). The option tops both.
|
package/src/sink.ts
CHANGED
|
@@ -71,6 +71,8 @@ export interface OtelSinkConfig {
|
|
|
71
71
|
tls?: Partial<Record<OtelSignal, OtlpTls>>
|
|
72
72
|
/** Send each task's span, metrics and log as it ends, not all at the run's end. */
|
|
73
73
|
live?: boolean
|
|
74
|
+
/** Live mode's batch window (ms): what ends within it goes in one send. Default 1000. */
|
|
75
|
+
liveBatchMs?: number
|
|
74
76
|
/** False under `OTEL_TRACES_EXPORTER=none`; absent is true. */
|
|
75
77
|
tracesEnabled?: boolean
|
|
76
78
|
metricsEnabled: boolean
|
|
@@ -119,6 +121,7 @@ function isCertificateRefusal(err: unknown): boolean {
|
|
|
119
121
|
}
|
|
120
122
|
|
|
121
123
|
const RETRY_DELAYS_MS = [200, 800] as const
|
|
124
|
+
const LIVE_BATCH_MS = 1000
|
|
122
125
|
const MAX_TIMER_MS = 2 ** 31 - 1
|
|
123
126
|
const MAX_RETRY_AFTER_MS = 2000
|
|
124
127
|
const RETRYABLE_STATUS = new Set([429, 502, 503, 504])
|
|
@@ -294,7 +297,8 @@ export class OtelSink implements TelemetrySink {
|
|
|
294
297
|
private readonly liveLogs: TaskLogEntry[] = []
|
|
295
298
|
private readonly liveEvents: LiveEvent[] = []
|
|
296
299
|
private pumping: Promise<void> | undefined
|
|
297
|
-
private
|
|
300
|
+
private window: ReturnType<typeof setTimeout> | undefined
|
|
301
|
+
private readonly warned = new Set<OtelSignal>()
|
|
298
302
|
private summary: RunSummaryRecord | undefined
|
|
299
303
|
private uploaded = false
|
|
300
304
|
// Core's own bounded capture buffer, so which task's output survives a
|
|
@@ -318,6 +322,7 @@ export class OtelSink implements TelemetrySink {
|
|
|
318
322
|
headers: config.headers,
|
|
319
323
|
signalHeaders: config.signalHeaders ?? {},
|
|
320
324
|
live: config.live === true,
|
|
325
|
+
liveBatchMs: config.liveBatchMs ?? LIVE_BATCH_MS,
|
|
321
326
|
tracesEnabled: config.tracesEnabled !== false,
|
|
322
327
|
metricsEnabled: config.metricsEnabled,
|
|
323
328
|
logsEnabled: config.logsEnabled,
|
|
@@ -467,8 +472,13 @@ export class OtelSink implements TelemetrySink {
|
|
|
467
472
|
}
|
|
468
473
|
}
|
|
469
474
|
|
|
470
|
-
/**
|
|
471
|
-
|
|
475
|
+
/**
|
|
476
|
+
* Every POST's signal, live ones included: core's flush deadline aborts
|
|
477
|
+
* it, so a send still hanging on a slow collector when the run ends is
|
|
478
|
+
* cut there, not left holding the process open (item 1055).
|
|
479
|
+
*/
|
|
480
|
+
private readonly lifetime = new AbortController()
|
|
481
|
+
private readonly deadline = this.lifetime.signal
|
|
472
482
|
|
|
473
483
|
/** The task's span, for its metric points' exemplars; none with traces off. */
|
|
474
484
|
private spanOf(taskId: string): { span?: { traceId: string; spanId: string } } {
|
|
@@ -482,31 +492,42 @@ export class OtelSink implements TelemetrySink {
|
|
|
482
492
|
if (this.cfg.live && this.cfg.logsEnabled) this.liveEvents.push(event)
|
|
483
493
|
}
|
|
484
494
|
|
|
485
|
-
/**
|
|
495
|
+
/**
|
|
496
|
+
* Live mode: send what is waiting once the batch window has passed, unless
|
|
497
|
+
* a window or a send is already open (it takes this too). A request per
|
|
498
|
+
* task cost the run a millisecond of CPU each; a window holds them to a
|
|
499
|
+
* few a second.
|
|
500
|
+
*/
|
|
486
501
|
private kick(): void {
|
|
487
|
-
if (!this.cfg.live || this.
|
|
488
|
-
this.pumping
|
|
489
|
-
|
|
490
|
-
.
|
|
502
|
+
if (!this.cfg.live || this.uploaded || this.window !== undefined) return
|
|
503
|
+
if (this.pumping !== undefined) return
|
|
504
|
+
this.window = setTimeout(() => {
|
|
505
|
+
this.window = undefined
|
|
506
|
+
this.pumping = this.pump().finally(() => {
|
|
491
507
|
this.pumping = undefined
|
|
508
|
+
if (this.waiting()) this.kick()
|
|
492
509
|
})
|
|
510
|
+
}, this.cfg.liveBatchMs)
|
|
511
|
+
this.window.unref()
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
private waiting(): boolean {
|
|
515
|
+
return (
|
|
516
|
+
this.spans.length + this.taskPoints.length + this.liveLogs.length + this.liveEvents.length > 0
|
|
517
|
+
)
|
|
493
518
|
}
|
|
494
519
|
|
|
520
|
+
/** Sends everything waiting now, as one request per signal (or a few, when large). */
|
|
495
521
|
private async pump(): Promise<void> {
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
)
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
this.shipSpans(spans, true),
|
|
506
|
-
this.shipPoints(points),
|
|
507
|
-
this.shipLiveLogs(logs, events),
|
|
508
|
-
])
|
|
509
|
-
}
|
|
522
|
+
const spans = this.spans.splice(0)
|
|
523
|
+
const points = this.taskPoints.splice(0)
|
|
524
|
+
const logs = this.liveLogs.splice(0)
|
|
525
|
+
const events = this.liveEvents.splice(0)
|
|
526
|
+
await Promise.all([
|
|
527
|
+
this.shipSpans(spans),
|
|
528
|
+
this.shipPoints(points),
|
|
529
|
+
this.shipLiveLogs(logs, events),
|
|
530
|
+
])
|
|
510
531
|
}
|
|
511
532
|
|
|
512
533
|
private async shipPoints(points: TaskMetricPoint[]): Promise<void> {
|
|
@@ -517,7 +538,7 @@ export class OtelSink implements TelemetrySink {
|
|
|
517
538
|
buildTaskMetricsRequest(this.cfg.serviceName, vxVersion, group, this.resource),
|
|
518
539
|
),
|
|
519
540
|
)
|
|
520
|
-
await this.send('metrics', this.cfg.metricsUrl, bodies
|
|
541
|
+
await this.send('metrics', this.cfg.metricsUrl, bodies)
|
|
521
542
|
}
|
|
522
543
|
|
|
523
544
|
private async shipLiveLogs(logs: TaskLogEntry[], events: LiveEvent[]): Promise<void> {
|
|
@@ -540,7 +561,7 @@ export class OtelSink implements TelemetrySink {
|
|
|
540
561
|
)
|
|
541
562
|
: []),
|
|
542
563
|
]
|
|
543
|
-
if (bodies.length > 0) await this.send('logs', this.cfg.logsUrl, bodies
|
|
564
|
+
if (bodies.length > 0) await this.send('logs', this.cfg.logsUrl, bodies)
|
|
544
565
|
}
|
|
545
566
|
|
|
546
567
|
onRunSummary(summary: RunSummaryRecord): void {
|
|
@@ -550,11 +571,11 @@ export class OtelSink implements TelemetrySink {
|
|
|
550
571
|
async flush(signal?: AbortSignal): Promise<void> {
|
|
551
572
|
if (this.uploaded) return
|
|
552
573
|
this.uploaded = true
|
|
553
|
-
this.
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
574
|
+
clearTimeout(this.window)
|
|
575
|
+
if (signal !== undefined) {
|
|
576
|
+
if (signal.aborted) this.lifetime.abort(signal.reason)
|
|
577
|
+
else
|
|
578
|
+
signal.addEventListener('abort', () => this.lifetime.abort(signal.reason), { once: true })
|
|
558
579
|
}
|
|
559
580
|
// Finalize the root span now that the run is over (run.end set the end).
|
|
560
581
|
if (this.run !== undefined && this.traceId) {
|
|
@@ -583,20 +604,29 @@ export class OtelSink implements TelemetrySink {
|
|
|
583
604
|
)
|
|
584
605
|
this.spans.splice(1, 0, ...stages)
|
|
585
606
|
}
|
|
586
|
-
|
|
607
|
+
// Live: a send still on its way is not waited for before the rest; the
|
|
608
|
+
// pump takes what is left (the root span included) beside it, and the
|
|
609
|
+
// run's metrics go with no task point left to carry.
|
|
610
|
+
await Promise.all([
|
|
611
|
+
this.pumping,
|
|
612
|
+
this.cfg.live ? this.pump() : undefined,
|
|
613
|
+
this.shipTraces(),
|
|
614
|
+
this.shipMetrics(),
|
|
615
|
+
this.shipLogs(),
|
|
616
|
+
])
|
|
587
617
|
}
|
|
588
618
|
|
|
589
619
|
private async shipTraces(): Promise<void> {
|
|
590
|
-
await this.shipSpans(this.spans
|
|
620
|
+
await this.shipSpans(this.spans)
|
|
591
621
|
}
|
|
592
622
|
|
|
593
|
-
private async shipSpans(spans: OtlpSpan[]
|
|
623
|
+
private async shipSpans(spans: OtlpSpan[]): Promise<void> {
|
|
594
624
|
if (this.cfg.tracesEnabled === false || spans.length === 0) return
|
|
595
625
|
const vxVersion = this.run?.vxVersion ?? '0.0.0'
|
|
596
626
|
const bodies = requestBodies(spans, (group) =>
|
|
597
627
|
JSON.stringify(buildTraceRequest(this.cfg.serviceName, vxVersion, group, this.resource)),
|
|
598
628
|
)
|
|
599
|
-
await this.send('traces', this.cfg.tracesUrl, bodies
|
|
629
|
+
await this.send('traces', this.cfg.tracesUrl, bodies)
|
|
600
630
|
}
|
|
601
631
|
|
|
602
632
|
private async shipMetrics(): Promise<void> {
|
|
@@ -667,16 +697,8 @@ export class OtelSink implements TelemetrySink {
|
|
|
667
697
|
return own === undefined || this.injected ? this.cfg.post : defaultPost(own)
|
|
668
698
|
}
|
|
669
699
|
|
|
670
|
-
/**
|
|
671
|
-
|
|
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> {
|
|
700
|
+
/** POSTs every body at once; one warning per signal for the run, however many fail. */
|
|
701
|
+
private async send(signal: OtelSignal, url: string, bodies: readonly string[]): Promise<void> {
|
|
680
702
|
const gzip = this.cfg.gzip.includes(signal)
|
|
681
703
|
const headers: Record<string, string> = {
|
|
682
704
|
'content-type': 'application/json',
|
|
@@ -700,11 +722,8 @@ export class OtelSink implements TelemetrySink {
|
|
|
700
722
|
)
|
|
701
723
|
).filter((m) => m !== undefined)
|
|
702
724
|
// export is fully optional — a down collector never affects a run
|
|
703
|
-
if (failed.length === 0) return
|
|
704
|
-
|
|
705
|
-
if (this.warnedLive.has(signal)) return
|
|
706
|
-
this.warnedLive.add(signal)
|
|
707
|
-
}
|
|
725
|
+
if (failed.length === 0 || this.warned.has(signal)) return
|
|
726
|
+
this.warned.add(signal)
|
|
708
727
|
// Name the URL: three signals ship concurrently and each is caught
|
|
709
728
|
// here on its own, so a bare "export failed" cannot tell a down
|
|
710
729
|
// collector from one misconfigured signal endpoint.
|