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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
11 changes: 9 additions & 2 deletions src/analytics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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) {
Expand All @@ -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 },
},
Expand Down Expand Up @@ -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" },
Expand Down
6 changes: 3 additions & 3 deletions src/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
};
Expand Down Expand Up @@ -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.
Expand Down
32 changes: 32 additions & 0 deletions src/request.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
5 changes: 5 additions & 0 deletions src/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")),
};
}

Expand All @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down