GitHub Agentic Workflows

OpenTelemetry

GitHub Agentic Workflows can export distributed traces to OpenTelemetry Protocol (OTLP) compatible backends. This page is the canonical reference for observability configuration and runtime behavior.

Set observability.otlp in workflow frontmatter:

observability:
otlp:
endpoint: ${{ secrets.OTLP_ENDPOINT }}
headers:
Authorization: ${{ secrets.OTLP_TOKEN }}
X-Tenant: my-org
FieldTypeDescription
observability.otlp.endpointstring, object, or arrayOTLP/HTTP collector endpoint URL. Accepts a plain URL string, a single {url, headers} object, or an array of {url, headers} objects for concurrent fan-out to multiple collectors. When a static URL is provided, its hostname is automatically added to the network firewall allowlist.
observability.otlp.headersmap or stringHTTP headers sent with every OTLP export request. Only applies when endpoint is a plain string; object and array endpoint entries carry their own per-endpoint headers.
observability.otlp.if-missingstring (error, warn, ignore)Controls behavior when OTLP endpoint/header values resolve to empty values at runtime. error (default) fails startup. warn logs a warning and skips MCP gateway OTLP configuration. ignore skips MCP gateway OTLP configuration without warning. This setting affects MCP gateway setup only.
observability.otlp.attributesmapCustom attributes attached to the job setup span, job conclusion span, and outcome summary span. Keys are attribute names; values are strings. GitHub Actions expressions such as ${{ vars.SESSION_ID }} or ${{ github.actor }} are evaluated at runtime. Values resolving to an empty string are omitted. Each non-empty value is masked from runner logs with ::add-mask::.

Beyond the plain URL string shown above, endpoint also accepts an object or an array.

Object form (single endpoint with per-endpoint headers):

observability:
otlp:
endpoint:
url: ${{ secrets.OTLP_ENDPOINT }}
headers:
Authorization: ${{ secrets.OTLP_TOKEN }}
X-Tenant: acme

Array form (concurrent fan-out to multiple endpoints):

observability:
otlp:
endpoint:
- url: ${{ secrets.OTLP_ENDPOINT_PRIMARY }}
headers:
Authorization: ${{ secrets.OTLP_TOKEN_PRIMARY }}
- url: ${{ secrets.OTLP_ENDPOINT_BACKUP }}
headers:
Authorization: ${{ secrets.OTLP_TOKEN_BACKUP }}

If one endpoint fails in array mode, export still continues for the remaining endpoints.

For the string endpoint form, headers accepts either a map (as shown above) or a comma-separated string:

observability:
otlp:
endpoint: ${{ secrets.OTLP_ENDPOINT }}
headers: "Authorization=${{ secrets.OTLP_TOKEN }},X-Tenant=acme"

When observability.otlp is configured, gh-aw injects:

VariableDescription
OTEL_EXPORTER_OTLP_HEADERSComma-separated key=value headers for the first endpoint (when headers are configured).
OTEL_SERVICE_NAMEgh-aw.<sanitized-workflow-id> when WorkflowID is available (for example, Repo Triage/Weeklygh-aw.repo-triage-weekly); falls back to sanitized workflow name when only the name is available, otherwise gh-aw.
GH_AW_OTLP_ENDPOINTSJSON array of all endpoint entries, used by gh-aw JavaScript span exporters for fan-out.
GH_AW_OTLP_IF_MISSINGSet to warn or ignore when observability.otlp.if-missing is configured.
GH_AW_OTLP_ATTRIBUTESJSON-encoded object of custom span attributes from observability.otlp.attributes. Injected only when the field is set.
COPILOT_OTEL_FILE_EXPORTER_PATHPath for Copilot CLI span output (/tmp/gh-aw/copilot-otel.jsonl).

observability.otlp.attributes attaches arbitrary key/value attributes to the job setup, job conclusion, and outcome summary spans:

observability:
otlp:
endpoint: ${{ secrets.OTLP_ENDPOINT }}
headers:
Authorization: ${{ secrets.OTLP_TOKEN }}
attributes:
deployment.environment: production
langfuse.session.id: ${{ github.run_id }}
langfuse.user.id: ${{ github.actor }}

