Skip to content

Commit 073404c

Browse files
authored
Emit gh-aw.aic as OTLP sum metric for backend-native consumption (#38279)
1 parent b508103 commit 073404c

4 files changed

Lines changed: 333 additions & 2 deletions

File tree

‎actions/setup/js/send_otlp_span.cjs‎

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -593,6 +593,111 @@ function buildOTLPPayload({ traceId, spanId, parentSpanId, spanName, startMs, en
593593
});
594594
}
595595

596+
// ---------------------------------------------------------------------------
597+
// OTLP metrics payload builder and sender
598+
// ---------------------------------------------------------------------------
599+
600+
/**
601+
* Build an OTLP/HTTP JSON metrics payload for a single Sum data point.
602+
*
603+
* Produces a `resourceMetrics` payload ready to POST to `/v1/metrics`.
604+
* Uses a Sum with `isMonotonic: true` so backends treat each emitted value as
605+
* a per-run total (cumulative, always non-negative).
606+
*
607+
* @param {{
608+
* name: string,
609+
* description: string,
610+
* unit: string,
611+
* value: number,
612+
* startMs: number,
613+
* endMs: number,
614+
* attributes: Array<{key: string, value: object}>,
615+
* serviceName: string,
616+
* scopeVersion?: string,
617+
* resourceAttributes?: Array<{key: string, value: object}>,
618+
* }} opts
619+
* @returns {object} - Ready to be serialised as JSON and POSTed to `/v1/metrics`
620+
*/
621+
function buildOTLPMetricsPayload({ name, description, unit, value, startMs, endMs, attributes, serviceName, scopeVersion, resourceAttributes }) {
622+
const resourceAttrs = buildOTLPResourceAttributes(serviceName, scopeVersion, resourceAttributes);
623+
return {
624+
resourceMetrics: [
625+
{
626+
resource: { attributes: resourceAttrs },
627+
scopeMetrics: [
628+
{
629+
scope: { name: "gh-aw", ...(scopeVersion ? { version: scopeVersion } : {}) },
630+
metrics: [
631+
{
632+
name,
633+
description,
634+
unit,
635+
sum: {
636+
dataPoints: [
637+
{
638+
attributes,
639+
startTimeUnixNano: toNanoString(startMs),
640+
timeUnixNano: toNanoString(endMs),
641+
asDouble: value,
642+
},
643+
],
644+
aggregationTemporality: 2, // AGGREGATION_TEMPORALITY_CUMULATIVE
645+
isMonotonic: true,
646+
},
647+
},
648+
],
649+
},
650+
],
651+
},
652+
],
653+
};
654+
}
655+
656+
/**
657+
* POST an OTLP metrics payload to `{endpoint}/v1/metrics`.
658+
*
659+
* Failures are surfaced as `console.warn` and never thrown — metric export
660+
* failures must not break the workflow.
661+
*
662+
* @param {string} endpoint
663+
* @param {object} payload
664+
* @param {{ headersOverride?: string }} [opts]
665+
* @returns {Promise<void>}
666+
*/
667+
async function sendOTLPMetric(endpoint, payload, { headersOverride = undefined } = {}) {
668+
const url = endpoint.replace(/\/$/, "") + "/v1/metrics";
669+
const rawHeaders = headersOverride !== undefined ? headersOverride : process.env.OTEL_EXPORTER_OTLP_HEADERS || "";
670+
const extraHeaders = parseOTLPHeaders(rawHeaders);
671+
const headers = { "Content-Type": "application/json", ...extraHeaders };
672+
const body = JSON.stringify(payload);
673+
try {
674+
const response = hasProxyConfigured(endpoint) ? sendOTLPViaCurl(url, headers, body) : await fetch(url, { method: "POST", headers, body });
675+
if (!response.ok) {
676+
console.warn(`OTLP metrics export failed: ${response.status} ${response.statusText}`);
677+
}
678+
} catch (err) {
679+
console.warn(`OTLP metrics export error: ${err instanceof Error ? err.message : String(err)}`);
680+
}
681+
}
682+
683+
/**
684+
* Send an OTLP metrics payload to all configured endpoints concurrently.
685+
*
686+
* @param {OTLPEndpointEntry[]} endpoints
687+
* @param {object} payload
688+
* @returns {Promise<void>}
689+
*/
690+
async function sendOTLPMetricToAllEndpoints(endpoints, payload) {
691+
if (endpoints.length === 0) return;
692+
await Promise.allSettled(
693+
endpoints.map(ep =>
694+
sendOTLPMetric(ep.url, payload, {
695+
headersOverride: ep.headers !== undefined ? ep.headers : "",
696+
})
697+
)
698+
);
699+
}
700+
596701
// ---------------------------------------------------------------------------
597702
// Local JSONL mirror
598703
// ---------------------------------------------------------------------------
@@ -2335,6 +2440,27 @@ async function sendJobConclusionSpan(spanName, options = {}) {
23352440

23362441
// Pass skipJSONL: true so sendOTLPToAllEndpoints/sendOTLPSpan don't double-write the mirror.
23372442
await sendOTLPToAllEndpoints(endpoints, payload, { skipJSONL: true });
2443+
2444+
// Emit gh-aw.aic as a proper OTLP metric (Sum, cumulative) so backends can
2445+
// aggregate, alert on, and dashboard AIC without custom field mappings.
2446+
// Only emitted from the job that owns token usage to avoid double-counting.
2447+
if (typeof aiCredits === "number" && aiCredits > 0 && jobEmitsOwnTokenUsage) {
2448+
const metricAttributes = [buildAttr("gh-aw.workflow.name", workflowName), buildAttr("gh-aw.run.id", runId), buildAttr("gh-aw.run.status", runStatus), buildAttr("gh-aw.job.name", jobName)];
2449+
if (engineId) metricAttributes.push(buildAttr("gh-aw.engine.id", engineId));
2450+
const aicMetricsPayload = buildOTLPMetricsPayload({
2451+
name: "gh_aw.aic",
2452+
description: "AI Credits consumed by this workflow run",
2453+
unit: "AIC",
2454+
value: aiCredits,
2455+
startMs,
2456+
endMs,
2457+
attributes: metricAttributes,
2458+
serviceName,
2459+
scopeVersion: version,
2460+
resourceAttributes,
2461+
});
2462+
await sendOTLPMetricToAllEndpoints(endpoints, aicMetricsPayload);
2463+
}
23382464
}
23392465

