Skip to content

Commit ce2fdf5

Browse files
Copilotpelikhan
andauthored
Report eval results in OTLP spans
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
1 parent ec4f275 commit ce2fdf5

3 files changed

Lines changed: 110 additions & 1 deletion

File tree

.changeset/report-evals-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: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -754,6 +754,41 @@ function buildGraderTelemetry(graderOutput, eventTimeMs) {
754754
return { attributes, events };
755755
}
756756

757+
/**
758+
* Build summary attributes and per-result events from BinEval JSONL records.
759+
* Only bounded, structured fields are included; free-form questions are excluded.
760+
*
761+
* @param {unknown[]} evalResults
762+
* @param {number} eventTimeMs
763+
* @returns {{attributes: Array<{key: string, value: object}>, events: Array<{timeUnixNano: string, name: string, attributes: Array<{key: string, value: object}>}>}}
764+
*/
765+
function buildEvalTelemetry(evalResults, eventTimeMs) {
766+
if (!Array.isArray(evalResults)) {
767+
return { attributes: [], events: [] };
768+
}
769+
const results = evalResults.filter(result => result && typeof result === "object" && typeof result.id === "string" && result.id);
770+
if (results.length === 0) {
771+
return { attributes: [], events: [] };
772+
}
773+
774+
const normalizedAnswers = results.map(result => {
775+
const answer = typeof result.answer === "string" ? result.answer.toUpperCase() : "UNKNOWN";
776+
return answer === "YES" || answer === "NO" ? answer : "UNKNOWN";
777+
});
778+
const countAnswer = answer => normalizedAnswers.filter(value => value === answer).length;
779+
const attributes = [buildAttr("gh-aw.evals.count", results.length), buildAttr("gh-aw.evals.yes", countAnswer("YES")), buildAttr("gh-aw.evals.no", countAnswer("NO")), buildAttr("gh-aw.evals.unknown", countAnswer("UNKNOWN"))];
780+
const timeUnixNano = toNanoString(eventTimeMs);
781+
const events = results.map((result, index) => {
782+
const resultAttributes = [buildAttr("gh-aw.eval.id", result.id), buildAttr("gh-aw.eval.answer", normalizedAnswers[index])];
783+
if (typeof result.model === "string" && result.model) {
784+
resultAttributes.push(buildAttr("gh-aw.eval.model", result.model));
785+
}
786+
return { timeUnixNano, name: "eval.result", attributes: resultAttributes };
787+
});
788+
789+
return { attributes, events };
790+
}
791+
757792
// ---------------------------------------------------------------------------
758793
// Custom OTLP attributes (GH_AW_OTLP_ATTRIBUTES)
759794
// ---------------------------------------------------------------------------
@@ -2376,6 +2411,19 @@ async function sendJobConclusionSpan(spanName, options = {}) {
23762411
}
23772412

23782413
const graderTelemetry = jobName === "agent" ? buildGraderTelemetry(readJSONIfExists("/tmp/gh-aw/agent/graders/grader_results.json"), endMs) : { attributes: [], events: [] };
2414+
const evalTelemetry =
2415+
jobName === "evals"
2416+
? buildEvalTelemetry(
2417+
(() => {
2418+
try {
2419+
return parseJsonlContent(fs.readFileSync("/tmp/gh-aw/evals.jsonl", "utf8"));
2420+
} catch {
2421+
return [];
2422+
}
2423+
})(),
2424+
endMs
2425+
)
2426+
: { attributes: [], events: [] };
23792427

23802428
const resourceAttributes = buildGitHubActionsResourceAttributes({
23812429
repository,
@@ -2435,7 +2483,7 @@ async function sendJobConclusionSpan(spanName, options = {}) {
24352483
});
24362484
};
24372485

2438-
const spanEvents = [...buildSpanEvents(endMs), ...graderTelemetry.events];
2486+
const spanEvents = [...buildSpanEvents(endMs), ...graderTelemetry.events, ...evalTelemetry.events];
24392487

