From 599fc3074a8359e354436c48d4a6c6c97d50291c Mon Sep 17 00:00:00 2001
From: Mara Nikola Kiefer <mnkiefer@github.com>
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);
