Docs
IntegrationsCrawl activity

Netlify edge function

Report every request to Heading from a Netlify edge function, on any plan that supports edge functions

Netlify log drains are an Enterprise feature. An edge function is not, so this is the Netlify recipe for everyone else. One file, deployed with your site, reporting each request to Heading after the response has been served.

Official reference: Edge functions overview and the edge function API.

INGEST_HOST is a placeholder

INGEST_HOST stands in for the ingest hostname, which is not final yet. The setup dialog in Heading shows the real endpoint and property id. Copy them from there and replace INGEST_HOST everywhere it appears on this page.

Before you start

Get the endpoint and the bearer token from Heading. Open Tracking → Outcomes, find the Crawl activity card and select Set up. Put the token in a Netlify environment variable, not in the source file.

Deploy the function

Add the file

Create netlify/edge-functions/heading-crawl.js in your repository:

const PROPERTY_ID = "YOUR_PROPERTY_ID";
const INGEST_URL = `https://INGEST_HOST/api/ingest/crawl/${PROPERTY_ID}`;

export default async (request, context) => {
  // Serve the visitor first. Nothing below is on the response path.
  const response = await context.next();

  const url = new URL(request.url);
  const event = {
    timestamp: new Date().toISOString(),
    host: url.hostname,
    path: url.pathname,
    method: request.method,
    statusCode: response.status,
    userAgent: request.headers.get("user-agent") ?? "",
    referer: request.headers.get("referer") ?? "",
    ip: context.ip,
  };

  context.waitUntil(
    fetch(INGEST_URL, {
      method: "POST",
      headers: {
        "content-type": "application/x-ndjson",
        authorization: `Bearer ${Netlify.env.get("HEADING_CRAWL_TOKEN")}`,
      },
      body: `${JSON.stringify(event)}\n`,
    }).catch(() => {
      // A reporting failure must never affect the visitor's response.
    })
  );

  return response;
};

export const config = { path: "/*" };

context.waitUntil keeps the report off the response path, so a visitor waits for nothing extra. The catch means an ingest outage cannot take your site with it: the worst case is a gap in the data.

Set the token

In the Netlify dashboard, open Project configuration → Environment variables and add HEADING_CRAWL_TOKEN with the token from the setup dialog. Scope it to production, and make sure the scope includes Functions: an edge function cannot read a variable without it.

Set it in the UI, the CLI or the API. A variable declared in netlify.toml is not available to edge functions. For netlify dev, put the same value in a local .env file and keep that file out of git.

Deploy

Push the branch, or run netlify deploy. Then load a page on your site and watch the setup dialog in Heading. This recipe reports per request, so the first event lands in seconds rather than minutes.

Match every path

path: "/*" is deliberate. Heading filters server-side and drops roughly a hundred lines for every one it keeps, and the lines it keeps are not only the crawler ones: your human traffic builds the page inventory and produces every AI-referred entry.

Do not add a user agent check to the function either. A function that reports only what its own list already believes to be a crawler can only confirm that list, and the list goes stale the week a new crawler appears.

Edge function invocations count against your Netlify plan. Check your plan's included volume before putting this on a high-traffic site.

What Heading stores

The same answer appears on every recipe page, because it is the question your client's security reviewer will ask.

DataStored
Request pathYes, with the query string stripped before anything is written
HostYes, and it has to match the property's domain
User agentYes. It is how a crawler is identified
Status codeYes. What your origin served the crawler is half the value
Time of the requestYes, as a UTC timestamp
RefererYes, matched against the AI platforms Heading already knows
IP addressNo. Checked against the operator's published ranges at ingest, then discarded
CookiesNo. Never read, never stored
Request or response bodyNo. Never read, never stored
Query stringNo. Stripped from the path on arrival

The function above sends url.pathname, so the query string never leaves your edge in the first place. Raw events are kept for a short window and pruned as new ones arrive. The daily counts built from them are what the page reads afterwards. Because no IP, cookie, query string or body is retained, connecting a log source adds no personal data to Heading and brings in no new subprocessor.

Check it worked

Setup is not finished when the function deploys. It is finished when a real event lands.

Open Tracking → Outcomes, find the Crawl activity card, and select Set up. The delivery log there lists the attempts the endpoint has seen, accepted and rejected alike, and it updates within a few seconds of one arriving. Each row carries the response code, how many log lines were in the payload, how many of them matched, and, for a rejection, the reason and the fix. Two attempts leave no row at all: a request carrying no token, and a request sent to a property id that does not exist. An empty log under a sender you know is running means one of those two, so check the header and the URL before anything else.

StatusMeaning
Not verifiedNothing has arrived yet. Setup is not finished
ConnectedBatches are arriving and the most recent one was accepted
RejectingBatches are arriving and every one is being turned away. Nothing is being recorded
Nothing arrivingBatches arrived before and none has arrived for 24 hours. Check the sender at your edge is still pointed here
DisconnectedSomeone switched ingestion off for this property in Heading. Anything still arriving is refused with a 403 and nothing from it is stored. Reconnect from the same dialog to start accepting events again

An accepted delivery that stores nothing is normal. Most requests to a site are not crawler requests. What is worth looking at is a full day of deliveries where lines received is high and lines kept is zero.

Troubleshooting

Nothing is arriving. Confirm the deploy that includes the file is the published one, and check the function is listed under the deploy's edge functions. netlify dev runs it locally, and a console.log in the handler tells you whether it is being invoked at all.

Events arrive and all of them are rejected. A 401 is the token. Check HEADING_CRAWL_TOKEN is set for the production context, not only for previews, and that it is the token for this property. Tokens are per property: the token for one property is rejected by another property's endpoint, and the PROPERTY_ID in the URL has to be the property the token belongs to. Rotating the token in Heading invalidates the old one immediately.

host_not_matched. A log line whose host is not the property's domain is dropped rather than counted as this property's traffic. The batch around it is still accepted, because the other lines in it may be yours, so this shows up as a note on an accepted delivery and never as a rejected one. Edge functions also run on deploy previews and branch deploys, which answer on *.netlify.app. The note counts the dropped lines and names the hosts they were for, up to three, most frequent first. Either scope the environment variable to production only, so previews send an unauthenticated request that is refused at the door, or return early in the function when url.hostname is not your production domain.

Requests got slower. The reporting call is inside context.waitUntil, so it is not on the response path. If you see added latency, check you have not awaited the fetch to Heading before returning the response.