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
1 change: 1 addition & 0 deletions src/middleware/blocklist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const BLOCKED_PATTERNS = [
export function middleware() {
return async (ctx: Context<AppState>, 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;
Expand Down
5 changes: 5 additions & 0 deletions src/middleware/embed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ import { AppState } from "../types/AppState.ts";
export function router(njk: Environment) {
const router = new Router<AppState>();

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);
Expand Down
5 changes: 5 additions & 0 deletions src/middleware/metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ export function middleware(metrics: MetricsCollector) {
export function router(metrics: MetricsCollector, key?: string) {
const router = new Router<AppState>();

router.use(async (ctx, next) => {
ctx.state.analytics.request.middleware = "metrics";
await next();
});

router.get("/metrics", (ctx) => {
ctx.state.metrics.router.endpoint = "metrics";

Expand Down
5 changes: 2 additions & 3 deletions src/middleware/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,12 @@ 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,
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
Expand Down
5 changes: 5 additions & 0 deletions src/middleware/statics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ import { AppState } from "../types/AppState.ts";
export function router() {
const router = new Router<AppState>();

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";
Expand Down
1 change: 1 addition & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions src/support/AnalyticsCollector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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() };
}

Expand Down
1 change: 1 addition & 0 deletions src/types/ReportingTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export type RequestProperties = {
hostname: string,
path: string,
params: Record<string, string>,
middleware: string,
userAgent: string,
browser?: string,
platform?: string
Expand Down
26 changes: 18 additions & 8 deletions tests/unit/support/AnalyticsCollector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,15 +84,15 @@ 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;

// Stub API response
// 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));

Expand All @@ -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"
}
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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);

Expand Down