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
11 changes: 8 additions & 3 deletions actions/setup/js/send_otlp_span.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -705,10 +705,10 @@ async function sendJobConclusionSpan(spanName, options = {}) {
const statusCode = isAgentFailure ? 2 : 1;
let statusMessage = isAgentFailure ? `agent ${agentConclusion}` : undefined;

// When the agent failed, read agent_output.json to surface structured error details.
// Lazy-read: skip I/O entirely when the job succeeded or was cancelled.
const agentOutput = isAgentFailure ? readJSONIfExists("/tmp/gh-aw/agent_output.json") || {} : {};
// Always read agent_output.json so output metrics are available on all outcomes.
const agentOutput = readJSONIfExists("/tmp/gh-aw/agent_output.json") || {};
const outputErrors = Array.isArray(agentOutput.errors) ? agentOutput.errors : [];
const outputItems = Array.isArray(agentOutput.items) ? agentOutput.items : [];
const errorMessages = outputErrors
.map(e => (e && typeof e.message === "string" ? e.message : String(e)))
.filter(Boolean)
Expand Down Expand Up @@ -754,6 +754,11 @@ async function sendJobConclusionSpan(spanName, options = {}) {
attributes.push(buildAttr("gh-aw.error.count", outputErrors.length));
attributes.push(buildAttr("gh-aw.error.messages", errorMessages.join(" | ")));
}
attributes.push(buildAttr("gh-aw.output.item_count", outputItems.length));
const itemTypes = [...new Set(outputItems.map(i => (i && typeof i.type === "string" ? i.type : "")).filter(Boolean))].sort();
if (itemTypes.length > 0) {
attributes.push(buildAttr("gh-aw.output.item_types", itemTypes.join(",")));
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

gh-aw.output.item_types is built from arbitrary agent_output.json content and can grow without bound (many unique/long types). While sanitizeOTLPPayload() will truncate strings for the HTTP export, the local JSONL mirror is written with the raw payload, so this new attribute can significantly inflate /tmp/gh-aw/otel.jsonl artifacts. Consider explicitly truncating the joined item_types string (and/or capping the number of types) before adding it to attributes, ideally reusing MAX_ATTR_VALUE_LENGTH.

Suggested change
attributes.push(buildAttr("gh-aw.output.item_types", itemTypes.join(",")));
const maxItemTypes =
typeof MAX_ATTR_VALUE_LENGTH === "number" && MAX_ATTR_VALUE_LENGTH > 0
? MAX_ATTR_VALUE_LENGTH
: 4096;
const boundedItemTypes = itemTypes.slice(0, maxItemTypes);
const joinedItemTypes = boundedItemTypes.join(",");
const truncatedItemTypes =
joinedItemTypes.length > maxItemTypes
? joinedItemTypes.slice(0, maxItemTypes)
: joinedItemTypes;
attributes.push(buildAttr("gh-aw.output.item_types", truncatedItemTypes));

Copilot uses AI. Check for mistakes.
}

// Enrich span with the most recent GitHub API rate-limit snapshot for post-run
// observability. Reads the last entry from github_rate_limits.jsonl so that
Expand Down
26 changes: 24 additions & 2 deletions actions/setup/js/send_otlp_span.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -1590,10 +1590,17 @@ describe("sendJobConclusionSpan", () => {
const startMs = 1_700_000_000_000;
const endMs = 1_700_000_005_000;
const statSpy = vi.spyOn(fs, "statSync").mockReturnValue(/** @type {Partial<fs.Stats>} */ { mtimeMs: endMs });
const readFileSpy = vi.spyOn(fs, "readFileSync").mockImplementation(filePath => {
if (filePath === "/tmp/gh-aw/agent_output.json") {
return JSON.stringify({ items: [{ type: "issue" }, { type: "pull_request" }] });
}
throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
});

await sendJobConclusionSpan("gh-aw.agent.conclusion", { startMs });

statSpy.mockRestore();
readFileSpy.mockRestore();
expect(mockFetch).toHaveBeenCalledTimes(2);

const agentBody = JSON.parse(mockFetch.mock.calls[0][1].body);
Expand All @@ -1609,6 +1616,8 @@ describe("sendJobConclusionSpan", () => {
expect(agentSpan.parentSpanId).toBe(conclusionSpan.spanId);
expect(agentSpan.parentSpanId).not.toBe("abcdef1234567890");
expect(conclusionSpan.parentSpanId).toBe("abcdef1234567890");
expect(agentSpan.attributes).toContainEqual({ key: "gh-aw.output.item_count", value: { intValue: 2 } });
expect(conclusionSpan.attributes).toContainEqual({ key: "gh-aw.output.item_count", value: { intValue: 2 } });
});

it("does not emit a dedicated agent span when agent_output mtime is unavailable", async () => {
Expand Down Expand Up @@ -2230,17 +2239,30 @@ describe("sendJobConclusionSpan", () => {
expect(span.status.message).toBe("agent failure");
});

it("does not read agent_output.json when agent conclusion is success", async () => {
it("reads agent_output.json and adds output metrics when agent conclusion is success", async () => {
const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200, statusText: "OK" });
vi.stubGlobal("fetch", mockFetch);

process.env.OTEL_EXPORTER_OTLP_ENDPOINT = "https://traces.example.com";
process.env.GH_AW_AGENT_CONCLUSION = "success";
readFileSpy.mockImplementation(filePath => {
if (filePath === "/tmp/gh-aw/agent_output.json") {
return JSON.stringify({
items: [{ type: "pull_request" }, { type: "issue" }, { type: "pull_request" }, {}],
});
}
throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
});

await sendJobConclusionSpan("gh-aw.job.conclusion");

const agentOutputCalls = readFileSpy.mock.calls.filter(([p]) => p === "/tmp/gh-aw/agent_output.json");
expect(agentOutputCalls).toHaveLength(0);
expect(agentOutputCalls).toHaveLength(1);
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
const span = body.resourceSpans[0].scopeSpans[0].spans[0];
const attrs = span.attributes;
expect(attrs).toContainEqual({ key: "gh-aw.output.item_count", value: { intValue: 4 } });
expect(attrs).toContainEqual({ key: "gh-aw.output.item_types", value: { stringValue: "issue,pull_request" } });
});

it("does not add error attributes when agent_output.json is absent on failure", async () => {
Expand Down
Loading