Skip to content

Commit 4d1d3ec

Browse files
authored
Surface AWF steering counters in compact audit usage data (#63664)
1 parent d3f48b2 commit 4d1d3ec

18 files changed

Lines changed: 417 additions & 40 deletions

‎.changeset/minor-audit-gateway-steering.md‎

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/generate_usage_activity_summary.cjs‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,15 @@
77
// session: aggregate Copilot session event counters
88
// gateway: tool-call counts, sizes, durations, and per-server/tool breakdowns
99
// integrity: aggregate DIFC filtering counts from gateway/RPC logs
10+
// steering: aggregate AWF steering-event counts by event type
1011
// safe_outputs: total item count and per-type breakdown from safe-output-items manifest
1112
// experiments: A/B experiment variant assignments for the current run
1213
// working_set: cumulative input-token traffic relative to peak invocation input
1314

1415
const fs = require("fs");
1516
const path = require("path");
1617
const { readExperimentAssignments } = require("./experiment_helpers.cjs");
18+
const { countSteeringEventsByTypeInApiProxyJsonl } = require("./steering_helpers.cjs");
1719
const { calculateWorkingSetFromJSONL } = require("./working_set_metrics.cjs");
1820

1921
require("./shim.cjs");
@@ -29,6 +31,14 @@ const PLACEHOLDER_DEST_KEY = "-:-";
2931
const ERROR_DOMAIN_PREFIX = "error:";
3032
const AGENT_TOKEN_USAGE_PATH = "/tmp/gh-aw/usage/agent/token_usage.jsonl";
3133
const RPC_EVENT_TO_TYPE = { rpc_request: "REQUEST", rpc_response: "RESPONSE", difc_filtered: "DIFC_FILTERED" };
34+
const API_PROXY_EVENT_LOG_PATHS = [
35+
"/tmp/gh-aw/sandbox/firewall/logs/api-proxy-logs/event-logs.jsonl",
36+
"/tmp/gh-aw/sandbox/firewall/logs/api-proxy-logs/events.jsonl",
37+
"/tmp/gh-aw/sandbox/firewall/audit/api-proxy-logs/event-logs.jsonl",
38+
"/tmp/gh-aw/sandbox/firewall/audit/api-proxy-logs/events.jsonl",
39+
"/tmp/gh-aw/sandbox/firewall-audit-logs/api-proxy-logs/event-logs.jsonl",
40+
"/tmp/gh-aw/sandbox/firewall-audit-logs/api-proxy-logs/events.jsonl",
41+
];
3242

3343
function findFiles(rootDir, shouldIncludeFile, maxDepth = Number.POSITIVE_INFINITY, currentDepth = 0) {
3444
if (!fs.existsSync(rootDir)) {
@@ -335,6 +345,31 @@ function parseSessionLogs(sessionLogDirs = ["/tmp/gh-aw/sandbox/agent/logs/copil
335345
return session.total_events > 0 ? session : null;
336346
}
337347

348+
/**
349+
* Parse the first AWF API proxy event log with steering events.
350+
*
351+
* @param {string[]} eventLogPaths
352+
* @returns {{ total_events: number, event_counts: Record<string, number> } | null}
353+
*/
354+
function parseSteeringEvents(eventLogPaths = API_PROXY_EVENT_LOG_PATHS) {
355+
for (const eventLogPath of eventLogPaths) {
356+
try {
357+
const stat = fs.statSync(eventLogPath);
358+
if (!stat || stat.size <= 0) {
359+
continue;
360+
}
361+
const eventCounts = countSteeringEventsByTypeInApiProxyJsonl(fs.readFileSync(eventLogPath, "utf-8"));
362+
const totalEvents = Object.values(eventCounts).reduce((total, count) => total + count, 0);
363+
if (totalEvents > 0) {
364+
return { total_events: totalEvents, event_counts: eventCounts };
365+
}
366+
} catch {
367+
// Ignore missing or unreadable candidate files and try the next layout.
368+
}
369+
}
370+
return null;
371+
}
372+
338373
/**
339374
* @param {unknown} value
340375
* @returns {number}
@@ -809,6 +844,11 @@ function main() {
809844
summary.integrity = gatewayActivity.integrity;
810845
}
811846

847+
const steering = parseSteeringEvents();
848+
if (steering) {
849+
summary.steering = steering;
850+
}
851+
812852
// Parse safe outputs manifest.
813853
// parseSafeOutputsManifest() has three distinct outcomes that drive the three
814854
// states downstream consumers need to distinguish:
@@ -870,6 +910,7 @@ if (require.main === module) {
870910
module.exports = {
871911
parseFirewallLogs,
872912
parseSessionLogs,
913+
parseSteeringEvents,
873914
parseGatewayLogs,
874915
parseGatewayActivity,
875916
parseSafeOutputsManifest,

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

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ const __filename = fileURLToPath(import.meta.url);
99
const __dirname = path.dirname(__filename);
1010

1111
const req = createRequire(import.meta.url);
12-
const { parseFirewallLogs, parseSessionLogs, parseGatewayActivity, parseSafeOutputsManifest, parseExperimentsData, calculateWorkingSetFromJSONL, parseWorkingSetMetrics, MANIFEST_FILE_PATH } = req("./generate_usage_activity_summary.cjs");
12+
const { parseFirewallLogs, parseSessionLogs, parseSteeringEvents, parseGatewayActivity, parseSafeOutputsManifest, parseExperimentsData, calculateWorkingSetFromJSONL, parseWorkingSetMetrics, MANIFEST_FILE_PATH } =
13+
req("./generate_usage_activity_summary.cjs");
1314

1415
describe("generate_usage_activity_summary.cjs", () => {
1516
/** Unique directory for each test to avoid cross-test interference */
@@ -116,6 +117,47 @@ describe("generate_usage_activity_summary.cjs", () => {
116117
});
117118
});
118119

120+
describe("parseSteeringEvents", () => {
121+
it("aggregates steering counters from the first available AWF event log", () => {
122+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "steering-events-test-"));
123+
const missingPath = path.join(root, "missing.jsonl");
124+
const eventsPath = path.join(root, "events.jsonl");
125+
fs.writeFileSync(eventsPath, ['{"event":"token_steering"}', '{"type":"TOKEN_STEERING"}', '{"event_name":"timeout_steering"}', '{"eventName":"model_steering"}', '{"event":"request"}'].join("\n"));
126+
127+
try {
128+
expect(parseSteeringEvents([missingPath, eventsPath])).toEqual({
129+
total_events: 4,
130+
event_counts: {
131+
model_steering: 1,
132+
timeout_steering: 1,
133+
token_steering: 2,
134+
},
135+
});
136+
} finally {
137+
fs.rmSync(root, { recursive: true, force: true });
138+
}
139+
});
140+
141+
it("falls back past empty and steering-free event logs", () => {
142+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "steering-events-fallback-test-"));
143+
const emptyPath = path.join(root, "event-logs.jsonl");
144+
const unrelatedPath = path.join(root, "unrelated.jsonl");
145+
const eventsPath = path.join(root, "events.jsonl");
146+
fs.writeFileSync(emptyPath, "");
147+
fs.writeFileSync(unrelatedPath, '{"event":"request"}\n');
148+
fs.writeFileSync(eventsPath, '{"event":"token_steering"}\n');
149+
150+
try {
151+
expect(parseSteeringEvents([emptyPath, unrelatedPath, eventsPath])).toEqual({
152+
total_events: 1,
153+
event_counts: { token_steering: 1 },
154+
});
155+
} finally {
156+
fs.rmSync(root, { recursive: true, force: true });
157+
}
158+
});
159+
});
160+
119161
describe("parseGatewayActivity", () => {
120162
it("aggregates RPC v2 tool calls, payload sizes, durations, failures, and integrity filtering", () => {
121163
const root = fs.mkdtempSync(path.join(os.tmpdir(), "gateway-activity-test-"));

‎actions/setup/js/steering_helpers.cjs‎

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,10 @@ const STEERING_EVENT_PATTERN = /steering/i;
1313
/**
1414
* Resolve an event name from a firewall proxy event entry.
1515
*
16-
* Supports three log schema variants:
16+
* Supports log schema variants used across AWF versions:
1717
* - Top-level `event` field: `{ event: "token_steering", ... }`
1818
* - Top-level `type` field: `{ type: "model_steering", ... }`
19+
* - Top-level `event_name` or `eventName` field
1920
* - Nested payload: `{ payload: { event: "steering" } }`
2021
*
2122
* @param {unknown} entry
@@ -31,6 +32,12 @@ function getApiProxyEventName(entry) {
3132
if ("type" in entry && typeof entry.type === "string") {
3233
return entry.type;
3334
}
35+
if ("event_name" in entry && typeof entry.event_name === "string") {
36+
return entry.event_name;
37+
}
38+
if ("eventName" in entry && typeof entry.eventName === "string") {
39+
return entry.eventName;
40+
}
3441
if ("payload" in entry) {
3542
const payload = entry.payload;
3643
if (payload && typeof payload === "object" && !Array.isArray(payload)) {
@@ -46,28 +53,39 @@ function getApiProxyEventName(entry) {
4653
}
4754

4855
/**
49-
* Count steering events in proxy event-log JSONL content.
56+
* Count steering events by normalized event name in proxy event-log JSONL content.
5057
*
5158
* Known steering event names: "steering", "token_steering", "model_steering".
5259
* Any event whose name is exactly "steering" or ends with "_steering" is counted.
5360
*
5461
* @param {string} jsonlContent
55-
* @returns {number}
62+
* @returns {Record<string, number>}
5663
*/
57-
function countSteeringEventsInApiProxyJsonl(jsonlContent) {
58-
let count = 0;
64+
function countSteeringEventsByTypeInApiProxyJsonl(jsonlContent) {
65+
/** @type {Record<string, number>} */
66+
const counts = {};
5967
for (const parsed of parseJsonlContent(jsonlContent, line => STEERING_EVENT_PATTERN.test(line))) {
6068
const eventName = getApiProxyEventName(parsed).toLowerCase();
61-
// Known steering events: "steering", "token_steering", "model_steering".
6269
if (eventName === "steering" || eventName.endsWith("_steering")) {
63-
count += 1;
70+
counts[eventName] = (counts[eventName] || 0) + 1;
6471
}
6572
}
66-
return count;
73+
return Object.fromEntries(Object.entries(counts).sort(([left], [right]) => left.localeCompare(right)));
74+
}
75+
76+
/**
77+
* Count all steering events in proxy event-log JSONL content.
78+
*
79+
* @param {string} jsonlContent
80+
* @returns {number}
81+
*/
82+
function countSteeringEventsInApiProxyJsonl(jsonlContent) {
83+
return Object.values(countSteeringEventsByTypeInApiProxyJsonl(jsonlContent)).reduce((total, count) => total + count, 0);
6784
}
6885

6986
module.exports = {
7087
STEERING_EVENT_PATTERN,
7188
getApiProxyEventName,
89+
countSteeringEventsByTypeInApiProxyJsonl,
7290
countSteeringEventsInApiProxyJsonl,
7391
};

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

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect } from "vitest";
2-
import { getApiProxyEventName, countSteeringEventsInApiProxyJsonl } from "./steering_helpers.cjs";
2+
import { getApiProxyEventName, countSteeringEventsByTypeInApiProxyJsonl, countSteeringEventsInApiProxyJsonl } from "./steering_helpers.cjs";
33

44
describe("steering_helpers", () => {
55
describe("getApiProxyEventName", () => {
@@ -19,6 +19,11 @@ describe("steering_helpers", () => {
1919
expect(getApiProxyEventName({ type: "model_steering", request_id: "r2" })).toBe("model_steering");
2020
});
2121

22+
it("returns snake-case and camel-case event name fields", () => {
23+
expect(getApiProxyEventName({ event_name: "timeout_steering" })).toBe("timeout_steering");
24+
expect(getApiProxyEventName({ eventName: "model_steering" })).toBe("model_steering");
25+
});
26+
2227
it("returns payload.event when top-level fields are absent", () => {
2328
expect(getApiProxyEventName({ payload: { event: "steering" }, request_id: "r3" })).toBe("steering");
2429
});
@@ -39,6 +44,15 @@ describe("steering_helpers", () => {
3944
});
4045

4146
describe("countSteeringEventsInApiProxyJsonl", () => {
47+
it("aggregates counters for each normalized steering event", () => {
48+
const content = ['{"event":"TOKEN_STEERING"}', '{"type":"token_steering"}', '{"event_name":"timeout_steering"}', '{"eventName":"model_steering"}'].join("\n");
49+
expect(countSteeringEventsByTypeInApiProxyJsonl(content)).toEqual({
50+
model_steering: 1,
51+
timeout_steering: 1,
52+
token_steering: 2,
53+
});
54+
});
55+
4256
it("counts events with exact 'steering' name", () => {
4357
const content = '{"event":"steering","request_id":"r1"}\n';
4458
expect(countSteeringEventsInApiProxyJsonl(content)).toBe(1);
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# ADR-63664: Surface AWF steering counters in compact usage data
2+
3+
**Date**: 2026-09-26
4+
**Status**: Draft
5+
**Deciders**: gh-aw maintainers
6+
7+
---
8+
9+
### Context
10+
11+
The PR adds support for reading AWF API proxy steering events from multiple log file layouts and schema variants, then backfills those counts into compact usage artifacts and audit output. Today, detailed steering behavior requires raw firewall logs, while compact usage summaries and usage-only audits do not expose per-event steering totals. The changed files span JavaScript artifact generation, Go audit backfill logic, schema updates, tests, and documentation, which indicates a cross-cutting product decision rather than a localized bug fix. The decision is how gh-aw should represent steering behavior when only compact usage artifacts are available.
12+
13+
### Decision
14+
15+
We will aggregate AWF steering events by normalized event name inside the compact usage activity summary and propagate those counters into audit token-usage output when detailed firewall analysis is unavailable. We will support the log filename and schema variants visible in the PR evidence, including `events.jsonl`, `event-logs.jsonl`, and top-level or nested event-name fields. We chose this because it preserves steering visibility for `gh aw audit --artifacts usage` and related usage-only flows without requiring raw firewall log downloads.
16+
17+
### Alternatives Considered
18+
19+
#### Alternative 1: Keep steering analysis only in raw firewall log processing
20+
21+
This was a realistic option because gh-aw already derives detailed steering information from firewall artifacts when those logs are present. It was not chosen because the PR evidence explicitly adds steering data to `usage/activity/summary.json` and backfills audit results from compact usage artifacts, showing that raw-log-only visibility is insufficient for the intended audit workflows.
22+
23+
#### Alternative 2: Publish only a single total steering-event count
24+
25+
This was considered because earlier code paths already tracked `total_steering_events`, and a single aggregate is simpler to compute and document. It was not chosen because the PR updates both JS and Go paths to preserve per-event counters such as `token_steering` and `timeout_steering`, which provides more actionable inspection of AWF behavior than a single total.
26+
27+
### Consequences
28+
29+
#### Positive
30+
- Usage-only audit flows can report steering behavior even when raw firewall logs are unavailable.
31+
- Operators gain per-event steering counters, which makes it easier to distinguish token, timeout, model, or other steering causes.
32+
- The implementation becomes more robust across AWF versions by recognizing multiple log filenames and event-name field variants.
33+
34+
#### Negative
35+
- Steering parsing logic now exists in both JavaScript artifact-generation code and Go audit-analysis code, which increases maintenance overhead.
36+
- Supporting multiple historical file layouts and schema variants adds complexity and ongoing compatibility expectations.
37+
- Compact usage artifacts and schemas become broader, which increases documentation and regression-test surface area.
38+
39+
#### Neutral
40+
- Audit consumers now need to understand both `total_steering_events` and `steering_event_counts` fields in token-usage output.
41+
- The change does not replace detailed firewall analysis; it supplements it when only usage artifacts are available.
42+
- Additional tests are required to keep the JS and Go aggregation paths aligned as AWF logging evolves.
43+
44+
---
45+
46+
*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.*

‎docs/src/content/docs/reference/artifacts.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,13 @@ Its `activity/summary.json` file uses the `usage-activity-summary/v1` schema. Th
259259
"filtered_tool_counts": { "issue_read": 2 },
260260
"filtered_reason_counts": { "integrity": 2 }
261261
},
262+
"steering": {
263+
"total_events": 3,
264+
"event_counts": {
265+
"token_steering": 2,
266+
"timeout_steering": 1
267+
}
268+
},
262269
"working_set": {
263270
"measurement_state": "measured",
264271
"rebuild_factor": 3.9017857142857144,
@@ -325,6 +332,8 @@ label name, and GitHub database or node ID when returned by the API.
325332

326333
The conclusion job derives `gateway` and `integrity` from MCP gateway logs, falling back to `rpc-messages.jsonl` when `gateway.jsonl` is unavailable. These compact aggregates let `gh aw logs --artifacts usage` report MCP call, payload-size, duration, failure, and integrity-filter metrics without downloading raw logs. Cross-run reports include `runs_with_filtered_events`; the existing logs report summary remains the source for the total number of runs.
327334

335+
The `steering` section aggregates AWF API proxy events by normalized event name. `gh aw audit --artifacts usage` exposes these counters in `firewall_token_usage.steering_event_counts`, so steering behavior can be inspected without downloading raw firewall logs.
336+
328337
`rebuild_factor` is `cumulative_input_tokens / peak_input_tokens`, where each invocation contributes the canonical `input_tokens` value from the agent `token_usage.jsonl` record. Cache-read and cache-write fields are not added because provider normalization has already produced that logical input count. The factor is omitted when `measurement_state` is `unavailable`; `partial` means usable records were measured but malformed or unsupported records were ignored.
329338

330339
Working-Set Rebuild Factor measures cumulative context reconstruction relative to peak invocation context. It is an efficiency/trajectory metric, not a measurement of semantic coherence debt and not a predictor of task success. It cannot identify missing task facts or classify outcome quality. The metric is conceptually inspired by [“The Working Set of a Coding Agent: Coherence Debt in Repository-Scale Tasks”](https://arxiv.org/abs/2608.16630), while deliberately limiting the implementation to observable token traffic.

‎pkg/cli/audit_analysis_fanout.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ func collectAuditAnalysisResults(ctx context.Context, run WorkflowRun, runOutput
4040
}
4141
if usageSummary != nil {
4242
results.workingSet = usageSummary.WorkingSet
43+
applyUsageActivitySteeringSummary(usageSummary.Steering, &results.tokenUsageSummary)
4344
}
4445
return results, nil
4546
}

0 commit comments

Comments
 (0)