Skip to content

Commit 7497857

Browse files
authored
Emit grader results in OpenTelemetry spans (#57015)
1 parent 505c116 commit 7497857

5 files changed

Lines changed: 190 additions & 6 deletions

File tree

‎.changeset/insert-grader-results-otel.md‎

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎actions/setup/js/send_otlp_span.cjs‎

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -708,6 +708,52 @@ function buildExperimentAttributes(assignments) {
708708
return attrs;
709709
}
710710

711+
/**
712+
* Build summary attributes and per-result events from valid deterministic grader output.
713+
* Free-form grader messages, details, and errors are intentionally excluded because
714+
* custom graders may derive them from trace content containing sensitive values.
715+
*
716+
* @param {any} graderOutput
717+
* @param {number} eventTimeMs
718+
* @returns {{attributes: Array<{key: string, value: object}>, events: Array<{timeUnixNano: string, name: string, attributes: Array<{key: string, value: object}>}>}}
719+
*/
720+
function buildGraderTelemetry(graderOutput, eventTimeMs) {
721+
if (!graderOutput || typeof graderOutput !== "object" || !Array.isArray(graderOutput.results) || graderOutput.results.length === 0) {
722+
return { attributes: [], events: [] };
723+
}
724+
725+
const results = graderOutput.results.filter(result => result && typeof result === "object" && typeof result.id === "string" && result.id);
726+
if (results.length === 0) {
727+
return { attributes: [], events: [] };
728+
}
729+
730+
const countByStatus = status => results.filter(result => result.status === status).length;
731+
const attributes = [
732+
buildAttr("gh-aw.graders.count", results.length),
733+
buildAttr("gh-aw.graders.passed", countByStatus("pass")),
734+
buildAttr("gh-aw.graders.failed", countByStatus("fail")),
735+
buildAttr("gh-aw.graders.errors", countByStatus("error")),
736+
buildAttr("gh-aw.graders.unavailable", countByStatus("unavailable")),
737+
buildAttr("gh-aw.graders.other", results.length - countByStatus("pass") - countByStatus("fail") - countByStatus("error") - countByStatus("unavailable")),
738+
];
739+
const timeUnixNano = toNanoString(eventTimeMs);
740+
const events = results.map(result => {
741+
const resultAttributes = [buildAttr("gh-aw.grader.id", result.id)];
742+
if (typeof result.name === "string" && result.name) resultAttributes.push(buildAttr("gh-aw.grader.name", result.name));
743+
if (typeof result.status === "string" && result.status) resultAttributes.push(buildAttr("gh-aw.grader.status", result.status));
744+
if (typeof result.source === "string" && result.source) resultAttributes.push(buildAttr("gh-aw.grader.source", result.source));
745+
if (typeof result.unit === "string" && result.unit) resultAttributes.push(buildAttr("gh-aw.grader.unit", result.unit));
746+
if (typeof result.value === "number" && Number.isFinite(result.value)) resultAttributes.push(buildDoubleAttr("gh-aw.grader.value", result.value));
747+
if (typeof result.passed === "boolean") resultAttributes.push(buildAttr("gh-aw.grader.passed", result.passed));
748+
if (typeof result.severity === "string" && result.severity) resultAttributes.push(buildAttr("gh-aw.grader.severity", result.severity));
749+
if (typeof result.baselineValue === "number" && Number.isFinite(result.baselineValue)) resultAttributes.push(buildDoubleAttr("gh-aw.grader.baseline_value", result.baselineValue));
750+
if (typeof result.deltaFromBaseline === "number" && Number.isFinite(result.deltaFromBaseline)) resultAttributes.push(buildDoubleAttr("gh-aw.grader.delta_from_baseline", result.deltaFromBaseline));
751+
return { timeUnixNano, name: "grader.result", attributes: resultAttributes };
752+
});
753+
754+
return { attributes, events };
755+
}
756+
711757
// ---------------------------------------------------------------------------
712758
// Custom OTLP attributes (GH_AW_OTLP_ATTRIBUTES)
713759
// ---------------------------------------------------------------------------
@@ -1991,6 +2037,8 @@ function readAgentRuntimeMetrics() {
19912037
* - `/tmp/gh-aw/agent_usage.json` – per-type token breakdown written by parse_token_usage.cjs;
19922038
* provides `input_tokens`, `output_tokens`,
19932039
* `cache_read_tokens`, and `cache_write_tokens` counters
2040+
* - `/tmp/gh-aw/agent/graders/grader_results.json` – deterministic grader
2041+
* summary attributes and per-result span events
19942042
*
19952043
* @param {string} spanName - OTLP span name (e.g. `"gh-aw.job.conclusion"`)
19962044
* @param {{ startMs?: number }} [options]
@@ -2327,6 +2375,8 @@ async function sendJobConclusionSpan(spanName, options = {}) {
23272375
}
23282376
}
23292377

2378+
const graderTelemetry = jobName === "agent" ? buildGraderTelemetry(readJSONIfExists("/tmp/gh-aw/agent/graders/grader_results.json"), endMs) : { attributes: [], events: [] };
2379+
23302380
const resourceAttributes = buildGitHubActionsResourceAttributes({
23312381
repository,
23322382
runId,
@@ -2385,7 +2435,7 @@ async function sendJobConclusionSpan(spanName, options = {}) {
23852435
});
23862436
};
23872437

2388-
const spanEvents = buildSpanEvents(endMs);
2438+
const spanEvents = [...buildSpanEvents(endMs), ...graderTelemetry.events];
23892439

23902440
// Prefer the timestamp written at the very beginning of the Execute Agent CLI step
23912441
// (captures true step start on the host, before the AWF container launches) so the
@@ -2480,6 +2530,11 @@ async function sendJobConclusionSpan(spanName, options = {}) {
24802530
}
24812531
}
24822532

