From ac2f1b02cfcf0d754b3eb2a0d7d487d7dc549761 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 25 Apr 2026 21:54:25 +0000 Subject: [PATCH 1/4] Initial plan From 8e4193eb2ec9f05b8cc07a981f30e43358cc45ce Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 25 Apr 2026 22:05:53 +0000 Subject: [PATCH 2/4] feat: add OTel GenAI semantic convention attributes to agent span - Replace gh-aw.tokens.* with gen_ai.usage.* on the agent span - Replace gh-aw.model with gen_ai.request.model on the agent span - Add gen_ai.system (from engine_id) to the agent span - Change agent span kind to SPAN_KIND_CLIENT Resolves the gh-aw.agent.agent span not emitting standard OTel GenAI semantic convention attributes required by out-of-the-box LLM dashboards in Grafana, Datadog, and Honeycomb. Agent-Logs-Url: https://github.com/github/gh-aw/sessions/0cb5dbc8-7a44-478a-93be-8cb8a9ab1222 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/send_otlp_span.cjs | 43 +++--- actions/setup/js/send_otlp_span.test.cjs | 162 +++++++++++++++++------ 2 files changed, 147 insertions(+), 58 deletions(-) diff --git a/actions/setup/js/send_otlp_span.cjs b/actions/setup/js/send_otlp_span.cjs index fa5322c4e7e..87b3524490a 100644 --- a/actions/setup/js/send_otlp_span.cjs +++ b/actions/setup/js/send_otlp_span.cjs @@ -742,31 +742,12 @@ async function sendJobConclusionSpan(spanName, options = {}) { if (jobName) attributes.push(buildAttr("gh-aw.job.name", jobName)); if (engineId) attributes.push(buildAttr("gh-aw.engine.id", engineId)); - if (model) attributes.push(buildAttr("gh-aw.model", model)); if (eventName) attributes.push(buildAttr("gh-aw.event_name", eventName)); attributes.push(buildAttr("gh-aw.staged", staged)); 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)); } @@ -877,6 +858,27 @@ async function sendJobConclusionSpan(spanName, options = {}) { const conclusionSpanId = generateSpanId(); if (jobName === "agent" && typeof agentStartMs === "number" && agentStartMs > 0 && typeof agentEndMs === "number" && agentEndMs > agentStartMs) { const agentSpanEvents = buildSpanEvents(agentEndMs); + + // Build OTel GenAI semantic convention attributes for the dedicated agent span. + // These follow the OpenTelemetry GenAI specification and enable out-of-the-box + // LLM dashboards in Grafana, Datadog, and Honeycomb without custom mappings. + const agentUsage = readJSONIfExists("/tmp/gh-aw/agent_usage.json") || {}; + const agentAttributes = [...attributes]; + if (model) agentAttributes.push(buildAttr("gen_ai.request.model", model)); + if (engineId) agentAttributes.push(buildAttr("gen_ai.system", engineId)); + if (typeof agentUsage.input_tokens === "number" && agentUsage.input_tokens > 0) { + agentAttributes.push(buildAttr("gen_ai.usage.input_tokens", agentUsage.input_tokens)); + } + if (typeof agentUsage.output_tokens === "number" && agentUsage.output_tokens > 0) { + agentAttributes.push(buildAttr("gen_ai.usage.output_tokens", agentUsage.output_tokens)); + } + if (typeof agentUsage.cache_read_tokens === "number" && agentUsage.cache_read_tokens > 0) { + agentAttributes.push(buildAttr("gen_ai.usage.cache_read_input_tokens", agentUsage.cache_read_tokens)); + } + if (typeof agentUsage.cache_write_tokens === "number" && agentUsage.cache_write_tokens > 0) { + agentAttributes.push(buildAttr("gen_ai.usage.cache_creation_input_tokens", agentUsage.cache_write_tokens)); + } + const agentPayload = buildOTLPPayload({ traceId, spanId: generateSpanId(), @@ -886,11 +888,12 @@ async function sendJobConclusionSpan(spanName, options = {}) { endMs: agentEndMs, serviceName, scopeVersion: version, - attributes, + attributes: agentAttributes, resourceAttributes, statusCode, statusMessage, events: agentSpanEvents, + kind: SPAN_KIND_CLIENT, }); appendToOTLPJSONL(agentPayload); if (endpoint) { diff --git a/actions/setup/js/send_otlp_span.test.cjs b/actions/setup/js/send_otlp_span.test.cjs index fb86464e261..ff9f2d7307a 100644 --- a/actions/setup/js/send_otlp_span.test.cjs +++ b/actions/setup/js/send_otlp_span.test.cjs @@ -1787,6 +1787,87 @@ describe("sendJobConclusionSpan", () => { expect(span.name).toBe("gh-aw.safe-outputs.conclusion"); }); + it("emits the agent span with SPAN_KIND_CLIENT (3)", 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"; + process.env.INPUT_JOB_NAME = "agent"; + + const startMs = 1_700_000_000_000; + const endMs = 1_700_000_005_000; + const statSpy = vi.spyOn(fs, "statSync").mockReturnValue(/** @type {Partial} */ { mtimeMs: endMs }); + const readFileSpy = vi.spyOn(fs, "readFileSync").mockImplementation(() => { + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); + }); + + await sendJobConclusionSpan("gh-aw.agent.conclusion", { startMs }); + + statSpy.mockRestore(); + readFileSpy.mockRestore(); + + const agentBody = JSON.parse(mockFetch.mock.calls[0][1].body); + const agentSpan = agentBody.resourceSpans[0].scopeSpans[0].spans[0]; + expect(agentSpan.name).toBe("gh-aw.agent.agent"); + expect(agentSpan.kind).toBe(3); // SPAN_KIND_CLIENT + }); + + it("includes gen_ai.request.model on the agent span from aw_info.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"; + process.env.INPUT_JOB_NAME = "agent"; + + const startMs = 1_700_000_000_000; + const endMs = 1_700_000_005_000; + const statSpy = vi.spyOn(fs, "statSync").mockReturnValue(/** @type {Partial} */ { mtimeMs: endMs }); + const readFileSpy = vi.spyOn(fs, "readFileSync").mockImplementation(filePath => { + if (filePath === "/tmp/gh-aw/aw_info.json") { + return JSON.stringify({ model: "claude-3-5-sonnet-20241022", engine_id: "claude" }); + } + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); + }); + + await sendJobConclusionSpan("gh-aw.agent.conclusion", { startMs }); + + statSpy.mockRestore(); + readFileSpy.mockRestore(); + + const agentBody = JSON.parse(mockFetch.mock.calls[0][1].body); + const agentSpan = agentBody.resourceSpans[0].scopeSpans[0].spans[0]; + expect(agentSpan.name).toBe("gh-aw.agent.agent"); + const attrs = Object.fromEntries(agentSpan.attributes.map(a => [a.key, a.value.stringValue ?? a.value.intValue])); + expect(attrs["gen_ai.request.model"]).toBe("claude-3-5-sonnet-20241022"); + expect(attrs["gen_ai.system"]).toBe("claude"); + }); + + it("omits gen_ai.request.model and gen_ai.system from the agent span when model and engine_id are 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"; + process.env.INPUT_JOB_NAME = "agent"; + + const startMs = 1_700_000_000_000; + const endMs = 1_700_000_005_000; + const statSpy = vi.spyOn(fs, "statSync").mockReturnValue(/** @type {Partial} */ { mtimeMs: endMs }); + const readFileSpy = vi.spyOn(fs, "readFileSync").mockImplementation(() => { + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); + }); + + await sendJobConclusionSpan("gh-aw.agent.conclusion", { startMs }); + + statSpy.mockRestore(); + readFileSpy.mockRestore(); + + const agentBody = JSON.parse(mockFetch.mock.calls[0][1].body); + const agentSpan = agentBody.resourceSpans[0].scopeSpans[0].spans[0]; + const keys = agentSpan.attributes.map(a => a.key); + expect(keys).not.toContain("gen_ai.request.model"); + expect(keys).not.toContain("gen_ai.system"); + }); + it("includes gh-aw.run.attempt attribute from GITHUB_RUN_ATTEMPT env var", async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200, statusText: "OK" }); vi.stubGlobal("fetch", mockFetch); @@ -2836,10 +2917,14 @@ describe("sendJobConclusionSpan", () => { }); }); - describe("token breakdown enrichment in conclusion span", () => { + describe("token breakdown enrichment in agent span", () => { let readFileSpy; + let statSpy; beforeEach(() => { + process.env.INPUT_JOB_NAME = "agent"; + const agentEndMs = 1_700_000_005_000; + statSpy = vi.spyOn(fs, "statSync").mockReturnValue(/** @type {Partial} */ { mtimeMs: agentEndMs }); readFileSpy = vi.spyOn(fs, "readFileSync").mockImplementation(() => { throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); }); @@ -2847,9 +2932,10 @@ describe("sendJobConclusionSpan", () => { afterEach(() => { readFileSpy.mockRestore(); + statSpy.mockRestore(); }); - it("includes all four token breakdown attributes when agent_usage.json is present", async () => { + it("includes all four gen_ai token breakdown attributes on the agent span when agent_usage.json is present", async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200, statusText: "OK" }); vi.stubGlobal("fetch", mockFetch); @@ -2863,18 +2949,18 @@ describe("sendJobConclusionSpan", () => { throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); }); - await sendJobConclusionSpan("gh-aw.job.conclusion"); + await sendJobConclusionSpan("gh-aw.agent.conclusion", { startMs: 1_700_000_000_000 }); - 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); + const agentBody = JSON.parse(mockFetch.mock.calls[0][1].body); + const agentSpan = agentBody.resourceSpans[0].scopeSpans[0].spans[0]; + const attrs = Object.fromEntries(agentSpan.attributes.map(a => [a.key, a.value.intValue ?? a.value.stringValue])); + expect(attrs["gen_ai.usage.input_tokens"]).toBe(48200); + expect(attrs["gen_ai.usage.output_tokens"]).toBe(1350); + expect(attrs["gen_ai.usage.cache_read_input_tokens"]).toBe(41000); + expect(attrs["gen_ai.usage.cache_creation_input_tokens"]).toBe(3100); }); - it("omits all token breakdown attributes when agent_usage.json is absent", async () => { + it("omits all gen_ai 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); @@ -2882,18 +2968,18 @@ describe("sendJobConclusionSpan", () => { // readFileSpy already throws ENOENT for all paths - await sendJobConclusionSpan("gh-aw.job.conclusion"); + await sendJobConclusionSpan("gh-aw.agent.conclusion", { startMs: 1_700_000_000_000 }); - 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"); + const agentBody = JSON.parse(mockFetch.mock.calls[0][1].body); + const agentSpan = agentBody.resourceSpans[0].scopeSpans[0].spans[0]; + const keys = agentSpan.attributes.map(a => a.key); + expect(keys).not.toContain("gen_ai.usage.input_tokens"); + expect(keys).not.toContain("gen_ai.usage.output_tokens"); + expect(keys).not.toContain("gen_ai.usage.cache_read_input_tokens"); + expect(keys).not.toContain("gen_ai.usage.cache_creation_input_tokens"); }); - it("omits a token attribute when its value is zero", async () => { + it("omits a gen_ai token attribute when its value is zero", async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200, statusText: "OK" }); vi.stubGlobal("fetch", mockFetch); @@ -2907,19 +2993,19 @@ describe("sendJobConclusionSpan", () => { throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); }); - await sendJobConclusionSpan("gh-aw.job.conclusion"); + await sendJobConclusionSpan("gh-aw.agent.conclusion", { startMs: 1_700_000_000_000 }); - 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"); + const agentBody = JSON.parse(mockFetch.mock.calls[0][1].body); + const agentSpan = agentBody.resourceSpans[0].scopeSpans[0].spans[0]; + const attrs = Object.fromEntries(agentSpan.attributes.map(a => [a.key, a.value.intValue ?? a.value.stringValue])); + expect(attrs["gen_ai.usage.input_tokens"]).toBe(1000); + expect(attrs["gen_ai.usage.cache_read_input_tokens"]).toBe(500); + const keys = agentSpan.attributes.map(a => a.key); + expect(keys).not.toContain("gen_ai.usage.output_tokens"); + expect(keys).not.toContain("gen_ai.usage.cache_creation_input_tokens"); }); - it("omits token breakdown attributes when agent_usage.json contains invalid JSON", async () => { + it("omits gen_ai 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); @@ -2932,15 +3018,15 @@ describe("sendJobConclusionSpan", () => { throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); }); - await sendJobConclusionSpan("gh-aw.job.conclusion"); + await sendJobConclusionSpan("gh-aw.agent.conclusion", { startMs: 1_700_000_000_000 }); - 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"); + const agentBody = JSON.parse(mockFetch.mock.calls[0][1].body); + const agentSpan = agentBody.resourceSpans[0].scopeSpans[0].spans[0]; + const keys = agentSpan.attributes.map(a => a.key); + expect(keys).not.toContain("gen_ai.usage.input_tokens"); + expect(keys).not.toContain("gen_ai.usage.output_tokens"); + expect(keys).not.toContain("gen_ai.usage.cache_read_input_tokens"); + expect(keys).not.toContain("gen_ai.usage.cache_creation_input_tokens"); }); }); From c312aefccd97f02bf2de42b9b25a94df69982188 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 25 Apr 2026 22:15:21 +0000 Subject: [PATCH 3/4] fix: use correct OTel GenAI spec attribute names on agent span MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix cache token names: cache_read.input_tokens / cache_creation.input_tokens (dot separator, not underscore — per OTel registry) - Replace deprecated gen_ai.system with gen_ai.provider.name (Required) - Add gen_ai.operation.name = "chat" (Required by spec) - Add gen_ai.workflow.name = workflowName (spec: multi_agent_rag pattern) Agent-Logs-Url: https://github.com/github/gh-aw/sessions/fae7df36-dd28-47b3-a92a-eaa1130ad17a Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/send_otlp_span.cjs | 13 +++++++--- actions/setup/js/send_otlp_span.test.cjs | 32 ++++++++++++++---------- 2 files changed, 29 insertions(+), 16 deletions(-) diff --git a/actions/setup/js/send_otlp_span.cjs b/actions/setup/js/send_otlp_span.cjs index 87b3524490a..111af4dd348 100644 --- a/actions/setup/js/send_otlp_span.cjs +++ b/actions/setup/js/send_otlp_span.cjs @@ -864,8 +864,15 @@ async function sendJobConclusionSpan(spanName, options = {}) { // LLM dashboards in Grafana, Datadog, and Honeycomb without custom mappings. const agentUsage = readJSONIfExists("/tmp/gh-aw/agent_usage.json") || {}; const agentAttributes = [...attributes]; + // gen_ai.operation.name is Required by the OTel GenAI spec for inference spans. + // All gh-aw agent executions are chat-style LLM completions. + agentAttributes.push(buildAttr("gen_ai.operation.name", "chat")); if (model) agentAttributes.push(buildAttr("gen_ai.request.model", model)); - if (engineId) agentAttributes.push(buildAttr("gen_ai.system", engineId)); + // gen_ai.provider.name is the current Required attribute (replaces deprecated gen_ai.system). + if (engineId) agentAttributes.push(buildAttr("gen_ai.provider.name", engineId)); + // gen_ai.workflow.name identifies the agentic workflow, matching the OTel spec example + // use-cases (e.g. "multi_agent_rag", "customer_support_pipeline"). + if (workflowName) agentAttributes.push(buildAttr("gen_ai.workflow.name", workflowName)); if (typeof agentUsage.input_tokens === "number" && agentUsage.input_tokens > 0) { agentAttributes.push(buildAttr("gen_ai.usage.input_tokens", agentUsage.input_tokens)); } @@ -873,10 +880,10 @@ async function sendJobConclusionSpan(spanName, options = {}) { agentAttributes.push(buildAttr("gen_ai.usage.output_tokens", agentUsage.output_tokens)); } if (typeof agentUsage.cache_read_tokens === "number" && agentUsage.cache_read_tokens > 0) { - agentAttributes.push(buildAttr("gen_ai.usage.cache_read_input_tokens", agentUsage.cache_read_tokens)); + agentAttributes.push(buildAttr("gen_ai.usage.cache_read.input_tokens", agentUsage.cache_read_tokens)); } if (typeof agentUsage.cache_write_tokens === "number" && agentUsage.cache_write_tokens > 0) { - agentAttributes.push(buildAttr("gen_ai.usage.cache_creation_input_tokens", agentUsage.cache_write_tokens)); + agentAttributes.push(buildAttr("gen_ai.usage.cache_creation.input_tokens", agentUsage.cache_write_tokens)); } const agentPayload = buildOTLPPayload({ diff --git a/actions/setup/js/send_otlp_span.test.cjs b/actions/setup/js/send_otlp_span.test.cjs index ff9f2d7307a..14deced9e95 100644 --- a/actions/setup/js/send_otlp_span.test.cjs +++ b/actions/setup/js/send_otlp_span.test.cjs @@ -1812,7 +1812,7 @@ describe("sendJobConclusionSpan", () => { expect(agentSpan.kind).toBe(3); // SPAN_KIND_CLIENT }); - it("includes gen_ai.request.model on the agent span from aw_info.json", async () => { + it("includes gen_ai.request.model, gen_ai.provider.name, gen_ai.operation.name and gen_ai.workflow.name on the agent span from aw_info.json", async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200, statusText: "OK" }); vi.stubGlobal("fetch", mockFetch); @@ -1824,7 +1824,7 @@ describe("sendJobConclusionSpan", () => { const statSpy = vi.spyOn(fs, "statSync").mockReturnValue(/** @type {Partial} */ { mtimeMs: endMs }); const readFileSpy = vi.spyOn(fs, "readFileSync").mockImplementation(filePath => { if (filePath === "/tmp/gh-aw/aw_info.json") { - return JSON.stringify({ model: "claude-3-5-sonnet-20241022", engine_id: "claude" }); + return JSON.stringify({ model: "claude-3-5-sonnet-20241022", engine_id: "claude", workflow_name: "otel-advisor" }); } throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); }); @@ -1838,11 +1838,13 @@ describe("sendJobConclusionSpan", () => { const agentSpan = agentBody.resourceSpans[0].scopeSpans[0].spans[0]; expect(agentSpan.name).toBe("gh-aw.agent.agent"); const attrs = Object.fromEntries(agentSpan.attributes.map(a => [a.key, a.value.stringValue ?? a.value.intValue])); + expect(attrs["gen_ai.operation.name"]).toBe("chat"); expect(attrs["gen_ai.request.model"]).toBe("claude-3-5-sonnet-20241022"); - expect(attrs["gen_ai.system"]).toBe("claude"); + expect(attrs["gen_ai.provider.name"]).toBe("claude"); + expect(attrs["gen_ai.workflow.name"]).toBe("otel-advisor"); }); - it("omits gen_ai.request.model and gen_ai.system from the agent span when model and engine_id are absent", async () => { + it("omits gen_ai.request.model, gen_ai.provider.name and gen_ai.workflow.name from the agent span when model, engine_id and workflow_name are absent", async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200, statusText: "OK" }); vi.stubGlobal("fetch", mockFetch); @@ -1863,9 +1865,13 @@ describe("sendJobConclusionSpan", () => { const agentBody = JSON.parse(mockFetch.mock.calls[0][1].body); const agentSpan = agentBody.resourceSpans[0].scopeSpans[0].spans[0]; + const attrs = Object.fromEntries(agentSpan.attributes.map(a => [a.key, a.value.stringValue ?? a.value.intValue])); + // gen_ai.operation.name is always present + expect(attrs["gen_ai.operation.name"]).toBe("chat"); const keys = agentSpan.attributes.map(a => a.key); expect(keys).not.toContain("gen_ai.request.model"); - expect(keys).not.toContain("gen_ai.system"); + expect(keys).not.toContain("gen_ai.provider.name"); + expect(keys).not.toContain("gen_ai.workflow.name"); }); it("includes gh-aw.run.attempt attribute from GITHUB_RUN_ATTEMPT env var", async () => { @@ -2956,8 +2962,8 @@ describe("sendJobConclusionSpan", () => { const attrs = Object.fromEntries(agentSpan.attributes.map(a => [a.key, a.value.intValue ?? a.value.stringValue])); expect(attrs["gen_ai.usage.input_tokens"]).toBe(48200); expect(attrs["gen_ai.usage.output_tokens"]).toBe(1350); - expect(attrs["gen_ai.usage.cache_read_input_tokens"]).toBe(41000); - expect(attrs["gen_ai.usage.cache_creation_input_tokens"]).toBe(3100); + expect(attrs["gen_ai.usage.cache_read.input_tokens"]).toBe(41000); + expect(attrs["gen_ai.usage.cache_creation.input_tokens"]).toBe(3100); }); it("omits all gen_ai token breakdown attributes when agent_usage.json is absent", async () => { @@ -2975,8 +2981,8 @@ describe("sendJobConclusionSpan", () => { const keys = agentSpan.attributes.map(a => a.key); expect(keys).not.toContain("gen_ai.usage.input_tokens"); expect(keys).not.toContain("gen_ai.usage.output_tokens"); - expect(keys).not.toContain("gen_ai.usage.cache_read_input_tokens"); - expect(keys).not.toContain("gen_ai.usage.cache_creation_input_tokens"); + expect(keys).not.toContain("gen_ai.usage.cache_read.input_tokens"); + expect(keys).not.toContain("gen_ai.usage.cache_creation.input_tokens"); }); it("omits a gen_ai token attribute when its value is zero", async () => { @@ -2999,10 +3005,10 @@ describe("sendJobConclusionSpan", () => { const agentSpan = agentBody.resourceSpans[0].scopeSpans[0].spans[0]; const attrs = Object.fromEntries(agentSpan.attributes.map(a => [a.key, a.value.intValue ?? a.value.stringValue])); expect(attrs["gen_ai.usage.input_tokens"]).toBe(1000); - expect(attrs["gen_ai.usage.cache_read_input_tokens"]).toBe(500); + expect(attrs["gen_ai.usage.cache_read.input_tokens"]).toBe(500); const keys = agentSpan.attributes.map(a => a.key); expect(keys).not.toContain("gen_ai.usage.output_tokens"); - expect(keys).not.toContain("gen_ai.usage.cache_creation_input_tokens"); + expect(keys).not.toContain("gen_ai.usage.cache_creation.input_tokens"); }); it("omits gen_ai token breakdown attributes when agent_usage.json contains invalid JSON", async () => { @@ -3025,8 +3031,8 @@ describe("sendJobConclusionSpan", () => { const keys = agentSpan.attributes.map(a => a.key); expect(keys).not.toContain("gen_ai.usage.input_tokens"); expect(keys).not.toContain("gen_ai.usage.output_tokens"); - expect(keys).not.toContain("gen_ai.usage.cache_read_input_tokens"); - expect(keys).not.toContain("gen_ai.usage.cache_creation_input_tokens"); + expect(keys).not.toContain("gen_ai.usage.cache_read.input_tokens"); + expect(keys).not.toContain("gen_ai.usage.cache_creation.input_tokens"); }); }); From ce76a46e5fcdea1e6ca7a0ef19e7da56f0ce7b82 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 25 Apr 2026 23:11:29 +0000 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20clarify=20gen=5Fai.provider.name=20c?= =?UTF-8?q?omment=20=E2=80=94=20may=20be=20omitted=20when=20engine=5Fid=20?= =?UTF-8?q?unavailable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Logs-Url: https://github.com/github/gh-aw/sessions/ae987696-d9f6-41cb-9753-93ad59b563ea Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/send_otlp_span.cjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/actions/setup/js/send_otlp_span.cjs b/actions/setup/js/send_otlp_span.cjs index 111af4dd348..1d4d83082d7 100644 --- a/actions/setup/js/send_otlp_span.cjs +++ b/actions/setup/js/send_otlp_span.cjs @@ -868,7 +868,8 @@ async function sendJobConclusionSpan(spanName, options = {}) { // All gh-aw agent executions are chat-style LLM completions. agentAttributes.push(buildAttr("gen_ai.operation.name", "chat")); if (model) agentAttributes.push(buildAttr("gen_ai.request.model", model)); - // gen_ai.provider.name is the current Required attribute (replaces deprecated gen_ai.system). + // Emit gen_ai.provider.name when engineId is available; it may be omitted when + // engine metadata is unavailable, so this span does not guarantee full GenAI spec compliance. if (engineId) agentAttributes.push(buildAttr("gen_ai.provider.name", engineId)); // gen_ai.workflow.name identifies the agentic workflow, matching the OTel spec example // use-cases (e.g. "multi_agent_rag", "customer_support_pipeline").