-
Notifications
You must be signed in to change notification settings - Fork 373
docs: add otlp.cjs helper and guide for emitting custom OTLP attributes from shared workflows #29623
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
docs: add otlp.cjs helper and guide for emitting custom OTLP attributes from shared workflows #29623
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
62b9cb1
docs: add guide for emitting custom OTLP attributes from shared workf…
Copilot 927c3f6
feat: add otlp.cjs high-level helper and update custom OTLP attribute…
Copilot ddbf503
feat: require shim.cjs in otlp.cjs to ensure global.core and global.c…
Copilot 39dfff0
refactor: remove core() helper from otlp.cjs; use require() inline in…
Copilot fca2491
fix: sanitize OTLP payload before writing JSONL mirror in otlp.cjs
Copilot 36d5fe4
fix: skip HTTP export when OTEL endpoint unset; sanitized JSONL still…
Copilot d5666cc
fix: silently skip logSpan when GITHUB_AW_OTEL_TRACE_ID is missing
Copilot 8c0c844
docs: fix logSpan non-fatal description to reflect silent skip on mis…
Copilot 039a0e3
docs: add OTLP telemetry section to qmd.md for index size and search …
Copilot File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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: `<toolName>.run`. | ||
| * | ||
| * @param {Record<string, string | number | boolean>} [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<void>} | ||
| */ | ||
| 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 }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string, string | undefined>} */ | ||
| 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(); | ||
| }); | ||
| }); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There’s no unit test covering the “endpoint not set” path. Since the guide explicitly calls out debugging without a live collector, it would be good to add a test that deletes
OTEL_EXPORTER_OTLP_ENDPOINTand asserts the expected behavior (e.g., no HTTP export attempt / no warning spam, while still producing the JSONL mirror if that’s intended).