Skip to content

Commit a2ec12e

Browse files
lpcoxCopilot
andcommitted
feat(api-proxy): add always-on audit trail for token tracking
Add token-tracker-audit.jsonl that unconditionally logs TRACK_START and TRACK_END events for every tracked request. This provides visibility into the token tracking lifecycle in CI without requiring AWF_DEBUG_TOKENS=1. When token-usage.jsonl is missing, the check-token-usage.js CI script now prints the audit trail contents to help diagnose why tracking failed (e.g. whether the tracker was never invoked, or invoked but failed to extract usage from responses). The audit trail is minimal (timestamp + event + key metadata) to avoid significant disk overhead in production. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 54ff94b commit a2ec12e

5 files changed

Lines changed: 80 additions & 17 deletions

File tree

containers/api-proxy/token-persistence.js

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const { logRequest } = require('./logging');
1616
const TOKEN_LOG_DIR = process.env.AWF_TOKEN_LOG_DIR || '/var/log/api-proxy';
1717
const TOKEN_LOG_FILE = path.join(TOKEN_LOG_DIR, 'token-usage.jsonl');
1818
const DIAG_LOG_FILE = path.join(TOKEN_LOG_DIR, 'token-diag.jsonl');
19+
const AUDIT_LOG_FILE = path.join(TOKEN_LOG_DIR, 'token-tracker-audit.jsonl');
1920
const DIAG_ENABLED = process.env.AWF_DEBUG_TOKENS === '1';
2021

2122
// AWF version used to identify schema version in JSONL records.
@@ -33,6 +34,7 @@ const TOKEN_DIAG_SCHEMA = `token-diag/v${AWF_VERSION || '0.0.0-dev'}`;
3334

3435
let logStream = null;
3536
let diagStream = null;
37+
let auditStream = null;
3638

3739
/**
3840
* Write a diagnostic line to the diagnostics log file.
@@ -72,6 +74,25 @@ function diag(msg, data) {
7274
} catch { /* best-effort */ }
7375
}
7476

