---
url: https://simpledatalogs.com/docs/api/events.md
description: >-
  Every field of an event or log object, field aliases from logging libraries,
  identity fields, props and validation limits.
---

# 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 },
}
```

::: warning 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](/reference/privacy).

### Page views

The [Sessions tab](/guide/web-analytics) 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`, `platform` or `format`, 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.

::: danger 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](/reference/privacy).
:::

## 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](/api/log-lines): send plain text instead of JSON.
* [Responses](/api/responses): what each answer means.
* [PostHog, Mixpanel and Amplitude](/integrations/payload-shapes): their event shapes, understood on `/v1/ingest`.