23402466
module.exports = {
@@ -2374,4 +2500,7 @@ module.exports = {
23742500
buildExperimentAttributes,
23752501
parseOTLPCustomAttributes,
23762502
buildCustomOTLPAttributes,
2503+
buildOTLPMetricsPayload,
2504+
sendOTLPMetric,
2505+
sendOTLPMetricToAllEndpoints,
23772506
};

‎actions/setup/js/send_otlp_span.test.cjs‎

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ const {
3838
resolveEngineId,
3939
parseOTLPCustomAttributes,
4040
buildCustomOTLPAttributes,
41+
buildOTLPMetricsPayload,
4142
} = await import("./send_otlp_span.cjs");
4243

4344
const { readExperimentAssignments, EXPERIMENT_ASSIGNMENTS_PATH } = await import("./experiment_helpers.cjs");
@@ -6493,3 +6494,158 @@ describe("sendJobConclusionSpan custom attributes", () => {
64936494
expect(attrMap["langfuse.user.id"]).toBe("my-user-id");
64946495
});
64956496
});
6497+
6498+
// ---------------------------------------------------------------------------
6499+
// buildOTLPMetricsPayload
6500+
// ---------------------------------------------------------------------------
6501+
6502+
describe("buildOTLPMetricsPayload", () => {
6503+
it("produces a valid OTLP resourceMetrics payload for a Sum metric", () => {
6504+
const payload = buildOTLPMetricsPayload({
6505+
name: "gh_aw.aic",
6506+
description: "AI Credits consumed by this workflow run",
6507+
unit: "AIC",
6508+
value: 0.125,
6509+
startMs: 1_700_000_000_000,
6510+
endMs: 1_700_000_060_000,
6511+
attributes: [buildAttr("gh-aw.workflow.name", "my-workflow")],
6512+
serviceName: "gh-aw",
6513+
scopeVersion: "1.2.3",
6514+
});
6515+
6516+
expect(payload).toHaveProperty("resourceMetrics");
6517+
const rm = payload.resourceMetrics[0];
6518+
// resource carries service.name
6519+
const serviceNameAttr = rm.resource.attributes.find(a => a.key === "service.name");
6520+
expect(serviceNameAttr).toBeDefined();
6521+
expect(serviceNameAttr.value.stringValue).toBe("gh-aw");
6522+
6523+
// scope name and version
6524+
const sm = rm.scopeMetrics[0];
6525+
expect(sm.scope.name).toBe("gh-aw");
6526+
expect(sm.scope.version).toBe("1.2.3");
6527+
6528+
// metric definition
6529+
const metric = sm.metrics[0];
6530+
expect(metric.name).toBe("gh_aw.aic");
6531+
expect(metric.unit).toBe("AIC");
6532+
expect(metric).toHaveProperty("sum");
6533+
expect(metric.sum.aggregationTemporality).toBe(2); // CUMULATIVE
6534+
expect(metric.sum.isMonotonic).toBe(true);
6535+
6536+
// data point
6537+
const dp = metric.sum.dataPoints[0];
6538+
expect(dp.asDouble).toBe(0.125);
6539+
expect(dp.startTimeUnixNano).toBe(toNanoString(1_700_000_000_000));
6540+
expect(dp.timeUnixNano).toBe(toNanoString(1_700_000_060_000));
6541+
6542+
// attribute forwarded to data point
6543+
const wfAttr = dp.attributes.find(a => a.key === "gh-aw.workflow.name");
6544+
expect(wfAttr).toBeDefined();
6545+
expect(wfAttr.value.stringValue).toBe("my-workflow");
6546+
});
6547+
6548+
it("omits scope version when not provided", () => {
6549+
const payload = buildOTLPMetricsPayload({
6550+
name: "gh_aw.aic",
6551+
description: "AIC",
6552+
unit: "AIC",
6553+
value: 1,
6554+
startMs: 0,
6555+
endMs: 1000,
6556+
attributes: [],
6557+
serviceName: "gh-aw",
6558+
});
6559+
const sm = payload.resourceMetrics[0].scopeMetrics[0];
6560+
expect(sm.scope).not.toHaveProperty("version");
6561+
});
6562+
});
6563+
6564+
// ---------------------------------------------------------------------------
6565+
// sendJobConclusionSpan — gh-aw.aic metric emission
6566+
// ---------------------------------------------------------------------------
6567+
6568+
describe("sendJobConclusionSpan gh-aw.aic metric", () => {
6569+
let readFileSpy;
6570+
let statSpy;
6571+
6572+
beforeEach(() => {
6573+
vi.resetModules();
6574+
process.env.INPUT_JOB_NAME = "agent";
6575+
process.env.GH_AW_AGENT_CONCLUSION = "success";
6576+
readFileSpy = vi.spyOn(fs, "readFileSync");
6577+
statSpy = vi.spyOn(fs, "statSync").mockImplementation(() => {
6578+
throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
6579+
});
6580+
readFileSpy.mockImplementation(filePath => {
6581+
if (filePath === "/tmp/gh-aw/agent_usage.json") {
6582+
return JSON.stringify({ input_tokens: 1000, output_tokens: 200, ai_credits: 0.5 });
6583+
}
6584+
throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
6585+
});
6586+
});
6587+
6588+
afterEach(() => {
6589+
vi.unstubAllGlobals();
6590+
readFileSpy.mockRestore();
6591+
statSpy.mockRestore();
6592+
delete process.env.INPUT_JOB_NAME;
6593+
delete process.env.GH_AW_AGENT_CONCLUSION;
6594+
delete process.env.GH_AW_AIC;
6595+
delete process.env.GH_AW_OTLP_ENDPOINTS;
6596+
});
6597+
6598+
it("sends a /v1/metrics POST with gh_aw.aic when aiCredits > 0", async () => {
6599+
const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200, statusText: "OK" });
6600+
vi.stubGlobal("fetch", mockFetch);
6601+
process.env.GH_AW_OTLP_ENDPOINTS = JSON.stringify([{ url: "https://traces.example.com" }]);
6602+
6603+
await sendJobConclusionSpan("gh-aw.agent.conclusion", { startMs: 1_700_000_000_000 });
6604+
6605+
// Identify the /v1/metrics call (separate from /v1/traces calls)
6606+
const metricsCalls = mockFetch.mock.calls.filter(([url]) => url.includes("/v1/metrics"));
6607+
expect(metricsCalls.length).toBe(1);
6608+
6609+
const metricsBody = JSON.parse(metricsCalls[0][1].body);
6610+
expect(metricsBody).toHaveProperty("resourceMetrics");
6611+
const metric = metricsBody.resourceMetrics[0].scopeMetrics[0].metrics[0];
6612+
expect(metric.name).toBe("gh_aw.aic");
6613+
expect(metric.unit).toBe("AIC");
6614+
expect(metric.sum.isMonotonic).toBe(true);
6615+
expect(metric.sum.dataPoints[0].asDouble).toBe(0.5);
6616+
6617+
// Dimension attributes on the data point
6618+
const dpAttrMap = Object.fromEntries(metric.sum.dataPoints[0].attributes.map(a => [a.key, a.value.stringValue ?? a.value.doubleValue]));
6619+
expect(dpAttrMap["gh-aw.job.name"]).toBe("agent");
6620+
});
6621+
6622+
it("does not send a /v1/metrics POST when aiCredits is 0", async () => {
6623+
const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200, statusText: "OK" });
6624+
vi.stubGlobal("fetch", mockFetch);
6625+
process.env.GH_AW_OTLP_ENDPOINTS = JSON.stringify([{ url: "https://traces.example.com" }]);
6626+
readFileSpy.mockImplementation(filePath => {
6627+
if (filePath === "/tmp/gh-aw/agent_usage.json") {
6628+
return JSON.stringify({ input_tokens: 0, output_tokens: 0, ai_credits: 0 });
6629+
}
6630+
throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
6631+
});
6632+
6633+
await sendJobConclusionSpan("gh-aw.agent.conclusion", { startMs: 1_700_000_000_000 });
6634+
6635+
const metricsCalls = mockFetch.mock.calls.filter(([url]) => url.includes("/v1/metrics"));
6636+
expect(metricsCalls.length).toBe(0);
6637+
});
6638+
6639+
it("does not send a /v1/metrics POST for non-agent jobs", async () => {
6640+
process.env.INPUT_JOB_NAME = "conclusion";
6641+
const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200, statusText: "OK" });
6642+
vi.stubGlobal("fetch", mockFetch);
6643+
process.env.GH_AW_OTLP_ENDPOINTS = JSON.stringify([{ url: "https://traces.example.com" }]);
6644+
process.env.GH_AW_AIC = "0.5";
6645+
6646+
await sendJobConclusionSpan("gh-aw.conclusion", { startMs: 1_700_000_000_000 });
6647+
6648+
const metricsCalls = mockFetch.mock.calls.filter(([url]) => url.includes("/v1/metrics"));
6649+
expect(metricsCalls.length).toBe(0);
6650+
});
6651+
});

