Skip to content

Commit 176ca18

Browse files
authored
OTel: propagate GenAI semantic attrs to gh-aw.agent.conclusion spans (#33245)
1 parent 422b6a2 commit 176ca18

3 files changed

Lines changed: 29 additions & 15 deletions

File tree

.github/workflows/smoke-otel-backends.lock.yml

Lines changed: 1 addition & 1 deletion
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: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1747,6 +1747,15 @@ async function sendJobConclusionSpan(spanName, options = {}) {
17471747
if (typeof runtimeMetrics.estimatedCostUsd === "number") {
17481748
attributes.push(buildAttr("gh-aw.estimated_cost_usd", runtimeMetrics.estimatedCostUsd));
17491749
}
1750+
if (jobName === "agent") {
1751+
// Emit OTel GenAI semantic attributes on agent conclusion spans even when the
1752+
// dedicated agent sub-span is missing, so LLM activity remains discoverable.
1753+
attributes.push(buildAttr("gen_ai.operation.name", "chat"));
1754+
if (workflowName) attributes.push(buildAttr("gen_ai.workflow.name", workflowName));
1755+
if (runtimeMetrics.stopReason) {
1756+
attributes.push(buildArrayAttr("gen_ai.response.finish_reasons", [runtimeMetrics.stopReason]));
1757+
}
1758+
}
17501759

17511760
if (agentConclusion) {
17521761
attributes.push(buildAttr("gh-aw.agent.conclusion", agentConclusion));
@@ -1939,20 +1948,8 @@ async function sendJobConclusionSpan(spanName, options = {}) {
19391948
// Token-usage attributes are included here (and only here) to prevent
19401949
// double-counting with the conclusion span.
19411950
const agentAttributes = [...attributes, ...usageAttrs];
1942-
// gen_ai.operation.name is Required by the OTel GenAI spec for inference spans.
1943-
// All gh-aw agent executions are chat-style LLM completions.
1944-
agentAttributes.push(buildAttr("gen_ai.operation.name", "chat"));
1945-
// gen_ai.request.model is already present in agentAttributes via the spread above
1946-
// (added to attributes at the top of this function); do not push again.
1947-
// gen_ai.workflow.name identifies the agentic workflow, matching the OTel spec example
1948-
// use-cases (e.g. "multi_agent_rag", "customer_support_pipeline").
1949-
if (workflowName) agentAttributes.push(buildAttr("gen_ai.workflow.name", workflowName));
1950-
// gen_ai.response.finish_reasons is a standard OTel GenAI response attribute (array of strings).
1951-
// It exposes the stop_reason from the agent's result line so operators can detect truncated
1952-
// runs (e.g. "max_tokens") that would otherwise silently appear as STATUS_OK.
1953-
if (runtimeMetrics.stopReason) {
1954-
agentAttributes.push(buildArrayAttr("gen_ai.response.finish_reasons", [runtimeMetrics.stopReason]));
1955-
}
1951+
// gen_ai.operation.name / gen_ai.workflow.name / gen_ai.response.finish_reasons
1952+
// are already included via the shared attributes list above.
19561953

19571954
const agentPayload = buildOTLPPayload({
19581955
traceId,

actions/setup/js/send_otlp_span.test.cjs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2820,6 +2820,12 @@ describe("sendJobConclusionSpan", () => {
28202820
expect(attrs["gh-aw.engine.id"]).toBe("claude");
28212821
expect(attrs["gh-aw.engine"]).toBeUndefined();
28222822
expect(attrs["gen_ai.workflow.name"]).toBe("otel-advisor");
2823+
2824+
const conclusionBody = JSON.parse(mockFetch.mock.calls[1][1].body);
2825+
const conclusionSpan = conclusionBody.resourceSpans[0].scopeSpans[0].spans[0];
2826+
const conclusionAttrs = Object.fromEntries(conclusionSpan.attributes.map(a => [a.key, a.value.stringValue ?? a.value.intValue]));
2827+
expect(conclusionAttrs["gen_ai.operation.name"]).toBe("chat");
2828+
expect(conclusionAttrs["gen_ai.workflow.name"]).toBe("otel-advisor");
28232829
});
28242830

28252831
it("does not duplicate gen_ai.request.model on the agent span", async () => {
@@ -2970,6 +2976,12 @@ describe("sendJobConclusionSpan", () => {
29702976
const finishAttr = agentSpan.attributes.find(a => a.key === "gen_ai.response.finish_reasons");
29712977
expect(finishAttr).toBeDefined();
29722978
expect(finishAttr.value.arrayValue.values).toEqual([{ stringValue: "end_turn" }]);
2979+
2980+
const conclusionBody = JSON.parse(mockFetch.mock.calls[1][1].body);
2981+
const conclusionSpan = conclusionBody.resourceSpans[0].scopeSpans[0].spans[0];
2982+
const conclusionFinishAttr = conclusionSpan.attributes.find(a => a.key === "gen_ai.response.finish_reasons");
2983+
expect(conclusionFinishAttr).toBeDefined();
2984+
expect(conclusionFinishAttr.value.arrayValue.values).toEqual([{ stringValue: "end_turn" }]);
29732985
});
29742986

29752987
it("omits gen_ai.response.finish_reasons from the agent span when stop_reason is absent in agent-stdio.log", async () => {
@@ -2999,6 +3011,11 @@ describe("sendJobConclusionSpan", () => {
29993011
expect(agentSpan.name).toBe("gh-aw.agent.agent");
30003012
const keys = agentSpan.attributes.map(a => a.key);
30013013
expect(keys).not.toContain("gen_ai.response.finish_reasons");
3014+
3015+
const conclusionBody = JSON.parse(mockFetch.mock.calls[1][1].body);
3016+
const conclusionSpan = conclusionBody.resourceSpans[0].scopeSpans[0].spans[0];
3017+
const conclusionKeys = conclusionSpan.attributes.map(a => a.key);
3018+
expect(conclusionKeys).not.toContain("gen_ai.response.finish_reasons");
30023019
});
30033020

30043021
it("includes gen_ai.response.finish_reasons with max_tokens on the agent span when truncated", async () => {

0 commit comments

Comments
 (0)