diff --git a/README.md b/README.md index 9f81676..1de42b0 100644 --- a/README.md +++ b/README.md @@ -10,14 +10,14 @@ Track a request in your Next.js middleware (now named proxy.ts): ```typescript // proxy.ts -import { NextResponse, type NextRequest } from "next/server" +import { NextResponse, type NextRequest, after } from "next/server" import { AgentAnalytics } from "@upstash/agent-analytics" import { redis } from "./redis" const analytics = new AgentAnalytics({ redis }) export const proxy = async (request: NextRequest) => { - await analytics.track(request) + after(() => analytics.track(request)) return NextResponse.next() } diff --git a/src/analytics.test.ts b/src/analytics.test.ts index 811c180..e7bf9ac 100644 --- a/src/analytics.test.ts +++ b/src/analytics.test.ts @@ -130,7 +130,11 @@ describe("ingestion", () => { test("track(Request) infers dimensions and writes the event", async () => { const req = new Request("https://upstash.com/blog", { - headers: { "user-agent": "PerplexityBot/1.0", referer: "https://www.perplexity.ai/" }, + headers: { + "user-agent": "PerplexityBot/1.0", + referer: "https://www.perplexity.ai/", + accept: "Text/Markdown, text/html;q=0.8", + }, }); expect(await analytics.track(req)).toBe(1); @@ -146,6 +150,7 @@ describe("ingestion", () => { ); expect(tracked).toBeDefined(); expect(String(tracked!.provider)).toBe("perplexity"); + expect(String(tracked!.accept)).toBe("text/markdown, text/html;q=0.8"); }); test("track(Request) from an unknown agent records nothing and resolves to null", async () => { @@ -247,7 +252,7 @@ describe("querying without an index", () => { describe("getIndex schema reconciliation", () => { // The schema getIndex() should converge to, regardless of what existed before. - const EXPECTED_FIELDS = ["count", "hour", "path", "provider"]; + const EXPECTED_FIELDS = ["accept", "count", "hour", "path", "provider"]; /** A bare index handle for inspecting the server-side schema via describe(). */ function indexHandle(name: string) { @@ -256,6 +261,7 @@ describe("getIndex schema reconciliation", () => { schema: { count: { type: "U64" as const, fast: true as const }, hour: { type: "U64" as const, fast: true as const }, + accept: { type: "KEYWORD" as const }, provider: { type: "KEYWORD" as const }, path: { type: "KEYWORD" as const }, }, @@ -311,6 +317,7 @@ describe("getIndex schema reconciliation", () => { schema: { count: { type: "U64", fast: true }, hour: { type: "U64", fast: true }, + accept: { type: "KEYWORD" }, provider: { type: "KEYWORD" }, path: { type: "KEYWORD" }, sourceUrl: { type: "KEYWORD" }, diff --git a/src/analytics.ts b/src/analytics.ts index df345de..178c478 100644 --- a/src/analytics.ts +++ b/src/analytics.ts @@ -40,6 +40,7 @@ export class IndexNotFoundError extends Error { const EVENT_SCHEMA = { count: { type: "U64" as const, fast: true as const }, hour: { type: "U64" as const, fast: true as const }, + accept: { type: "KEYWORD" as const }, provider: { type: "KEYWORD" as const }, path: { type: "KEYWORD" as const }, }; @@ -287,9 +288,8 @@ export class AgentAnalytics { * applies to the event form and defaults to now. * * Returns a promise resolving to the counter's new value. In a request - * handler you usually don't want to block the response on it — hand it to the - * runtime's `waitUntil` instead (see the README for the Vercel/Next.js - * pattern). + * handler you usually don't want to block the response on it — schedule it + * with Next.js `after()` instead (see the README proxy example). * * The request form resolves to `null` when the request can't be attributed * to a known agent — only known agents are recorded. diff --git a/src/request.test.ts b/src/request.test.ts new file mode 100644 index 0000000..0d2a88e --- /dev/null +++ b/src/request.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from "bun:test"; + +import { eventFromRequest } from "./request.ts"; + +describe("eventFromRequest", () => { + test("captures the normalized Accept header", () => { + const event = eventFromRequest( + new Request("https://upstash.com/blog#section", { + headers: { + "user-agent": "ClaudeBot/1.0", + accept: " Text/Markdown, text/html;q=0.8 ", + }, + }), + ); + + expect(event).toEqual({ + provider: "claude", + path: "https://upstash.com/blog", + accept: "text/markdown, text/html;q=0.8", + }); + }); + + test("omits Accept when the header is missing", () => { + const event = eventFromRequest( + new Request("https://upstash.com/docs", { + headers: { "user-agent": "ClaudeBot/1.0" }, + }), + ); + + expect(event?.accept).toBeUndefined(); + }); +}); diff --git a/src/request.ts b/src/request.ts index 1ee8f7a..7c4f1e7 100644 --- a/src/request.ts +++ b/src/request.ts @@ -21,6 +21,7 @@ export function eventFromRequest(req: Request): TrackedEvent | undefined { return { provider, path: normalizeUrl(request.nextUrl?.href ?? request.url), + accept: normalizeHeader(request.headers.get("accept")), }; } @@ -35,6 +36,10 @@ function normalizeUrl(url: string): string { } } +function normalizeHeader(value: string | null): string | undefined { + return value?.trim().toLowerCase() || undefined; +} + /** * Infer the AI agent from the user-agent and referrer headers. Returns * `undefined` when no known agent matches — the request is then not recorded. diff --git a/src/types.ts b/src/types.ts index 500dc3b..c8e6a90 100644 --- a/src/types.ts +++ b/src/types.ts @@ -22,6 +22,8 @@ export type TrackedEvent = { provider: Provider; /** The path on our site that was cited. */ path: string; + /** The request's Accept header, when present. */ + accept?: string; }; /** The dimensions that can be grouped/filtered on. */