Skip to content

Add optional OpenTelemetry trace export for job lifecycle#4465

Draft
stefanpenner wants to merge 14 commits into
actions:masterfrom
stefanpenner:otel-trace-recorder
Draft

Add optional OpenTelemetry trace export for job lifecycle#4465
stefanpenner wants to merge 14 commits into
actions:masterfrom
stefanpenner:otel-trace-recorder

Conversation

@stefanpenner

Copy link
Copy Markdown

Summary

  • Adds an OpenTelemetry trace recorder that implements the existing listener.MetricsRecorder interface alongside the Prometheus exporter
  • When configured with an OTLP endpoint, emits three child spans per completed job: runner.queue, runner.startup, runner.execution
  • Uses a CompositeRecorder to fan out to both Prometheus and OTel when both are enabled
  • Zero behavior change when otel_endpoint is not set

Motivation

GitHub Actions workflows can be reconstructed as OpenTelemetry traces (workflow → job → step), but there's a visibility gap between "job was queued" and "step started executing." ARC has the timestamps that explain this gap — QueueTime, ScaleSetAssignTime, RunnerAssignTime, FinishTime — but currently only exposes them as Prometheus histogram aggregates.

This PR emits those timestamps as individual trace spans, giving per-job visibility into:

  • Queue wait — time before ARC acquires the job
  • Runner startup — time for pod creation and runner registration
  • Execution — time spent running the job

Trace correlation

Spans use deterministic IDs (TraceID = MD5(runID-attempt), SpanID = BigEndian(jobID)) that are compatible with tools like otel-explorer which reconstruct workflow traces from the GitHub API. ARC's runner spans automatically merge into the same trace as the workflow/job/step spans — no correlation configuration needed.

Configuration

{
  "otel_endpoint": "otel-collector.monitoring:4318",
  "otel_insecure": true
}

Or via Helm values:

listenerTemplate:
  spec:
    containers:
      - name: listener
        env:
          - name: OTEL_ENDPOINT
            value: "otel-collector.monitoring:4318"

Files changed

File Change
cmd/ghalistener/metrics/otel.go OTelRecorder implementing listener.MetricsRecorder
cmd/ghalistener/metrics/composite.go CompositeRecorder fan-out wrapper
cmd/ghalistener/metrics/otel_test.go Tests for recorder, composite, and deterministic IDs
cmd/ghalistener/main.go Wire OTel recorder alongside Prometheus
cmd/ghalistener/config/config.go Add otel_endpoint and otel_insecure fields
go.mod / go.sum Add OTel SDK dependencies

Test plan

  • All existing metrics tests pass
  • New tests: 3-span emission, missing timestamps, common attributes, run attempt override, no-op methods, composite delegation, deterministic ID stability
  • go build ./cmd/ghalistener/ compiles clean
  • Integration test with real ARC deployment + OTel Collector
  • Verify spans appear in Tempo/Jaeger alongside workflow traces

🤖 Generated with Claude Code

@murphpdx

Copy link
Copy Markdown

I would love to have this!

@austinpray-mixpanel

Copy link
Copy Markdown

I would also love this

stefanpenner and others added 3 commits June 22, 2026 09:16
Adds an OTel trace recorder that implements the existing
MetricsRecorder interface. When configured with an OTLP endpoint,
the listener emits three child spans per completed job:

  - runner.queue:     QueueTime → ScaleSetAssignTime
  - runner.startup:   ScaleSetAssignTime → RunnerAssignTime
  - runner.execution: RunnerAssignTime → FinishTime

Spans use deterministic trace/span IDs (MD5 of runID-attempt,
big-endian jobID) compatible with tools that reconstruct GitHub
Actions workflows as OpenTelemetry traces.

Configuration: set otel_endpoint (and optionally otel_insecure)
in the listener config JSON, or pass via Helm values. When no
endpoint is configured, behavior is unchanged.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add OTel CI/CD semantic convention attributes (cicd.pipeline.*,
  cicd.worker.*, vcs.repository.url.full) alongside github.* attrs
- Normalize conclusion values from ARC variants (Succeeded, Failed)
  to standard GitHub API values (success, failure, cancelled)
- Map to cicd.pipeline.task.run.result using OTel semconv result
  values (cancellation, skip, timeout, error)
- Support OTEL_EXPORTER_OTLP_ENDPOINT and OTEL_EXPORTER_OTLP_INSECURE
  env vars as config fallback for listener template injection

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@stefanpenner stefanpenner force-pushed the otel-trace-recorder branch from be17c6a to 914cf58 Compare June 22, 2026 15:17
stefanpenner and others added 11 commits July 1, 2026 16:13
The otlptracehttp exporter was pinned at v1.35.0 while otel/sdk/trace
were at v1.43.0. Mixed minors compile but miss exporter fixes (incl.
endpoint/env handling) and would churn under dependabot immediately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Trace ID: sha256("{run_id}-{run_attempt}")[:16] (was MD5, which
  also hard-fails under FIPS-140-only Go).
- Parent the listener spans to the contract job span,
  sha256("job-{run_id}-{run_attempt}-{job_name}")[:8], using the job
  display name — the span the runner actually emits. Previously they
  parented to toSpanID(jobId GUID), an ID nothing in the stack emits,
  so they rendered as orphans and never nested under the job span.
- Add the contract's golden conformance vectors as tests
  (99999/1 -> acad1e2a107636235fd56bb742499bd0, job 'build' ->
  81606d47848a59c0), shared with the runner's L0 suite.

Normative contract: actions/runner docs/otel-id-contract.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Attribute alignment (names, types, values match
Runner.Worker/OTelTraceExporter.cs so one query spans both sides):
- github.run_id is now a string (runner emits the raw string form;
  TraceQL attribute matching is type-sensitive)
