From 74597d7dc2b1c822e21926fb1a584a410b7a5ec4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Apr 2026 23:14:01 +0000 Subject: [PATCH 1/3] Initial plan From 833c8d48a87b6ceda5dc383046bc1b9c46928387 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Apr 2026 23:25:04 +0000 Subject: [PATCH 2/3] fix: write JSONL mirror unconditionally, decoupled from OTLP endpoint Move payload build and appendToOTLPJSONL call before the endpoint guard in both sendJobSetupSpan and sendJobConclusionSpan. Add skipJSONL option to sendOTLPSpan to prevent double-write. Update action_conclusion_otlp to always call sendJobConclusionSpan regardless of endpoint config. Fixes the contradiction between the design intent (JSONL artifact usable without a live collector) and the implementation (JSONL only written when OTEL_EXPORTER_OTLP_ENDPOINT is set). Agent-Logs-Url: https://github.com/github/gh-aw/sessions/70973bc8-b004-48c7-88e1-aff85f79b3dc Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/action_conclusion_otlp.cjs | 19 ++++--- .../setup/js/action_conclusion_otlp.test.cjs | 10 ++-- actions/setup/js/send_otlp_span.cjs | 51 ++++++++++++------- actions/setup/js/send_otlp_span.test.cjs | 24 ++++++++- 4 files changed, 74 insertions(+), 30 deletions(-) diff --git a/actions/setup/js/action_conclusion_otlp.cjs b/actions/setup/js/action_conclusion_otlp.cjs index 8bdcb6e32f1..d48f7c2b125 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 is always written) * * 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 (JSONL mirror still written)"); + } 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 sent`); + } } 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..a75b1ae4891 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 but JSONL mirror is still written", 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 (JSONL mirror still written)"); }); - 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 }); }); }); 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 () => { From 8d16696e60d5354cda491798fc7a15d9154a9793 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Apr 2026 23:46:16 +0000 Subject: [PATCH 3/3] fix: improve log message accuracy per review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Line 64: "JSONL mirror still written" → "will attempt JSONL mirror" (appendToOTLPJSONL swallows errors, so success isn't guaranteed) - Line 72: "conclusion span sent" → "conclusion span export attempted" (sendOTLPSpan only console.warn's on HTTP failures, never throws) Agent-Logs-Url: https://github.com/github/gh-aw/sessions/75332d4d-b924-493d-ade8-8c7aaf047e0b Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/action_conclusion_otlp.cjs | 6 +++--- actions/setup/js/action_conclusion_otlp.test.cjs | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/actions/setup/js/action_conclusion_otlp.cjs b/actions/setup/js/action_conclusion_otlp.cjs index d48f7c2b125..4cc83964639 100644 --- a/actions/setup/js/action_conclusion_otlp.cjs +++ b/actions/setup/js/action_conclusion_otlp.cjs @@ -27,7 +27,7 @@ * 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 (HTTP export skipped when not set; - * JSONL mirror is always written) + * JSONL mirror write is attempted regardless) * * Runtime files read (optional): * /tmp/gh-aw/github_rate_limits.jsonl – GitHub API rate-limit log written by @@ -61,7 +61,7 @@ async function run() { const spanName = jobName ? `gh-aw.${jobName}.conclusion` : "gh-aw.job.conclusion"; if (!endpoint) { - console.log("[otlp] OTEL_EXPORTER_OTLP_ENDPOINT not set, skipping OTLP export (JSONL mirror still written)"); + 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}`); } @@ -69,7 +69,7 @@ async function run() { await sendOtlpSpan.sendJobConclusionSpan(spanName, { startMs }); if (endpoint) { - console.log(`[otlp] conclusion span sent`); + console.log(`[otlp] conclusion span export attempted`); } } diff --git a/actions/setup/js/action_conclusion_otlp.test.cjs b/actions/setup/js/action_conclusion_otlp.test.cjs index a75b1ae4891..efe7daeb4f0 100644 --- a/actions/setup/js/action_conclusion_otlp.test.cjs +++ b/actions/setup/js/action_conclusion_otlp.test.cjs @@ -68,10 +68,10 @@ describe("action_conclusion_otlp.cjs", () => { }); describe("when OTEL_EXPORTER_OTLP_ENDPOINT is not set", () => { - it("should log that OTLP export is skipped but JSONL mirror is still written", 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 OTLP export (JSONL mirror still written)"); + expect(console.log).toHaveBeenCalledWith("[otlp] OTEL_EXPORTER_OTLP_ENDPOINT not set, skipping OTLP export (will attempt JSONL mirror)"); }); it("should still call sendJobConclusionSpan for JSONL mirror", async () => { @@ -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 () => {