GitHub Agentic Workflows

OpenTelemetry

GitHub Agentic Workflows can export distributed traces to OpenTelemetry Protocol (OTLP) compatible backends.

Use this page as 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.

The endpoint field accepts three forms.

String form (backward-compatible):

observability:
otlp:
endpoint: ${{ secrets.OTLP_ENDPOINT }}
headers:
Authorization: ${{ secrets.OTLP_TOKEN }}

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.

The headers field applies to the string endpoint form and accepts either a map or a comma-separated string.

Map form:

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

String form:

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_NAMEAlways 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.
COPILOT_OTEL_FILE_EXPORTER_PATHPath for Copilot CLI span output (/tmp/gh-aw/copilot-otel.jsonl).

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.operation.nameAlways "chat"
gen_ai.systemStandardized OTel system name (for example, github_models, anthropic, openai, google_vertex_ai)
gh-aw.engineRaw 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

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 ──────────────────────────────────────
// 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/observability-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)],
}));