From 654fdf1b67622a5f84c784baf217107b28b63436 Mon Sep 17 00:00:00 2001 From: "Dominik K." Date: Thu, 4 Jun 2026 22:38:38 +0200 Subject: [PATCH 1/2] docs: use after for analytics proxy Uses after for the analytics tracking as recommended by Next.js https://nextjs.org/docs/app/api-reference/functions/after --- README.md | 4 ++-- src/analytics.ts | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) 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.ts b/src/analytics.ts index df345de..2946e72 100644 --- a/src/analytics.ts +++ b/src/analytics.ts @@ -287,9 +287,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. From e6040403b3436ad6c3c87895f768622269ffd1b6 Mon Sep 17 00:00:00 2001 From: "Dominik K." Date: Thu, 4 Jun 2026 22:50:35 +0200 Subject: [PATCH 2/2] feat: track accept header dimension Co-authored-by: Cursor --- src/analytics.test.ts | 11 +++++++++-- src/analytics.ts | 1 + src/request.test.ts | 32 ++++++++++++++++++++++++++++++++ src/request.ts | 5 +++++ src/types.ts | 2 ++ 5 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 src/request.test.ts 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 2946e72..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 }, }; 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. */