Sending HTTP log events

Contents

$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 classifies them alongside your $pageview events, powering bot analytics in web analytics.

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 like any other event. Here's the shape:

JSON
{
"api_key": "<ph_project_token>",
"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 <ph_client_api_host>/i/v0/e/, or wrap multiple events in the batch API (<ph_client_api_host>/batch/) if you ship logs in batches.

Properties

PropertyRequiredPurpose
$raw_user_agentYesThe client's User-Agent header. Bot detection reads this – without it, every event classifies as Automation.
$current_urlYesThe full request URL. Web analytics breakdowns and UTM attribution read it.
$hostRecommendedThe request host. Derive it from the URL if your log line doesn't carry it separately.
$pathnameRecommendedThe request path without the query string.
$process_person_profileRecommendedSet to false so log traffic doesn't create a person profile per distinct ID. See below.
$ipRecommendedThe client IP. Enables GeoIP enrichment and IP-based bot classification.
$referrerOptionalThe Referer header.
methodOptionalThe HTTP method.
status_codeOptionalThe response status code.
timestamp (top level)OptionalISO 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 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
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 instead – it keeps log delivery off the request path entirely.

Cloudflare Logpush example (Enterprise plan)

Cloudflare 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. 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
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://<your-worker>.workers.dev?header_Authorization=Bearer%20<secret>. 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 first, so req.ip, req.hostname, and req.protocol reflect the client rather than the proxy.

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 for the classification functions and virtual properties you can query them with.

Still have questions?

Was this page useful?