diff --git a/worker/mcp-entry.ts b/worker/mcp-entry.ts index 9d159440..5e613942 100644 --- a/worker/mcp-entry.ts +++ b/worker/mcp-entry.ts @@ -1,8 +1,11 @@ import { handleMcp } from "./src/mcp"; +import { logTrafficWithRequest } from "./src/shared/traffic-logger"; import type { McpEnv } from "./src/env"; export default { fetch(request: Request, env: McpEnv, ctx: ExecutionContext) { - return handleMcp(request, env, ctx); + return logTrafficWithRequest("mcp-server", env, ctx, request, async () => { + return handleMcp(request, env, ctx); + }); }, } satisfies ExportedHandler; diff --git a/worker/src/env.ts b/worker/src/env.ts index 234f7c28..0b364367 100644 --- a/worker/src/env.ts +++ b/worker/src/env.ts @@ -1,4 +1,5 @@ export interface McpEnv { + TRAFFIC_LOGS: R2Bucket; OPENAPI_JSON?: string; MCP_API_BASE?: string; MCP_ALLOWED_HOSTS?: string; diff --git a/worker/src/index.ts b/worker/src/index.ts index 67afba0b..08652a58 100644 --- a/worker/src/index.ts +++ b/worker/src/index.ts @@ -1,4 +1,5 @@ import type { Env } from "./env"; +import { logTrafficWithRequest } from "./shared/traffic-logger"; export type { Env } from "./env"; @@ -17,39 +18,41 @@ const CORS = { }; export default { - async fetch(req: Request, env: Env): Promise { - console.log(JSON.stringify({ - method: req.method, - path: new URL(req.url).pathname, - country: req.cf?.country ?? "unknown", - colo: req.cf?.colo ?? "unknown", - userAgent: req.headers.get("User-Agent") ?? "", - referer: req.headers.get("Referer") ?? "", - accept: req.headers.get("Accept") ?? "", - timestamp: new Date().toISOString(), - })); - if (req.method === "OPTIONS") return new Response(null, { headers: CORS }); + async fetch(req: Request, env: Env, ctx: ExecutionContext): Promise { + return logTrafficWithRequest("rest-api", env, ctx, req, async () => { + console.log(JSON.stringify({ + method: req.method, + path: new URL(req.url).pathname, + country: req.cf?.country ?? "unknown", + colo: req.cf?.colo ?? "unknown", + userAgent: req.headers.get("User-Agent") ?? "", + referer: req.headers.get("Referer") ?? "", + accept: req.headers.get("Accept") ?? "", + timestamp: new Date().toISOString(), + })); + if (req.method === "OPTIONS") return new Response(null, { headers: CORS }); - const u = new URL(req.url); - const p = u.pathname.replace(/\/+$/, ""); + const u = new URL(req.url); + const p = u.pathname.replace(/\/+$/, ""); - try { - if (p === "/" || p === "") return text("PS-Wiki API v1"); - if (p === "/openapi.json") return json(env.OPENAPI_JSON ?? DEFAULT_OPENAPI, 200); + try { + if (p === "/" || p === "") return text("PS-Wiki API v1"); + if (p === "/openapi.json") return json(env.OPENAPI_JSON ?? DEFAULT_OPENAPI, 200); - if (p === "/v1/terms") return listTerms(u, env); - if (p.startsWith("/v1/terms/")) { - const id = decodeURIComponent(p.split("/").pop() || ""); - return getTerm(id, env); - } - if (p === "/v1/tags") return listTags(env); - if (p === "/v1/changes") return changes(u, env); + if (p === "/v1/terms") return listTerms(u, env); + if (p.startsWith("/v1/terms/")) { + const id = decodeURIComponent(p.split("/").pop() || ""); + return getTerm(id, env); + } + if (p === "/v1/tags") return listTags(env); + if (p === "/v1/changes") return changes(u, env); - return json({ error: "not_found" }, 404); - } catch (e: unknown) { - console.error("Unhandled request error", e); - return json({ error: "internal_error" }, 500); - } + return json({ error: "not_found" }, 404); + } catch (e: unknown) { + console.error("Unhandled request error", e); + return json({ error: "internal_error" }, 500); + } + }); } }; diff --git a/worker/src/shared/traffic-logger.ts b/worker/src/shared/traffic-logger.ts new file mode 100644 index 00000000..2cfeb2d0 --- /dev/null +++ b/worker/src/shared/traffic-logger.ts @@ -0,0 +1,143 @@ +/** + * Traffic Logger — Drop-in module for writing request logs to R2. + * + * Works on the Workers Free plan. No Logpush or Tail Worker required. + * Each request writes one JSON object to your R2 bucket via an R2 binding. + * + * Usage in your Worker: + * + * import { logTraffic } from "./traffic-logger"; + * + * export default { + * async fetch(request, env, ctx) { + * return logTraffic("rest-api", env, ctx, async () => { + * // ...your existing handler logic... + * return new Response("hello"); + * }); + * }, + * }; + * + * R2 binding required in wrangler.jsonc: + * + * "r2_buckets": [ + * { "binding": "TRAFFIC_LOGS", "bucket_name": "" } + * ] + */ + +export interface TrafficLogEntry { + timestamp: string; + worker: string; + method: string; + url: string; + pathname: string; + status: number; + durationMs: number; + requestId: string; + cf?: { + colo?: string; + country?: string; + asn?: number; + }; + error?: string; +} + +/** + * Wraps a request handler and logs traffic to R2. + * + * @param workerName - Identifier for this Worker (e.g. "rest-api", "mcp-server") + * @param env - Worker env (must contain TRAFFIC_LOGS R2 binding) + * @param ctx - ExecutionContext (for waitUntil) + * @param handler - Your actual request handler + * @returns - The response from your handler + */ +export function logTraffic( + workerName: string, + env: { TRAFFIC_LOGS: R2Bucket }, + ctx: { waitUntil: (p: Promise) => void }, + handler: () => Promise, +): Promise { + return logTrafficWithRequest(workerName, env, ctx, null, handler); +} + +/** + * Full version — pass the original Request if you want cf metadata captured. + */ +export function logTrafficWithRequest( + workerName: string, + env: { TRAFFIC_LOGS: R2Bucket }, + ctx: { waitUntil: (p: Promise) => void }, + request: Request | null, + handler: () => Promise, +): Promise { + const start = Date.now(); + const requestId = crypto.randomUUID(); + const timestamp = new Date().toISOString(); + + // Run the handler first, then log — logging is fire-and-forget + return handler().then( + (response) => { + writeLogEntry( + env, ctx, workerName, request, response.status, start, + requestId, timestamp, undefined, + ); + return response; + }, + (error) => { + writeLogEntry( + env, ctx, workerName, request, 500, start, + requestId, timestamp, String(error), + ); + throw error; + }, + ); +} + +function writeLogEntry( + env: { TRAFFIC_LOGS: R2Bucket }, + ctx: { waitUntil: (p: Promise) => void }, + workerName: string, + request: Request | null, + status: number, + startMs: number, + requestId: string, + timestamp: string, + error: string | undefined, +): void { + const durationMs = Date.now() - startMs; + + const entry: TrafficLogEntry = { + timestamp, + worker: workerName, + method: request?.method ?? "UNKNOWN", + url: request?.url ?? "", + pathname: request ? new URL(request.url).pathname : "", + status, + durationMs, + requestId, + }; + + // Capture Cloudflare cf metadata if available + const cf = (request as any)?.cf as Record | undefined; + if (cf) { + entry.cf = { + colo: cf.colo as string | undefined, + country: cf.country as string | undefined, + asn: cf.asn as number | undefined, + }; + } + + if (error) { + entry.error = error; + } + + // R2 key format: //.json + const datePart = timestamp.slice(0, 10); // YYYY-MM-DD + const key = `${workerName}/${datePart}/${requestId}.json`; + + // Fire-and-forget — don't block the response + ctx.waitUntil( + env.TRAFFIC_LOGS.put(key, JSON.stringify(entry), { + httpMetadata: { contentType: "application/json" }, + }), + ); +} diff --git a/worker/tests/mcp.test.ts b/worker/tests/mcp.test.ts index 8ca47d5f..2b1d0f1e 100644 --- a/worker/tests/mcp.test.ts +++ b/worker/tests/mcp.test.ts @@ -4,6 +4,7 @@ import worker from "../mcp-entry"; import type { McpEnv } from "../src/env"; const env: McpEnv = { + TRAFFIC_LOGS: { put: vi.fn(async () => undefined) } as unknown as R2Bucket, MCP_API_BASE: "https://api.ps-wiki.ning.guru", MCP_ALLOWED_HOSTS: "mcp.ps-wiki.ning.guru", MCP_ALLOWED_ORIGINS: "mcp.ps-wiki.ning.guru", diff --git a/worker/wrangler-mcp.toml b/worker/wrangler-mcp.toml index 5bd99c42..82d2c361 100644 --- a/worker/wrangler-mcp.toml +++ b/worker/wrangler-mcp.toml @@ -20,3 +20,7 @@ MCP_ALLOWED_HOSTS = "mcp.ps-wiki.ning.guru,pswiki-mcp.jinning.workers.dev" MCP_ALLOWED_ORIGINS = "mcp.ps-wiki.ning.guru,pswiki-mcp.jinning.workers.dev" MCP_TIMEOUT_MS = "5000" MCP_MAX_RESPONSE_BYTES = "1000000" + +[[r2_buckets]] +binding = "TRAFFIC_LOGS" +bucket_name = "ps-wiki" diff --git a/worker/wrangler.toml b/worker/wrangler.toml index 99f9385c..6cc01655 100644 --- a/worker/wrangler.toml +++ b/worker/wrangler.toml @@ -18,3 +18,7 @@ ORIGIN_BASE = "https://raw.githubusercontent.com/ps-wiki/ps-wiki.github.io/main/ INDEX_URL = "https://ps-wiki.github.io/api/index.json" TAGS_URL = "https://ps-wiki.github.io/api/tags.json" SITE_BASE = "https://ps-wiki.ning.guru" + +[[r2_buckets]] +binding = "TRAFFIC_LOGS" +bucket_name = "ps-wiki"