From 57aba37ac951abb392f188fd593188d6c0bb716a Mon Sep 17 00:00:00 2001 From: ndm13 Date: Fri, 19 Dec 2025 22:55:33 -0500 Subject: [PATCH 1/3] Provide handling middleware in request data --- src/middleware/blocklist.ts | 1 + src/middleware/embed.ts | 5 +++++ src/middleware/metrics.ts | 5 +++++ src/middleware/state.ts | 1 + src/middleware/statics.ts | 5 +++++ src/server.ts | 1 + src/types/ReportingTypes.ts | 1 + 7 files changed, 19 insertions(+) diff --git a/src/middleware/blocklist.ts b/src/middleware/blocklist.ts index 7b60748..b66be67 100644 --- a/src/middleware/blocklist.ts +++ b/src/middleware/blocklist.ts @@ -17,6 +17,7 @@ const BLOCKED_PATTERNS = [ export function middleware() { return async (ctx: Context, next: Next) => { if (BLOCKED_PATTERNS.some((pattern) => pattern.test(ctx.request.url.pathname))) { + ctx.state.analytics.request.middleware = "blocklist"; ctx.state.metrics.router.endpoint = "blocklist"; ctx.state.metrics.router.type = "unknown"; ctx.response.status = 404; diff --git a/src/middleware/embed.ts b/src/middleware/embed.ts index 70d578c..9a55515 100644 --- a/src/middleware/embed.ts +++ b/src/middleware/embed.ts @@ -11,6 +11,11 @@ import { AppState } from "../types/AppState.ts"; export function router(njk: Environment) { const router = new Router(); + router.use(async (ctx, next) => { + ctx.state.analytics.request.middleware = "embed"; + await next(); + }); + const scenario = new ScenarioHandler(njk); router.get("/scenario/:id/:tail", async (ctx) => { await scenario.handle(ctx); diff --git a/src/middleware/metrics.ts b/src/middleware/metrics.ts index e36ea82..ce86c50 100644 --- a/src/middleware/metrics.ts +++ b/src/middleware/metrics.ts @@ -27,6 +27,11 @@ export function middleware(metrics: MetricsCollector) { export function router(metrics: MetricsCollector, key?: string) { const router = new Router(); + router.use(async (ctx, next) => { + ctx.state.analytics.request.middleware = "metrics"; + await next(); + }); + router.get("/metrics", (ctx) => { ctx.state.metrics.router.endpoint = "metrics"; diff --git a/src/middleware/state.ts b/src/middleware/state.ts index 803e004..10ba587 100644 --- a/src/middleware/state.ts +++ b/src/middleware/state.ts @@ -21,6 +21,7 @@ export function middleware(api: AIDungeonAPI, linkConfig: RelatedLinksConfig) { hostname: ctx.request.url.hostname, path: ctx.request.url.pathname, params: Object.fromEntries(ctx.request.url.searchParams), + middleware: "unknown", userAgent: ctx.request.userAgent.ua, browser: ctx.request.userAgent.browser?.name, platform: ctx.request.userAgent.os?.name diff --git a/src/middleware/statics.ts b/src/middleware/statics.ts index aac4047..d16ad10 100644 --- a/src/middleware/statics.ts +++ b/src/middleware/statics.ts @@ -5,6 +5,11 @@ import { AppState } from "../types/AppState.ts"; export function router() { const router = new Router(); + router.use(async (ctx, next) => { + ctx.state.analytics.request.middleware = "statics"; + await next(); + }); + router.get("/healthcheck", (ctx) => { ctx.state.metrics.router.endpoint = "healthcheck"; ctx.state.metrics.router.type = "static"; diff --git a/src/server.ts b/src/server.ts index 5eed6ac..4f93c09 100644 --- a/src/server.ts +++ b/src/server.ts @@ -110,6 +110,7 @@ app.use(blocklist.middleware()); // Fallback redirect to AI Dungeon app.use((ctx) => { + ctx.state.analytics.request.middleware = "redirect"; ctx.state.metrics.router.endpoint = "unsupported"; ctx.state.metrics.router.type = "redirect"; ctx.response.redirect(ctx.state.links.redirectBase + ctx.request.url.pathname); diff --git a/src/types/ReportingTypes.ts b/src/types/ReportingTypes.ts index e61cabf..07e9503 100644 --- a/src/types/ReportingTypes.ts +++ b/src/types/ReportingTypes.ts @@ -66,6 +66,7 @@ export type RequestProperties = { hostname: string, path: string, params: Record, + middleware: string, userAgent: string, browser?: string, platform?: string From 43f2ef546b8f34b724b2a9c14f2144ce2dfded8d Mon Sep 17 00:00:00 2001 From: ndm13 Date: Sat, 27 Dec 2025 00:03:27 -0500 Subject: [PATCH 2/3] Clean up `analytics->content` in state to remove default content status - This is a potential breaking change, since the database will now be receiving an empty object instead of `{ status: "unknown" }` for content-free queries --- src/middleware/state.ts | 4 +--- src/support/AnalyticsCollector.ts | 4 ++-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/middleware/state.ts b/src/middleware/state.ts index 10ba587..ad9c8fd 100644 --- a/src/middleware/state.ts +++ b/src/middleware/state.ts @@ -14,9 +14,7 @@ export function middleware(api: AIDungeonAPI, linkConfig: RelatedLinksConfig) { }, analytics: { timestamp: Date.now(), - content: { - status: "unknown" - }, + content: {}, request: { hostname: ctx.request.url.hostname, path: ctx.request.url.pathname, diff --git a/src/support/AnalyticsCollector.ts b/src/support/AnalyticsCollector.ts index 7419c8e..bc7b2d9 100644 --- a/src/support/AnalyticsCollector.ts +++ b/src/support/AnalyticsCollector.ts @@ -62,7 +62,7 @@ export class AnalyticsCollector { async record(entry: AnalyticsEntry) { const id = entry.content!.id; - if (entry.content.status === "unknown" && id) { + if (!entry.content.status && id) { const cached = this.cache[id]; if (cached && (Date.now() - cached.timestamp < this.config.cacheExpiration)) { entry.content = cached.content; @@ -80,7 +80,7 @@ export class AnalyticsCollector { } } - if (entry.content.status !== "unknown" && entry.content.id) { + if (!entry.content.status && entry.content.id) { this.cache[entry.content.id] = { content: entry.content, timestamp: Date.now() }; } From 0f47889f10a57810de4c83795a0988c933cb03c2 Mon Sep 17 00:00:00 2001 From: ndm13 Date: Sat, 27 Dec 2025 00:22:16 -0500 Subject: [PATCH 3/3] Update test for AnalyticsCollector --- tests/unit/support/AnalyticsCollector.test.ts | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/tests/unit/support/AnalyticsCollector.test.ts b/tests/unit/support/AnalyticsCollector.test.ts index 06a6b1d..33eafb9 100644 --- a/tests/unit/support/AnalyticsCollector.test.ts +++ b/tests/unit/support/AnalyticsCollector.test.ts @@ -84,7 +84,7 @@ describe("AnalyticsCollector", () => { it("should fetch content if status is unknown", async () => { const entry: AnalyticsEntry = { - content: { id: "456", type: "scenario", status: "unknown" }, + content: { id: "456", type: "scenario" }, timestamp: Date.now() } as any; @@ -92,7 +92,7 @@ describe("AnalyticsCollector", () => { // We provide minimal data to satisfy the mapper const scenarioData = { title: "Test Scenario", - user: { profile: { title: "author" } } + user: { id: "user-1", profile: { title: "author" } } }; (api.getScenarioEmbed as any) = spy(() => Promise.resolve(scenarioData as any)); @@ -111,11 +111,12 @@ describe("AnalyticsCollector", () => { it("should fetch user content if status is unknown", async () => { const entry: AnalyticsEntry = { - content: { id: "user-123", type: "profile", status: "unknown" }, + content: { id: "user-123", type: "profile" }, timestamp: Date.now() } as any; const userData = { + id: "user-123", profile: { title: "Test User" } @@ -136,24 +137,31 @@ describe("AnalyticsCollector", () => { it("should use cached content for subsequent requests", async () => { const entry1: AnalyticsEntry = { - content: { id: "789", type: "adventure", status: "unknown" }, + content: { id: "789", type: "adventure" }, timestamp: Date.now() } as any; - (api.getAdventureEmbed as any) = spy(() => Promise.resolve({ title: "Adventure 1", user: {} } as any)); + // Create a spy that returns a promise + const getAdventureEmbedSpy = spy(() => Promise.resolve({ + title: "Adventure 1", + userId: "user-1", + user: { profile: { title: "User 1" } } + } as any)); + (api.getAdventureEmbed as any) = getAdventureEmbedSpy; // First record - fetches from API await collector.record(entry1); const entry2: AnalyticsEntry = { - content: { id: "789", type: "adventure", status: "unknown" }, + content: { id: "789", type: "adventure" }, timestamp: Date.now() } as any; // Second record - should use cache await collector.record(entry2); - assertSpyCalls(api.getAdventureEmbed as any, 1); + // Assert that the spy was called exactly once + assertSpyCalls(getAdventureEmbedSpy, 1); }); it("should retry on Supabase error", async () => { @@ -184,10 +192,12 @@ describe("AnalyticsCollector", () => { it("should prune expired cache entries", async () => { const entry: AnalyticsEntry = { - content: { id: "cache-test", type: "user", status: "success" }, + content: { id: "cache-test", type: "profile" }, timestamp: Date.now() } as any; + (api.getUserEmbed as any) = spy(() => Promise.resolve({ id: "user-1", profile: { title: "Test User" } } as any)); + // Record entry to populate cache await collector.record(entry);