feat(audit): add phase0 run/reaction baseline audit events
This commit is contained in:
+109
-1
@@ -4,6 +4,20 @@ import { join, resolve } from 'path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { AuditLogger } from './logger.js';
|
||||
|
||||
function waitForFlush(): Promise<void> {
|
||||
return new Promise((resolvePromise) => setTimeout(resolvePromise, 25));
|
||||
}
|
||||
|
||||
function readAuditEvents(filePath: string): Array<{ event_type: string; level: string; event: Record<string, unknown> }> {
|
||||
const content = readFileSync(filePath, 'utf-8').trim();
|
||||
if (!content) {
|
||||
return [];
|
||||
}
|
||||
return content
|
||||
.split('\n')
|
||||
.map((line) => JSON.parse(line) as { event_type: string; level: string; event: Record<string, unknown> });
|
||||
}
|
||||
|
||||
describe('AuditLogger', () => {
|
||||
it('expands ~ in audit path before writing logs', async () => {
|
||||
const previousHome = process.env.HOME;
|
||||
@@ -28,7 +42,7 @@ describe('AuditLogger', () => {
|
||||
|
||||
logger.systemStart('test-component');
|
||||
await logger.close();
|
||||
await new Promise((resolvePromise) => setTimeout(resolvePromise, 25));
|
||||
await waitForFlush();
|
||||
|
||||
const loggerPath = (logger as unknown as { config: { path: string } }).config.path;
|
||||
const expectedPath = resolve(tempHome, '.local/share/flynn/audit.log');
|
||||
@@ -46,4 +60,98 @@ describe('AuditLogger', () => {
|
||||
rmSync(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('writes run and reaction baseline events with expected levels', async () => {
|
||||
const previousHome = process.env.HOME;
|
||||
const tempHome = mkdtempSync(join(tmpdir(), 'flynn-audit-home-'));
|
||||
process.env.HOME = tempHome;
|
||||
|
||||
try {
|
||||
const logger = new AuditLogger({
|
||||
enabled: true,
|
||||
path: '~/.local/share/flynn/audit.log',
|
||||
max_size_mb: 10,
|
||||
keep_days: 30,
|
||||
levels: {
|
||||
tools: 'debug',
|
||||
sessions: 'debug',
|
||||
automation: 'debug',
|
||||
},
|
||||
});
|
||||
|
||||
logger.runState({
|
||||
session_id: 'telegram:123',
|
||||
channel: 'telegram',
|
||||
sender: '123',
|
||||
source: 'channel',
|
||||
state: 'start',
|
||||
request_id: 'req-1',
|
||||
});
|
||||
logger.runState({
|
||||
session_id: 'telegram:123',
|
||||
channel: 'telegram',
|
||||
sender: '123',
|
||||
source: 'channel',
|
||||
state: 'error',
|
||||
request_id: 'req-1',
|
||||
error: 'model timeout',
|
||||
});
|
||||
logger.runCancel({
|
||||
session_id: 'telegram:123',
|
||||
channel: 'telegram',
|
||||
sender: '123',
|
||||
source: 'channel',
|
||||
requested: true,
|
||||
acknowledged: true,
|
||||
request_id: 'req-1',
|
||||
latency_ms: 120,
|
||||
});
|
||||
logger.reactionMatch({
|
||||
session_id: 'telegram:123',
|
||||
channel: 'telegram',
|
||||
sender: '123',
|
||||
source: 'channel',
|
||||
rule_name: 'daily-briefing-hint',
|
||||
candidate_count: 4,
|
||||
filter_summary: 'contains:briefing',
|
||||
});
|
||||
logger.reactionSkip({
|
||||
session_id: 'telegram:123',
|
||||
channel: 'telegram',
|
||||
sender: '123',
|
||||
source: 'channel',
|
||||
reason: 'no_match',
|
||||
candidate_count: 4,
|
||||
});
|
||||
|
||||
await logger.close();
|
||||
await waitForFlush();
|
||||
|
||||
const expectedPath = resolve(tempHome, '.local/share/flynn/audit.log');
|
||||
const events = readAuditEvents(expectedPath);
|
||||
const eventTypes = events.map((event) => event.event_type);
|
||||
|
||||
expect(eventTypes).toContain('run.state');
|
||||
expect(eventTypes).toContain('run.cancel');
|
||||
expect(eventTypes).toContain('reaction.match');
|
||||
expect(eventTypes).toContain('reaction.skip');
|
||||
|
||||
const runError = events.find((event) => (
|
||||
event.event_type === 'run.state'
|
||||
&& event.event.state === 'error'
|
||||
));
|
||||
expect(runError?.level).toBe('error');
|
||||
|
||||
const reactionSkip = events.find((event) => event.event_type === 'reaction.skip');
|
||||
expect(reactionSkip?.level).toBe('debug');
|
||||
expect(reactionSkip?.event.reason).toBe('no_match');
|
||||
} finally {
|
||||
if (previousHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = previousHome;
|
||||
}
|
||||
rmSync(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,10 @@ import type {
|
||||
SessionAutoCompactEvent,
|
||||
UserActionEvent,
|
||||
QueuePreemptEvent,
|
||||
RunStateEvent,
|
||||
RunCancelEvent,
|
||||
ReactionMatchEvent,
|
||||
ReactionSkipEvent,
|
||||
BackendRouteEvent,
|
||||
BackendSuccessEvent,
|
||||
BackendFallbackEvent,
|
||||
@@ -211,6 +215,28 @@ export class AuditLogger {
|
||||
this.write({ level: 'info', event_type: 'queue.preempt', event: event as unknown as Record<string, unknown> });
|
||||
}
|
||||
|
||||
runState(event: RunStateEvent): void {
|
||||
const level = event.state === 'error' ? 'error' : 'info';
|
||||
if (!this.shouldLog('sessions', level)) {return;}
|
||||
this.write({ level, event_type: 'run.state', event: event as unknown as Record<string, unknown> });
|
||||
}
|
||||
|
||||
runCancel(event: RunCancelEvent): void {
|
||||
const level = event.acknowledged ? 'info' : 'warn';
|
||||
if (!this.shouldLog('sessions', level)) {return;}
|
||||
this.write({ level, event_type: 'run.cancel', event: event as unknown as Record<string, unknown> });
|
||||
}
|
||||
|
||||
reactionMatch(event: ReactionMatchEvent): void {
|
||||
if (!this.shouldLog('sessions', 'info')) {return;}
|
||||
this.write({ level: 'info', event_type: 'reaction.match', event: event as unknown as Record<string, unknown> });
|
||||
}
|
||||
|
||||
reactionSkip(event: ReactionSkipEvent): void {
|
||||
if (!this.shouldLog('sessions', 'debug')) {return;}
|
||||
this.write({ level: 'debug', event_type: 'reaction.skip', event: event as unknown as Record<string, unknown> });
|
||||
}
|
||||
|
||||
backendRoute(event: BackendRouteEvent): void {
|
||||
if (!this.shouldLog('sessions', 'info')) {return;}
|
||||
this.write({ level: 'info', event_type: 'backend.route', event: event as unknown as Record<string, unknown> });
|
||||
|
||||
@@ -12,6 +12,8 @@ export type AuditEventType =
|
||||
// Session lifecycle
|
||||
| 'session.create' | 'session.message' | 'session.delete' | 'session.transfer' | 'session.compact' | 'session.checkpoint' | 'session.auto_compact' | 'user.action'
|
||||
| 'queue.preempt'
|
||||
| 'run.state' | 'run.cancel'
|
||||
| 'reaction.match' | 'reaction.skip'
|
||||
| 'backend.route' | 'backend.success' | 'backend.fallback'
|
||||
// Automation - Cron
|
||||
| 'cron.trigger' | 'cron.sent' | 'cron.add' | 'cron.remove'
|
||||
@@ -232,6 +234,47 @@ export interface QueuePreemptEvent {
|
||||
cancelled_active_run: boolean;
|
||||
}
|
||||
|
||||
export interface RunStateEvent {
|
||||
session_id: string;
|
||||
channel: string;
|
||||
sender: string;
|
||||
source: 'gateway' | 'channel';
|
||||
state: 'start' | 'complete' | 'cancel_requested' | 'cancelled' | 'error';
|
||||
request_id?: string;
|
||||
duration_ms?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface RunCancelEvent {
|
||||
session_id: string;
|
||||
channel: string;
|
||||
sender: string;
|
||||
source: 'gateway' | 'channel';
|
||||
requested: boolean;
|
||||
acknowledged: boolean;
|
||||
request_id?: string;
|
||||
latency_ms?: number;
|
||||
}
|
||||
|
||||
export interface ReactionMatchEvent {
|
||||
session_id?: string;
|
||||
channel: string;
|
||||
sender: string;
|
||||
source: 'gateway' | 'channel';
|
||||
rule_name: string;
|
||||
candidate_count?: number;
|
||||
filter_summary?: string;
|
||||
}
|
||||
|
||||
export interface ReactionSkipEvent {
|
||||
session_id?: string;
|
||||
channel: string;
|
||||
sender: string;
|
||||
source: 'gateway' | 'channel';
|
||||
reason: 'no_rules' | 'no_match' | 'disabled' | 'channel_mismatch' | 'filter_miss';
|
||||
candidate_count: number;
|
||||
}
|
||||
|
||||
export interface BackendRouteEvent {
|
||||
session_id: string;
|
||||
channel: string;
|
||||
|
||||
Reference in New Issue
Block a user