Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion actions/setup/js/send_otlp_span.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,19 @@ function buildAttr(key, value) {
return { key, value: { stringValue: String(value) } };
}

/**
* Build an OTLP key-value attribute with an array of string values.
* Used for OTel attributes whose type is `string[]`, such as
* `gen_ai.response.finish_reasons`.
*
* @param {string} key
* @param {string[]} values
* @returns {{ key: string, value: { arrayValue: { values: Array<{ stringValue: string }> } } }}
*/
function buildArrayAttr(key, values) {
return { key, value: { arrayValue: { values: values.map(v => ({ stringValue: String(v) })) } } };
}

/**
* Build the workflow-call identifier for the current run when enough GitHub
* context is available.
Expand Down Expand Up @@ -1084,6 +1097,7 @@ function getErrorMessage(errorEntry) {
* @typedef {Object} AgentRuntimeMetrics
* @property {number | undefined} turns
* @property {number | undefined} estimatedCostUsd
* @property {string | undefined} stopReason
* @property {number} warningCount
*/

Expand All @@ -1094,7 +1108,7 @@ function getErrorMessage(errorEntry) {
*/
function readAgentRuntimeMetrics() {
/** @type {AgentRuntimeMetrics} */
const metrics = { turns: undefined, estimatedCostUsd: undefined, warningCount: 0 };
const metrics = { turns: undefined, estimatedCostUsd: undefined, stopReason: undefined, warningCount: 0 };

try {
const content = fs.readFileSync(AGENT_STDIO_LOG_PATH, "utf8");
Expand Down Expand Up @@ -1127,6 +1141,9 @@ function readAgentRuntimeMetrics() {
if (typeof parsed.total_cost_usd === "number" && Number.isFinite(parsed.total_cost_usd) && parsed.total_cost_usd >= 0) {
metrics.estimatedCostUsd = parsed.total_cost_usd;
}
if (typeof parsed.stop_reason === "string" && parsed.stop_reason) {
metrics.stopReason = parsed.stop_reason;
}
} catch {
// Ignore non-JSON and truncated log lines.
}
Expand Down Expand Up @@ -1481,6 +1498,12 @@ async function sendJobConclusionSpan(spanName, options = {}) {
// 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));
// gen_ai.response.finish_reasons is a standard OTel GenAI response attribute (array of strings).
// It exposes the stop_reason from the agent's result line so operators can detect truncated
// runs (e.g. "max_tokens") that would otherwise silently appear as STATUS_OK.
if (runtimeMetrics.stopReason) {
agentAttributes.push(buildArrayAttr("gen_ai.response.finish_reasons", [runtimeMetrics.stopReason]));
}

const agentPayload = buildOTLPPayload({
traceId,
Expand Down Expand Up @@ -1547,6 +1570,7 @@ module.exports = {
generateSpanId,
toNanoString,
buildAttr,
buildArrayAttr,
buildGitHubActionsResourceAttributes,
buildOTLPSpan,
buildOTLPBatchPayload,
Expand Down
89 changes: 89 additions & 0 deletions actions/setup/js/send_otlp_span.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -2509,6 +2509,95 @@ describe("sendJobConclusionSpan", () => {
expect(attrs["gh-aw.engine"]).toBe("custom-engine");
});

it("includes gen_ai.response.finish_reasons on the agent span when stop_reason is present in agent-stdio.log", async () => {
const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200, statusText: "OK" });
vi.stubGlobal("fetch", mockFetch);

process.env.GH_AW_OTLP_ENDPOINTS = JSON.stringify([{ url: "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<fs.Stats>} */ { mtimeMs: endMs });
const readFileSpy = vi.spyOn(fs, "readFileSync").mockImplementation(filePath => {
if (filePath === "/tmp/gh-aw/agent-stdio.log") {
return '{"type":"result","subtype":"success","num_turns":3,"total_cost_usd":0.5,"stop_reason":"end_turn"}\n';
}
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 finishAttr = agentSpan.attributes.find(a => a.key === "gen_ai.response.finish_reasons");
expect(finishAttr).toBeDefined();
expect(finishAttr.value.arrayValue.values).toEqual([{ stringValue: "end_turn" }]);
});

it("omits gen_ai.response.finish_reasons from the agent span when stop_reason is absent in agent-stdio.log", async () => {
const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200, statusText: "OK" });
vi.stubGlobal("fetch", mockFetch);

process.env.GH_AW_OTLP_ENDPOINTS = JSON.stringify([{ url: "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<fs.Stats>} */ { mtimeMs: endMs });
const readFileSpy = vi.spyOn(fs, "readFileSync").mockImplementation(filePath => {
if (filePath === "/tmp/gh-aw/agent-stdio.log") {
return '{"type":"result","num_turns":2,"total_cost_usd":0.25}\n';
}
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 keys = agentSpan.attributes.map(a => a.key);
expect(keys).not.toContain("gen_ai.response.finish_reasons");
});

it("includes gen_ai.response.finish_reasons with max_tokens on the agent span when truncated", async () => {
const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200, statusText: "OK" });
vi.stubGlobal("fetch", mockFetch);

process.env.GH_AW_OTLP_ENDPOINTS = JSON.stringify([{ url: "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<fs.Stats>} */ { mtimeMs: endMs });
const readFileSpy = vi.spyOn(fs, "readFileSync").mockImplementation(filePath => {
if (filePath === "/tmp/gh-aw/agent-stdio.log") {
return '{"type":"result","subtype":"error","num_turns":10,"total_cost_usd":2.0,"stop_reason":"max_tokens"}\n';
}
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 finishAttr = agentSpan.attributes.find(a => a.key === "gen_ai.response.finish_reasons");
expect(finishAttr).toBeDefined();
expect(finishAttr.value.arrayValue.values).toEqual([{ stringValue: "max_tokens" }]);
});

it("includes gen_ai.request.model on the conclusion span when model is set in aw_info.json", async () => {
const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200, statusText: "OK" });
vi.stubGlobal("fetch", mockFetch);
Expand Down
1 change: 1 addition & 0 deletions docs/src/content/docs/reference/frontmatter.md
Original file line number Diff line number Diff line change
Expand Up @@ -948,6 +948,7 @@ The agent span (`gh-aw.agent.agent`) uses [OpenTelemetry GenAI semantic conventi
| `gen_ai.usage.output_tokens` | Total output tokens produced |
| `gen_ai.usage.cache_read.input_tokens` | Cache-read tokens reused |
| `gen_ai.usage.cache_creation.input_tokens` | Cache-creation tokens written |
| `gen_ai.response.finish_reasons` | Array containing the agent's stop reason (e.g. `["end_turn"]`, `["max_tokens"]`) |

> [!NOTE]
> Prior to v0.70, the agent span used private `gh-aw.*` attribute names (`gh-aw.model`, `gh-aw.tokens.input`, etc.) and `SPAN_KIND_INTERNAL`. These attributes were removed and replaced with the `gen_ai.*` convention above. Update any dashboards or alert rules that reference the old attribute names.
Expand Down