Skip to content
Merged
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
8 changes: 7 additions & 1 deletion src/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,13 @@ export function buildOpenApiSpec(): Record<string, unknown> {
get: { summary: "Aggregate network metrics" },
},
"/api/v1/metrics/history": {
get: { summary: "Recent aggregate metrics snapshots, oldest first" },
get: {
summary: "Recent aggregate metrics snapshots, oldest first",
description:
"Returns the buffered metrics history. Pass ?since=<ISO-8601 timestamp> " +
"to return only snapshots with timestamp values strictly after that point.",
parameters: ["since"],
},
},
},
};
Expand Down
64 changes: 63 additions & 1 deletion src/routes/metrics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,74 @@ describe("metrics route", () => {
expect(typeof res.body.snapshots[0].timestamp).toBe("string");
});

it("returns the full metrics history when since is omitted", async () => {
const app = createApp();
await seed(app);

await request(app).get("/api/v1/metrics");
await request(app).get("/api/v1/metrics");
await request(app).get("/api/v1/metrics");

const res = await request(app).get("/api/v1/metrics/history");
expect(res.status).toBe(200);
expect(res.body.snapshots).toHaveLength(3);
});

it("filters metrics history to snapshots after a valid since timestamp", async () => {
jest.useFakeTimers();
try {
const app = createApp();
await seed(app);

jest.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
await request(app).get("/api/v1/metrics");

jest.setSystemTime(new Date("2026-01-01T00:00:10.000Z"));
await request(app).get("/api/v1/metrics");

jest.setSystemTime(new Date("2026-01-01T00:00:20.000Z"));
await request(app).get("/api/v1/metrics");

const res = await request(app)
.get("/api/v1/metrics/history")
.query({ since: "2026-01-01T00:00:10.000Z" });

expect(res.status).toBe(200);
expect(res.body.snapshots).toHaveLength(1);
expect(res.body.snapshots[0].timestamp).toBe("2026-01-01T00:00:20.000Z");
} finally {
jest.useRealTimers();
}
});

it("rejects an invalid since timestamp", async () => {
const res = await request(createApp())
.get("/api/v1/metrics/history")
.query({ since: "not-a-date" });

expect(res.status).toBe(400);
expect(res.body.error.message).toBe(
'"since" must be a valid ISO-8601 timestamp',
);
});

it("rejects repeated since timestamp query parameters", async () => {
const res = await request(createApp()).get(
"/api/v1/metrics/history?since=2026-01-01T00:00:00.000Z&since=2026-01-02T00:00:00.000Z",
);

expect(res.status).toBe(400);
expect(res.body.error.message).toBe(
'"since" must be a valid ISO-8601 timestamp',
);
});

it("records snapshots on a fixed interval when configured", async () => {
jest.useFakeTimers();
try {
const originalEnv = process.env.METRICS_SNAPSHOT_INTERVAL_MS;
process.env.METRICS_SNAPSHOT_INTERVAL_MS = "1000";

const app = createApp();
await seed(app);

Expand Down
28 changes: 26 additions & 2 deletions src/routes/metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Router, Request, Response } from "express";
import { LiquidityService } from "../services/liquidityService";
import { AnchorService } from "../services/anchorService";
import { SettlementService } from "../services/settlementService";
import { ApiError } from "../errors/ApiError";
import { BoundedHistory } from "../utils/history";

/** Maximum number of metrics snapshots retained for `GET /history`. */
Expand Down Expand Up @@ -71,8 +72,31 @@ export function metricsRouter(deps: {
});

// The last (up to) `MAX_HISTORY` metrics snapshots, oldest first.
router.get("/history", (_req: Request, res: Response) => {
res.json({ snapshots: history.all() });
// When ?since=<ISO-8601 timestamp> is provided, only snapshots with a
// timestamp strictly after that point are returned.
router.get("/history", (req: Request, res: Response) => {
const since = req.query.since;
const snapshots = history.all();

if (since === undefined) {
res.json({ snapshots });
return;
}

if (typeof since !== "string") {
throw ApiError.badRequest('"since" must be a valid ISO-8601 timestamp');
}

const sinceTime = new Date(since).getTime();
if (Number.isNaN(sinceTime)) {
throw ApiError.badRequest('"since" must be a valid ISO-8601 timestamp');
}

res.json({
snapshots: snapshots.filter(
(snapshot) => new Date(snapshot.timestamp).getTime() > sinceTime,
),
});
});

return router;
Expand Down
Loading