24402488
// Prefer the timestamp written at the very beginning of the Execute Agent CLI step
24412489
// (captures true step start on the host, before the AWF container launches) so the
@@ -2534,6 +2582,7 @@ async function sendJobConclusionSpan(spanName, options = {}) {
25342582
// conclusion span, rather than its dedicated child span or downstream jobs
25352583
// which may have downloaded the agent artifact.
25362584
attributes.push(...graderTelemetry.attributes);
2585+
attributes.push(...evalTelemetry.attributes);
25372586

25382587
// Only attach token-usage attributes to jobs that actually executed model usage.
25392588
// Most downstream jobs (conclusion, safe_outputs) may have agent_usage.json on
@@ -2620,6 +2669,7 @@ module.exports = {
26202669
appendToOTLPJSONL,
26212670
buildExperimentAttributes,
26222671
buildGraderTelemetry,
2672+
buildEvalTelemetry,
26232673
parseOTLPCustomAttributes,
26242674
buildCustomOTLPAttributes,
26252675
};

actions/setup/js/send_otlp_span.test.cjs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ const {
3636
buildEpisodeAttributesFromContext,
3737
buildExperimentAttributes,
3838
buildGraderTelemetry,
39+
buildEvalTelemetry,
3940
hasProxyConfigured,
4041
resolveEngineId,
4142
parseOTLPCustomAttributes,
@@ -2669,6 +2670,30 @@ describe("sendJobConclusionSpan", () => {
26692670
expect((spans[1].events ?? []).map(event => event.name)).not.toContain("grader.result");
26702671
});
26712672

2673+
it("emits eval results only on the evals job conclusion span", async () => {
2674+
const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200, statusText: "OK" });
2675+
vi.stubGlobal("fetch", mockFetch);
2676+
process.env.GH_AW_OTLP_ENDPOINTS = JSON.stringify([{ url: "https://traces.example.com" }]);
2677+
process.env.INPUT_JOB_NAME = "evals";
2678+
const readFileSpy = vi.spyOn(fs, "readFileSync").mockImplementation(filePath => {
2679+
if (filePath === "/tmp/gh-aw/evals.jsonl") {
2680+
return `${JSON.stringify({ id: "quality", answer: "YES", model: "gpt-5" })}\n`;
2681+
}
2682+
throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
2683+
});
2684+
2685+
await sendJobConclusionSpan("gh-aw.evals.conclusion");
2686+
process.env.INPUT_JOB_NAME = "conclusion";
2687+
await sendJobConclusionSpan("gh-aw.conclusion.conclusion");
2688+
readFileSpy.mockRestore();
2689+
2690+
const spans = mockFetch.mock.calls.map(([, request]) => JSON.parse(request.body).resourceSpans[0].scopeSpans[0].spans[0]);
2691+
expect(spans[0].attributes).toContainEqual(buildAttr("gh-aw.evals.count", 1));
2692+
expect(spans[0].events).toContainEqual(expect.objectContaining({ name: "eval.result" }));
2693+
expect(spans[1].attributes.map(attribute => attribute.key)).not.toContain("gh-aw.evals.count");
2694+
expect((spans[1].events ?? []).map(event => event.name)).not.toContain("eval.result");
2695+
});
2696+
26722697
it("emits live episode attributes on conclusion spans from aw_info workflow_call context", async () => {
26732698
const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200, statusText: "OK" });
26742699
vi.stubGlobal("fetch", mockFetch);
@@ -6568,6 +6593,35 @@ describe("buildGraderTelemetry", () => {
65686593
});
65696594
});
65706595

6596+
// ---------------------------------------------------------------------------
6597+
// buildEvalTelemetry
6598+
// ---------------------------------------------------------------------------
6599+
6600+
describe("buildEvalTelemetry", () => {
6601+
it("builds summary attributes and one event per eval result", () => {
6602+
const telemetry = buildEvalTelemetry(
6603+
[
6604+
{ id: "quality", question: "Sensitive free-form content", answer: "yes", model: "gpt-5" },
6605+
{ id: "tests", answer: "NO" },
6606+
{ id: "unknown", answer: "MAYBE" },
6607+
],
6608+
1700000000000
6609+
);
6610+
6611+
expect(telemetry.attributes).toEqual([buildAttr("gh-aw.evals.count", 3), buildAttr("gh-aw.evals.yes", 1), buildAttr("gh-aw.evals.no", 1), buildAttr("gh-aw.evals.unknown", 1)]);
6612+
expect(telemetry.events[0]).toEqual({
6613+
timeUnixNano: toNanoString(1700000000000),
6614+
name: "eval.result",
6615+
attributes: [buildAttr("gh-aw.eval.id", "quality"), buildAttr("gh-aw.eval.answer", "YES"), buildAttr("gh-aw.eval.model", "gpt-5")],
6616+
});
6617+
expect(JSON.stringify(telemetry)).not.toContain("Sensitive free-form content");
6618+
});
6619+
6620+
it.each([null, undefined, [], [null, {}, { id: "" }]])("returns empty telemetry without valid results", results => {
6621+
expect(buildEvalTelemetry(results, 1)).toEqual({ attributes: [], events: [] });
6622+
});
6623+
});
6624+
65716625
// ---------------------------------------------------------------------------
65726626
// parseOTLPEndpoints
65736627
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)