Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions src/eventlog-ring.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { describe, it } from "node:test";
import assert from "node:assert";
import { type AppEvent, listEvents, RingEventLog, summarizeEvents } from "./events.js";

function makeEvent(index: number, type = "usage.recorded"): AppEvent {
return {
id: `evt-${index}`,
ts: index,
type,
payload: { index },
};
}

void describe("RingEventLog", () => {
void it("keeps chronological order below capacity", () => {
const log = new RingEventLog(3);
log.push(makeEvent(1));
log.push(makeEvent(2));

assert.strictEqual(log.length, 2);
assert.deepStrictEqual(
log.toArray().map((event) => event.payload.index),
[1, 2]
);
});

void it("keeps all entries exactly at capacity", () => {
const log = new RingEventLog(3);
log.push(makeEvent(1));
log.push(makeEvent(2));
log.push(makeEvent(3));

assert.strictEqual(log.length, 3);
assert.deepStrictEqual(
log.toArray().map((event) => event.payload.index),
[1, 2, 3]
);
});

void it("evicts oldest entries in O(1) while preserving chronological reads", () => {
const log = new RingEventLog(3);
for (let i = 1; i <= 5; i++) log.push(makeEvent(i));

assert.strictEqual(log.length, 3);
assert.strictEqual(log.at(0)?.payload.index, 3);
assert.strictEqual(log.at(-1)?.payload.index, 5);
assert.deepStrictEqual(
log.toArray().map((event) => event.payload.index),
[3, 4, 5]
);
});

void it("summarizes retained events after wraparound", () => {
const log = new RingEventLog(3);
log.push(makeEvent(1, "usage.recorded"));
log.push(makeEvent(2, "usage.settled"));
log.push(makeEvent(3, "usage.recorded"));
log.push(makeEvent(4, "webhook.test"));

assert.deepStrictEqual(summarizeEvents(log), {
counts: {
"usage.recorded": 1,
"usage.settled": 1,
"webhook.test": 1,
},
total: 3,
});
});

void it("preserves since/type/limit query semantics after wraparound", () => {
const log = new RingEventLog(4);
log.push(makeEvent(1, "usage.recorded"));
log.push(makeEvent(2, "usage.settled"));
log.push(makeEvent(3, "usage.recorded"));
log.push(makeEvent(4, "usage.settled"));
log.push(makeEvent(5, "usage.recorded"));

const items = listEvents({ since: 3, type: "usage.recorded", limit: 1 }, log);

assert.deepStrictEqual(
items.map((event) => event.payload.index),
[5]
);
});
});
95 changes: 93 additions & 2 deletions src/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,103 @@ export type AppEvent = {
};

export const EVENT_LOG_CAP = 10_000;
export const eventLog: AppEvent[] = [];

/** Fixed-capacity chronological event log with O(1) insertion and eviction. */
export class RingEventLog {
readonly capacity: number;
#items: AppEvent[];
#start = 0;
#size = 0;

constructor(capacity: number) {
if (!Number.isInteger(capacity) || capacity <= 0) {
throw new RangeError("event log capacity must be a positive integer");
}
this.capacity = capacity;
this.#items = new Array<AppEvent>(capacity);
}

get length(): number {
return this.#size;
}

clear(): void {
this.#items = new Array<AppEvent>(this.capacity);
this.#start = 0;
this.#size = 0;
}

push(event: AppEvent): void {
if (this.#size < this.capacity) {
this.#items[(this.#start + this.#size) % this.capacity] = event;
this.#size++;
return;
}

this.#items[this.#start] = event;
this.#start = (this.#start + 1) % this.capacity;
}

at(index: number): AppEvent | undefined {
const normalized = index < 0 ? this.#size + index : index;
if (normalized < 0 || normalized >= this.#size) return undefined;
return this.#items[(this.#start + normalized) % this.capacity];
}

toArray(): AppEvent[] {
const items: AppEvent[] = [];
for (let i = 0; i < this.#size; i++) {
const item = this.at(i);
if (item !== undefined) items.push(item);
}
return items;
}

[Symbol.iterator](): IterableIterator<AppEvent> {
return this.toArray()[Symbol.iterator]();
}
}

export const eventLog = new RingEventLog(EVENT_LOG_CAP);

/**
* Appends an audit event to the bounded in-memory event log.
*
* The ring buffer capacity follows EVENT_LOG_CAP. Runtime config currently
* exposes eventLogCap for readback only; /api/v1/config does not allow changing
* it, so preserving the constant avoids a hidden mutable cap.
*/
export function recordEvent(type: string, payload: Record<string, unknown>): void {
eventLog.push({ id: randomUUID(), ts: Date.now(), type, payload });
if (eventLog.length > EVENT_LOG_CAP) eventLog.shift();
}

export function clearEventLog(): void {
eventLog.clear();
}

export function listEvents(
{
limit,
since,
type,
}: {
limit: number;
since: number;
type?: string;
},
log: RingEventLog = eventLog
): AppEvent[] {
return log
.toArray()
.filter((event) => event.ts >= since && (type === undefined || event.type === type))
.slice(-limit);
}

export function summarizeEvents(log: RingEventLog = eventLog): {
counts: Record<string, number>;
total: number;
} {
const counts: Record<string, number> = {};
for (const event of log) counts[event.type] = (counts[event.type] ?? 0) + 1;
return { counts, total: log.length };
}
11 changes: 3 additions & 8 deletions src/routes/events.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Router, type Request, type Response } from "express";
import { EVENT_LOG_CAP, eventLog } from "../events.js";
import { EVENT_LOG_CAP, listEvents, summarizeEvents } from "../events.js";

/**
* Builds read-only audit-event routes.
Expand All @@ -8,9 +8,7 @@ export function createEventsRouter(): Router {
const router = Router();

router.get("/api/v1/events/summary", (_req, res: Response) => {
const counts: Record<string, number> = {};
for (const e of eventLog) counts[e.type] = (counts[e.type] ?? 0) + 1;
res.json({ counts, total: eventLog.length });
res.json(summarizeEvents());
});

router.get("/api/v1/events", (req: Request, res: Response) => {
Expand All @@ -20,10 +18,7 @@ export function createEventsRouter(): Router {
EVENT_LOG_CAP,
Math.max(1, Number((req.query.limit as string) ?? 100))
);
const items = eventLog
.filter((e) => e.ts >= since && (type === undefined || e.type === type))
.slice(-limit);
res.json({ items });
res.json({ items: listEvents({ limit, since, type }) });
});

return router;
Expand Down
4 changes: 2 additions & 2 deletions src/routes/operational.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { describe, it, beforeEach } from "node:test";
import assert from "node:assert";
import request from "supertest";
import { createApp } from "../index.js";
import { eventLog } from "../events.js";
import { clearEventLog } from "../events.js";
import {
apiKeyStore,
config,
Expand All @@ -23,7 +23,7 @@ const defaultConfig = {

beforeEach(() => {
apiKeyStore.clear();
eventLog.length = 0;
clearEventLog();
servicesDisabled.clear();
servicesMetadata.clear();
servicesStore.clear();
Expand Down
Loading