2533+
// Grader results are run-level outcomes. They belong only on the agent job's
2534+
// conclusion span, rather than its dedicated child span or downstream jobs
2535+
// which may have downloaded the agent artifact.
2536+
attributes.push(...graderTelemetry.attributes);
2537+
24832538
// Only attach token-usage attributes to jobs that actually executed model usage.
24842539
// Most downstream jobs (conclusion, safe_outputs) may have agent_usage.json on
24852540
// disk via artifact download but must NOT emit token data — otherwise every
@@ -2564,6 +2619,7 @@ module.exports = {
25642619
OTEL_JSONL_PATH,
25652620
appendToOTLPJSONL,
25662621
buildExperimentAttributes,
2622+
buildGraderTelemetry,
25672623
parseOTLPCustomAttributes,
25682624
buildCustomOTLPAttributes,
25692625
};

‎actions/setup/js/send_otlp_span.test.cjs‎

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ const {
3535
buildCurrentWorkflowCallId,
3636
buildEpisodeAttributesFromContext,
3737
buildExperimentAttributes,
38+
buildGraderTelemetry,
3839
hasProxyConfigured,
3940
resolveEngineId,
4041
parseOTLPCustomAttributes,
@@ -2644,6 +2645,30 @@ describe("sendJobConclusionSpan", () => {
26442645
expect(span.spanId).toMatch(/^[0-9a-f]{16}$/);
26452646
});
26462647

2648+
it("emits graders only on the agent job conclusion span", async () => {
2649+
const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200, statusText: "OK" });
2650+
vi.stubGlobal("fetch", mockFetch);
2651+
process.env.GH_AW_OTLP_ENDPOINTS = JSON.stringify([{ url: "https://traces.example.com" }]);
2652+
process.env.INPUT_JOB_NAME = "agent";
2653+
const readFileSpy = vi.spyOn(fs, "readFileSync").mockImplementation(filePath => {
2654+
if (filePath === "/tmp/gh-aw/agent/graders/grader_results.json") {
2655+
return JSON.stringify({ results: [{ id: "quality", status: "pass", value: 0.9 }] });
2656+
}
2657+
throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
2658+
});
2659+
2660+
await sendJobConclusionSpan("gh-aw.agent.conclusion");
2661+
process.env.INPUT_JOB_NAME = "conclusion";
2662+
await sendJobConclusionSpan("gh-aw.conclusion.conclusion");
2663+
readFileSpy.mockRestore();
2664+
2665+
const spans = mockFetch.mock.calls.map(([, request]) => JSON.parse(request.body).resourceSpans[0].scopeSpans[0].spans[0]);
2666+
expect(spans[0].attributes).toContainEqual(buildAttr("gh-aw.graders.count", 1));
2667+
expect(spans[0].events).toContainEqual(expect.objectContaining({ name: "grader.result" }));
2668+
expect(spans[1].attributes.map(attribute => attribute.key)).not.toContain("gh-aw.graders.count");
2669+
expect((spans[1].events ?? []).map(event => event.name)).not.toContain("grader.result");
2670+
});
2671+
26472672
it("emits live episode attributes on conclusion spans from aw_info workflow_call context", async () => {
26482673
const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200, statusText: "OK" });
26492674
vi.stubGlobal("fetch", mockFetch);
@@ -2692,6 +2717,9 @@ describe("sendJobConclusionSpan", () => {
26922717
if (filePath === "/tmp/gh-aw/agent_output.json") {
26932718
return JSON.stringify({ items: [{ type: "issue" }, { type: "pull_request" }] });
26942719
}
2720+
if (filePath === "/tmp/gh-aw/agent/graders/grader_results.json") {
2721+
return JSON.stringify({ results: [{ id: "quality", status: "pass" }] });
2722+
}
26952723
throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
26962724
});
26972725

