Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
34 changes: 24 additions & 10 deletions actions/setup/js/send_otlp_span.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -272,8 +272,9 @@ function sanitizeAttrs(attrs) {
/**
* Sanitize an OTLP traces payload before sending it over the wire.
*
* Walks the `resourceSpans[].resource.attributes` and
* `resourceSpans[].scopeSpans[].spans[].attributes` arrays and applies
* Walks the `resourceSpans[].resource.attributes`,
* `resourceSpans[].scopeSpans[].spans[].attributes`, and
* `resourceSpans[].scopeSpans[].spans[].events[].attributes` arrays and applies
* {@link sanitizeAttrs} to each, redacting values for sensitive keys and
* truncating excessively long string values.
*
Expand All @@ -292,7 +293,13 @@ function sanitizeOTLPPayload(payload) {
scopeSpans: Array.isArray(rs.scopeSpans)
? rs.scopeSpans.map(ss => ({
...ss,
spans: Array.isArray(ss.spans) ? ss.spans.map(span => ({ ...span, attributes: sanitizeAttrs(span.attributes) })) : ss.spans,
spans: Array.isArray(ss.spans)
? ss.spans.map(span => ({
...span,
attributes: sanitizeAttrs(span.attributes),
events: Array.isArray(span.events) ? span.events.map(ev => ({ ...ev, attributes: sanitizeAttrs(ev.attributes) })) : span.events,
}))
: ss.spans,
}))
: rs.scopeSpans,
})),
Expand Down Expand Up @@ -766,18 +773,25 @@ async function sendJobConclusionSpan(spanName, options = {}) {

// Build OTel exception span events — one per error — following the
// OpenTelemetry semantic convention for exceptions. Each event has
// name="exception" and an "exception.message" attribute, making individual
// errors queryable in backends like Grafana Tempo, Honeycomb, and Datadog.
// name="exception" with "exception.type" and "exception.message" attributes,
// making individual errors queryable and classifiable in backends like
// Grafana Tempo, Honeycomb, and Datadog.
const errorTimeNano = toNanoString(nowMs());
const spanEvents = isAgentFailure
? outputErrors
.map(e => (e && typeof e.message === "string" ? e.message : String(e)))
.filter(Boolean)
.map(msg => ({
timeUnixNano: errorTimeNano,
name: "exception",
attributes: [buildAttr("exception.message", msg.slice(0, MAX_ATTR_VALUE_LENGTH))],
}))
.map(msg => {
// Extract colon-prefixed type when available ("push_to_pull_request_branch:...")
const colonIdx = msg.indexOf(":");
const exceptionType = colonIdx > 0 && colonIdx < 64 && /^[a-z_][a-z0-9_.]*$/i.test(msg.slice(0, colonIdx)) ? `gh-aw.${msg.slice(0, colonIdx)}` : "gh-aw.AgentError";
const exceptionMessage = (colonIdx > 0 && exceptionType !== "gh-aw.AgentError" ? msg.slice(colonIdx + 1).trim() : msg).slice(0, MAX_ATTR_VALUE_LENGTH);
Comment on lines +785 to +789
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

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

The prefix validation regex is case-insensitive (/i), but the PR description/spec says the identifier must match ^[a-z_][a-z0-9_.]*$ (lowercase only). As-is, messages like Push_To_PR:... would be treated as a valid type and emitted as gh-aw.Push_To_PR, which can lead to inconsistent exception.type values and diverges from the documented behavior. Consider removing the i flag (or normalizing the extracted prefix to lowercase before validation/emission) and adding a test to cover uppercase prefixes (either expected rejection or normalization).

Copilot uses AI. Check for mistakes.
return {
timeUnixNano: errorTimeNano,
name: "exception",
attributes: [buildAttr("exception.type", exceptionType), buildAttr("exception.message", exceptionMessage)],
};
})
: [];

const payload = buildOTLPPayload({
Expand Down
148 changes: 148 additions & 0 deletions actions/setup/js/send_otlp_span.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,53 @@ describe("sanitizeOTLPPayload", () => {
const payload = { custom: "data" };
expect(sanitizeOTLPPayload(payload), "payload without resourceSpans should be returned as-is").toBe(payload);
});

it("redacts sensitive keys in span event attributes", () => {
const payload = makePayload([]);
// Manually add events with sensitive attributes to the span
const span = payload.resourceSpans[0].scopeSpans[0].spans[0];
span.events = [
{
timeUnixNano: "1000000000",
name: "exception",
attributes: [buildAttr("exception.message", "safe message"), buildAttr("auth_token", "super-secret-token")],
},
];
const sanitized = sanitizeOTLPPayload(payload);
const events = sanitized.resourceSpans[0].scopeSpans[0].spans[0].events;
expect(events).toHaveLength(1);
const msgAttr = events[0].attributes.find(a => a.key === "exception.message");
expect(msgAttr.value.stringValue, "non-sensitive event attribute should be unchanged").toBe("safe message");
const tokenAttr = events[0].attributes.find(a => a.key === "auth_token");
expect(tokenAttr.value.stringValue, "sensitive event attribute should be redacted").toBe("[REDACTED]");
});

it("truncates long string values in span event attributes", () => {
const payload = makePayload([]);
const span = payload.resourceSpans[0].scopeSpans[0].spans[0];
const longValue = "y".repeat(2000);
span.events = [
{
timeUnixNano: "1000000000",
name: "exception",
attributes: [buildAttr("exception.message", longValue)],
},
];
const sanitized = sanitizeOTLPPayload(payload);
const events = sanitized.resourceSpans[0].scopeSpans[0].spans[0].events;
const msgAttr = events[0].attributes.find(a => a.key === "exception.message");
expect(msgAttr.value.stringValue.length, "long event attribute should be truncated").toBe(1024);
});

it("preserves span events without attributes unchanged", () => {
const payload = makePayload([]);
const span = payload.resourceSpans[0].scopeSpans[0].spans[0];
span.events = [{ timeUnixNano: "1000000000", name: "custom-event" }];
const sanitized = sanitizeOTLPPayload(payload);
const events = sanitized.resourceSpans[0].scopeSpans[0].spans[0].events;
expect(events).toHaveLength(1);
expect(events[0].name).toBe("custom-event");
});
});

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -2040,8 +2087,10 @@ describe("sendJobConclusionSpan", () => {
const span = body.resourceSpans[0].scopeSpans[0].spans[0];
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: "Rate limit exceeded" } });
expect(span.events[1].name).toBe("exception");
expect(span.events[1].attributes).toContainEqual({ key: "exception.type", value: { stringValue: "gh-aw.AgentError" } });
expect(span.events[1].attributes).toContainEqual({ key: "exception.message", value: { stringValue: "Tool call failed" } });
});

Expand Down Expand Up @@ -2121,6 +2170,7 @@ describe("sendJobConclusionSpan", () => {
expect(span.events).toHaveLength(7);
for (let i = 0; i < 7; i++) {
expect(span.events[i].name).toBe("exception");
expect(span.events[i].attributes).toContainEqual({ key: "exception.type", value: { stringValue: "gh-aw.AgentError" } });
expect(span.events[i].attributes).toContainEqual({ key: "exception.message", value: { stringValue: `Error ${i + 1}` } });
}
});
Expand All @@ -2146,6 +2196,104 @@ describe("sendJobConclusionSpan", () => {
expect(span.events).toHaveLength(1);
expect(span.events[0].timeUnixNano).toMatch(/^\d+$/);
});

it("extracts exception.type from colon-prefixed error messages", 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 = "failure";

readFileSpy.mockImplementation(filePath => {
if (filePath === "/tmp/gh-aw/agent_output.json") {
return JSON.stringify({ errors: [{ message: "push_to_pull_request_branch:Cannot push to remote" }] });
}
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];
expect(span.events).toHaveLength(1);
const typeAttr = span.events[0].attributes.find(a => a.key === "exception.type");
expect(typeAttr.value.stringValue).toBe("gh-aw.push_to_pull_request_branch");
const msgAttr = span.events[0].attributes.find(a => a.key === "exception.message");
expect(msgAttr.value.stringValue).toBe("Cannot push to remote");
});

it("falls back to gh-aw.AgentError when message has no colon prefix", 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 = "failure";

readFileSpy.mockImplementation(filePath => {
if (filePath === "/tmp/gh-aw/agent_output.json") {
return JSON.stringify({ errors: [{ message: "Something went wrong" }] });
}
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];
expect(span.events).toHaveLength(1);
const typeAttr = span.events[0].attributes.find(a => a.key === "exception.type");
expect(typeAttr.value.stringValue).toBe("gh-aw.AgentError");
const msgAttr = span.events[0].attributes.find(a => a.key === "exception.message");
expect(msgAttr.value.stringValue).toBe("Something went wrong");
});

it("falls back to gh-aw.AgentError when colon prefix contains invalid characters", 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 = "failure";

readFileSpy.mockImplementation(filePath => {
if (filePath === "/tmp/gh-aw/agent_output.json") {
return JSON.stringify({ errors: [{ message: "Error with spaces:details here" }] });
}
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 typeAttr = span.events[0].attributes.find(a => a.key === "exception.type");
expect(typeAttr.value.stringValue).toBe("gh-aw.AgentError");
// Full original message kept when type extraction fails
const msgAttr = span.events[0].attributes.find(a => a.key === "exception.message");
expect(msgAttr.value.stringValue).toBe("Error with spaces:details here");
});

it("falls back to gh-aw.AgentError when colon prefix exceeds 64 characters", 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 = "failure";

const longPrefix = "a".repeat(65);
readFileSpy.mockImplementation(filePath => {
if (filePath === "/tmp/gh-aw/agent_output.json") {
return JSON.stringify({ errors: [{ message: `${longPrefix}:some error` }] });
}
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 typeAttr = span.events[0].attributes.find(a => a.key === "exception.type");
expect(typeAttr.value.stringValue).toBe("gh-aw.AgentError");
});
});

describe("rate-limit enrichment in conclusion span", () => {
Expand Down
Loading