The contract
The URL you POST to, and the payload. Everything else is yours to decide.
Which cloud, which log source, which language, whether you buffer or send per request, whether you use Terraform at all — none of that is part of the contract. This page is.
#The request
POST https://analytics-staging.obsero.ai/v1/events
content-type: application/json
x-obsero-domain: acme.com # which site this event belongs to
x-obsero-key: <tracking ID> # proves you own it
{
"path": "/pricing",
"method": "GET",
"statusCode": 200,
"headers": {
"host": "acme.com",
"user-agent": "Mozilla/5.0 ...; compatible; ChatGPT-User/1.0; +https://openai.com/bot",
"accept": "*/*",
"signature-agent": "\"https://chatgpt.com\"",
"signature-input": "sig1=(\"@authority\" \"signature-agent\");created=...",
"signature": "sig1=:...:"
}
}
| field | rules |
|---|---|
path | Path only. /pricing, never
/pricing?ref=x and never a full URL. Leaving the query string on makes every distinct
URL its own page in the analytics — the most common mistake by far. |
method | Uppercase. "GET". |
statusCode | A number, not a string. 200. |
headers | Lowercase names → verbatim values. This is what classification runs on, so send as many as you can get. |
#Headers worth carrying
Roughly in order of value:
| header | what it buys you |
|---|---|
user-agent | everything starts here |
from | GPTBot, Googlebot |
signature-agent, signature-input, signature | Web Bot Auth — proves an agent is who it claims to be, rather than taking a User-Agent at face value |
accept, accept-language | browsers vs */* bots |
sec-ch-ua, sec-ch-ua-platform, sec-ch-ua-mobile | client hints — browsers only |
sec-fetch-dest, sec-fetch-mode, sec-fetch-site | presence alone is the signal |
Never forward these
authorization, cookie, set-cookie. Both adapters strip
them before anything leaves your account. Keep that behaviour if you rewrite one.
#obsero.mjs — the whole contract in one file
Dependency-free, about 150 lines: buildEvent, sendEvent,
validateEvent. Run it to send a test event and confirm your tracking ID works before
you deploy anything:
node obsero.mjs --token <tracking-id> --domain acme.com
Then, in your own code, this is genuinely all it takes:
import { buildEvent, sendEvent } from "./obsero.mjs";
await sendEvent(
buildEvent({
path: req.url,
method: req.method,
statusCode: res.statusCode,
headers: req.headers,
}),
{ token: process.env.OBSERO_SITE_TOKEN, domain: "acme.com" },
);
If you can call that from your edge middleware, your origin server or your log processor, you are done. You do not need anything else in this repo — and if you are on Cloudflare, Fastly, Akamai or a bare origin, this is the integration. Do not port a cloud directory you do not need.
buildEvent does the two things that are easy to get wrong for you: it strips the
query string from path (and tolerates being handed an absolute URL), and it lowercases,
trims and filters the headers.
#Check it before you ship it
validateEvent is the machine-checkable form of everything on this page. It returns a
list of problems, empty when the payload is good:
cd aws # or gcp
make test
That runs the real adapter parser over sample log lines and checks each
resulting payload against validateEvent. No cloud account needed. If it passes, Obsero
will accept what you produce.
#Adapting an adapter
Both adapters have the same three-part shape, and only one part is the contract:
1. THE CONTRACT build the payload and POST it <- keep this
2. LOG PARSING log line/entry -> event <- yours to rewrite
3. TRANSPORT unwrap the batch, ack it <- yours to rewrite
Change parts 2 and 3 to match your log source, leave part 1 alone, then run
make test. Fixtures live in each cloud's test/adapter.test.mjs — add
one for any new log shape rather than testing against live traffic.
#Getting a tracking ID
Ask Obsero for one, then keep it out of the repo. Both site/terraform/ directories
read it from a gitignored terraform.tfvars:
obsero_site_token = "..."
setup.sh writes that file for you, chmod 600. The deployed adapters
read x-obsero-domain off each request's own Host header, so there is no domain to
configure — only the token.
Code on this page
obsero.mjsbuildEvent, validateEvent, sendEventaws/test/adapter.test.mjsthe AWS adapter checked against the contractgcp/test/adapter.test.mjsthe GCP adapter checked against the contract