@@ -2716,6 +2744,9 @@ describe("sendJobConclusionSpan", () => {
27162744
expect(conclusionSpan.parentSpanId).toBe("abcdef1234567890");
27172745
expect(agentSpan.attributes).toContainEqual({ key: "gh-aw.output.item_count", value: { intValue: 2 } });
27182746
expect(conclusionSpan.attributes).toContainEqual({ key: "gh-aw.output.item_count", value: { intValue: 2 } });
2747+
expect(agentSpan.attributes.map(attribute => attribute.key)).not.toContain("gh-aw.graders.count");
2748+
expect(conclusionSpan.attributes).toContainEqual(buildAttr("gh-aw.graders.count", 1));
2749+
expect(conclusionSpan.events).toContainEqual(expect.objectContaining({ name: "grader.result" }));
27192750
const agentKeys = agentSpan.attributes.map(a => a.key);
27202751
const conclusionKeys = conclusionSpan.attributes.map(a => a.key);
27212752
expect(agentKeys).not.toContain("gh-aw.max_ai_credits");
@@ -6449,6 +6480,94 @@ describe("sendJobConclusionSpan", () => {
64496480
});
64506481
});
64516482

6483+
// ---------------------------------------------------------------------------
6484+
// buildGraderTelemetry
6485+
// ---------------------------------------------------------------------------
6486+
6487+
describe("buildGraderTelemetry", () => {
6488+
it("builds summary attributes and one event per grader result", () => {
6489+
const telemetry = buildGraderTelemetry(
6490+
{
6491+
results: [
6492+
{
6493+
id: "quality",
6494+
name: "Quality",
6495+
value: 0.75,
6496+
unit: "ratio",
6497+
passed: true,
6498+
status: "pass",
6499+
source: "builtin",
6500+
severity: "info",
6501+
baselineValue: 0.5,
6502+
deltaFromBaseline: 0.25,
6503+
},
6504+
{ id: "reliability", name: "Reliability", value: null, passed: false, status: "fail", source: "inline" },
6505+
{ id: "broken", status: "error", source: "inline" },
6506+
{ id: "missing", status: "unavailable", source: "builtin" },
6507+
],
6508+
},
6509+
1700000000000
6510+
);
6511+
6512+
expect(telemetry.attributes).toEqual([
6513+
buildAttr("gh-aw.graders.count", 4),
6514+
buildAttr("gh-aw.graders.passed", 1),
6515+
buildAttr("gh-aw.graders.failed", 1),
6516+
buildAttr("gh-aw.graders.errors", 1),
6517+
buildAttr("gh-aw.graders.unavailable", 1),
6518+
buildAttr("gh-aw.graders.other", 0),
6519+
]);
6520+
expect(telemetry.events).toHaveLength(4);
6521+
expect(telemetry.events[0]).toEqual({
6522+
timeUnixNano: toNanoString(1700000000000),
6523+
name: "grader.result",
6524+
attributes: [
6525+
buildAttr("gh-aw.grader.id", "quality"),
6526+
buildAttr("gh-aw.grader.name", "Quality"),
6527+
buildAttr("gh-aw.grader.status", "pass"),
6528+
buildAttr("gh-aw.grader.source", "builtin"),
6529+
buildAttr("gh-aw.grader.unit", "ratio"),
6530+
buildDoubleAttr("gh-aw.grader.value", 0.75),
6531+
buildAttr("gh-aw.grader.passed", true),
6532+
buildAttr("gh-aw.grader.severity", "info"),
6533+
buildDoubleAttr("gh-aw.grader.baseline_value", 0.5),
6534+
buildDoubleAttr("gh-aw.grader.delta_from_baseline", 0.25),
6535+
],
6536+
});
6537+
});
6538+
6539+
it("omits free-form and non-finite grader values", () => {
6540+
const telemetry = buildGraderTelemetry(
6541+
{
6542+
results: [
6543+
{
6544+
id: "custom",
6545+
status: "error",
6546+
value: Number.NaN,
6547+
message: "sensitive message",
6548+
details: "sensitive details",
6549+
error: "sensitive error",
6550+
},
6551+
],
6552+
},
6553+
1
6554+
);
6555+
6556+
const eventKeys = telemetry.events[0].attributes.map(attribute => attribute.key);
6557+
expect(eventKeys).toEqual(["gh-aw.grader.id", "gh-aw.grader.status"]);
6558+
expect(JSON.stringify(telemetry)).not.toContain("sensitive");
6559+
});
6560+
6561+
it.each([null, {}, { results: [] }, { results: [null, {}, { id: "" }] }])("returns empty telemetry for output without valid results", output => {
6562+
expect(buildGraderTelemetry(output, 1)).toEqual({ attributes: [], events: [] });
6563+
});
6564+
6565+
it("counts unrecognized statuses as other", () => {
6566+
const telemetry = buildGraderTelemetry({ results: [{ id: "skipped", status: "skipped" }] }, 1);
6567+
expect(telemetry.attributes).toContainEqual(buildAttr("gh-aw.graders.other", 1));
6568+
});
6569+
});
6570+
64526571
// ---------------------------------------------------------------------------
64536572
// parseOTLPEndpoints
64546573
// ---------------------------------------------------------------------------