- add github.run_attempt (string, matches runner)
- cicd.pipeline.task.run.id now carries the contract job span ID hex,
  as the runner does (was the scale-set job GUID)
- add cicd.pipeline.run.url.full and vcs.provider.name
- vcs.repository.url.full / run URL derive from the configured GitHub
  server URL (GHES-safe) instead of hardcoded https://github.com
- drop github.job_id/job_name (runner never emits them; display name
  lives in cicd.pipeline.task.name) and github.runner_name/runner_id
  (bespoke dupes of semconv cicd.worker.*)

Spans now carry a proper OTel Resource (service.name=gha-listener,
service.version, service.namespace, github.scale_set.name,
cicd.system.component=controller; OTEL_SERVICE_NAME /
OTEL_RESOURCE_ATTRIBUTES env override) and an InstrumentationScope.
Previously they exported with an empty resource, invisible to every
service-scoped query.

NewOTelRecorder now takes an OTelRecorderConfig, mirroring
metrics.ExporterConfig.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Nothing in production ever called SetRunAttempt — the scale-set
message protocol carries no run attempt, so the override was dead code
that only the unit test exercised. Replace it with a documented
defaultRunAttempt const and a KNOWN LIMITATION note: re-run (attempt
>= 2) listener spans join the first attempt's trace until the message
protocol carries the attempt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
RecordJobCompleted previously did a synchronous OTLP export (up to 5s)
on the listener's single message-loop goroutine — the one that acquires
jobs and computes desired runner count — so a slow or down collector
serialized 5s stalls into scaling decisions.

Spans are now handed to a bounded queue (64 entries) drained by a
background goroutine; on overflow the newest job's spans are dropped
with a warning. Shutdown drains the queue, bounded by its context, and
is idempotent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The deferred Shutdown captured the signal.NotifyContext ctx, which is
already canceled on SIGTERM — exactly when the queued spans need
flushing (pod rollout / scale-down). Mirror the sessionClient.Close
pattern: fresh context.Background() with a 5s bound, error logged
instead of dropped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
OTEL_EXPORTER_OTLP_ENDPOINT is a full URL per the OTLP spec, but it
was funneled into otlptracehttp.WithEndpoint (host:port only), which
produced an unparseable URL — every export from a spec-configured
deployment failed with only a per-job warning.

- config otel_endpoint is now parsed as a full base URL (scheme
  validated, /v1/traces appended when no path, TLS from the scheme)
  and passed via WithEndpointURL; the otel_insecure field is gone.
- When only the standard OTEL_EXPORTER_OTLP_(TRACES_)ENDPOINT env vars
  are set, the exporter is built with no endpoint option so the SDK's
  spec-compliant env handling applies (endpoint, headers, TLS).
- config.Read no longer copies env vars into the config.

Both activation paths are covered by httptest-backed exporter tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Wire the OTLP endpoint the same way the existing listener Prometheus
metrics flow:

- controller flag --listener-otel-endpoint (default: disabled)
- AutoscalingListenerReconciler.ListenerOTelEndpoint ->
  newScaleSetListenerConfig -> otel_endpoint in the listener's
  config.json secret
- gha-runner-scale-set-controller chart value otel.listenerEndpoint
  (commented out by default, like metrics), with chart tests for the
  rendered arg and the disabled default
- docs/gha-runner-scale-set-controller/otel-tracing.md documenting
  both activation paths (chart value and standard OTEL_* env via
  listenerTemplate), the ID contract, and known limitations

Follow-up (out of scope for this PR): per-scale-set configuration via
an AutoscalingRunnerSet field, and OTLP header/auth plumbing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ss-ref)

M1 — rename span-type attribute key from `type` to `github.record_type`
to match the runner's OTelTraceExporter.cs (lines 526, 618, 711).
Values (runner.queue / runner.startup / runner.execution) are unchanged.

M3 — port runner's result normalisation:
  • normalizeConclusion: add abandoned→failure; unknown raw → "error"
    (was: strings.ToLower pass-through)
  • conclusionToSemconv: unknown conclusion → "error"
    (was: raw conclusion pass-through)
Both mirror the runner's NormalizeConclusion / ToSemconvResult behaviour
(OTelTraceExporter.cs:1008-1033).

M2 — cicd.worker.name/id remain as SPAN attrs on the listener (one listener
serves many runners, so per-span is semantically correct). Added a code
comment explaining the deliberate scope difference vs. the runner's
resource-scoped attrs. Doc note added to runner repo separately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…obCompleted

The GitHub broker omits QueueTime from JobCompleted in production (observed
across multiple runs). RecordJobStarted now stores QueueTime and
ScaleSetAssignTime from the earlier JobStarted message — which does carry them
— in a bounded, TTL-capped map keyed by JobID. buildJobSpans falls back to
these stored timestamps when msg.QueueTime is zero, so the runner.queue span
is emitted correctly in the common production path.

Bounds: map capped at 1 000 entries (warn + drop on overflow); entries older
than 24 h are discarded at lookup to prevent leaks across months-long listener
runs.

Tests added:
  - TestOTelRecorder_QueueSpanFromJobStarted: JobStarted(QueueTime≠0) →
    JobCompleted(QueueTime=0) still emits runner.queue with correct interval
  - TestOTelRecorder_QueueEntryEvictedOnCompleted: duplicate JobCompleted for
    the same job emits runner.queue exactly once (entry evicted on first)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Verified live 2026-07-01 with diag logging: only ScaleSetAssignTime/
RunnerAssignTime are populated, so runner.queue currently never fires.
Keep the JobStarted fallback — cheap, tested, lights up if the broker
ever populates the field (GHES or future fix).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants