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
5 changes: 3 additions & 2 deletions actions/setup/js/action_otlp.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -336,7 +336,7 @@ describe("action_conclusion_otlp run()", () => {
delete process.env.GH_AW_AGENT_CONCLUSION;
});

it("records cancelled conclusion as STATUS_CODE_OK when GH_AW_AGENT_CONCLUSION is 'cancelled'", async () => {
it("records cancelled conclusion as STATUS_CODE_ERROR when GH_AW_AGENT_CONCLUSION is 'cancelled'", async () => {
process.env.OTEL_EXPORTER_OTLP_ENDPOINT = "http://localhost:14317";
process.env.GH_AW_AGENT_CONCLUSION = "cancelled";
let capturedBody;
Expand All @@ -349,7 +349,8 @@ describe("action_conclusion_otlp run()", () => {

const payload = JSON.parse(capturedBody);
const span = payload?.resourceSpans?.[0]?.scopeSpans?.[0]?.spans?.[0];
expect(span?.status?.code).toBe(1); // STATUS_CODE_OK (cancelled is not an error)
expect(span?.status?.code).toBe(2); // STATUS_CODE_ERROR
expect(span?.status?.message).toBe("agent cancelled");
const conclusionAttr = span?.attributes?.find(a => a.key === "gh-aw.agent.conclusion");
expect(conclusionAttr?.value?.stringValue).toBe("cancelled");
fetchSpy.mockRestore();
Expand Down
17 changes: 12 additions & 5 deletions actions/setup/js/send_otlp_span.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -700,11 +700,18 @@ async function sendJobConclusionSpan(spanName, options = {}) {
// Values: "success", "failure", "timed_out", "cancelled", "skipped".
const agentConclusion = process.env.GH_AW_AGENT_CONCLUSION || "";

// Mark the span as an error when the agent job failed or timed out.
// Mark the span as an error when the agent job failed, timed out, or was cancelled.
const isAgentFailure = agentConclusion === "failure" || agentConclusion === "timed_out";
const isAgentCancelled = agentConclusion === "cancelled";
const isAgentNonOK = isAgentFailure || isAgentCancelled;
// STATUS_CODE_ERROR = 2, STATUS_CODE_OK = 1
const statusCode = isAgentFailure ? 2 : 1;
let statusMessage = isAgentFailure ? `agent ${agentConclusion}` : undefined;
const statusCode = isAgentNonOK ? 2 : 1;
let statusMessage;
if (isAgentFailure) {
statusMessage = `agent ${agentConclusion}`;
} else if (isAgentCancelled) {
statusMessage = "agent cancelled";
}

// Always read agent_output.json so output metrics are available on all outcomes.
const agentOutput = readJSONIfExists("/tmp/gh-aw/agent_output.json") || {};
Expand Down Expand Up @@ -748,7 +755,7 @@ async function sendJobConclusionSpan(spanName, options = {}) {
if (agentConclusion) {
attributes.push(buildAttr("gh-aw.agent.conclusion", agentConclusion));
}
if (isAgentFailure && errorMessages.length > 0) {
if (errorMessages.length > 0) {
attributes.push(buildAttr("gh-aw.error.count", outputErrors.length));
attributes.push(buildAttr("gh-aw.error.messages", errorMessages.join(" | ")));
}
Expand Down Expand Up @@ -804,7 +811,7 @@ async function sendJobConclusionSpan(spanName, options = {}) {
// making individual errors queryable and classifiable in backends like
// Grafana Tempo, Honeycomb, and Datadog.
const buildSpanEvents = eventTimeMs => {
if (!isAgentFailure) {
if (outputErrors.length === 0) {
return [];
}
const errorTimeNano = toNanoString(eventTimeMs);
Expand Down
43 changes: 43 additions & 0 deletions actions/setup/js/send_otlp_span.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -2123,6 +2123,34 @@ describe("sendJobConclusionSpan", () => {
expect(errorMessages.value.stringValue).toBe("Rate limit exceeded | Tool call failed");
});

it("adds gh-aw.error attributes when agent_output.json has errors on 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({ errors: [{ message: "partial failure one" }, { message: "partial failure two" }] });
}
throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
});

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

const body = JSON.parse(mockFetch.mock.calls[0][1].body);
const span = body.resourceSpans[0].scopeSpans[0].spans[0];
const attrs = span.attributes;
expect(span.status.code).toBe(1);
expect(attrs).toContainEqual({ key: "gh-aw.error.count", value: { intValue: 2 } });
expect(attrs).toContainEqual({ key: "gh-aw.error.messages", value: { stringValue: "partial failure one | partial failure two" } });
expect(span.events).toHaveLength(2);
expect(span.events[0].name).toBe("exception");
expect(span.events[0].attributes).toContainEqual({ key: "exception.type", value: { stringValue: "gh-aw.AgentError" } });
expect(span.events[0].attributes).toContainEqual({ key: "exception.message", value: { stringValue: "partial failure one" } });
});

it("enriches statusMessage with the first error message on failure", async () => {
const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200, statusText: "OK" });
vi.stubGlobal("fetch", mockFetch);
Expand Down Expand Up @@ -2165,6 +2193,21 @@ describe("sendJobConclusionSpan", () => {
expect(span.status.message).toBe("agent timed_out: Execution exceeded 30 minute limit");
});

it("marks cancelled conclusion spans as errors", 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 = "cancelled";

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

const body = JSON.parse(mockFetch.mock.calls[0][1].body);
const span = body.resourceSpans[0].scopeSpans[0].spans[0];
expect(span.status.code).toBe(2);
expect(span.status.message).toBe("agent cancelled");
});

it("caps error messages at 5 entries", async () => {
const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200, statusText: "OK" });
vi.stubGlobal("fetch", mockFetch);
Expand Down
Loading