77+
/**
78+
* Always-on audit trail for token tracking lifecycle.
79+
*
80+
* Writes minimal structured events to token-tracker-audit.jsonl so that CI
81+
* failures can be diagnosed without AWF_DEBUG_TOKENS=1. Each tracked
82+
* request emits exactly two lines: TRACK_START and TRACK_END (with result).
83+
*/
84+
function auditTrack(event, data) {
85+
try {
86+
if (!auditStream) {
87+
fs.mkdirSync(TOKEN_LOG_DIR, { recursive: true });
88+
auditStream = fs.createWriteStream(AUDIT_LOG_FILE, { flags: 'a', mode: 0o644 });
89+
auditStream.on('error', () => { auditStream = null; });
90+
}
91+
const line = { ts: Date.now(), event, ...data };
92+
auditStream.write(JSON.stringify(line) + '\n');
93+
} catch { /* best-effort */ }
94+
}
95+
7596
/**
7697
* Get or create the JSONL append stream for token usage logs.
7798
* Uses a lazy singleton — created on first write.
@@ -258,6 +279,10 @@ function closeLogStream() {
258279
pending++;
259280
diagStream.end(() => { diagStream = null; pending--; check(); });
260281
}
282+
if (auditStream) {
283+
pending++;
284+
auditStream.end(() => { auditStream = null; pending--; check(); });
285+
}
261286
if (pending === 0) resolve();
262287
}),
263288
closeBlockedRequestDiagStream(),
@@ -269,6 +294,7 @@ module.exports = {
269294
TOKEN_USAGE_SCHEMA,
270295
TOKEN_DIAG_SCHEMA,
271296
diag,
297+
auditTrack,
272298
buildTokenDiagRecord,
273299
buildTokenUsageRecord,
274300
incrementTokenMetrics,

containers/api-proxy/token-tracker-http.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ const {
3434
buildTokenUsageRecord,
3535
incrementTokenMetrics,
3636
diag,
37+
auditTrack,
3738
} = require('./token-persistence');
3839
const { warnCacheReadRollupMismatch, mergeBudgetFields } = require('./token-tracker-shared');
3940

@@ -286,6 +287,7 @@ function finalizeHttpTracking(state, proxyRes, opts) {
286287

287288
// Only process successful responses (2xx)
288289
if (proxyRes.statusCode < 200 || proxyRes.statusCode >= 300) {
290+
auditTrack('TRACK_END', { rid: requestId, result: 'skip_status', status: proxyRes.statusCode });
289291
logRequest('debug', 'token_track_skip_status', {
290292
request_id: requestId,
291293
provider,
@@ -314,6 +316,7 @@ function finalizeHttpTracking(state, proxyRes, opts) {
314316

315317
const normalized = normalizeUsage(usage);
316318
if (!normalized) {
319+
auditTrack('TRACK_END', { rid: requestId, result: 'no_usage', streaming, bytes: state.totalBytes, overflow: state.overflow, ct: state.contentType, ce: contentEncoding });
317320
// Log at info level so failed extraction is visible in CI without debug mode
318321
logRequest('info', 'token_track_no_usage', {
319322
request_id: requestId,
@@ -329,6 +332,9 @@ function finalizeHttpTracking(state, proxyRes, opts) {
329332
if (typeof onSpanEnd === 'function') onSpanEnd(proxyRes.statusCode);
330333
return;
331334
}
335+
336+
auditTrack('TRACK_END', { rid: requestId, result: 'ok', streaming, model: model || requestModel, input: normalized.input_tokens, output: normalized.output_tokens, cache_read: normalized.cache_read_tokens });
337+
332338
if (state.observedCacheReadTokens > 0 && normalized.cache_read_tokens === 0) {
333339
warnCacheReadRollupMismatch({ logRequest, diag, requestId, provider, model, observedCacheReadTokens: state.observedCacheReadTokens, normalizedCacheReadTokens: normalized.cache_read_tokens, streaming });
334340
}
@@ -394,6 +400,8 @@ function trackTokenUsage(proxyRes, opts) {
394400
const contentEncoding = proxyRes.headers['content-encoding'] || '(none)';
395401
const compressed = isCompressedResponse(proxyRes.headers);
396402

403+
auditTrack('TRACK_START', { rid: requestId, provider, path: reqPath, streaming, ct: contentType, ce: contentEncoding, status: proxyRes.statusCode });
404+
397405
logRequest('debug', 'token_track_start', {
398406
request_id: requestId,
399407
provider,
@@ -410,6 +418,7 @@ function trackTokenUsage(proxyRes, opts) {
410418
// If the response uses an encoding we cannot decompress (e.g. zstd), skip
411419
// token tracking entirely and log a warning so the issue is visible in CI.
412420
if (compressed && isUnsupportedEncoding(proxyRes.headers)) {
421+
auditTrack('TRACK_SKIP_ENCODING', { rid: requestId, ce: contentEncoding });
413422
logRequest('warn', 'token_track_unsupported_encoding', {
414423
request_id: requestId,
415424
provider,

containers/api-proxy/token-tracker-ws.js

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const {
1616
buildTokenUsageRecord,
1717
incrementTokenMetrics,
1818
diag,
19+
auditTrack,
1920
} = require('./token-persistence');
2021
const { warnCacheReadRollupMismatch, mergeBudgetFields } = require('./token-tracker-shared');
2122

@@ -135,6 +136,7 @@ function parseWebSocketFrames(buf, fragments) {
135136
function trackWebSocketTokenUsage(upstreamSocket, opts) {
136137
const { requestId, provider, path: reqPath, startTime, metrics: metricsRef, onUsage } = opts;
137138

139+
auditTrack('WS_TRACK_START', { rid: requestId, provider, path: reqPath });
138140
logRequest('debug', 'ws_token_track_start', {
139141
request_id: requestId,
140142
provider,
@@ -213,19 +215,22 @@ function trackWebSocketTokenUsage(upstreamSocket, opts) {
213215
if (finalized) return;
214216
finalized = true;
215217

218+
const hasUsage = Object.keys(streamingUsage).length > 0;
219+
auditTrack('WS_TRACK_END', { rid: requestId, result: hasUsage ? 'ok' : 'no_usage', frames: frameCount, msgs: textMessageCount, bytes: totalBytes });
220+
216221
logRequest('debug', 'ws_token_track_end', {
217222
request_id: requestId,
218223
provider,
219224
total_bytes: totalBytes,
220225
frame_count: frameCount,
221226
text_message_count: textMessageCount,
222-
has_usage: Object.keys(streamingUsage).length > 0,
227+
has_usage: hasUsage,
223228
usage_keys: Object.keys(streamingUsage),
224229
model: streamingModel,
225230
});
226-
diag('WS_TRACK_END', { request_id: requestId, provider, total_bytes: totalBytes, frame_count: frameCount, text_message_count: textMessageCount, has_usage: Object.keys(streamingUsage).length > 0, usage_keys: Object.keys(streamingUsage), model: streamingModel });
231+
diag('WS_TRACK_END', { request_id: requestId, provider, total_bytes: totalBytes, frame_count: frameCount, text_message_count: textMessageCount, has_usage: hasUsage, usage_keys: Object.keys(streamingUsage), model: streamingModel });
227232

228-
if (Object.keys(streamingUsage).length === 0) return;
233+
if (!hasUsage) return;
229234

230235
const duration = Date.now() - startTime;
231236
const normalized = normalizeUsage(streamingUsage);

containers/api-proxy/token-tracker.schema.test.js

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -266,8 +266,9 @@ describe('token-usage JSONL record schema field', () => {
266266
proxyRes.emit('end');
267267

268268
setTimeout(() => {
269-
expect(mockStream.write).toHaveBeenCalledTimes(1);
270-
const parsed = mockStream.writtenRecords[0];
269+
const usageRecords = mockStream.writtenRecords.filter(r => r._schema);
270+
expect(usageRecords).toHaveLength(1);
271+
const parsed = usageRecords[0];
271272
expect(parsed._schema).toMatch(/^token-usage\/v\d+\.\d+\.\d+(-\w+)?$/);
272273
expect(parsed.request_id).toBe('schema-field-http');
273274
done();
@@ -289,8 +290,9 @@ describe('token-usage JSONL record schema field', () => {
289290
socket.emit('close');
290291

291292
setTimeout(() => {
292-
expect(mockStream.write).toHaveBeenCalledTimes(1);
293-
const parsed = mockStream.writtenRecords[0];
293+
const usageRecords = mockStream.writtenRecords.filter(r => r._schema);
294+
expect(usageRecords).toHaveLength(1);
295+
const parsed = usageRecords[0];
294296
expect(parsed._schema).toMatch(/^token-usage\/v\d+\.\d+\.\d+(-\w+)?$/);
295297
expect(parsed.request_id).toBe('schema-field-ws');
296298
done();
@@ -324,8 +326,9 @@ describe('token-usage JSONL record schema field', () => {
324326
proxyRes.emit('end');
325327

326328
setTimeout(() => {
327-
expect(mockStream.write).toHaveBeenCalledTimes(1);
328-
const parsed = mockStream.writtenRecords[0];
329+
const usageRecords = mockStream.writtenRecords.filter(r => r._schema);
330+
expect(usageRecords).toHaveLength(1);
331+
const parsed = usageRecords[0];
329332
expect(parsed).toMatchObject({
330333
request_id: 'budget-fields-http',
331334
effective_tokens_this_response: 40,
@@ -359,8 +362,9 @@ describe('token-usage JSONL record schema field', () => {
359362
proxyRes.emit('end');
360363

361364
setTimeout(() => {
362-
expect(mockStream.write).toHaveBeenCalledTimes(1);
363-
const parsed = mockStream.writtenRecords[0];
365+
const usageRecords = mockStream.writtenRecords.filter(r => r._schema);
366+
expect(usageRecords).toHaveLength(1);
367+
const parsed = usageRecords[0];
364368
expect(parsed.effective_tokens_this_response).toBeUndefined();
365369
expect(parsed.effective_tokens_total).toBeUndefined();
366370
expect(parsed.model_multiplier).toBeUndefined();
@@ -392,8 +396,9 @@ describe('token-usage JSONL record schema field', () => {
392396
socket.emit('close');
393397

394398
setTimeout(() => {
395-
expect(mockStream.write).toHaveBeenCalledTimes(1);
396-
const parsed = mockStream.writtenRecords[0];
399+
const usageRecords = mockStream.writtenRecords.filter(r => r._schema);
400+
expect(usageRecords).toHaveLength(1);
401+
const parsed = usageRecords[0];
397402
expect(parsed).toMatchObject({
398403
request_id: 'budget-fields-ws',
399404
effective_tokens_this_response: 112,
@@ -422,8 +427,9 @@ describe('token-usage JSONL record schema field', () => {
422427
socket.emit('close');
423428

424429
setTimeout(() => {
425-
expect(mockStream.write).toHaveBeenCalledTimes(1);
426-
const parsed = mockStream.writtenRecords[0];
430+
const usageRecords = mockStream.writtenRecords.filter(r => r._schema);
431+
expect(usageRecords).toHaveLength(1);
432+
const parsed = usageRecords[0];
427433
expect(parsed.effective_tokens_this_response).toBeUndefined();
428434
expect(parsed.effective_tokens_total).toBeUndefined();
429435
expect(parsed.model_multiplier).toBeUndefined();
@@ -481,8 +487,9 @@ describe('token-usage _schema exact version from AWF_VERSION', () => {
481487
writeStreamSpy.mockRestore();
482488
await isolated.closeLogStream();
483489

484-
expect(mockStream.write).toHaveBeenCalledTimes(1);
485-
const parsed = mockStream.writtenRecords[0];
490+
const usageRecords = mockStream.writtenRecords.filter(r => r._schema);
491+
expect(usageRecords).toHaveLength(1);
492+
const parsed = usageRecords[0];
486493
expect(parsed._schema).toBe('token-usage/v9.8.7');
487494
expect(parsed.request_id).toBe('exact-version-test');
488495
done();

scripts/ci/check-token-usage.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,22 @@ function main(argv) {
282282
'::error::Could not locate token-usage.jsonl in the agent artifact. ' +
283283
'The api-proxy did not record token usage.',
284284
);
285+
// Print audit trail if available to diagnose why tracking failed
286+
const auditFile = findFileRecursive(options.artifactRoot, 'token-tracker-audit.jsonl');
287+
if (auditFile) {
288+
try {
289+
const auditContent = fs.readFileSync(auditFile, 'utf8').trim();
290+
const lines = auditContent.split('\n').filter(Boolean);
291+
console.error(`\n--- Token tracker audit trail (${lines.length} events) ---`);
292+
for (const line of lines.slice(0, 100)) {
293+
console.error(` ${line}`);
294+
}
295+
if (lines.length > 100) console.error(` ... (${lines.length - 100} more lines)`);
296+
console.error('--- End audit trail ---\n');
297+
} catch { /* ignore read errors */ }
298+
} else {
299+
console.error(' (no token-tracker-audit.jsonl found — tracker may not have been invoked)');
300+
}
285301
return 1;
286302
}
287303

0 commit comments

Comments
 (0)