GitHub Agentic Workflows

OpenTelemetry

OpenTelemetry is the observability layer for GitHub Agentic Workflows. It lets workflows export traces and spans to an external backend, and it gives workflows a way to read that telemetry back when they need to inspect behavior across runs.

This guide covers the two ways gh-aw uses OpenTelemetry:

  • Write-side OTLP sends new workflow spans to a backend such as Sentry.
  • Read-side MCP lets a workflow query traces and spans that already exist in that backend.

Keep reusable telemetry setup in shared workflow files when multiple workflows use the same configuration.

Configure observability.otlp in workflow frontmatter when the workflow should emit new spans. A typical Sentry setup looks like this:

.github/workflows/daily-report.md
---
network:
allowed:
- "*.sentry.io"
observability:
otlp:
endpoint:
- url: ${{ secrets.GH_AW_OTEL_SENTRY_ENDPOINT }}
headers:
Authorization: ${{ secrets.GH_AW_OTEL_SENTRY_AUTHORIZATION }}
---

Once configured, gh-aw exports built-in workflow spans such as setup and conclusion events to the configured OTLP backend.

Configure mcp-servers when the agent needs to inspect telemetry that already exists in the backend. A typical Sentry read setup looks like this:

.github/workflows/telemetry-investigation.md
---
mcp-servers:
sentry:
command: "npx"
args: ["@sentry/mcp-server@0.33.0"]
allowed:
- whoami
- find_organizations
- find_projects
- get_trace_details
- search_events
- search_issues
env:
SENTRY_ACCESS_TOKEN: ${{ secrets.SENTRY_ACCESS_TOKEN }}
SENTRY_HOST: ${{ env.SENTRY_HOST || 'sentry.io' }}
---

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. GitHub Actions expressions also work here, so you can populate attributes from run metadata, variables, or secrets. Empty values are omitted, and non-empty values are masked in runner logs.

gh-aw emits a small set of built-in spans and trace artifacts once OTLP is configured.

The built-in agent span uses OpenTelemetry GenAI semantic conventions for standard model, token, and finish-reason fields. gh-aw also adds a small number of gh-aw-specific fields.

You usually do not need to configure or memorize these built-in attributes. They are mainly useful when building backend queries, dashboards, or deeper debugging workflows. For the exhaustive built-in inventory, see the OpenTelemetry attribute reference.

Trace files and artifacts

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 such as APM agents, data pipeline steps, and custom scanners attach their own measurements to the same distributed trace that gh-aw creates for each workflow run.

Quick start

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
- shared/my-tool.md
---
# Daily Report
Run the daily report using my-tool results.

logSpan API

const otlp = require('/tmp/gh-aw/actions/otlp.cjs');
await otlp.logSpan(toolName, attributes, options);
ParameterTypeDescription
toolNamestringLogical name for the tool, for example "my-scanner". Used as service.name and as the span name prefix <toolName>.run.
attributesRecord<string, scalar>Domain-specific attributes emitted on the span. All env plumbing is handled automatically.
options.startMsnumberSpan start time in milliseconds since epoch. Defaults to Date.now().
options.endMsnumberSpan end time in milliseconds 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.

Here, scalar means string, number, or boolean.

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.

Recording an error span

await otlp.logSpan('my-scanner', {
'my-scanner.items_scanned': 100,
}, { isError: true, errorMessage: 'database connection timed out' });

Practical notes

  • Use your-tool. as a prefix for tool-specific attributes, for example my-tool.items_processed.
  • Use OpenTelemetry semantic conventions for cross-cutting concerns, for example db.system and http.response.status_code.
  • Avoid attribute names containing token, secret, password, key, or auth.

Attribute values are sanitized automatically before the payload is exported or mirrored. Matching secret-like keys are redacted, and very long string values are truncated. The same sanitization is applied to both OTLP export and the local JSONL mirror.

For debugging, every span emitted by logSpan is 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 spans are written to /tmp/gh-aw/copilot-otel.jsonl and forwarded to configured endpoints at the end of the run. Both files are included in the agent artifact when OTLP is enabled.

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'
Advanced: low-level API

For full control, use the lower-level helpers from send_otlp_span.cjs directly. The key environment variables set by the actions/setup step are:

VariableDescription
GITHUB_AW_OTEL_TRACE_ID32-character hex trace ID shared by all spans in this run.
GITHUB_AW_OTEL_PARENT_SPAN_ID16-character hex span ID of the job setup span. Use it 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();
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')],
}));
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)],
}));