‎docs/src/content/docs/reference/open-telemetry.mdx‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ These attributes appear on built-in workflow setup, agent, and conclusion spans
8787
<tr><td><code>gh-aw.steering_event_count</code></td><td>Count of steering events recorded during the run.</td></tr>
8888
<tr><td><code>gh-aw.action_minutes</code></td><td>Elapsed runtime converted to minutes.</td></tr>
8989
<tr><td><code>gh-aw.tracker.id</code></td><td>Tracker identifier when present.</td></tr>
90-
<tr><td><code>gh-aw.aic</code></td><td>AI credits consumed for the run when available.</td></tr>
90+
<tr><td><code>gh-aw.aic</code></td><td>AI credits consumed for the run when available. Also emitted as a <code>gh_aw.aic</code> OTLP metric (see below).</td></tr>
9191
<tr><td><code>gh-aw.turns</code></td><td>Total agent turns recorded for the run.</td></tr>
9292
<tr><td><code>gh-aw.agent.conclusion</code></td><td>Normalized agent conclusion.</td></tr>
9393
<tr><td><code>gh-aw.detection.conclusion</code></td><td>Detection subsystem conclusion when present.</td></tr>
@@ -249,6 +249,26 @@ These attributes are emitted when experiments are active for a run.
249249
</tbody>
250250
</table>
251251