Values are plain strings; use GitHub Actions expressions (${{ github.run_id }}, ${{ github.actor }}, ${{ vars.MY_VAR }}, ${{ secrets.MY_SECRET }}) for dynamic values, resolved before the workflow runs. Attributes resolving to an empty string are omitted, and each non-empty value is masked from runner logs with ::add-mask:: so user-supplied identifiers (session IDs, user IDs) never appear in plaintext.

The agent span (gh-aw.agent.agent) uses OpenTelemetry GenAI semantic conventions and is emitted as SPAN_KIND_CLIENT.

AttributeDescription
gen_ai.request.modelModel name used for inference
gen_ai.response.modelResolved runtime model reported by the agent engine
gen_ai.operation.nameAlways "chat"
gen_ai.systemStandardized OTel system name (for example, github_models, anthropic, openai, google_vertex_ai)
gh-aw.engine.idRaw gh-aw engine identifier (for example, copilot, claude, codex, gemini)
gen_ai.workflow.nameWorkflow name
gen_ai.usage.input_tokensTotal input tokens consumed
gen_ai.usage.output_tokensTotal output tokens produced
gen_ai.usage.cache_read.input_tokensCache-read tokens reused
gen_ai.usage.cache_creation.input_tokensCache-creation tokens written
gen_ai.response.finish_reasonsArray containing the agent stop reason

Outcome evaluation data is emitted in OpenTelemetry spans after safe outputs are checked against repository state. These attributes are the span-level view of the outcomes model described in Outcomes.

Workflow-level outcome rollups appear on outcome summary or job conclusion spans. The table below is a high-level, non-exhaustive subset.

AttributeDescription
gh-aw.outcome.totalTotal evaluated outcomes for the run
gh-aw.outcome.acceptedNumber of accepted outcomes
gh-aw.outcome.rejectedNumber of rejected outcomes
gh-aw.outcome.pendingNumber of pending outcomes
gh-aw.outcome.ignoredNumber of ignored outcomes
gh-aw.outcome.acceptance_rateAccepted fraction for the evaluated outcomes
gh-aw.outcome.waste_rateRejected fraction for the evaluated outcomes

Per-item outcome spans can also carry more detailed fields such as object type, URL, comments, review activity, and zero-touch acceptance.

When observability is enabled, trace data is also mirrored to local JSONL files and uploaded in the agent artifact:

  • otel.jsonl for spans emitted by gh-aw JavaScript helpers
  • copilot-otel.jsonl for spans emitted by Copilot CLI

See Artifacts for artifact download details.

Shared agentic workflow imports can emit their own OTLP spans alongside the built-in gh-aw telemetry. This lets third-party tools — APM agents, data pipeline steps, custom scanners — attach their own measurements to the same distributed trace that gh-aw creates for each workflow run.

The otlp.cjs helper provides a minimal, stable API. Use it in any steps: entry of a shared import:

.github/workflows/shared/my-tool.md
---
# My Tool — shared import that instruments its own telemetry
steps:
- name: My Tool — do work and record telemetry
id: my-tool-run
uses: actions/github-script@v8
with:
script: |
const otlp = require('/tmp/gh-aw/actions/otlp.cjs');
const startMs = Date.now();
// ... do your tool's work here (e.g. const result = await myTool.run()) ...
const endMs = Date.now();
await otlp.logSpan('my-tool', {
'my-tool.version': '1.2.3',
'my-tool.items_processed': 42,
'my-tool.result': 'success',
}, { startMs, endMs });
---
My tool has run and its telemetry span will appear in the same distributed trace as the workflow run.

Import the shared file in any workflow alongside the OTLP configuration:

.github/workflows/my-workflow.md
---
on:
schedule: daily
engine: copilot
imports:
- shared/otlp.md # sets the OTLP endpoint + auth headers
- shared/my-tool.md # runs my-tool and records its span
---
# Daily Report
Run the daily report using my-tool results.
const otlp = require('/tmp/gh-aw/actions/otlp.cjs');
await otlp.logSpan(toolName, attributes, options);
ParameterTypeDescription
toolNamestringLogical name for the tool (e.g. "my-scanner"). Used as service.name and as the span name prefix <toolName>.run.
attributesRecord<string, string | number | boolean>Domain-specific attributes emitted on the span. All env plumbing is handled automatically.
options.startMsnumberSpan start time (ms since epoch). Defaults to Date.now().
options.endMsnumberSpan end time (ms since epoch). Defaults to Date.now().
options.isErrorbooleanWhen true, sets the span status to ERROR.
options.errorMessagestringHuman-readable status message included when isError is true.
options.traceIdstringOverride trace ID. Defaults to GITHUB_AW_OTEL_TRACE_ID.
options.parentSpanIdstringOverride parent span ID. Defaults to GITHUB_AW_OTEL_PARENT_SPAN_ID.
options.endpointstringOverride OTLP endpoint. Defaults to OTEL_EXPORTER_OTLP_ENDPOINT.

