diff --git a/actions/setup/js/otlp.cjs b/actions/setup/js/otlp.cjs new file mode 100644 index 0000000000..e953e156d0 --- /dev/null +++ b/actions/setup/js/otlp.cjs @@ -0,0 +1,118 @@ +// @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"); + +// 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")); + +// --------------------------------------------------------------------------- +// 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, 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; + 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)) { + 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 } : {}), + }); + + // Sanitize before mirroring so that the local JSONL debug file never + // contains secrets, just like the over-the-wire export. + appendToOTLPJSONL(sanitizeOTLPPayload(payload)); + // 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)}`); + } +} + +module.exports = { logSpan }; diff --git a/actions/setup/js/otlp.test.cjs b/actions/setup/js/otlp.test.cjs new file mode 100644 index 0000000000..8d8d59a034 --- /dev/null +++ b/actions/setup/js/otlp.test.cjs @@ -0,0 +1,255 @@ +// @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 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", "sanitizeOTLPPayload", "appendToOTLPJSONL", "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); + 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); + + // Patch the shared CJS module exports + sendOtlpModule.buildAttr = mockBuildAttr; + sendOtlpModule.buildOTLPPayload = mockBuildOTLPPayload; + sendOtlpModule.sendOTLPSpan = mockSendOTLPSpan; + sendOtlpModule.sanitizeOTLPPayload = mockSanitizeOTLPPayload; + sendOtlpModule.appendToOTLPJSONL = mockAppendToOTLPJSONL; + 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(); + }); + + // --------------------------------------------------------------------------- + // 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 + // --------------------------------------------------------------------------- + + 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(), { skipJSONL: true }); + }); + + 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(), { 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"] }; + 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 }); + }); + }); + + // --------------------------------------------------------------------------- + // logSpan — missing / invalid trace ID + // --------------------------------------------------------------------------- + + describe("logSpan — missing trace ID", () => { + 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).not.toHaveBeenCalled(); + }); + }); + + // --------------------------------------------------------------------------- + // 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/astro.config.mjs b/docs/astro.config.mjs index d482d78bfe..262edcf4de 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 0000000000..942628e0de --- /dev/null +++ b/docs/src/content/docs/guides/custom-otlp-attributes.md @@ -0,0 +1,166 @@ +--- +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. + +## Quick start + +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 — 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: + +```yaml wrap title=".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. +``` + +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. + +## `logSpan` API + +```javascript +const otlp = require('/tmp/gh-aw/actions/otlp.cjs'); + +await otlp.logSpan(toolName, attributes, options); +``` + +| 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`. | + +`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 + +```javascript +await otlp.logSpan('my-scanner', { + 'my-scanner.items_scanned': 100, +}, { isError: true, errorMessage: 'database connection timed out' }); +``` + +## Attribute naming recommendations + +- 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`). +- Avoid attribute names containing `token`, `secret`, `password`, `key`, or `auth` — the helpers automatically redact matching attribute values before sending. + +## Security + +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. + +## Debugging without a live collector + +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 +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 + diff --git a/docs/src/content/docs/reference/qmd.md b/docs/src/content/docs/reference/qmd.md index a74ce360b9..881c2da936 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