Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions actions/setup/js/otlp.cjs
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 };
255 changes: 255 additions & 0 deletions actions/setup/js/otlp.test.cjs
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 });
});

Copy link

Copilot AI May 1, 2026

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_ENDPOINT and asserts the expected behavior (e.g., no HTTP export attempt / no warning spam, while still producing the JSONL mirror if that’s intended).

Suggested change
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();
});

Copilot uses AI. Check for mistakes.
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();
});
});
});
1 change: 1 addition & 0 deletions docs/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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/' },
],
},
{
Expand Down
Loading