252+
## OTLP metrics
253+
254+
In addition to span attributes, gh-aw emits a native OTLP metric to `/v1/metrics` for AIC so backends can aggregate, alert, and dashboard without needing to extract values from span attributes.
255+
256+
<table>
257+
<thead>
258+
<tr>
259+
<th>Metric name</th>
260+
<th>Type</th>
261+
<th>Unit</th>
262+
<th>Description</th>
263+
</tr>
264+
</thead>
265+
<tbody>
266+
<tr><td><code>gh_aw.aic</code></td><td>Sum (cumulative, monotonic)</td><td>AIC</td><td>AI Credits consumed by the workflow run. Emitted once per run from the job that owns token usage (<code>agent</code> or <code>detection</code>).</td></tr>
267+
</tbody>
268+
</table>
269+
270+
Data point attributes: `gh-aw.workflow.name`, `gh-aw.run.id`, `gh-aw.run.status`, `gh-aw.job.name`, `gh-aw.engine.id` (when available).
271+
252272
## Trace files and artifacts
253273

254274
When observability is enabled, trace data is also mirrored to local JSONL files and uploaded in the <code>agent</code> artifact:

‎specs/otel-observability-spec.md‎

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -454,7 +454,7 @@ This section defines the attributes each span type MUST or MAY carry.
454454
| `gh-aw.staged` | boolean | Staging flag |
455455
| `gh-aw.trigger.*` | string | Trigger context (same fields as setup span) |
456456
| `gh-aw.frontmatter.*` | string | Frontmatter metadata (same fields as setup span) |
457-
| `gh-aw.aic` | double | AI credits consumed (AIC); emitted only when known and > 0 |
457+
| `gh-aw.aic` | double | AI credits consumed (AIC); emitted only when known and > 0. Also emitted as OTLP metric `gh_aw.aic` (see §10.6). |
458458
| `gh-aw.turns` | int | Number of agent turns |
459459
| `gh-aw.agent.conclusion` | string | Agent job outcome |
460460
| `gh-aw.detection.conclusion` | string | Threat detection outcome |
@@ -553,6 +553,32 @@ The fleet summary span (`gh-aw.outcome.summary`) aggregates all evaluated outcom
553553
| `gh-aw.outcome.workflows` | string | Comma-separated distinct workflow names |
554554
| `gh-aw.outcome.types` | string | Comma-separated distinct outcome types |
555555

