@usenotra/geo
v0.5.0
Published
Request capture SDK for AI traffic attribution and agent feedback collection
Maintainers
Readme
@usenotra/geo
Request capture SDK for AI traffic attribution. It captures every page request your
site serves and sends a neutral request envelope to Notra. All classification, crawler
versus AI referral versus human, happens server side at ingest, so a stale local table
never costs you traffic. Requests classified as human are discarded at ingest and never
stored. The core tracker ships no signature table. Only the optional
Next.js tagLinks path bundles the matcher, because it has to decide at the edge which
responses to rewrite for AI agents.
Zero dependencies. No Node APIs in the core, so it runs on edge runtimes.
Install
bun add @usenotra/geoNext.js
// proxy.ts on Next.js 16, middleware.ts before that
import { createGeoProxy } from "@usenotra/geo/next";
import { NextResponse } from "next/server";
const geo = createGeoProxy({ token: process.env.NOTRA_GEO_TOKEN ?? "" });
export function proxy(request: Request, event: { waitUntil(p: Promise<unknown>): void }) {
geo(request, event);
return NextResponse.next();
}geo(request, event) never throws and never blocks the response. When an event with
waitUntil is passed, the send is registered on it so it survives the response.
Nuxt and Nitro
// server/middleware/geo.ts
import { createGeoHandler } from "@usenotra/geo/nuxt";
const geo = createGeoHandler({ token: process.env.NOTRA_GEO_TOKEN ?? "" });
export default defineEventHandler(async (event) => {
await geo(event);
});The handler accepts an H3 event with a node.req, or any object exposing a standard
web request. Geo headers are usually absent behind Nitro, so those fields stay
undefined and ingest falls back to IP based lookup.
TanStack Start
// src/start.ts
import { createMiddleware, createStart } from "@tanstack/react-start";
import { createGeoMiddleware } from "@usenotra/geo/tanstack";
const geo = createMiddleware().server(createGeoMiddleware({
token: process.env.NOTRA_GEO_TOKEN!,
endpoint: "https://app.usenotra.com",
}));
export const startInstance = createStart(() => ({
requestMiddleware: [geo],
}));The middleware captures requests alongside your route handler and waits for the send before returning, so tracking completes on serverless hosts. It preserves the downstream response and errors. If src/start.ts already exists, add geo to its existing requestMiddleware array.
Netlify Edge Functions
// netlify/edge-functions/geo.ts
import { createGeoHandler } from "@usenotra/geo/netlify";
const geo = createGeoHandler({ token: Deno.env.get("NOTRA_GEO_TOKEN") ?? "" });
export default (request: Request, context: Context) => {
geo(request, context);
};
export const config = { path: "/*" };Netlify's context.geo is mapped into the envelope and context.waitUntil keeps the
send alive.
Options
| Option | Default | Meaning |
| --- | --- | --- |
| token | required | Per organization ingest token |
| endpoint | https://app.usenotra.com | Ingest origin, the SDK appends /api/geo/ingest |
| exclude | ["/api"] | Paths to skip. Pass [] to disable |
| sample | 1 | Fraction of eligible requests to send, 0 to 1 |
| onError | none | Called with anything that goes wrong. The SDK never throws |
| fetch | global fetch | Injectable fetch, for tests |
What gets captured
Only GET requests that look like pages. Asset requests are skipped: anything under
/_next/, /_nuxt/, /_vercel/, /_astro/, /static/, and any path ending in a
common static extension such as .css, .js, .png, .svg, .xml or .txt.
llms.txt and llms-full.txt are the exception: they are always captured.
exclude entries can be:
- a string, matching that path and everything below it, so
"/api"also excludes/api/users - a
RegExp, tested against the pathname - a function
(request, url) => boolean
Passing exclude: [] removes the default and captures every page path.
Payload
Each capture POSTs this JSON to ${endpoint}/api/geo/ingest with an
authorization: Bearer <token> header, keepalive, and a 2 second timeout.
type GeoRequestPayload = {
timestamp?: string;
method: string;
url: string;
ip?: string;
geo?: {
country?: string;
region?: string;
city?: string;
timezone?: string;
latitude?: string;
longitude?: string;
};
referer?: string;
userAgent?: string;
accept?: string;
acceptLanguage?: string;
requestId?: string;
};ip comes from the first entry of x-forwarded-for, then x-real-ip, then
x-vercel-forwarded-for. geo comes from the x-vercel-ip-* headers with
cf-ipcountry as a country fallback, or from the platform context on Netlify.
requestId comes from x-request-id or x-vercel-id.
No request body, no cookies, and no headers beyond the ones listed above are ever read or sent.
Journey attribution for markdown
@usenotra/geo/markdown tags the links you hand to an agent so every follow up
request is attributed to the same crawl session. Links carry a short ntr id,
and the ingest side stores it as the journey for that visit.
import {
getJourneyId,
mintJourneyId,
tagMarkdownLinks,
} from "@usenotra/geo/markdown";
export async function GET(request: Request) {
const markdown = await readMarkdown();
const journeyId = getJourneyId(request) ?? mintJourneyId();
return new Response(
tagMarkdownLinks(markdown, journeyId, { host: "example.com" }),
{ headers: { "content-type": "text/markdown; charset=utf-8" } }
);
}mintJourneyId()returns a new url safe id built fromcrypto.getRandomValuesgetJourneyId(request)reads a validntrparam from aRequest,URLor string and returnsnullwhen there is nonetagMarkdownLinks(markdown, journeyId, options)rewrites inline links and reference definitions in place
Only same site targets are tagged: paths starting with /, and absolute urls
whose host matches options.host. Existing query strings are preserved
(/docs?a=1 becomes /docs?a=1&ntr=<id>), fragments stay at the end
(/docs?a=1#usage becomes /docs?a=1&ntr=<id>#usage), and anchors, mailto:,
tel:, image embeds, external hosts and links that already carry an ntr param
are left untouched. Fenced code blocks and inline code spans are never rewritten.
Requests captured by the tracker already carry the full url, so no tracker configuration is needed for journeys to show up.
One-switch tagging (Next.js)
On Next.js you do not have to touch every markdown route. Turn on tagLinks and the
proxy tags markdown responses for AI agents on the way out.
// proxy.ts on Next.js 16, middleware.ts before that
import { createGeoProxy } from "@usenotra/geo/next";
import { NextResponse } from "next/server";
const geo = createGeoProxy({
token: process.env.NOTRA_GEO_TOKEN ?? "",
tagLinks: true,
});
export async function proxy(request: Request, event: { waitUntil(p: Promise<unknown>): void }) {
const tagged = await geo(request, event);
if (tagged) {
return tagged;
}
return NextResponse.next();
}tagLinks accepts true for the defaults, or an object:
| Field | Default | Meaning |
| --- | --- | --- |
| host | request hostname | Host treated as same site when tagging absolute urls |
| paths | .md, llms.txt, llms-full.txt | Which routes are markdown shaped |
| html | false | Also tag the anchors in HTML pages served to AI agents |
paths entries use the same matching semantics as exclude: a string matches that
path and everything below it, a RegExp is tested against the pathname.
Mechanics
For each request the proxy checks, in order:
- the loop guard header
x-notra-geo-tagis absent - the method is
GET - the user agent matches a known AI agent from the shipped signature table
- the pathname matches one of the
pathsmatchers
When all four hold, the proxy resolves the journey id from the incoming ntr param or
mints a new one, then fetches the same url again with the original request headers plus
x-notra-geo-tag: 1. That second request re-enters the proxy, sees the guard header and
falls straight through to your route, so there is no loop. The origin response is only
rewritten when it is ok and its content type contains text/markdown or text/plain.
Anything else falls through untouched and the proxy behaves exactly as it did before.
HTML pages
With html: true the same flow covers ordinary pages. When the pathname does not match
the markdown matchers, the proxy falls back to the HTML path: it applies the tracker's
own filter first, so /api, everything in exclude, and every asset path or static
extension are skipped and never refetched. The origin response is rewritten only when it
is ok and its content type contains text/html. Markdown always wins on markdown
shaped paths, so nothing about the existing behaviour changes.
tagHtmlLinks(html, journeyId, options) is also exported on its own from
@usenotra/geo/html for stacks without the Next.js proxy.
What it rewrites, and only this: the href of <a> start tags, single or double
quoted, when the target is a path starting with / or an absolute url whose host
matches options.host. Existing query strings and fragments survive, with the param
inserted before the fragment, and & encoded queries stay encoded.
What it never touches:
<link>tags, sorel="canonical", stylesheets, preloads and alternates are left exactly as the origin wrote themsrcattributes on images, scripts, iframes and media- anything inside
<script>or<style>, since the rewrite is scoped to<astart tags mailto:,tel:,javascript:,data:and fragment only hrefs- protocol relative urls pointing at another host, and any external host
- urls that already carry an
ntrparam
An invalid journey id returns the input unchanged. Relative hrefs that do not start
with / are left alone rather than guessed at.
Because the whole path is gated on the AI agent user agent check, human visitors and search crawlers get the untouched origin response byte for byte. There is no cloaking of content, only an extra query param on internal links for agents, and no effect on your CDN or browser caching for normal traffic.
On success the request is tracked as usual and the proxy returns a new Response with
the tagged body, the origin status and the origin headers minus content-length.
Human traffic never pays for this: the agent check happens before the origin fetch, so a normal browser request goes through the untouched path.
Tagging needs no ingest token. With token empty the proxy still tags and simply sends
no envelope.
Other stacks
tagMarkdownLinks stays the way to tag per route on Nuxt, Netlify or anything else.
Call it inside the handler that builds the markdown, as shown above.
Core API
@usenotra/geo exports the pieces the framework adapters are built from:
Tracker—new Tracker(options).track(request)returns a promise that never rejectsserializeRequest(request)— builds the envelope from a standardRequestshouldTrackRequest(request, url, exclude?)— the GET, asset and exclude filtersendRequestLog(payload, options)— the fire and forget POST
Signature table
@usenotra/geo/signatures and @usenotra/geo/classify still ship the sourced AI agent
signature table and its matcher. The tracker does not use them. The ingest side uses
them to classify each captured envelope server side, and the Next.js tagLinks proxy
uses the matcher locally to decide which responses to tag. Every entry carries a
source URL and a confidence of verified, reported or heuristic. See
SOURCES.md, which also documents agents that are deliberately absent
because they cannot be honestly detected by user agent, including Pi, ChatGPT Atlas,
Google-Extended and Applebot-Extended.
User agent matching is spoofable. The verification field on each signature points at
the operator's published IP range list or reverse DNS method where one exists.
Agent feedback
@usenotra/geo/feedback adds a submit_feedback tool to your MCP server so the agents using your product can report bugs, request features and leave praise in your Notra inbox. Copy your feedback URL from the Feedback page in your dashboard. It is unique to your organization and needs no token.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { registerFeedbackTool } from "@usenotra/geo/feedback";
const server = new McpServer({ name: "acme", version: "1.0.0" });
registerFeedbackTool(server, {
url: "https://api.usenotra.com/v1/feedback/acme",
productName: "Acme",
});Outside of MCP, submitFeedback(input, { url }) posts a single entry and resolves with { id, deduplicated }. The MCP helper needs zod (already required by the MCP SDK); the plain client has no dependencies.
License
MIT. Copyright (c) 2026 Notra, Inc. See LICENSE.
