Appearance
Events and log objects
JSON and NDJSON bodies carry objects. An object with an event name is an event. An object with a message or level and no name is a log line. Both share the same fields.
json
{
"event": "screenshot_taken",
"event_id": "0199f0c4-6c1e-7a3b-9d2e-5b8f1a2c3d4e",
"ts": "2026-10-17T09:14:00.123+02:00",
"device_id": "c0ffee-42",
"props": {"type": "area", "width": 1280, "is_retina": true}
}json
{
"level": "error",
"message": "Upload failed",
"event_id": "0199f0c4-6c1f-7c55-8e10-2a9b3c4d5e6f",
"ts": "2026-10-17T09:14:02Z",
"props": {"status": 503}
}Fields
| Field | Type | Required | Meaning |
|---|---|---|---|
event | string | Yes, for events | The event name, such as screenshot_taken. At most 200 bytes |
event_id | UUID string | Strongly recommended | Set by the sender. Makes retries safe: copies with the same id are stored once |
ts | ISO 8601 with an offset, or an epoch number | Yes, whenever event_id is sent | When it happened. Without it, ingest's receive time is used |
device_id, user_id, install_id, anonymous_id, session_id | string | No | Identity fields; they unlock active devices and per-device pages |
level | debug, info, notice, warning, error, critical | No | For log lines |
message | string | No | For log lines |
props | object | No | Everything else worth charting: string, number or boolean values |
Any other top-level field is kept and moved under props.
event_id and ts
Set both when the event happens, never at send time. Then a request that is retried after a lost answer stores each event once.
js
const event = {
event: 'level_completed',
event_id: crypto.randomUUID(),
ts: new Date().toISOString(),
props: { level: 3 },
}Always send ts with event_id
Without ts, each copy of a retried event gets a different receive time, and the copies can't be deduplicated. UUIDv4 and UUIDv7 both work.
ts accepts:
- ISO 8601 with an offset:
2026-10-17T09:14:00Z,2026-10-17T09:14:00.123+02:00. - An epoch number, read by magnitude: below 1e11 is seconds, below 1e14 milliseconds, below 1e17 microseconds, otherwise nanoseconds. Float seconds, as zap writes them, work too.
A ts more than 10 years old or after 2095 goes to Unparsed as ts_out_of_range. One that doesn't read as a time is invalid_ts. A ts older than your environment's raw retention (7 days on Free) would expire at once, so it goes to Unparsed as older_than_retention instead of vanishing.
Identity fields
Send one of device_id, install_id, anonymous_id, user_id or session_id to count active devices. When several are present, the first in that order is the device the dashboard counts. Every identity field you send is also kept as a prop.
Send user_id next to device_id once a person signs in. Both are stored, so boards can rank devices, rank users, and list the users with the most devices.
Use a random install id or an anonymous id. An email address in an identity field is stored as a stable pseudonym, email: plus a keyed hash, unless the project's owner chose to keep emails readable. See Privacy for senders.
Page views
The Sessions tab reads page_view events with these props:
| Prop | Meaning |
|---|---|
path | The page, e.g. /pricing; the query string and a trailing / are dropped, and #/route stays for hash routers. A full url works too |
referrer or referrer_host | Where the visitor came from, on the visit's first page view |
utm_source, utm_medium, utm_campaign | The landing URL's UTM tags |
A visitor is the first of device_id, install_id or anonymous_id; without one, a browser request on a public key gets a daily-rotating hash of its address and User-Agent. A new session_id starts a new visit.
Levels
level is one of six values: debug, info, notice, warning, error, critical. Common spellings map onto them, case-insensitively, such as WARN → warning and fatal → critical.
Numbers from pino and Monolog map too:
| Library | Number → level |
|---|---|
| pino | 10, 20 → debug · 30 → info · 40 → warning · 50 → error · 60 → critical |
| Monolog | 100 → debug · 200 → info · 250 → notice · 300 → warning · 400 → error · 500, 550, 600 → critical |
An unknown level is never a rejection. The row's level is left empty, and the value is kept as props.level.
Props
Props are what you chart and break down by. Keep them to strings, numbers and booleans:
json
{"event": "export_finished", "props": {"format": "png", "duration_ms": 412, "is_pro": true}}- Low-cardinality strings, such as
plan,platformorformat, become breakdowns automatically. - Numbers can be summed and averaged in custom widgets.
- An event holds at most 100 props keys, nested keys included, and each key is at most 128 bytes.
No personal data in props
Never put emails, names, IP addresses, tokens or other personal data in props. JSON events are stored as sent; only text log lines are masked. See Privacy for senders.
Field aliases
Logging libraries use other names for the same fields. JSON events, NDJSON lines and JSON lines inside text bodies all accept them:
| Field | Aliases, in priority order |
|---|---|
message | msg |
level | severity, lvl, and the pino and Monolog numbers above |
ts | time, timestamp, @timestamp: ISO 8601 (no offset means UTC) or an epoch number |
So a pino line works as it is:
json
{"level": 50, "time": 1792232040000, "msg": "Upload failed", "pid": 81, "hostname": "web-1"}- The documented field always wins when it is present and not empty.
- The first alias that reads fills the field and is removed from props. An alias that doesn't read stays in props; it never causes a rejection.
What gets refused
An object that fails a check goes to the Unparsed tab with its reason. The rest of the request is stored as usual.
| Reason | Why |
|---|---|
missing_event | No event, and no message or level either |
invalid_event | event isn't a string, or is longer than 200 bytes |
invalid_ts, ts_out_of_range | ts doesn't read as a time, or is outside the accepted window |
older_than_retention | ts is older than the environment's raw retention, so the row would expire at once |
invalid_event_id | event_id is neither a string nor an integer |
invalid_identity | An identity field isn't a string or an integer |
invalid_message | message isn't a string |
invalid_props | props isn't an object |
too_many_props, props_key_too_long, props_too_deep | Over 100 keys, a key over 128 bytes, or nested deeper than 32 levels |
props_path_collision | Two props name the same path, such as "a.b" and {"a": {"b": …}} |
invalid_json | A line or body that isn't JSON |
Batches
A JSON body can be one object, an array of objects, or a wrapper with a batch array (Segment, PostHog) or an events array (Amplitude). An NDJSON body has one object per line.
One request turns into at most 10,000 events; anything past that is kept on the Unparsed tab in slices (too_many_events), never dropped. Within the 512 KB limit you will rarely get close.
Next steps
- Text log lines: send plain text instead of JSON.
- Responses: what each answer means.
- PostHog, Mixpanel and Amplitude: their event shapes, understood on
/v1/ingest.