From 62e8708b754c8ef0ade4171fe9636040f7ceecf8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 00:44:32 +0000 Subject: [PATCH 1/2] Initial plan From 9bf1b6794a567b54d853f37f9684ba90de59c544 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 00:51:05 +0000 Subject: [PATCH 2/2] feat: add token breakdown attributes to conclusion spans Read agent_usage.json in sendJobConclusionSpan and emit gh-aw.tokens.input, gh-aw.tokens.output, gh-aw.tokens.cache_read, and gh-aw.tokens.cache_write span attributes. Closes # Agent-Logs-Url: https://github.com/github/gh-aw/sessions/5f05f55a-111f-459e-9aa5-34bca00d4a14 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/send_otlp_span.cjs | 24 ++++- actions/setup/js/send_otlp_span.test.cjs | 108 +++++++++++++++++++++++ 2 files changed, 131 insertions(+), 1 deletion(-) diff --git a/actions/setup/js/send_otlp_span.cjs b/actions/setup/js/send_otlp_span.cjs index 810ecd05eef..3f85a663808 100644 --- a/actions/setup/js/send_otlp_span.cjs +++ b/actions/setup/js/send_otlp_span.cjs @@ -641,7 +641,10 @@ function readLastRateLimitEntry() { * - `GITHUB_REPOSITORY` – `owner/repo` string * * Runtime files read: - * - `/tmp/gh-aw/aw_info.json` – workflow/engine metadata written by the agent job + * - `/tmp/gh-aw/aw_info.json` – workflow/engine metadata written by the agent job + * - `/tmp/gh-aw/agent_usage.json` – per-type token breakdown written by parse_token_usage.cjs; + * provides `input_tokens`, `output_tokens`, + * `cache_read_tokens`, and `cache_write_tokens` counters * * @param {string} spanName - OTLP span name (e.g. `"gh-aw.job.conclusion"`) * @param {{ startMs?: number }} [options] @@ -724,6 +727,25 @@ async function sendJobConclusionSpan(spanName, options = {}) { if (!isNaN(effectiveTokens) && effectiveTokens > 0) { attributes.push(buildAttr("gh-aw.effective_tokens", effectiveTokens)); } + + // Enrich span with per-type token breakdown from agent_usage.json (written by + // parse_token_usage.cjs). These four attributes enable cache-hit-rate panels, + // per-type cost attribution, and fine-grained threshold alerts in Grafana / + // Honeycomb / Datadog without requiring the step summary HTML. + const agentUsage = readJSONIfExists("/tmp/gh-aw/agent_usage.json") || {}; + if (typeof agentUsage.input_tokens === "number" && agentUsage.input_tokens > 0) { + attributes.push(buildAttr("gh-aw.tokens.input", agentUsage.input_tokens)); + } + if (typeof agentUsage.output_tokens === "number" && agentUsage.output_tokens > 0) { + attributes.push(buildAttr("gh-aw.tokens.output", agentUsage.output_tokens)); + } + if (typeof agentUsage.cache_read_tokens === "number" && agentUsage.cache_read_tokens > 0) { + attributes.push(buildAttr("gh-aw.tokens.cache_read", agentUsage.cache_read_tokens)); + } + if (typeof agentUsage.cache_write_tokens === "number" && agentUsage.cache_write_tokens > 0) { + attributes.push(buildAttr("gh-aw.tokens.cache_write", agentUsage.cache_write_tokens)); + } + if (agentConclusion) { attributes.push(buildAttr("gh-aw.agent.conclusion", agentConclusion)); } diff --git a/actions/setup/js/send_otlp_span.test.cjs b/actions/setup/js/send_otlp_span.test.cjs index b45990dcf4f..77067e13ec0 100644 --- a/actions/setup/js/send_otlp_span.test.cjs +++ b/actions/setup/js/send_otlp_span.test.cjs @@ -2448,6 +2448,114 @@ describe("sendJobConclusionSpan", () => { }); }); + describe("token breakdown enrichment in conclusion span", () => { + let readFileSpy; + + beforeEach(() => { + readFileSpy = vi.spyOn(fs, "readFileSync").mockImplementation(() => { + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); + }); + }); + + afterEach(() => { + readFileSpy.mockRestore(); + }); + + it("includes all four token breakdown attributes when agent_usage.json is present", 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"; + + const usage = { input_tokens: 48200, output_tokens: 1350, cache_read_tokens: 41000, cache_write_tokens: 3100, effective_tokens: 9800 }; + 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.job.conclusion"); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + const span = body.resourceSpans[0].scopeSpans[0].spans[0]; + const attrs = Object.fromEntries(span.attributes.map(a => [a.key, a.value.intValue ?? a.value.stringValue])); + expect(attrs["gh-aw.tokens.input"]).toBe(48200); + expect(attrs["gh-aw.tokens.output"]).toBe(1350); + expect(attrs["gh-aw.tokens.cache_read"]).toBe(41000); + expect(attrs["gh-aw.tokens.cache_write"]).toBe(3100); + }); + + it("omits all token breakdown attributes when agent_usage.json is absent", 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"; + + // readFileSpy already throws ENOENT for all paths + + 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 keys = span.attributes.map(a => a.key); + expect(keys).not.toContain("gh-aw.tokens.input"); + expect(keys).not.toContain("gh-aw.tokens.output"); + expect(keys).not.toContain("gh-aw.tokens.cache_read"); + expect(keys).not.toContain("gh-aw.tokens.cache_write"); + }); + + it("omits a token attribute when its value is zero", 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"; + + const usage = { input_tokens: 1000, output_tokens: 0, cache_read_tokens: 500, cache_write_tokens: 0 }; + 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.job.conclusion"); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + const span = body.resourceSpans[0].scopeSpans[0].spans[0]; + const attrs = Object.fromEntries(span.attributes.map(a => [a.key, a.value.intValue ?? a.value.stringValue])); + expect(attrs["gh-aw.tokens.input"]).toBe(1000); + expect(attrs["gh-aw.tokens.cache_read"]).toBe(500); + const keys = span.attributes.map(a => a.key); + expect(keys).not.toContain("gh-aw.tokens.output"); + expect(keys).not.toContain("gh-aw.tokens.cache_write"); + }); + + it("omits token breakdown attributes when agent_usage.json contains invalid JSON", 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"; + + readFileSpy.mockImplementation(filePath => { + if (filePath === "/tmp/gh-aw/agent_usage.json") { + return "not valid json"; + } + 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 keys = span.attributes.map(a => a.key); + expect(keys).not.toContain("gh-aw.tokens.input"); + expect(keys).not.toContain("gh-aw.tokens.output"); + expect(keys).not.toContain("gh-aw.tokens.cache_read"); + expect(keys).not.toContain("gh-aw.tokens.cache_write"); + }); + }); + describe("staged / deployment.environment", () => { let readFileSpy;