diff --git a/actions/setup/js/action_conclusion_otlp.cjs b/actions/setup/js/action_conclusion_otlp.cjs index 8bdcb6e32f1..4cc83964639 100644 --- a/actions/setup/js/action_conclusion_otlp.cjs +++ b/actions/setup/js/action_conclusion_otlp.cjs @@ -26,7 +26,8 @@ * execution window rather than this step's overhead. * GITHUB_AW_OTEL_TRACE_ID – parent trace ID (set by action_setup_otlp.cjs) * GITHUB_AW_OTEL_PARENT_SPAN_ID – parent span ID (set by action_setup_otlp.cjs) - * OTEL_EXPORTER_OTLP_ENDPOINT – OTLP endpoint (no-op when not set) + * OTEL_EXPORTER_OTLP_ENDPOINT – OTLP endpoint (HTTP export skipped when not set; + * JSONL mirror write is attempted regardless) * * Runtime files read (optional): * /tmp/gh-aw/github_rate_limits.jsonl – GitHub API rate-limit log written by @@ -50,10 +51,6 @@ const { getActionInput } = require("./action_input_utils.cjs"); */ async function run() { const endpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT; - if (!endpoint) { - console.log("[otlp] OTEL_EXPORTER_OTLP_ENDPOINT not set, skipping conclusion span"); - return; - } // Read the job-start timestamp written by action_setup_otlp so the conclusion // span duration covers the actual job execution window, not just this step's overhead. @@ -62,10 +59,18 @@ async function run() { const jobName = getActionInput("JOB_NAME"); const spanName = jobName ? `gh-aw.${jobName}.conclusion` : "gh-aw.job.conclusion"; - console.log(`[otlp] sending conclusion span "${spanName}" to ${endpoint}`); + + if (!endpoint) { + console.log("[otlp] OTEL_EXPORTER_OTLP_ENDPOINT not set, skipping OTLP export (will attempt JSONL mirror)"); + } else { + console.log(`[otlp] sending conclusion span "${spanName}" to ${endpoint}`); + } await sendOtlpSpan.sendJobConclusionSpan(spanName, { startMs }); - console.log(`[otlp] conclusion span sent`); + + if (endpoint) { + console.log(`[otlp] conclusion span export attempted`); + } } module.exports = { run }; diff --git a/actions/setup/js/action_conclusion_otlp.test.cjs b/actions/setup/js/action_conclusion_otlp.test.cjs index 7b414437076..efe7daeb4f0 100644 --- a/actions/setup/js/action_conclusion_otlp.test.cjs +++ b/actions/setup/js/action_conclusion_otlp.test.cjs @@ -68,17 +68,17 @@ describe("action_conclusion_otlp.cjs", () => { }); describe("when OTEL_EXPORTER_OTLP_ENDPOINT is not set", () => { - it("should log that the endpoint is not set and skip span", async () => { + it("should log that OTLP export is skipped and JSONL mirror will be attempted", async () => { await run(); - expect(console.log).toHaveBeenCalledWith("[otlp] OTEL_EXPORTER_OTLP_ENDPOINT not set, skipping conclusion span"); - expect(mockSendJobConclusionSpan).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith("[otlp] OTEL_EXPORTER_OTLP_ENDPOINT not set, skipping OTLP export (will attempt JSONL mirror)"); }); - it("should not call sendJobConclusionSpan", async () => { + it("should still call sendJobConclusionSpan for JSONL mirror", async () => { await run(); - expect(mockSendJobConclusionSpan).not.toHaveBeenCalled(); + expect(mockSendJobConclusionSpan).toHaveBeenCalledOnce(); + expect(mockSendJobConclusionSpan).toHaveBeenCalledWith("gh-aw.job.conclusion", { startMs: undefined }); }); }); @@ -93,10 +93,10 @@ describe("action_conclusion_otlp.cjs", () => { expect(mockSendJobConclusionSpan).toHaveBeenCalledOnce(); }); - it("should log the conclusion span as sent", async () => { + it("should log the conclusion span export as attempted", async () => { await run(); - expect(console.log).toHaveBeenCalledWith("[otlp] conclusion span sent"); + expect(console.log).toHaveBeenCalledWith("[otlp] conclusion span export attempted"); }); it("should log the endpoint URL in the sending message", async () => { diff --git a/actions/setup/js/send_otlp_span.cjs b/actions/setup/js/send_otlp_span.cjs index f65bada1bf0..8c077000c20 100644 --- a/actions/setup/js/send_otlp_span.cjs +++ b/actions/setup/js/send_otlp_span.cjs @@ -310,12 +310,16 @@ function sanitizeOTLPPayload(payload) { * * @param {string} endpoint - OTLP base URL (e.g. https://traces.example.com:4317) * @param {object} payload - Serialisable OTLP JSON object - * @param {{ maxRetries?: number, baseDelayMs?: number }} [opts] + * @param {{ maxRetries?: number, baseDelayMs?: number, skipJSONL?: boolean }} [opts] * @returns {Promise} */ -async function sendOTLPSpan(endpoint, payload, { maxRetries = 2, baseDelayMs = 100 } = {}) { +async function sendOTLPSpan(endpoint, payload, { maxRetries = 2, baseDelayMs = 100, skipJSONL = false } = {}) { // Mirror payload locally so it survives even when the collector is unreachable. - appendToOTLPJSONL(payload); + // Callers that already wrote the JSONL mirror pass skipJSONL: true to avoid a + // duplicate line. + if (!skipJSONL) { + appendToOTLPJSONL(payload); + } const url = endpoint.replace(/\/$/, "") + "/v1/traces"; const extraHeaders = parseOTLPHeaders(process.env.OTEL_EXPORTER_OTLP_HEADERS || ""); @@ -463,11 +467,8 @@ async function sendJobSetupSpan(options = {}) { // scripts to establish the correct parent span context. const spanId = generateSpanId(); - const endpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT || ""; - if (!endpoint) { - return { traceId, spanId }; - } - + // Build the full payload unconditionally so the JSONL mirror is always written, + // enabling artifact-based debugging even without a live OTLP collector. const startMs = options.startMs ?? nowMs(); const endMs = nowMs(); @@ -525,7 +526,16 @@ async function sendJobSetupSpan(options = {}) { resourceAttributes, }); - await sendOTLPSpan(endpoint, payload); + // Always mirror to JSONL — the artifact is useful even without a live collector. + appendToOTLPJSONL(payload); + + const endpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT || ""; + if (!endpoint) { + return { traceId, spanId }; + } + + // Pass skipJSONL: true so sendOTLPSpan doesn't double-write the mirror. + await sendOTLPSpan(endpoint, payload, { skipJSONL: true }); return { traceId, spanId }; } @@ -595,8 +605,11 @@ function readLastRateLimitEntry() { * setup action. The span carries workflow metadata read from `aw_info.json` * and the effective token count from `GH_AW_EFFECTIVE_TOKENS`. * - * This is a no-op when `OTEL_EXPORTER_OTLP_ENDPOINT` is not set. All errors - * are surfaced as `console.warn` messages and never re-thrown. + * The span payload is always built and mirrored to the local JSONL file so + * that it can be inspected via GitHub Actions artifacts without needing a live + * collector. The HTTP export to the OTLP endpoint is skipped when + * `OTEL_EXPORTER_OTLP_ENDPOINT` is not set. All errors are surfaced as + * `console.warn` messages and never re-thrown. * * Environment variables consumed: * - `OTEL_EXPORTER_OTLP_ENDPOINT` – collector endpoint @@ -623,11 +636,6 @@ function readLastRateLimitEntry() { * @returns {Promise} */ async function sendJobConclusionSpan(spanName, options = {}) { - const endpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT || ""; - if (!endpoint) { - return; - } - const startMs = options.startMs ?? nowMs(); // Read workflow metadata from aw_info.json (written by the agent job setup step). @@ -765,7 +773,16 @@ async function sendJobConclusionSpan(spanName, options = {}) { statusMessage, }); - await sendOTLPSpan(endpoint, payload); + // Always mirror to JSONL — the artifact is useful even without a live collector. + appendToOTLPJSONL(payload); + + const endpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT || ""; + if (!endpoint) { + return; + } + + // Pass skipJSONL: true so sendOTLPSpan doesn't double-write the mirror. + await sendOTLPSpan(endpoint, payload, { skipJSONL: true }); } module.exports = { diff --git a/actions/setup/js/send_otlp_span.test.cjs b/actions/setup/js/send_otlp_span.test.cjs index 762f9c8e228..3b7505ce477 100644 --- a/actions/setup/js/send_otlp_span.test.cjs +++ b/actions/setup/js/send_otlp_span.test.cjs @@ -657,6 +657,14 @@ describe("sendOTLPSpan JSONL mirror", () => { warnSpy.mockRestore(); }); + + it("skips JSONL mirror when skipJSONL is true", async () => { + const payload = { resourceSpans: [{ note: "skip-test" }] }; + await sendOTLPSpan("https://traces.example.com", payload, { skipJSONL: true }); + + expect(appendSpy).not.toHaveBeenCalled(); + expect(fetch).toHaveBeenCalledOnce(); + }); }); // --------------------------------------------------------------------------- @@ -838,6 +846,15 @@ describe("sendJobSetupSpan", () => { expect(fetch).not.toHaveBeenCalled(); }); + it("writes JSONL mirror even when OTEL_EXPORTER_OTLP_ENDPOINT is not set", async () => { + await sendJobSetupSpan(); + expect(appendSpy).toHaveBeenCalledOnce(); + const [filePath, content] = appendSpy.mock.calls[0]; + expect(filePath).toBe(OTEL_JSONL_PATH); + const payload = JSON.parse(content.trim()); + expect(payload).toHaveProperty("resourceSpans"); + }); + it("returns the same trace ID when called with INPUT_TRACE_ID and no endpoint", async () => { process.env.INPUT_TRACE_ID = "a".repeat(32); const { traceId } = await sendJobSetupSpan(); @@ -1360,9 +1377,14 @@ describe("sendJobConclusionSpan", () => { appendSpy.mockRestore(); }); - it("is a no-op when OTEL_EXPORTER_OTLP_ENDPOINT is not set", async () => { + it("skips OTLP export but writes JSONL mirror when OTEL_EXPORTER_OTLP_ENDPOINT is not set", async () => { await sendJobConclusionSpan("gh-aw.job.conclusion"); expect(fetch).not.toHaveBeenCalled(); + expect(appendSpy).toHaveBeenCalledOnce(); + const [filePath, content] = appendSpy.mock.calls[0]; + expect(filePath).toBe(OTEL_JSONL_PATH); + const payload = JSON.parse(content.trim()); + expect(payload).toHaveProperty("resourceSpans"); }); it("sends a span with the given span name", async () => {