556+
### 10.6 OTLP Metrics Signal
557+
558+
In addition to span attributes, gh-aw emits a native OTLP metric payload to `/v1/metrics` so that AIC is a first-class consumable metric for backends that support dashboarding and alerting on OTel metrics (e.g. Grafana, Datadog, Honeycomb).
559+
560+
#### Metrics emitted
561+
562+
| Metric name | Type | Unit | Description |
563+
|---|---|---|---|
564+
| `gh_aw.aic` | Sum (cumulative, monotonic) | AIC | AI Credits consumed by the workflow run. Emitted once from the job that owns token usage (`agent` or `detection`). |
565+
566+
#### Data point attributes
567+
568+
| Attribute | Type | Description |
569+
|---|---|---|
570+
| `gh-aw.workflow.name` | string | Workflow name |
571+
| `gh-aw.run.id` | string | GitHub Actions run ID |
572+
| `gh-aw.run.status` | string | Final run status (`success`, `failure`, `timeout`, `cancelled`) |
573+
| `gh-aw.job.name` | string | Job name (`agent` or `detection`) |
574+
| `gh-aw.engine.id` | string | Engine identifier (when available) |
575+
576+
Resource attributes mirror those on conclusion spans (§10.2).
577+
578+
#### Aggregation temporality
579+
580+
`AGGREGATION_TEMPORALITY_CUMULATIVE` — each data point represents the total AIC for that single workflow run. Backends should **sum** across runs to compute fleet totals, or average to track per-run cost trends.
581+
556582
### 10.7 MCP Gateway Span Attribute Contract
557583

558584
This section defines the attributes emitted by the MCP gateway (`gh-aw-mcpg`). These spans are emitted under the `mcp-gateway` service but share the workflow's trace ID (linked via `GITHUB_AW_OTEL_TRACE_ID` and `GITHUB_AW_OTEL_PARENT_SPAN_ID` passed to the gateway container per §6.3).

0 commit comments

Comments
 (0)