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
19 changes: 12 additions & 7 deletions actions/setup/js/action_conclusion_otlp.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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 };
Expand Down
14 changes: 7 additions & 7 deletions actions/setup/js/action_conclusion_otlp.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
});
});

Expand All @@ -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 () => {
Expand Down
51 changes: 34 additions & 17 deletions actions/setup/js/send_otlp_span.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>}
*/
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 || "");
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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 };
}

Expand Down Expand Up @@ -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
Expand All @@ -623,11 +636,6 @@ function readLastRateLimitEntry() {
* @returns {Promise<void>}
*/
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).
Expand Down Expand Up @@ -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 = {
Expand Down
24 changes: 23 additions & 1 deletion actions/setup/js/send_otlp_span.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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 () => {
Expand Down
Loading