Add optional OpenTelemetry trace export for job lifecycle#4465
Draft
stefanpenner wants to merge 14 commits into
Draft
Add optional OpenTelemetry trace export for job lifecycle#4465stefanpenner wants to merge 14 commits into
stefanpenner wants to merge 14 commits into
Conversation
3 tasks
|
I would love to have this! |
|
I would also love this |
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>
be17c6a to
914cf58
Compare
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
listener.MetricsRecorderinterface alongside the Prometheus exporterrunner.queue,runner.startup,runner.executionCompositeRecorderto fan out to both Prometheus and OTel when both are enabledotel_endpointis not setMotivation
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:
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:
Files changed
cmd/ghalistener/metrics/otel.goOTelRecorderimplementinglistener.MetricsRecordercmd/ghalistener/metrics/composite.goCompositeRecorderfan-out wrappercmd/ghalistener/metrics/otel_test.gocmd/ghalistener/main.gocmd/ghalistener/config/config.gootel_endpointandotel_insecurefieldsgo.mod/go.sumTest plan
go build ./cmd/ghalistener/compiles clean🤖 Generated with Claude Code