@vzn/vx-otel 0.0.523 → 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 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`) and the commit (`vcs.ref.head.*`),
156
- under `OTEL_RESOURCE_ATTRIBUTES`, which wins. Each task metric point carries
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
- ```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
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. 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.
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.523",
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.523"
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 host: a backend joins them on
286
- * `service.instance.id`. Under `OTEL_RESOURCE_ATTRIBUTES`, which wins.
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
- ...(opts.live === true ? { live: true } : {}),
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 readonly warnedLive = new Set<OtelSignal>()
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
- /** Core's flush deadline, passed to every POST. */
471
- private deadline: AbortSignal | undefined
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
- /** Live mode: send what is waiting, unless a send is already on its way (it takes it next). */
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.pumping !== undefined || this.uploaded) return
488
- this.pumping = Promise.resolve()
489
- .then(() => this.pump())
490
- .finally(() => {
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
- 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
- }
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, true)
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, true)
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.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()
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
- await Promise.all([this.shipTraces(), this.shipMetrics(), this.shipLogs()])
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, false)
620
+ await this.shipSpans(this.spans)
591
621
  }
592
622
 
593
- private async shipSpans(spans: OtlpSpan[], live: boolean): Promise<void> {
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, live)
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
- * 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> {
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
- if (once) {
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.