‎actions/setup/js/trace_graders.cjs‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -524,8 +524,7 @@ function normalizeResult(id, rawResult, meta) {
524524
if (typeof rawResult === "object" && rawResult !== null && !Array.isArray(rawResult)) {
525525
// Object result from custom script
526526
value = rawResult.value;
527-
if (rawResult.unit) base.unit = String(rawResult.unit);
528-
if (rawResult.severity) base.severity = String(rawResult.severity);
527+
if (typeof rawResult.severity === "string" && ["error", "warning", "info", "note"].includes(rawResult.severity)) base.severity = rawResult.severity;
529528
if (rawResult.details) base.details = String(rawResult.details);
530529
if (rawResult.message) base.message = String(rawResult.message);
531530
if (typeof rawResult.passed === "boolean") base.passed = rawResult.passed;

‎actions/setup/js/trace_graders.test.cjs‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -415,14 +415,19 @@ describe("trace_graders", () => {
415415
expect(r.status).toBe("fail");
416416
});
417417

418-
it("handles object results from custom scripts", () => {
418+
it("uses manifest metadata for object results from custom scripts", () => {
419419
const r = normalizeResult("test", { value: 42, unit: "ms", severity: "warning", details: "too slow" }, { ...meta, source: "inline" });
420420
expect(r.value).toBe(42);
421-
expect(r.unit).toBe("ms");
421+
expect(r.unit).toBe("count");
422422
expect(r.severity).toBe("warning");
423423
expect(r.details).toBe("too slow");
424424
});
425425

426+
it("omits an unrecognized custom severity", () => {
427+
const r = normalizeResult("test", { value: 42, severity: "sensitive trace content" }, { ...meta, source: "inline" });
428+
expect(r.severity).toBeUndefined();
429+
});
430+
426431
it("handles null result as unavailable", () => {
427432
const r = normalizeResult("test", null, meta);
428433
expect(r.status).toBe("unavailable");
@@ -572,7 +577,7 @@ describe("trace_graders", () => {
572577
}
573578
`;
574579
const trace = makeTrace({ toolCalls: [{ name: "a" }, { name: "b" }] });
575-
const result = runCustomGrader("test", script, trace, meta);
580+
const result = runCustomGrader("test", script, trace, { ...meta, unit: "count" });
576581
expect(result.value).toBe(2);
577582
expect(result.unit).toBe("count");
578583
});

0 commit comments

Comments
 (0)