From 62b9cb15e441d1deba7ab125c1925e7733ae7746 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 1 May 2026 20:38:40 +0000 Subject: [PATCH 1/9] docs: add guide for emitting custom OTLP attributes from shared workflows Agent-Logs-Url: https://github.com/github/gh-aw/sessions/c8b78e5c-b8d6-41f8-b2b6-ec03d42c4e89 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- docs/astro.config.mjs | 1 + .../docs/guides/custom-otlp-attributes.md | 219 ++++++++++++++++++ 2 files changed, 220 insertions(+) create mode 100644 docs/src/content/docs/guides/custom-otlp-attributes.md diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index d482d78bfee..262edcf4de2 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -285,6 +285,7 @@ export default defineConfig({ { label: 'Ephemerals', link: '/guides/ephemerals/' }, { label: 'Web Search', link: '/guides/web-search/' }, { label: 'Audit Reports', link: '/guides/audit-with-agents/' }, + { label: 'Custom OTLP Attributes', link: '/guides/custom-otlp-attributes/' }, ], }, { diff --git a/docs/src/content/docs/guides/custom-otlp-attributes.md b/docs/src/content/docs/guides/custom-otlp-attributes.md new file mode 100644 index 00000000000..46fb92e4036 --- /dev/null +++ b/docs/src/content/docs/guides/custom-otlp-attributes.md @@ -0,0 +1,219 @@ +--- +title: Emitting Custom OTLP Attributes +description: How to add custom OpenTelemetry spans and attributes from shared agentic workflows so third-party tools can upload their own telemetry data alongside built-in instrumentation. +sidebar: + order: 19 +--- + +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. + +## How the helpers become available + +When a workflow runs, the `actions/setup` action copies all JavaScript helpers to `/tmp/gh-aw/actions/` before the agent job begins. Every `steps:` step in a workflow or shared import can `require()` these helpers directly, with no additional installation. + +```javascript +const { + buildAttr, + buildOTLPPayload, + sendOTLPSpan, + generateSpanId, + toNanoString, + SPAN_KIND_INTERNAL, +} = require('/tmp/gh-aw/actions/send_otlp_span.cjs'); +``` + +The helpers use only Node.js built-ins and native `fetch`, so they work in any GitHub Actions runner environment. + +## Trace context environment variables + +After the `actions/setup` step runs, it writes two environment variables to `$GITHUB_ENV` that are available to every subsequent step in the same job: + +| Variable | Description | +|----------|-------------| +| `GITHUB_AW_OTEL_TRACE_ID` | The 32-char hex trace ID shared by all spans in this workflow run. Use this as the `traceId` for any span you emit so it appears in the same trace tree. | +| `GITHUB_AW_OTEL_PARENT_SPAN_ID` | The 16-char hex span ID of the `gh-aw..setup` span. Use this as `parentSpanId` to nest your span directly under the job setup span. | + +The OTLP endpoint and headers are already resolved and exported as: + +| Variable | Description | +|----------|-------------| +| `OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP collector base URL (e.g. `https://traces.example.com:4318`). Empty when no endpoint is configured. | +| `OTEL_EXPORTER_OTLP_HEADERS` | Comma-separated `key=value` authentication headers. | + +## Core helper API + +| Function | Description | +|----------|-------------| +| `buildAttr(key, value)` | Returns a single OTLP key-value attribute. Handles `string`, `number`, and `boolean` types. | +| `buildOTLPPayload(opts)` | Assembles a complete OTLP/HTTP JSON traces payload for one span. | +| `sendOTLPSpan(endpoint, payload)` | POSTs the payload to `{endpoint}/v1/traces`. Non-fatal: failures are logged as warnings and never thrown. Writes a local JSONL mirror at `/tmp/gh-aw/otel.jsonl` regardless of whether a collector is configured. | +| `generateSpanId()` | Returns a random 16-char hex span ID. | +| `toNanoString(ms)` | Converts a millisecond timestamp to the nanosecond string format required by OTLP. | +| `SPAN_KIND_INTERNAL` | Span kind constant for an in-process operation (value: `1`). | +| `SPAN_KIND_CLIENT` | Span kind constant for an outbound call (value: `3`). | + +## Emitting a custom span from `steps:` + +Add a `steps:` entry to your shared import's frontmatter. Use `actions/github-script@v8` to run JavaScript and call the helpers: + +```yaml wrap title=".github/workflows/shared/my-tool.md" +--- +# My Tool — shared import that instruments its own telemetry + +steps: + - name: My Tool — emit OTLP span + id: my-tool-otlp + uses: actions/github-script@v8 + env: + OTEL_EXPORTER_OTLP_ENDPOINT: ${{ env.OTEL_EXPORTER_OTLP_ENDPOINT }} + GITHUB_AW_OTEL_TRACE_ID: ${{ env.GITHUB_AW_OTEL_TRACE_ID }} + GITHUB_AW_OTEL_PARENT_SPAN_ID: ${{ env.GITHUB_AW_OTEL_PARENT_SPAN_ID }} + with: + script: | + const { + buildAttr, + buildOTLPPayload, + sendOTLPSpan, + generateSpanId, + SPAN_KIND_CLIENT, + } = require('/tmp/gh-aw/actions/send_otlp_span.cjs'); + + const startMs = Date.now(); + + // ── run your tool's work here ───────────────────────────────────── + // const result = await myTool.run(); + // ───────────────────────────────────────────────────────────────── + + const endMs = Date.now(); + + const endpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT; + const traceId = process.env.GITHUB_AW_OTEL_TRACE_ID; + const parentSpanId = process.env.GITHUB_AW_OTEL_PARENT_SPAN_ID; + + if (!traceId) { + core.warning('GITHUB_AW_OTEL_TRACE_ID is not set; skipping OTLP export'); + return; + } + + const payload = buildOTLPPayload({ + traceId, + spanId: generateSpanId(), + parentSpanId, // nests under the job setup span + spanName: 'my-tool.run', + startMs, + endMs, + serviceName: 'my-tool', + kind: SPAN_KIND_CLIENT, + attributes: [ + buildAttr('my-tool.version', '1.2.3'), + buildAttr('my-tool.items_processed', 42), + buildAttr('my-tool.result', 'success'), + ], + }); + + await sendOTLPSpan(endpoint, payload); +--- + +My tool has been configured and its telemetry span will appear in the same trace as the workflow run. +``` + +Import it in any workflow: + +```yaml wrap title=".github/workflows/my-workflow.md" +--- +on: + schedule: daily +engine: copilot +imports: + - shared/observability-otlp.md # sets OTLP endpoint + headers + - shared/my-tool.md # installs my-tool and emits its span +--- + +# Daily Report + +Run the daily report using my-tool results. +``` + +## Adding resource attributes + +Pass `resourceAttributes` to `buildOTLPPayload` to add attributes at the resource (process) level. These are indexed separately from span attributes and appear under the resource in most OTLP backends: + +```javascript +const payload = buildOTLPPayload({ + traceId, + spanId: generateSpanId(), + parentSpanId, + spanName: 'my-tool.run', + startMs, + endMs, + serviceName: 'my-tool', + attributes: [ + buildAttr('my-tool.items_processed', 42), + ], + resourceAttributes: [ + buildAttr('my-tool.version', '1.2.3'), + buildAttr('deployment.environment', 'production'), + ], +}); +``` + +## Emitting multiple spans + +Call `sendOTLPSpan` multiple times — once per logical operation. Assign a unique span ID to each call. Link related spans under the same parent to build a trace tree: + +```javascript +const setupSpanId = generateSpanId(); +const querySpanId = generateSpanId(); + +// Parent span: overall operation +await sendOTLPSpan(endpoint, buildOTLPPayload({ + traceId, spanId: setupSpanId, parentSpanId, + spanName: 'my-tool.setup', startMs: t0, endMs: t1, + serviceName: 'my-tool', + attributes: [buildAttr('my-tool.phase', 'setup')], +})); + +// Child span: sub-operation 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', + attributes: [buildAttr('my-tool.query.rows', 1234)], +})); +``` + +## Attribute naming recommendations + +Follow existing gh-aw conventions to ensure your attributes are easy to find and filter in dashboards: + +- Use `your-tool.` as a prefix for tool-specific attributes (e.g. `my-tool.items_processed`). +- Use [OpenTelemetry semantic conventions](https://opentelemetry.io/docs/specs/semconv/) for cross-cutting concerns (e.g. `db.system`, `http.response.status_code`, `gen_ai.usage.input_tokens`). +- Avoid attribute names containing `token`, `secret`, `password`, `key`, or `auth` — the helpers automatically redact the values of matching attributes before sending (see [Security](#security)). + +## Security + +All attribute values pass through `sanitizeOTLPPayload` before the payload is sent over the wire. This function: + +- **Redacts** the string 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 automatic — you do not need to call it yourself. `sendOTLPSpan` applies it internally before every HTTP request. + +## Debugging without a live collector + +`sendOTLPSpan` always appends every payload as a JSON line to `/tmp/gh-aw/otel.jsonl`, even when `OTEL_EXPORTER_OTLP_ENDPOINT` is not set. This file is included in the `firewall-audit-logs` artifact so you can inspect spans locally after the run: + +```bash +# Download logs for a run +gh aw logs --artifacts firewall + +# Inspect spans emitted by your tool +cat otel.jsonl | jq 'select(.resourceSpans[].scopeSpans[].spans[].name | startswith("my-tool"))' +``` + +## Related documentation + +- [Observability (`observability:`)](/gh-aw/reference/frontmatter/#observability-observability) — configure the OTLP endpoint and headers +- [Imports](/gh-aw/reference/imports/) — how shared workflow imports work +- [Deterministic Agentic Patterns](/gh-aw/guides/deterministic-agentic-patterns/) — adding custom `steps:` to workflows +- [Artifacts](/gh-aw/reference/artifacts/) — downloading the `otel.jsonl` mirror and other artifacts From 927c3f69f4cb82faebc761f8eb2ff0a707eb3f71 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 1 May 2026 20:52:52 +0000 Subject: [PATCH 2/9] feat: add otlp.cjs high-level helper and update custom OTLP attributes guide Agent-Logs-Url: https://github.com/github/gh-aw/sessions/b39f1de9-8317-42a1-af11-3ac6661f7e12 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/otlp.cjs | 128 ++++++++++ actions/setup/js/otlp.test.cjs | 211 ++++++++++++++++ .../docs/guides/custom-otlp-attributes.md | 227 +++++++----------- 3 files changed, 426 insertions(+), 140 deletions(-) create mode 100644 actions/setup/js/otlp.cjs create mode 100644 actions/setup/js/otlp.test.cjs diff --git a/actions/setup/js/otlp.cjs b/actions/setup/js/otlp.cjs new file mode 100644 index 00000000000..1094b475cdc --- /dev/null +++ b/actions/setup/js/otlp.cjs @@ -0,0 +1,128 @@ +// @ts-check +"use strict"; + +/** + * otlp.cjs + * + * Stable, service-level API for emitting custom OpenTelemetry spans from shared + * agentic workflow imports. Wraps the low-level `send_otlp_span.cjs` helpers + * and reads all required environment variables automatically, so callers only + * need to provide a tool name and any domain-specific attributes they want to record. + * + * Design goals: + * - Minimal public surface: one primary function (`logSpan`) for the common case. + * - Zero configuration: endpoint, trace context, and service name are resolved + * from the environment automatically. + * - Non-fatal: export failures are logged as warnings and never throw. + * - Stable: callers are isolated from internal refactors of `send_otlp_span.cjs`. + * + * Usage (in a `steps:` github-script step inside a shared import): + * + * const otlp = require('/tmp/gh-aw/actions/otlp.cjs'); + * const start = Date.now(); + * // ... do work ... + * await otlp.logSpan('my-tool', { 'my-tool.items_processed': 42, 'my-tool.result': 'ok' }, { startMs: start }); + */ + +const path = require("path"); + +// --------------------------------------------------------------------------- +// Internal: lazy-load send_otlp_span.cjs so the module can be required from +// the runtime actions directory (/tmp/gh-aw/actions/) without knowing the +// absolute path of this file at author time. +// --------------------------------------------------------------------------- + +/** @type {typeof import('./send_otlp_span.cjs') | null} */ +let _core = null; + +/** + * Return the low-level send_otlp_span module, resolved relative to this file. + * @returns {typeof import('./send_otlp_span.cjs')} + */ +function core() { + if (!_core) { + _core = require(path.join(__dirname, "send_otlp_span.cjs")); + } + return _core; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * @typedef {Object} LogSpanOptions + * @property {number} [startMs] - Span start time (ms since epoch). Defaults to `Date.now()`. + * @property {number} [endMs] - Span end time (ms since epoch). Defaults to `Date.now()`. + * @property {string} [traceId] - Override the trace ID. Defaults to `GITHUB_AW_OTEL_TRACE_ID`. + * @property {string} [parentSpanId] - Override the parent span ID. Defaults to `GITHUB_AW_OTEL_PARENT_SPAN_ID`. + * @property {string} [endpoint] - Override the OTLP endpoint. Defaults to `OTEL_EXPORTER_OTLP_ENDPOINT`. + * @property {boolean} [isError] - When `true`, the span status is set to ERROR (code 2). + * @property {string} [errorMessage] - Human-readable status message included when `isError` is `true`. + */ + +/** + * Emit a single OTLP span for the given tool, correlated with the current + * workflow run's distributed trace. + * + * All environment plumbing (endpoint, trace ID, parent span ID) is handled + * automatically; callers only provide the tool name and their own attributes. + * + * Attribute values may be `string`, `number`, or `boolean`. Keys that match + * sensitive patterns (`token`, `secret`, `password`, etc.) are automatically + * redacted before the payload is sent over the wire. + * + * @param {string} toolName + * Logical name for the tool being instrumented (e.g. `"my-scanner"`). + * Used as both the OTLP `service.name` resource attribute and as the span + * name prefix: `.run`. + * + * @param {Record} [attributes] + * Domain-specific span attributes emitted under the tool's own namespace. + * Example: `{ 'my-scanner.issues_found': 3, 'my-scanner.version': '1.2.0' }`. + * + * @param {LogSpanOptions} [options] + * + * @returns {Promise} + */ +async function logSpan(toolName, attributes = {}, options = {}) { + try { + const { buildAttr, buildOTLPPayload, sendOTLPSpan, generateSpanId, isValidTraceId, isValidSpanId, SPAN_KIND_CLIENT } = core(); + + const now = Date.now(); + const startMs = options.startMs ?? now; + const endMs = options.endMs ?? now; + + const endpoint = options.endpoint ?? process.env.OTEL_EXPORTER_OTLP_ENDPOINT ?? ""; + const traceId = options.traceId ?? process.env.GITHUB_AW_OTEL_TRACE_ID ?? ""; + const parentSpanId = options.parentSpanId ?? process.env.GITHUB_AW_OTEL_PARENT_SPAN_ID ?? ""; + + if (!isValidTraceId(traceId)) { + console.warn(`[otlp] ${toolName}: GITHUB_AW_OTEL_TRACE_ID is not set or invalid; skipping span`); + return; + } + + const spanAttrs = Object.entries(attributes).map(([k, v]) => buildAttr(k, v)); + + const payload = buildOTLPPayload({ + traceId, + spanId: generateSpanId(), + ...(isValidSpanId(parentSpanId) ? { parentSpanId } : {}), + spanName: `${toolName}.run`, + startMs, + endMs, + serviceName: toolName, + kind: SPAN_KIND_CLIENT, + attributes: spanAttrs, + statusCode: options.isError ? 2 : 1, + ...(options.isError && options.errorMessage ? { statusMessage: options.errorMessage } : {}), + }); + + await sendOTLPSpan(endpoint, payload); + } catch (err) { + // Export failures must never break the workflow. + console.warn(`[otlp] ${toolName}: failed to emit span: ${err instanceof Error ? err.message : String(err)}`); + } +} + +module.exports = { logSpan }; diff --git a/actions/setup/js/otlp.test.cjs b/actions/setup/js/otlp.test.cjs new file mode 100644 index 00000000000..bd2157ddee3 --- /dev/null +++ b/actions/setup/js/otlp.test.cjs @@ -0,0 +1,211 @@ +// @ts-check +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createRequire } from "module"; + +const req = createRequire(import.meta.url); + +// --------------------------------------------------------------------------- +// Load otlp.cjs (module under test) and patch its send_otlp_span dependency. +// Both share the same CJS module cache, so we can replace exports on the +// already-loaded send_otlp_span module and the otlp module picks them up. +// --------------------------------------------------------------------------- + +const sendOtlpModule = req("./send_otlp_span.cjs"); +const otlp = req("./otlp.cjs"); + +/** 32 lowercase hex chars — valid OTLP trace ID */ +const VALID_TRACE_ID = "aabbccdd00112233aabbccdd00112233"; +/** 16 lowercase hex chars — valid OTLP span ID */ +const VALID_SPAN_ID = "aabbccdd00112233"; + +// Stable stubs that we swap in before each test +const mockBuildAttr = vi.fn(); +const mockBuildOTLPPayload = vi.fn(); +const mockSendOTLPSpan = vi.fn(); +const mockGenerateSpanId = vi.fn(); +const mockIsValidTraceId = vi.fn(); +const mockIsValidSpanId = vi.fn(); + +// Capture originals so we can restore them after each test +const PATCHED_KEYS = ["buildAttr", "buildOTLPPayload", "sendOTLPSpan", "generateSpanId", "isValidTraceId", "isValidSpanId"]; +const originals = Object.fromEntries(PATCHED_KEYS.map(k => [k, sendOtlpModule[k]])); + +describe("otlp.cjs", () => { + /** @type {Record} */ + let savedEnv; + + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "warn").mockImplementation(() => {}); + + // Re-apply default implementations after clearAllMocks (which resets them) + mockBuildAttr.mockImplementation((key, value) => ({ key, value })); + mockBuildOTLPPayload.mockReturnValue({ resourceSpans: [] }); + mockSendOTLPSpan.mockResolvedValue(undefined); + mockGenerateSpanId.mockReturnValue(VALID_SPAN_ID); + mockIsValidTraceId.mockImplementation(id => id === VALID_TRACE_ID); + mockIsValidSpanId.mockImplementation(id => id === VALID_SPAN_ID); + + // Patch the shared CJS module exports + sendOtlpModule.buildAttr = mockBuildAttr; + sendOtlpModule.buildOTLPPayload = mockBuildOTLPPayload; + sendOtlpModule.sendOTLPSpan = mockSendOTLPSpan; + sendOtlpModule.generateSpanId = mockGenerateSpanId; + sendOtlpModule.isValidTraceId = mockIsValidTraceId; + sendOtlpModule.isValidSpanId = mockIsValidSpanId; + // Keep SPAN_KIND_CLIENT as-is (it's a constant and does not need a stub) + + savedEnv = { + OTEL_EXPORTER_OTLP_ENDPOINT: process.env.OTEL_EXPORTER_OTLP_ENDPOINT, + GITHUB_AW_OTEL_TRACE_ID: process.env.GITHUB_AW_OTEL_TRACE_ID, + GITHUB_AW_OTEL_PARENT_SPAN_ID: process.env.GITHUB_AW_OTEL_PARENT_SPAN_ID, + }; + + process.env.GITHUB_AW_OTEL_TRACE_ID = VALID_TRACE_ID; + process.env.GITHUB_AW_OTEL_PARENT_SPAN_ID = VALID_SPAN_ID; + process.env.OTEL_EXPORTER_OTLP_ENDPOINT = "https://otel.example.com"; + }); + + afterEach(() => { + for (const [k, v] of Object.entries(originals)) { + sendOtlpModule[k] = v; + } + for (const [k, v] of Object.entries(savedEnv)) { + if (v === undefined) { + delete process.env[k]; + } else { + process.env[k] = v; + } + } + vi.restoreAllMocks(); + }); + + // --------------------------------------------------------------------------- + // logSpan — happy path + // --------------------------------------------------------------------------- + + describe("logSpan", () => { + it("calls sendOTLPSpan with a payload that includes the tool name as service name", async () => { + await otlp.logSpan("my-scanner", { "my-scanner.issues_found": 3 }); + + expect(mockSendOTLPSpan).toHaveBeenCalledOnce(); + expect(mockBuildOTLPPayload).toHaveBeenCalledOnce(); + const payloadOpts = mockBuildOTLPPayload.mock.calls[0][0]; + expect(payloadOpts.serviceName).toBe("my-scanner"); + expect(payloadOpts.spanName).toBe("my-scanner.run"); + }); + + it("uses the trace ID from GITHUB_AW_OTEL_TRACE_ID", async () => { + await otlp.logSpan("my-scanner", {}); + + const payloadOpts = mockBuildOTLPPayload.mock.calls[0][0]; + expect(payloadOpts.traceId).toBe(VALID_TRACE_ID); + }); + + it("uses the parent span ID from GITHUB_AW_OTEL_PARENT_SPAN_ID", async () => { + await otlp.logSpan("my-scanner", {}); + + const payloadOpts = mockBuildOTLPPayload.mock.calls[0][0]; + expect(payloadOpts.parentSpanId).toBe(VALID_SPAN_ID); + }); + + it("reads the endpoint from OTEL_EXPORTER_OTLP_ENDPOINT", async () => { + await otlp.logSpan("my-scanner", {}); + + expect(mockSendOTLPSpan).toHaveBeenCalledWith("https://otel.example.com", expect.anything()); + }); + + it("converts attributes object to buildAttr calls", async () => { + await otlp.logSpan("my-scanner", { "my-scanner.count": 5, "my-scanner.ok": true, "my-scanner.label": "x" }); + + expect(mockBuildAttr).toHaveBeenCalledWith("my-scanner.count", 5); + expect(mockBuildAttr).toHaveBeenCalledWith("my-scanner.ok", true); + expect(mockBuildAttr).toHaveBeenCalledWith("my-scanner.label", "x"); + }); + + it("uses statusCode 1 (OK) by default", async () => { + await otlp.logSpan("my-scanner", {}); + + const payloadOpts = mockBuildOTLPPayload.mock.calls[0][0]; + expect(payloadOpts.statusCode).toBe(1); + }); + + it("uses statusCode 2 (ERROR) when isError is true", async () => { + await otlp.logSpan("my-scanner", {}, { isError: true, errorMessage: "scan failed" }); + + const payloadOpts = mockBuildOTLPPayload.mock.calls[0][0]; + expect(payloadOpts.statusCode).toBe(2); + expect(payloadOpts.statusMessage).toBe("scan failed"); + }); + + it("accepts options.traceId override", async () => { + const customTrace = "ccddee0011223344ccddee0011223344"; + mockIsValidTraceId.mockImplementation(id => id === customTrace); + + await otlp.logSpan("my-scanner", {}, { traceId: customTrace }); + + const payloadOpts = mockBuildOTLPPayload.mock.calls[0][0]; + expect(payloadOpts.traceId).toBe(customTrace); + }); + + it("accepts options.endpoint override", async () => { + await otlp.logSpan("my-scanner", {}, { endpoint: "https://custom.otel.io" }); + + expect(mockSendOTLPSpan).toHaveBeenCalledWith("https://custom.otel.io", expect.anything()); + }); + }); + + // --------------------------------------------------------------------------- + // logSpan — missing / invalid trace ID + // --------------------------------------------------------------------------- + + describe("logSpan — missing trace ID", () => { + it("warns and skips the span when GITHUB_AW_OTEL_TRACE_ID is not set", async () => { + delete process.env.GITHUB_AW_OTEL_TRACE_ID; + mockIsValidTraceId.mockReturnValue(false); + + await otlp.logSpan("my-scanner", { "my-scanner.count": 1 }); + + expect(mockSendOTLPSpan).not.toHaveBeenCalled(); + expect(console.warn).toHaveBeenCalledWith(expect.stringContaining("GITHUB_AW_OTEL_TRACE_ID is not set")); + }); + }); + + // --------------------------------------------------------------------------- + // logSpan — error resilience + // --------------------------------------------------------------------------- + + describe("logSpan — error resilience", () => { + it("does not throw when sendOTLPSpan rejects", async () => { + mockSendOTLPSpan.mockRejectedValue(new Error("network failure")); + + await expect(otlp.logSpan("my-scanner", {})).resolves.toBeUndefined(); + expect(console.warn).toHaveBeenCalledWith(expect.stringContaining("network failure")); + }); + + it("does not throw when an internal helper throws synchronously", async () => { + mockBuildOTLPPayload.mockImplementation(() => { + throw new Error("unexpected"); + }); + + await expect(otlp.logSpan("my-scanner", {})).resolves.toBeUndefined(); + expect(console.warn).toHaveBeenCalledWith(expect.stringContaining("unexpected")); + }); + }); + + // --------------------------------------------------------------------------- + // logSpan — omits parentSpanId when invalid + // --------------------------------------------------------------------------- + + describe("logSpan — parent span ID handling", () => { + it("omits parentSpanId when GITHUB_AW_OTEL_PARENT_SPAN_ID is not set", async () => { + delete process.env.GITHUB_AW_OTEL_PARENT_SPAN_ID; + mockIsValidSpanId.mockReturnValue(false); + + await otlp.logSpan("my-scanner", {}); + + const payloadOpts = mockBuildOTLPPayload.mock.calls[0][0]; + expect(payloadOpts.parentSpanId).toBeUndefined(); + }); + }); +}); diff --git a/docs/src/content/docs/guides/custom-otlp-attributes.md b/docs/src/content/docs/guides/custom-otlp-attributes.md index 46fb92e4036..9380d652eb2 100644 --- a/docs/src/content/docs/guides/custom-otlp-attributes.md +++ b/docs/src/content/docs/guides/custom-otlp-attributes.md @@ -7,117 +7,39 @@ sidebar: 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. -## How the helpers become available +## Quick start -When a workflow runs, the `actions/setup` action copies all JavaScript helpers to `/tmp/gh-aw/actions/` before the agent job begins. Every `steps:` step in a workflow or shared import can `require()` these helpers directly, with no additional installation. - -```javascript -const { - buildAttr, - buildOTLPPayload, - sendOTLPSpan, - generateSpanId, - toNanoString, - SPAN_KIND_INTERNAL, -} = require('/tmp/gh-aw/actions/send_otlp_span.cjs'); -``` - -The helpers use only Node.js built-ins and native `fetch`, so they work in any GitHub Actions runner environment. - -## Trace context environment variables - -After the `actions/setup` step runs, it writes two environment variables to `$GITHUB_ENV` that are available to every subsequent step in the same job: - -| Variable | Description | -|----------|-------------| -| `GITHUB_AW_OTEL_TRACE_ID` | The 32-char hex trace ID shared by all spans in this workflow run. Use this as the `traceId` for any span you emit so it appears in the same trace tree. | -| `GITHUB_AW_OTEL_PARENT_SPAN_ID` | The 16-char hex span ID of the `gh-aw..setup` span. Use this as `parentSpanId` to nest your span directly under the job setup span. | - -The OTLP endpoint and headers are already resolved and exported as: - -| Variable | Description | -|----------|-------------| -| `OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP collector base URL (e.g. `https://traces.example.com:4318`). Empty when no endpoint is configured. | -| `OTEL_EXPORTER_OTLP_HEADERS` | Comma-separated `key=value` authentication headers. | - -## Core helper API - -| Function | Description | -|----------|-------------| -| `buildAttr(key, value)` | Returns a single OTLP key-value attribute. Handles `string`, `number`, and `boolean` types. | -| `buildOTLPPayload(opts)` | Assembles a complete OTLP/HTTP JSON traces payload for one span. | -| `sendOTLPSpan(endpoint, payload)` | POSTs the payload to `{endpoint}/v1/traces`. Non-fatal: failures are logged as warnings and never thrown. Writes a local JSONL mirror at `/tmp/gh-aw/otel.jsonl` regardless of whether a collector is configured. | -| `generateSpanId()` | Returns a random 16-char hex span ID. | -| `toNanoString(ms)` | Converts a millisecond timestamp to the nanosecond string format required by OTLP. | -| `SPAN_KIND_INTERNAL` | Span kind constant for an in-process operation (value: `1`). | -| `SPAN_KIND_CLIENT` | Span kind constant for an outbound call (value: `3`). | - -## Emitting a custom span from `steps:` - -Add a `steps:` entry to your shared import's frontmatter. Use `actions/github-script@v8` to run JavaScript and call the helpers: +The `otlp.cjs` helper provides a minimal, stable API. Use it in any `steps:` entry of a shared import: ```yaml wrap title=".github/workflows/shared/my-tool.md" --- # My Tool — shared import that instruments its own telemetry steps: - - name: My Tool — emit OTLP span - id: my-tool-otlp + - name: My Tool — do work and record telemetry + id: my-tool-run uses: actions/github-script@v8 - env: - OTEL_EXPORTER_OTLP_ENDPOINT: ${{ env.OTEL_EXPORTER_OTLP_ENDPOINT }} - GITHUB_AW_OTEL_TRACE_ID: ${{ env.GITHUB_AW_OTEL_TRACE_ID }} - GITHUB_AW_OTEL_PARENT_SPAN_ID: ${{ env.GITHUB_AW_OTEL_PARENT_SPAN_ID }} with: script: | - const { - buildAttr, - buildOTLPPayload, - sendOTLPSpan, - generateSpanId, - SPAN_KIND_CLIENT, - } = require('/tmp/gh-aw/actions/send_otlp_span.cjs'); + const otlp = require('/tmp/gh-aw/actions/otlp.cjs'); const startMs = Date.now(); - - // ── run your tool's work here ───────────────────────────────────── + // ── do your tool's work here ────────────────────────────────────── // const result = await myTool.run(); // ───────────────────────────────────────────────────────────────── - const endMs = Date.now(); - const endpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT; - const traceId = process.env.GITHUB_AW_OTEL_TRACE_ID; - const parentSpanId = process.env.GITHUB_AW_OTEL_PARENT_SPAN_ID; - - if (!traceId) { - core.warning('GITHUB_AW_OTEL_TRACE_ID is not set; skipping OTLP export'); - return; - } - - const payload = buildOTLPPayload({ - traceId, - spanId: generateSpanId(), - parentSpanId, // nests under the job setup span - spanName: 'my-tool.run', - startMs, - endMs, - serviceName: 'my-tool', - kind: SPAN_KIND_CLIENT, - attributes: [ - buildAttr('my-tool.version', '1.2.3'), - buildAttr('my-tool.items_processed', 42), - buildAttr('my-tool.result', 'success'), - ], - }); - - await sendOTLPSpan(endpoint, payload); + 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 been configured and its telemetry span will appear in the same trace as the workflow run. +My tool has run and its telemetry span will appear in the same distributed trace as the workflow run. ``` -Import it in any workflow: +Import the shared file in any workflow alongside the OTLP configuration: ```yaml wrap title=".github/workflows/my-workflow.md" --- @@ -125,8 +47,8 @@ on: schedule: daily engine: copilot imports: - - shared/observability-otlp.md # sets OTLP endpoint + headers - - shared/my-tool.md # installs my-tool and emits its span + - shared/observability-otlp.md # sets the OTLP endpoint + auth headers + - shared/my-tool.md # runs my-tool and records its span --- # Daily Report @@ -134,86 +56,111 @@ imports: Run the daily report using my-tool results. ``` -## Adding resource attributes +That is the complete integration. The `otlp.cjs` helper reads all required environment variables automatically — endpoint, trace ID, parent span ID — so no additional configuration is needed in the step. -Pass `resourceAttributes` to `buildOTLPPayload` to add attributes at the resource (process) level. These are indexed separately from span attributes and appear under the resource in most OTLP backends: +## `logSpan` API ```javascript -const payload = buildOTLPPayload({ - traceId, - spanId: generateSpanId(), - parentSpanId, - spanName: 'my-tool.run', - startMs, - endMs, - serviceName: 'my-tool', - attributes: [ - buildAttr('my-tool.items_processed', 42), - ], - resourceAttributes: [ - buildAttr('my-tool.version', '1.2.3'), - buildAttr('deployment.environment', 'production'), - ], -}); +const otlp = require('/tmp/gh-aw/actions/otlp.cjs'); + +await otlp.logSpan(toolName, attributes, options); ``` -## Emitting multiple spans +| Parameter | Type | Description | +|-----------|------|-------------| +| `toolName` | `string` | Logical name for the tool (e.g. `"my-scanner"`). Used as `service.name` and as the span name prefix `.run`. | +| `attributes` | `Record` | Domain-specific attributes emitted on the span. All env plumbing is handled automatically. | +| `options.startMs` | `number` | Span start time (ms since epoch). Defaults to `Date.now()`. | +| `options.endMs` | `number` | Span end time (ms since epoch). Defaults to `Date.now()`. | +| `options.isError` | `boolean` | When `true`, sets the span status to `ERROR`. | +| `options.errorMessage` | `string` | Human-readable status message included when `isError` is `true`. | +| `options.traceId` | `string` | Override trace ID. Defaults to `GITHUB_AW_OTEL_TRACE_ID`. | +| `options.parentSpanId` | `string` | Override parent span ID. Defaults to `GITHUB_AW_OTEL_PARENT_SPAN_ID`. | +| `options.endpoint` | `string` | Override OTLP endpoint. Defaults to `OTEL_EXPORTER_OTLP_ENDPOINT`. | -Call `sendOTLPSpan` multiple times — once per logical operation. Assign a unique span ID to each call. Link related spans under the same parent to build a trace tree: +`logSpan` is non-fatal: export failures and invalid trace IDs are surfaced as `console.warn` messages and never throw. -```javascript -const setupSpanId = generateSpanId(); -const querySpanId = generateSpanId(); - -// Parent span: overall operation -await sendOTLPSpan(endpoint, buildOTLPPayload({ - traceId, spanId: setupSpanId, parentSpanId, - spanName: 'my-tool.setup', startMs: t0, endMs: t1, - serviceName: 'my-tool', - attributes: [buildAttr('my-tool.phase', 'setup')], -})); +### Recording an error span -// Child span: sub-operation 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', - attributes: [buildAttr('my-tool.query.rows', 1234)], -})); +```javascript +await otlp.logSpan('my-scanner', { + 'my-scanner.items_scanned': 100, +}, { isError: true, errorMessage: 'database connection timed out' }); ``` ## Attribute naming recommendations -Follow existing gh-aw conventions to ensure your attributes are easy to find and filter in dashboards: - - Use `your-tool.` as a prefix for tool-specific attributes (e.g. `my-tool.items_processed`). -- Use [OpenTelemetry semantic conventions](https://opentelemetry.io/docs/specs/semconv/) for cross-cutting concerns (e.g. `db.system`, `http.response.status_code`, `gen_ai.usage.input_tokens`). -- Avoid attribute names containing `token`, `secret`, `password`, `key`, or `auth` — the helpers automatically redact the values of matching attributes before sending (see [Security](#security)). +- Use [OpenTelemetry semantic conventions](https://opentelemetry.io/docs/specs/semconv/) 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. ## Security -All attribute values pass through `sanitizeOTLPPayload` before the payload is sent over the wire. This function: +All attribute values are sanitized before the payload is sent over the wire: -- **Redacts** the string 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]`. +- **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 automatic — you do not need to call it yourself. `sendOTLPSpan` applies it internally before every HTTP request. +Sanitization is automatic — you do not need to call it yourself. ## Debugging without a live collector -`sendOTLPSpan` always appends every payload as a JSON line to `/tmp/gh-aw/otel.jsonl`, even when `OTEL_EXPORTER_OTLP_ENDPOINT` is not set. This file is included in the `firewall-audit-logs` artifact so you can inspect spans locally after the run: +Every span is always appended as a JSON line to `/tmp/gh-aw/otel.jsonl`, even when `OTEL_EXPORTER_OTLP_ENDPOINT` is not set. This file is included in the `firewall-audit-logs` artifact so you can inspect spans after the run: ```bash -# Download logs for a run +# Download firewall/telemetry artifacts for a run gh aw logs --artifacts firewall # Inspect spans emitted by your tool cat otel.jsonl | jq 'select(.resourceSpans[].scopeSpans[].spans[].name | startswith("my-tool"))' ``` +## Advanced: low-level API + +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: + +| Variable | Description | +|----------|-------------| +| `GITHUB_AW_OTEL_TRACE_ID` | 32-char hex trace ID shared by all spans in this run. | +| `GITHUB_AW_OTEL_PARENT_SPAN_ID` | 16-char hex span ID of the job setup span; use as `parentSpanId` to nest spans under it. | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP collector base URL. | +| `OTEL_EXPORTER_OTLP_HEADERS` | Comma-separated `key=value` authentication headers. | + +```javascript +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)], +})); +``` + ## Related documentation - [Observability (`observability:`)](/gh-aw/reference/frontmatter/#observability-observability) — configure the OTLP endpoint and headers - [Imports](/gh-aw/reference/imports/) — how shared workflow imports work - [Deterministic Agentic Patterns](/gh-aw/guides/deterministic-agentic-patterns/) — adding custom `steps:` to workflows - [Artifacts](/gh-aw/reference/artifacts/) — downloading the `otel.jsonl` mirror and other artifacts + From ddbf5035754bce9c5b65cc25c2ce939ab990a32f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 1 May 2026 21:00:08 +0000 Subject: [PATCH 3/9] feat: require shim.cjs in otlp.cjs to ensure global.core and global.context are available Agent-Logs-Url: https://github.com/github/gh-aw/sessions/048292c0-1229-4789-a893-5e967b10c76f Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/otlp.cjs | 5 +++++ actions/setup/js/otlp.test.cjs | 15 +++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/actions/setup/js/otlp.cjs b/actions/setup/js/otlp.cjs index 1094b475cdc..57379c958e2 100644 --- a/actions/setup/js/otlp.cjs +++ b/actions/setup/js/otlp.cjs @@ -26,6 +26,11 @@ const path = require("path"); +// Ensures global.core / global.context shims are available when this module +// is loaded outside the github-script runtime (e.g., in plain Node.js or the +// MCP server context where those globals are not injected automatically). +require(path.join(__dirname, "shim.cjs")); + // --------------------------------------------------------------------------- // Internal: lazy-load send_otlp_span.cjs so the module can be required from // the runtime actions directory (/tmp/gh-aw/actions/) without knowing the diff --git a/actions/setup/js/otlp.test.cjs b/actions/setup/js/otlp.test.cjs index bd2157ddee3..46bd0759205 100644 --- a/actions/setup/js/otlp.test.cjs +++ b/actions/setup/js/otlp.test.cjs @@ -80,6 +80,21 @@ describe("otlp.cjs", () => { vi.restoreAllMocks(); }); + // --------------------------------------------------------------------------- + // shim.cjs integration — global.core must be available after require + // --------------------------------------------------------------------------- + + describe("shim integration", () => { + it("populates global.core when otlp.cjs is loaded", () => { + // otlp.cjs requires shim.cjs at module load time; by the time we reach + // this test global.core must already be set (either by the real + // github-script runtime or by the shim). + expect(global.core).toBeDefined(); + expect(typeof global.core.warning).toBe("function"); + expect(typeof global.core.info).toBe("function"); + }); + }); + // --------------------------------------------------------------------------- // logSpan — happy path // --------------------------------------------------------------------------- From 39dfff0732399f390cc796eb59bb39b5548b108b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 1 May 2026 21:09:23 +0000 Subject: [PATCH 4/9] refactor: remove core() helper from otlp.cjs; use require() inline instead Agent-Logs-Url: https://github.com/github/gh-aw/sessions/ed5944ce-df74-4de6-a600-01a233a15173 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/otlp.cjs | 22 +--------------------- 1 file changed, 1 insertion(+), 21 deletions(-) diff --git a/actions/setup/js/otlp.cjs b/actions/setup/js/otlp.cjs index 57379c958e2..dd67ca8fa69 100644 --- a/actions/setup/js/otlp.cjs +++ b/actions/setup/js/otlp.cjs @@ -31,26 +31,6 @@ const path = require("path"); // MCP server context where those globals are not injected automatically). require(path.join(__dirname, "shim.cjs")); -// --------------------------------------------------------------------------- -// Internal: lazy-load send_otlp_span.cjs so the module can be required from -// the runtime actions directory (/tmp/gh-aw/actions/) without knowing the -// absolute path of this file at author time. -// --------------------------------------------------------------------------- - -/** @type {typeof import('./send_otlp_span.cjs') | null} */ -let _core = null; - -/** - * Return the low-level send_otlp_span module, resolved relative to this file. - * @returns {typeof import('./send_otlp_span.cjs')} - */ -function core() { - if (!_core) { - _core = require(path.join(__dirname, "send_otlp_span.cjs")); - } - return _core; -} - // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- @@ -92,7 +72,7 @@ function core() { */ async function logSpan(toolName, attributes = {}, options = {}) { try { - const { buildAttr, buildOTLPPayload, sendOTLPSpan, generateSpanId, isValidTraceId, isValidSpanId, SPAN_KIND_CLIENT } = core(); + const { buildAttr, buildOTLPPayload, sendOTLPSpan, generateSpanId, isValidTraceId, isValidSpanId, SPAN_KIND_CLIENT } = require(path.join(__dirname, "send_otlp_span.cjs")); const now = Date.now(); const startMs = options.startMs ?? now; From fca2491ee42c8f1795f5d576b8a89bb519932ad8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 1 May 2026 21:36:15 +0000 Subject: [PATCH 5/9] fix: sanitize OTLP payload before writing JSONL mirror in otlp.cjs Agent-Logs-Url: https://github.com/github/gh-aw/sessions/0b99b385-a7e4-4c5f-87e5-bda110b61540 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/otlp.cjs | 8 ++++++-- actions/setup/js/otlp.test.cjs | 26 +++++++++++++++++++++++--- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/actions/setup/js/otlp.cjs b/actions/setup/js/otlp.cjs index dd67ca8fa69..22d6b0586d1 100644 --- a/actions/setup/js/otlp.cjs +++ b/actions/setup/js/otlp.cjs @@ -72,7 +72,7 @@ require(path.join(__dirname, "shim.cjs")); */ async function logSpan(toolName, attributes = {}, options = {}) { try { - const { buildAttr, buildOTLPPayload, sendOTLPSpan, generateSpanId, isValidTraceId, isValidSpanId, SPAN_KIND_CLIENT } = require(path.join(__dirname, "send_otlp_span.cjs")); + const { buildAttr, buildOTLPPayload, sendOTLPSpan, sanitizeOTLPPayload, appendToOTLPJSONL, generateSpanId, isValidTraceId, isValidSpanId, SPAN_KIND_CLIENT } = require(path.join(__dirname, "send_otlp_span.cjs")); const now = Date.now(); const startMs = options.startMs ?? now; @@ -103,7 +103,11 @@ async function logSpan(toolName, attributes = {}, options = {}) { ...(options.isError && options.errorMessage ? { statusMessage: options.errorMessage } : {}), }); - await sendOTLPSpan(endpoint, payload); + // Sanitize before mirroring so that the local JSONL debug file never + // contains secrets, just like the over-the-wire export. + appendToOTLPJSONL(sanitizeOTLPPayload(payload)); + // skipJSONL: true because we already wrote the sanitized mirror above. + await sendOTLPSpan(endpoint, payload, { skipJSONL: true }); } catch (err) { // Export failures must never break the workflow. console.warn(`[otlp] ${toolName}: failed to emit span: ${err instanceof Error ? err.message : String(err)}`); diff --git a/actions/setup/js/otlp.test.cjs b/actions/setup/js/otlp.test.cjs index 46bd0759205..5aab2e5db5b 100644 --- a/actions/setup/js/otlp.test.cjs +++ b/actions/setup/js/otlp.test.cjs @@ -22,12 +22,14 @@ const VALID_SPAN_ID = "aabbccdd00112233"; const mockBuildAttr = vi.fn(); const mockBuildOTLPPayload = vi.fn(); const mockSendOTLPSpan = vi.fn(); +const mockSanitizeOTLPPayload = vi.fn(); +const mockAppendToOTLPJSONL = vi.fn(); const mockGenerateSpanId = vi.fn(); const mockIsValidTraceId = vi.fn(); const mockIsValidSpanId = vi.fn(); // Capture originals so we can restore them after each test -const PATCHED_KEYS = ["buildAttr", "buildOTLPPayload", "sendOTLPSpan", "generateSpanId", "isValidTraceId", "isValidSpanId"]; +const PATCHED_KEYS = ["buildAttr", "buildOTLPPayload", "sendOTLPSpan", "sanitizeOTLPPayload", "appendToOTLPJSONL", "generateSpanId", "isValidTraceId", "isValidSpanId"]; const originals = Object.fromEntries(PATCHED_KEYS.map(k => [k, sendOtlpModule[k]])); describe("otlp.cjs", () => { @@ -42,6 +44,8 @@ describe("otlp.cjs", () => { mockBuildAttr.mockImplementation((key, value) => ({ key, value })); mockBuildOTLPPayload.mockReturnValue({ resourceSpans: [] }); mockSendOTLPSpan.mockResolvedValue(undefined); + mockSanitizeOTLPPayload.mockImplementation(p => p); + mockAppendToOTLPJSONL.mockReturnValue(undefined); mockGenerateSpanId.mockReturnValue(VALID_SPAN_ID); mockIsValidTraceId.mockImplementation(id => id === VALID_TRACE_ID); mockIsValidSpanId.mockImplementation(id => id === VALID_SPAN_ID); @@ -50,6 +54,8 @@ describe("otlp.cjs", () => { sendOtlpModule.buildAttr = mockBuildAttr; sendOtlpModule.buildOTLPPayload = mockBuildOTLPPayload; sendOtlpModule.sendOTLPSpan = mockSendOTLPSpan; + sendOtlpModule.sanitizeOTLPPayload = mockSanitizeOTLPPayload; + sendOtlpModule.appendToOTLPJSONL = mockAppendToOTLPJSONL; sendOtlpModule.generateSpanId = mockGenerateSpanId; sendOtlpModule.isValidTraceId = mockIsValidTraceId; sendOtlpModule.isValidSpanId = mockIsValidSpanId; @@ -127,7 +133,7 @@ describe("otlp.cjs", () => { it("reads the endpoint from OTEL_EXPORTER_OTLP_ENDPOINT", async () => { await otlp.logSpan("my-scanner", {}); - expect(mockSendOTLPSpan).toHaveBeenCalledWith("https://otel.example.com", expect.anything()); + expect(mockSendOTLPSpan).toHaveBeenCalledWith("https://otel.example.com", expect.anything(), { skipJSONL: true }); }); it("converts attributes object to buildAttr calls", async () => { @@ -166,7 +172,21 @@ describe("otlp.cjs", () => { it("accepts options.endpoint override", async () => { await otlp.logSpan("my-scanner", {}, { endpoint: "https://custom.otel.io" }); - expect(mockSendOTLPSpan).toHaveBeenCalledWith("https://custom.otel.io", expect.anything()); + expect(mockSendOTLPSpan).toHaveBeenCalledWith("https://custom.otel.io", expect.anything(), { skipJSONL: true }); + }); + + it("sanitizes the payload before writing to the JSONL mirror", async () => { + const rawPayload = { resourceSpans: ["raw"] }; + const sanitizedPayload = { resourceSpans: ["sanitized"] }; + mockBuildOTLPPayload.mockReturnValue(rawPayload); + mockSanitizeOTLPPayload.mockReturnValue(sanitizedPayload); + + await otlp.logSpan("my-scanner", {}); + + expect(mockSanitizeOTLPPayload).toHaveBeenCalledWith(rawPayload); + expect(mockAppendToOTLPJSONL).toHaveBeenCalledWith(sanitizedPayload); + // Wire export still uses the original payload (sendOTLPSpan sanitizes internally) + expect(mockSendOTLPSpan).toHaveBeenCalledWith(expect.any(String), rawPayload, { skipJSONL: true }); }); }); From 36d5fe481fdf51b765ef93c6bb12046a99da0a16 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 1 May 2026 21:41:28 +0000 Subject: [PATCH 6/9] fix: skip HTTP export when OTEL endpoint unset; sanitized JSONL still written; update docs Agent-Logs-Url: https://github.com/github/gh-aw/sessions/71150d30-6996-41ac-9437-d6062c8168cb Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/otlp.cjs | 6 ++++-- actions/setup/js/otlp.test.cjs | 9 +++++++++ docs/src/content/docs/guides/custom-otlp-attributes.md | 6 +++--- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/actions/setup/js/otlp.cjs b/actions/setup/js/otlp.cjs index 22d6b0586d1..51541422b94 100644 --- a/actions/setup/js/otlp.cjs +++ b/actions/setup/js/otlp.cjs @@ -106,8 +106,10 @@ async function logSpan(toolName, attributes = {}, options = {}) { // Sanitize before mirroring so that the local JSONL debug file never // contains secrets, just like the over-the-wire export. appendToOTLPJSONL(sanitizeOTLPPayload(payload)); - // skipJSONL: true because we already wrote the sanitized mirror above. - await sendOTLPSpan(endpoint, payload, { skipJSONL: true }); + // Only attempt the HTTP export when an endpoint is configured. + if (endpoint) { + await sendOTLPSpan(endpoint, payload, { skipJSONL: true }); + } } catch (err) { // Export failures must never break the workflow. console.warn(`[otlp] ${toolName}: failed to emit span: ${err instanceof Error ? err.message : String(err)}`); diff --git a/actions/setup/js/otlp.test.cjs b/actions/setup/js/otlp.test.cjs index 5aab2e5db5b..d1a60691c29 100644 --- a/actions/setup/js/otlp.test.cjs +++ b/actions/setup/js/otlp.test.cjs @@ -175,6 +175,15 @@ describe("otlp.cjs", () => { expect(mockSendOTLPSpan).toHaveBeenCalledWith("https://custom.otel.io", expect.anything(), { skipJSONL: true }); }); + it("does not attempt HTTP export when OTEL_EXPORTER_OTLP_ENDPOINT is not set", async () => { + delete process.env.OTEL_EXPORTER_OTLP_ENDPOINT; + + await otlp.logSpan("my-scanner", {}); + + expect(mockSendOTLPSpan).not.toHaveBeenCalled(); + expect(mockAppendToOTLPJSONL).toHaveBeenCalledOnce(); + }); + it("sanitizes the payload before writing to the JSONL mirror", async () => { const rawPayload = { resourceSpans: ["raw"] }; const sanitizedPayload = { resourceSpans: ["sanitized"] }; diff --git a/docs/src/content/docs/guides/custom-otlp-attributes.md b/docs/src/content/docs/guides/custom-otlp-attributes.md index 9380d652eb2..f40145cbf90 100644 --- a/docs/src/content/docs/guides/custom-otlp-attributes.md +++ b/docs/src/content/docs/guides/custom-otlp-attributes.md @@ -96,16 +96,16 @@ await otlp.logSpan('my-scanner', { ## Security -All attribute values are sanitized before the payload is sent over the wire: +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 automatic — you do not need to call it yourself. +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. ## Debugging without a live collector -Every span is always appended as a JSON line to `/tmp/gh-aw/otel.jsonl`, even when `OTEL_EXPORTER_OTLP_ENDPOINT` is not set. This file is included in the `firewall-audit-logs` artifact so you can inspect spans after the run: +Every span is always appended as a sanitized JSON line to `/tmp/gh-aw/otel.jsonl`, even when `OTEL_EXPORTER_OTLP_ENDPOINT` is not set. This file is included in the `firewall-audit-logs` artifact so you can inspect spans after the run: ```bash # Download firewall/telemetry artifacts for a run From d5666cc9f8883bc32b679ef9f4dcd92678b2b3c1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 1 May 2026 21:47:22 +0000 Subject: [PATCH 7/9] fix: silently skip logSpan when GITHUB_AW_OTEL_TRACE_ID is missing Agent-Logs-Url: https://github.com/github/gh-aw/sessions/9ebf1e4e-dce4-44e6-9dcb-194b3f152d88 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/otlp.cjs | 1 - actions/setup/js/otlp.test.cjs | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/actions/setup/js/otlp.cjs b/actions/setup/js/otlp.cjs index 51541422b94..e953e156d0f 100644 --- a/actions/setup/js/otlp.cjs +++ b/actions/setup/js/otlp.cjs @@ -83,7 +83,6 @@ async function logSpan(toolName, attributes = {}, options = {}) { const parentSpanId = options.parentSpanId ?? process.env.GITHUB_AW_OTEL_PARENT_SPAN_ID ?? ""; if (!isValidTraceId(traceId)) { - console.warn(`[otlp] ${toolName}: GITHUB_AW_OTEL_TRACE_ID is not set or invalid; skipping span`); return; } diff --git a/actions/setup/js/otlp.test.cjs b/actions/setup/js/otlp.test.cjs index d1a60691c29..8d8d59a034d 100644 --- a/actions/setup/js/otlp.test.cjs +++ b/actions/setup/js/otlp.test.cjs @@ -204,14 +204,14 @@ describe("otlp.cjs", () => { // --------------------------------------------------------------------------- describe("logSpan — missing trace ID", () => { - it("warns and skips the span when GITHUB_AW_OTEL_TRACE_ID is not set", async () => { + it("silently skips the span when GITHUB_AW_OTEL_TRACE_ID is not set", async () => { delete process.env.GITHUB_AW_OTEL_TRACE_ID; mockIsValidTraceId.mockReturnValue(false); await otlp.logSpan("my-scanner", { "my-scanner.count": 1 }); expect(mockSendOTLPSpan).not.toHaveBeenCalled(); - expect(console.warn).toHaveBeenCalledWith(expect.stringContaining("GITHUB_AW_OTEL_TRACE_ID is not set")); + expect(console.warn).not.toHaveBeenCalled(); }); }); From 8c0c84400a6f4660fe22ab2329ec41d61480ab54 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 1 May 2026 21:54:31 +0000 Subject: [PATCH 8/9] docs: fix logSpan non-fatal description to reflect silent skip on missing trace ID Agent-Logs-Url: https://github.com/github/gh-aw/sessions/03160233-1e86-4379-ae68-df738ddd0f87 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- docs/src/content/docs/guides/custom-otlp-attributes.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/content/docs/guides/custom-otlp-attributes.md b/docs/src/content/docs/guides/custom-otlp-attributes.md index f40145cbf90..942628e0de2 100644 --- a/docs/src/content/docs/guides/custom-otlp-attributes.md +++ b/docs/src/content/docs/guides/custom-otlp-attributes.md @@ -78,7 +78,7 @@ await otlp.logSpan(toolName, attributes, options); | `options.parentSpanId` | `string` | Override parent span ID. Defaults to `GITHUB_AW_OTEL_PARENT_SPAN_ID`. | | `options.endpoint` | `string` | Override OTLP endpoint. Defaults to `OTEL_EXPORTER_OTLP_ENDPOINT`. | -`logSpan` is non-fatal: export failures and invalid trace IDs are surfaced as `console.warn` messages and never throw. +`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. ### Recording an error span From 039a0e3691c2a6509031c472a3f730836343637b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 1 May 2026 22:00:35 +0000 Subject: [PATCH 9/9] docs: add OTLP telemetry section to qmd.md for index size and search hits Agent-Logs-Url: https://github.com/github/gh-aw/sessions/e70388f4-5e8e-49f7-babf-1405b37ecf31 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- docs/src/content/docs/reference/qmd.md | 57 ++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/docs/src/content/docs/reference/qmd.md b/docs/src/content/docs/reference/qmd.md index a74ce360b9e..881c2da9366 100644 --- a/docs/src/content/docs/reference/qmd.md +++ b/docs/src/content/docs/reference/qmd.md @@ -117,9 +117,66 @@ tools: In read-only mode, the index is restored from cache and no indexing steps are run. This is useful when the index is built separately and shared across workflows. +## Telemetry + +Use `otlp.cjs` in a shared import step to record qmd index size and search hits alongside the workflow's distributed trace. + +```yaml wrap title=".github/workflows/shared/qmd-otlp.md" +--- +# Shared import: emit qmd index size and search hit counts after the agent job. + +steps: + - name: Record qmd telemetry + id: qmd-otlp + uses: actions/github-script@v8 + with: + script: | + const fs = require('fs'); + const otlp = require('/tmp/gh-aw/actions/otlp.cjs'); + + // qmd writes index stats to /tmp/gh-aw/qmd/stats.json after indexing. + let indexSize = 0; + try { + const stats = JSON.parse(fs.readFileSync('/tmp/gh-aw/qmd/stats.json', 'utf8')); + indexSize = stats.index_size ?? 0; + } catch { /* index not available */ } + + // qmd appends one JSON line per query to /tmp/gh-aw/qmd/queries.jsonl. + let hits = 0; + try { + const lines = fs.readFileSync('/tmp/gh-aw/qmd/queries.jsonl', 'utf8').trim().split('\n'); + hits = lines.reduce((sum, l) => { + try { return sum + (JSON.parse(l).hits ?? 0); } catch { return sum; } + }, 0); + } catch { /* no queries yet */ } + + await otlp.logSpan('qmd', { + 'qmd.index.size': indexSize, + 'qmd.search.hits': hits, + }); +--- +``` + +Import the shared file in any workflow that uses qmd: + +```aw wrap +--- +on: push +engine: copilot +imports: + - shared/observability-otlp.md # sets OTEL_EXPORTER_OTLP_ENDPOINT + - shared/qmd-otlp.md # records index size and search hits +tools: + qmd: + checkouts: + - pattern: "docs/**/*.md" +--- +``` + ## Related Documentation - [Tools](/gh-aw/reference/tools/) - Overview of all available tools and configuration - [Frontmatter](/gh-aw/reference/frontmatter/) - Complete frontmatter configuration guide - [Cache Memory](/gh-aw/reference/cache-memory/) - Persistent memory across workflow runs - [GitHub Tools](/gh-aw/reference/github-tools/) - GitHub API operations +- [Custom OTLP Attributes](/gh-aw/guides/custom-otlp-attributes/) - Emit telemetry from shared imports