> AI agents: this is one page from PostHog's docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt # Sending HTTP log events - Docs Copy page # Sending HTTP log events - Docs `$http_log` is PostHog's event for server-side HTTP request logs. Most bots, crawlers, and AI agents never run JavaScript, so the PostHog JavaScript SDK never sees them – the only record of their visit is your server or CDN access log. Forward those log entries as `$http_log` events and [bot and traffic detection](/docs/web-analytics/bot-detection.md) classifies them alongside your `$pageview` events, powering bot analytics in [web analytics](/docs/web-analytics.md). You can send `$http_log` events from anywhere that can make an HTTP request: an edge worker, a reverse proxy, your application server, or a log shipper. This page documents the payload so you (or your coding agent) can implement it on any platform. ## The payload Send events to the [capture API](/docs/api/capture.md) like any other event. Here's the shape: JSON PostHog AI ```json { "api_key": "", "event": "$http_log", "distinct_id": "http_log_5f8ab21c93de4a7b8f0142cc93de4a7b8f01", "properties": { "$process_person_profile": false, "$current_url": "https://example.com/pricing?utm_source=newsletter", "$host": "example.com", "$pathname": "/pricing", "$referrer": "https://www.google.com/", "$ip": "203.0.113.7", "$raw_user_agent": "Mozilla/5.0 (compatible; GPTBot/1.2; +https://openai.com/gptbot)", "method": "GET", "status_code": 200 } } ``` POST it to `/i/v0/e/`, or wrap multiple events in the [batch API](/docs/api/capture.md#batch-events) (`/batch/`) if you ship logs in batches. ### Properties | Property | Required | Purpose | | --- | --- | --- | | $raw_user_agent | Yes | The client's User-Agent header. Bot detection reads this – without it, every event classifies as Automation. | | $current_url | Yes | The full request URL. Web analytics breakdowns and UTM attribution read it. | | $host | Recommended | The request host. Derive it from the URL if your log line doesn't carry it separately. | | $pathname | Recommended | The request path without the query string. | | $process_person_profile | Recommended | Set to false so log traffic doesn't create a [person profile](/docs/data/anonymous-vs-identified-events.md) per distinct ID. See below. | | $ip | Recommended | The client IP. Enables [GeoIP enrichment](/docs/cdp/transformations/geoip.md) and IP-based bot classification. | | $referrer | Optional | The Referer header. | | method | Optional | The HTTP method. | | status_code | Optional | The response status code. | | timestamp (top level) | Optional | ISO 8601 time of the request. Defaults to ingestion time, so set it if you ship logs with a delay. | Anything else from your log line (data center region, cache status, TLS fingerprint, bot scores from your CDN) can go in `properties` under any name and stays queryable. ### Distinct IDs and person profiles Server requests carry no PostHog cookie, so you decide the `distinct_id` yourself: - **Use a derived, per-client ID**: a hash of IP, host, and user agent gives one stable identity per client, which keeps unique-visitor counts meaningful. Prefix it with `http_log_` so you can recognize log traffic in queries. - **Avoid a single shared ID** (all traffic counts as one visitor) and **avoid random per-request IDs** (every request counts as a new visitor). - **Send events as anonymous** (`$process_person_profile: false`). High-cardinality log traffic would otherwise create a person profile per distinct ID, which adds cost and slows person-joined queries. Bot detection reads event properties, not the profile, so nothing is lost. ### Controlling volume A single page view fans out into many sub-resource requests (JS, CSS, images, fonts). If you only care about page and API traffic, skip requests whose path ends in an asset extension before sending. Keeping paths with no extension (plus `.html`) gets you close to a document-level stream at a fraction of the volume. ## Cloudflare Worker example This pass-through [Worker](/docs/libraries/cloudflare-workers.md) reports each request in the background, so it never delays a response. It works on every Cloudflare plan. Store your project token as a Worker variable, and add a route like `example.com/*` so it runs on your traffic. JavaScript PostHog AI ```javascript export default { async fetch(request, env, ctx) { const response = await fetch(request) ctx.waitUntil(sendHttpLog(request, response, env).catch(() => {})) return response }, } async function sendHttpLog(request, response, env) { const url = new URL(request.url) const ip = request.headers.get('cf-connecting-ip') || '' const userAgent = request.headers.get('user-agent') || '' await fetch('https://us.i.posthog.com/i/v0/e/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ api_key: env.POSTHOG_PROJECT_TOKEN, event: '$http_log', distinct_id: `http_log_${await hash(`${ip}:${url.hostname}:${userAgent}`)}`, properties: { $process_person_profile: false, $current_url: request.url, $host: url.hostname, $pathname: url.pathname, $referrer: request.headers.get('referer') || undefined, $ip: ip, $raw_user_agent: userAgent, method: request.method, status_code: response.status, cloudflare_country: request.cf?.country, cloudflare_asn: request.cf?.asn, cloudflare_ray_id: request.headers.get('cf-ray'), cloudflare_bot_score: request.cf?.botManagement?.score, }, }), }) } async function hash(input) { const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input)) return [...new Uint8Array(digest)] .slice(0, 16) .map((b) => b.toString(16).padStart(2, '0')) .join('') } ``` Only traffic proxied through Cloudflare (orange-cloud DNS) reaches Workers. `cloudflare_bot_score` only populates if your zone has Cloudflare Bot Management; without it, the optional chaining leaves the property out. On an Enterprise plan, consider [Logpush](#cloudflare-logpush-example-enterprise-plan) instead – it keeps log delivery off the request path entirely. ## Cloudflare Logpush example (Enterprise plan) [Cloudflare Logpush](https://developers.cloudflare.com/logs/logpush/) pushes the **HTTP requests** dataset to an HTTP endpoint as gzipped, newline-delimited JSON batches. PostHog's capture API expects individual events, so the pattern is a small relay Worker: Logpush delivers batches to the Worker, and the Worker maps each record to a `$http_log` event and forwards them to the [batch API](/docs/api/capture.md#batch-events). Unlike the pass-through Worker above, nothing runs on your visitors' request path. Deploy this as a Worker and note its URL (a `workers.dev` URL works). JavaScript PostHog AI ```javascript export default { async fetch(request, env) { if (request.method !== 'POST') { return new Response('Method not allowed', { status: 405 }) } if (request.headers.get('authorization') !== `Bearer ${env.LOGPUSH_SECRET}`) { return new Response('Unauthorized', { status: 401 }) } // Logpush bodies are gzipped. Sniff the magic bytes rather than trusting headers. const buf = await request.arrayBuffer() const bytes = new Uint8Array(buf) const text = bytes[0] === 0x1f && bytes[1] === 0x8b ? await new Response(new Response(buf).body.pipeThrough(new DecompressionStream('gzip'))).text() : new TextDecoder().decode(buf) const events = [] for (const line of text.split('\n')) { if (!line.trim()) { continue } let record try { record = JSON.parse(line) } catch { continue } // Also skips Logpush's {"content":"tests"} validation probe. if (!record.ClientRequestHost) { continue } events.push(await toHttpLogEvent(record)) } for (let i = 0; i < events.length; i += 500) { await fetch('https://us.i.posthog.com/batch/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ api_key: env.POSTHOG_PROJECT_TOKEN, batch: events.slice(i, i + 500) }), }) } return new Response('OK') }, } async function toHttpLogEvent(record) { const host = record.ClientRequestHost const uri = record.ClientRequestURI || '/' const ip = record.ClientIP || '' const userAgent = record.ClientRequestUserAgent || '' return { event: '$http_log', distinct_id: `http_log_${await hash(`${ip}:${host}:${userAgent}`)}`, timestamp: record.EdgeStartTimestamp, properties: { $process_person_profile: false, $current_url: `https://${host}${uri}`, $host: host, $pathname: uri.split('?')[0], $referrer: record.ClientRequestReferer || undefined, $ip: ip, $raw_user_agent: userAgent, method: record.ClientRequestMethod, status_code: record.EdgeResponseStatus, cloudflare_country: record.ClientCountry, cloudflare_ray_id: record.RayID, }, } } async function hash(input) { const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input)) return [...new Uint8Array(digest)] .slice(0, 16) .map((b) => b.toString(16).padStart(2, '0')) .join('') } ``` Then create the Logpush job on your zone, under **Analytics & Logs > Logpush > Create a Logpush job**: 1. Pick the **HTTP requests** dataset and the **HTTP destination**. 2. Set the destination to your Worker URL, passing the shared secret as a header parameter: `https://.workers.dev?header_Authorization=Bearer%20`. Store the same secret as the Worker's `LOGPUSH_SECRET` variable. 3. Select at least these fields: `EdgeStartTimestamp`, `ClientIP`, `ClientRequestHost`, `ClientRequestMethod`, `ClientRequestURI`, `ClientRequestUserAgent`, `ClientRequestReferer`, `EdgeResponseStatus`, `ClientCountry`, `RayID`. Without `ClientRequestUserAgent`, bot detection classifies everything as `Automation`. 4. In the advanced options, set the timestamp format to **RFC3339** so `EdgeStartTimestamp` maps directly onto the event timestamp. 5. Cloudflare validates the destination by sending a gzipped test file when you create the job. The Worker acknowledges it automatically (records without `ClientRequestHost` are skipped). Logpush also supports a sampling rate in the job settings if you want to cap volume at the source. ## Server middleware example The same idea from your own server, shown as Express middleware. Fire the capture request after the response finishes so logging never blocks the request path. Behind a load balancer or proxy, configure [`trust proxy`](https://expressjs.com/en/guide/behind-proxies.html) first, so `req.ip`, `req.hostname`, and `req.protocol` reflect the client rather than the proxy. JavaScript PostHog AI ```javascript import crypto from 'node:crypto' app.use((req, res, next) => { res.on('finish', () => { const ip = req.ip || '' const userAgent = req.get('user-agent') || '' const distinctId = 'http_log_' + crypto.createHash('sha256').update(`${ip}:${req.hostname}:${userAgent}`).digest('hex').slice(0, 32) fetch('https://us.i.posthog.com/i/v0/e/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ api_key: process.env.POSTHOG_PROJECT_TOKEN, event: '$http_log', distinct_id: distinctId, properties: { $process_person_profile: false, $current_url: `${req.protocol}://${req.get('host')}${req.originalUrl}`, $host: req.hostname, $pathname: req.path, $referrer: req.get('referer') || undefined, $ip: ip, $raw_user_agent: userAgent, method: req.method, status_code: res.statusCode, }, }), }).catch(() => {}) }) next() }) ``` ## Managed alternatives If you'd rather not write the capture call yourself: - **Vercel logs source** – in PostHog, go to **Data pipeline > Sources** and add the **Vercel logs** source, then point a Vercel log drain at the generated endpoint. Distinct ID hashing, person processing, and page-route filtering are settings on the source. - Check **Data pipeline > Sources** in your project for other managed sources as we add them. Once events are flowing, head to [bot and traffic detection](/docs/web-analytics/bot-detection.md) for the classification functions and virtual properties you can query them with. ### Still have questions? Ask PostHog AI ### Was this page useful? HelpfulCould be better