Skip to content

Commit d02af76

Browse files
authored
fix(otel): replace gen_ai.provider.name with gen_ai.system + engine-to-system mapping (#29204)
1 parent b0efbe5 commit d02af76

3 files changed

Lines changed: 69 additions & 8 deletions

File tree

actions/setup/js/send_otlp_span.cjs

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,26 @@ const { getErrorMessage } = require("./error_helpers.cjs");
2020
* - No third-party dependencies: uses only Node built-ins + native fetch.
2121
*/
2222

23+
// ---------------------------------------------------------------------------
24+
// OTel GenAI engine-to-system mapping
25+
// ---------------------------------------------------------------------------
26+
27+
/**
28+
* Maps gh-aw internal engine IDs to the OTel GenAI semantic-convention
29+
* `gen_ai.system` values expected by Grafana, Datadog, Honeycomb, and Sentry.
30+
* Unknown engines fall back to the engine ID as-is.
31+
*
32+
* Uses Object.create(null) to avoid prototype-pollution risks from keys like
33+
* "constructor" or "__proto__" returning unexpected non-string values.
34+
* @type {Record<string, string>}
35+
*/
36+
const ENGINE_TO_SYSTEM_MAP = Object.assign(Object.create(null), {
37+
copilot: "github_models",
38+
claude: "anthropic",
39+
codex: "openai",
40+
gemini: "google_vertex_ai",
41+
});
42+
2343
// ---------------------------------------------------------------------------
2444
// Low-level helpers
2545
// ---------------------------------------------------------------------------
@@ -913,9 +933,14 @@ async function sendJobConclusionSpan(spanName, options = {}) {
913933
// All gh-aw agent executions are chat-style LLM completions.
914934
agentAttributes.push(buildAttr("gen_ai.operation.name", "chat"));
915935
if (model) agentAttributes.push(buildAttr("gen_ai.request.model", model));
916-
// Emit gen_ai.provider.name when engineId is available; it may be omitted when
917-
// engine metadata is unavailable, so this span does not guarantee full GenAI spec compliance.
918-
if (engineId) agentAttributes.push(buildAttr("gen_ai.provider.name", engineId));
936+
// gen_ai.system is the OTel GenAI standard attribute for the LLM system/provider.
937+
// Map the gh-aw internal engine ID to the standardized value so backends can apply
938+
// native GenAI dashboard detection. The original engine ID is preserved in gh-aw.engine.
939+
if (engineId) {
940+
const genAiSystem = ENGINE_TO_SYSTEM_MAP[engineId] || engineId;
941+
agentAttributes.push(buildAttr("gen_ai.system", genAiSystem));
942+
agentAttributes.push(buildAttr("gh-aw.engine", engineId));
943+
}
919944
// gen_ai.workflow.name identifies the agentic workflow, matching the OTel spec example
920945
// use-cases (e.g. "multi_agent_rag", "customer_support_pipeline").
921946
if (workflowName) agentAttributes.push(buildAttr("gen_ai.workflow.name", workflowName));

actions/setup/js/send_otlp_span.test.cjs

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1887,7 +1887,7 @@ describe("sendJobConclusionSpan", () => {
18871887
expect(agentSpan.kind).toBe(3); // SPAN_KIND_CLIENT
18881888
});
18891889

1890-
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 () => {
1890+
it("includes gen_ai.request.model, gen_ai.system, gh-aw.engine, gen_ai.operation.name and gen_ai.workflow.name on the agent span from aw_info.json", async () => {
18911891
const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200, statusText: "OK" });
18921892
vi.stubGlobal("fetch", mockFetch);
18931893

@@ -1915,11 +1915,12 @@ describe("sendJobConclusionSpan", () => {
19151915
const attrs = Object.fromEntries(agentSpan.attributes.map(a => [a.key, a.value.stringValue ?? a.value.intValue]));
19161916
expect(attrs["gen_ai.operation.name"]).toBe("chat");
19171917
expect(attrs["gen_ai.request.model"]).toBe("claude-3-5-sonnet-20241022");
1918-
expect(attrs["gen_ai.provider.name"]).toBe("claude");
1918+
expect(attrs["gen_ai.system"]).toBe("anthropic");
1919+
expect(attrs["gh-aw.engine"]).toBe("claude");
19191920
expect(attrs["gen_ai.workflow.name"]).toBe("otel-advisor");
19201921
});
19211922

1922-
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 () => {
1923+
it("omits gen_ai.request.model, gen_ai.system, gh-aw.engine and gen_ai.workflow.name from the agent span when model, engine_id and workflow_name are absent", async () => {
19231924
const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200, statusText: "OK" });
19241925
vi.stubGlobal("fetch", mockFetch);
19251926

@@ -1945,10 +1946,41 @@ describe("sendJobConclusionSpan", () => {
19451946
expect(attrs["gen_ai.operation.name"]).toBe("chat");
19461947
const keys = agentSpan.attributes.map(a => a.key);
19471948
expect(keys).not.toContain("gen_ai.request.model");
1948-
expect(keys).not.toContain("gen_ai.provider.name");
1949+
expect(keys).not.toContain("gen_ai.system");
1950+
expect(keys).not.toContain("gh-aw.engine");
19491951
expect(keys).not.toContain("gen_ai.workflow.name");
19501952
});
19511953

1954+
it("uses the raw engine ID as gen_ai.system fallback for unknown engines on the agent span", async () => {
1955+
const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200, statusText: "OK" });
1956+
vi.stubGlobal("fetch", mockFetch);
1957+
1958+
process.env.OTEL_EXPORTER_OTLP_ENDPOINT = "https://traces.example.com";
1959+
process.env.INPUT_JOB_NAME = "agent";
1960+
1961+
const startMs = 1_700_000_000_000;
1962+
const endMs = 1_700_000_005_000;
1963+
const statSpy = vi.spyOn(fs, "statSync").mockReturnValue(/** @type {Partial<fs.Stats>} */ { mtimeMs: endMs });
1964+
const readFileSpy = vi.spyOn(fs, "readFileSync").mockImplementation(filePath => {
1965+
if (filePath === "/tmp/gh-aw/aw_info.json") {
1966+
return JSON.stringify({ engine_id: "custom-engine" });
1967+
}
1968+
throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
1969+
});
1970+
1971+
await sendJobConclusionSpan("gh-aw.agent.conclusion", { startMs });
1972+
1973+
statSpy.mockRestore();
1974+
readFileSpy.mockRestore();
1975+
1976+
const agentBody = JSON.parse(mockFetch.mock.calls[0][1].body);
1977+
const agentSpan = agentBody.resourceSpans[0].scopeSpans[0].spans[0];
1978+
const attrs = Object.fromEntries(agentSpan.attributes.map(a => [a.key, a.value.stringValue ?? a.value.intValue]));
1979+
// Unknown engine ID falls back to the raw value for gen_ai.system
1980+
expect(attrs["gen_ai.system"]).toBe("custom-engine");
1981+
expect(attrs["gh-aw.engine"]).toBe("custom-engine");
1982+
});
1983+
19521984
it("includes gen_ai.request.model on the conclusion span when model is set in aw_info.json", async () => {
19531985
const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200, statusText: "OK" });
19541986
vi.stubGlobal("fetch", mockFetch);

docs/src/content/docs/reference/frontmatter.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -887,7 +887,8 @@ The agent span (`gh-aw.agent.agent`) uses [OpenTelemetry GenAI semantic conventi
887887
|-----------|-------------|
888888
| `gen_ai.request.model` | Model name used for inference |
889889
| `gen_ai.operation.name` | Always `"chat"` |
890-
| `gen_ai.provider.name` | Engine identifier (e.g. `copilot`, `claude`) |
890+
| `gen_ai.system` | Standardized OTel system name (e.g. `github_models`, `anthropic`, `openai`, `google_vertex_ai`) |
891+
| `gh-aw.engine` | gh-aw internal engine identifier (e.g. `copilot`, `claude`, `codex`, `gemini`) |
891892
| `gen_ai.workflow.name` | Workflow name |
892893
| `gen_ai.usage.input_tokens` | Total input tokens consumed |
893894
| `gen_ai.usage.output_tokens` | Total output tokens produced |
@@ -897,6 +898,9 @@ The agent span (`gh-aw.agent.agent`) uses [OpenTelemetry GenAI semantic conventi
897898
> [!NOTE]
898899
> 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.
899900

901+
> [!NOTE]
902+
> Prior to v0.76, the engine was emitted as `gen_ai.provider.name` with the raw gh-aw engine ID. It is now emitted as the standard `gen_ai.system` attribute with a mapped OTel system name, and the raw engine ID is preserved in `gh-aw.engine`.
903+
900904
## Related Documentation
901905

902906
See also: [Trigger Events](/gh-aw/reference/triggers/), [AI Engines](/gh-aw/reference/engines/), [CLI Commands](/gh-aw/setup/cli/), [Workflow Structure](/gh-aw/reference/workflow-structure/), [Network Permissions](/gh-aw/reference/network/), [Command Triggers](/gh-aw/reference/command-triggers/), [MCPs](/gh-aw/guides/mcps/), [Tools](/gh-aw/reference/tools/), [Imports](/gh-aw/reference/imports/)

0 commit comments

Comments
 (0)