From 599fc3074a8359e354436c48d4a6c6c97d50291c Mon Sep 17 00:00:00 2001 From: Mara Nikola Kiefer Date: Wed, 10 Jun 2026 14:09:22 +0200 Subject: [PATCH] fix: usage tracking for engine jobs in sendJobConclusionSpan --- actions/setup/js/send_otlp_span.cjs | 8 +++---- actions/setup/js/send_otlp_span.test.cjs | 30 ++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/actions/setup/js/send_otlp_span.cjs b/actions/setup/js/send_otlp_span.cjs index b6f9b712029..032a081764b 100644 --- a/actions/setup/js/send_otlp_span.cjs +++ b/actions/setup/js/send_otlp_span.cjs @@ -1929,7 +1929,7 @@ async function sendJobConclusionSpan(spanName, options = {}) { const bodyModified = typeof awInfo.body_modified === "boolean" ? awInfo.body_modified : parseBooleanEnv(process.env.GH_AW_INFO_BODY_MODIFIED); const trackerId = process.env.GH_AW_TRACKER_ID || awInfo.tracker_id || ""; const jobName = process.env.INPUT_JOB_NAME || ""; - const jobEmitsOwnTokenUsage = jobName === "agent" || jobName === "detection"; + const jobEmitsOwnTokenUsage = jobName === "agent" || jobName === "detection" || (!!engineId && jobName === engineId); const runId = process.env.GITHUB_RUN_ID || ""; const runAttempt = awInfo.run_attempt || process.env.GITHUB_RUN_ATTEMPT || "1"; const actor = process.env.GITHUB_ACTOR || ""; @@ -2052,8 +2052,8 @@ async function sendJobConclusionSpan(spanName, options = {}) { if (frontmatterEmoji) attributes.push(buildAttr("gh-aw.frontmatter.emoji", frontmatterEmoji)); if (typeof bodyModified === "boolean") attributes.push(buildAttr("gh-aw.frontmatter.body_modified", bodyModified)); attributes.push(...buildEpisodeAttributesFromContext(awInfo, runId, runAttempt)); - // GH_AW_AIC is propagated to downstream jobs via needs.agent.outputs.*, so gate it - // behind jobEmitsOwnTokenUsage to prevent non-agent jobs from re-emitting it. + // GH_AW_AIC may be propagated to downstream jobs via workflow outputs, so gate it + // behind jobEmitsOwnTokenUsage to prevent non-owning jobs from re-emitting it. const aiCredits = jobEmitsOwnTokenUsage ? (normalizeNonNegativeNumber(process.env.GH_AW_AIC) ?? agentUsage.ai_credits) : undefined; if (typeof aiCredits === "number" && aiCredits > 0) { attributes.push(buildAttr("gh-aw.aic", aiCredits)); @@ -2302,7 +2302,7 @@ async function sendJobConclusionSpan(spanName, options = {}) { } } - // Only attach token-usage attributes to jobs that actually executed an agent. + // Only attach token-usage attributes to jobs that actually executed model usage. // Most downstream jobs (conclusion, safe_outputs) may have agent_usage.json on // disk via artifact download but must NOT emit token data — otherwise every // sum(gen_ai.usage.*) query is inflated by the number of downstream jobs. diff --git a/actions/setup/js/send_otlp_span.test.cjs b/actions/setup/js/send_otlp_span.test.cjs index dfff7d972b1..a4413efc8d2 100644 --- a/actions/setup/js/send_otlp_span.test.cjs +++ b/actions/setup/js/send_otlp_span.test.cjs @@ -5351,6 +5351,36 @@ describe("sendJobConclusionSpan", () => { expect(attrs["gh-aw.detection.conclusion"]).toBe("success"); }); + it("includes engine-job token breakdown and cost attributes when the job name matches the engine id", async () => { + const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200, statusText: "OK" }); + vi.stubGlobal("fetch", mockFetch); + + process.env.INPUT_JOB_NAME = "copilot"; + process.env.GH_AW_INFO_ENGINE_ID = "copilot"; + statSpy.mockImplementation(() => { + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); + }); + process.env.GH_AW_OTLP_ENDPOINTS = JSON.stringify([{ url: "https://traces.example.com" }]); + + const usage = { input_tokens: 5000, output_tokens: 200, cache_read_tokens: 100, ai_credits: 0.125 }; + readFileSpy.mockImplementation(filePath => { + if (filePath === "/tmp/gh-aw/agent_usage.json") { + return JSON.stringify(usage); + } + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); + }); + + await sendJobConclusionSpan("gh-aw.copilot.conclusion"); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + const attrs = Object.fromEntries(body.resourceSpans[0].scopeSpans[0].spans[0].attributes.map(a => [a.key, a.value.intValue ?? a.value.doubleValue ?? a.value.stringValue])); + expect(attrs["gen_ai.usage.input_tokens"]).toBe(5000); + expect(attrs["gen_ai.usage.output_tokens"]).toBe(200); + expect(attrs["gen_ai.usage.cache_read.input_tokens"]).toBe(100); + expect(attrs["gen_ai.usage.total_tokens"]).toBe(5200); + expect(attrs["gh-aw.aic"]).toBe(0.125); + }); + it("includes detection-job warning result attribute when detection finds threats", async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200, statusText: "OK" }); vi.stubGlobal("fetch", mockFetch);