logSpan is non-fatal and never throws. Export failures are surfaced as console.warn. When GITHUB_AW_OTEL_TRACE_ID is missing or invalid, the call returns silently — no warning, no side-effects.

await otlp.logSpan('my-scanner', {
'my-scanner.items_scanned': 100,
}, { isError: true, errorMessage: 'database connection timed out' });
  • Use your-tool. as a prefix for tool-specific attributes (e.g. my-tool.items_processed).
  • Use OpenTelemetry semantic conventions for cross-cutting concerns (e.g. db.system, http.response.status_code).
  • Avoid attribute names containing token, secret, password, key, or auth — the helpers automatically redact matching attribute values before sending.

Attribute values are sanitized automatically before the payload is exported or mirrored:

  • Redacts the value of any attribute whose key matches token, secret, password, passwd, key, auth, credential, api-key, or access-key (case-insensitive), replacing it with [REDACTED].
  • Truncates string values longer than 1,024 characters.

Sanitization is applied to both the over-the-wire OTLP export and the local JSONL debug mirror, so you do not need to call it yourself.

Every span emitted by logSpan is always appended as a sanitized JSON line to /tmp/gh-aw/otel.jsonl, even when OTEL_EXPORTER_OTLP_ENDPOINT is not set. When OTLP is configured, Copilot CLI’s own spans are written to /tmp/gh-aw/copilot-otel.jsonl and automatically forwarded to configured endpoints at the end of the run. Both files are included in the agent artifact when OTLP is enabled, so you can inspect spans after the run:

Terminal window
# Download agent artifacts for a run
gh aw logs <run-id> --artifacts agent
# Inspect spans emitted by your tool
cat otel.jsonl | jq 'select(.resourceSpans[].scopeSpans[].spans[].name | startswith("my-tool"))'
# Inspect Copilot CLI spans
cat copilot-otel.jsonl | jq '.resourceSpans'

For full control — multiple linked spans, custom resource attributes, or span events — use the underlying helpers from send_otlp_span.cjs directly. The key environment variables set by the actions/setup step are:

VariableDescription
GITHUB_AW_OTEL_TRACE_ID32-char hex trace ID shared by all spans in this run.
GITHUB_AW_OTEL_PARENT_SPAN_ID16-char hex span ID of the job setup span; use as parentSpanId to nest spans under it.
OTEL_EXPORTER_OTLP_ENDPOINTOTLP collector base URL.
OTEL_EXPORTER_OTLP_HEADERSComma-separated key=value authentication headers.
const {
buildAttr, buildOTLPPayload, sendOTLPSpan,
generateSpanId, SPAN_KIND_CLIENT,
} = require('/tmp/gh-aw/actions/send_otlp_span.cjs');
const traceId = process.env.GITHUB_AW_OTEL_TRACE_ID;
const parentSpanId = process.env.GITHUB_AW_OTEL_PARENT_SPAN_ID;
const endpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
const setupSpanId = generateSpanId();
const querySpanId = generateSpanId();
// Parent span for the overall operation
await sendOTLPSpan(endpoint, buildOTLPPayload({
traceId, spanId: setupSpanId, parentSpanId,
spanName: 'my-tool.setup', startMs: t0, endMs: t1,
serviceName: 'my-tool', kind: SPAN_KIND_CLIENT,
attributes: [buildAttr('my-tool.phase', 'setup')],
resourceAttributes: [buildAttr('my-tool.version', '1.2.3')],
}));
// Child span nested under the parent span above
await sendOTLPSpan(endpoint, buildOTLPPayload({
traceId, spanId: querySpanId, parentSpanId: setupSpanId,
spanName: 'my-tool.query', startMs: t1, endMs: t2,
serviceName: 'my-tool', kind: SPAN_KIND_CLIENT,
attributes: [buildAttr('my-tool.query.rows', 1234)],
}));