--- url: https://simpledatalogs.com/docs/guide/introduction.md description: >- What simpledatalogs is, what it is for, and how data flows from your app to the dashboard. --- # Introduction simpledatalogs is the simplest dashboard for your app's **events** and **log lines**. It is built for small apps, games and websites, and for the indie developers and small teams who make them. You send data over HTTPS with one key. The dashboard turns it into counters ("screenshots today", "active devices", "installs all time"), charts, per-device views and a searchable log table, without a schema or a query language. ## What you can do with it * **Count what matters.** Track `signup`, `level_completed` or `screenshot_taken`, and see today and the last 7, 30 or 90 days. On Scale, dashboards also offer Year and Lifetime. * **Read your logs.** Send log lines in almost any common format, then filter them by level, deployment and text. * **See devices, not just totals.** Send an anonymous `device_id`, and you get active devices and one page per device. * **Answer feature questions.** [Boards](/guide/boards) show reach, rates, conversions and retention from the events you already send. * **Know your website.** The [Sessions tab](/guide/web-analytics) shows visitors, visits, sources, journeys and funnels, without cookies. * **Watch your app run.** Requests, jobs, slow queries, traces and grouped exceptions land on the [App and Issues](/guide/observability) tabs. * **Switch without rewriting code.** If you already use OpenTelemetry, Segment, Sentry, Datadog logs, Loki or Splunk HEC, change an endpoint and a key. * **Ask your AI assistant.** Connect Claude, ChatGPT, Grok or Cursor to the read-only [MCP server](/mcp/) and ask "how many signups this week?". ## What it isn't simpledatalogs deliberately stays small. It has no session replay, no profiling, no cohorts builder and no query language. Errors from Sentry SDKs arrive as countable log lines and group into issues, but there is no performance view for them. AI is never used to parse your data or to produce a number. Every count comes from a deterministic query. ## How data flows ``` your app ──HTTPS──▶ edge.simpledatalogs.com ──queue──▶ parser ──▶ event store ──▶ simpledatalogs.com key 202 = stored dashboards, MCP ``` 1. Your app, server or log shipper sends a request to `https://edge.simpledatalogs.com` with an **ingest key**. 2. Ingest checks the key and stores the body unchanged. It answers `202` only once the body is safely stored. 3. Behind the queue, the parser reads each event or log line, masks personal data in text lines, and writes rows. 4. The dashboard at `https://simpledatalogs.com` reads those rows. Ingest never parses your payload, so a body it can't read is never lost: it lands in the **Unparsed** bin, where you can fix the format and read it again. ::: tip The one promise Nothing we acknowledge with a `202` is ever lost, and our SDKs never delete anything we haven't acknowledged. If you write your own sender, keep that promise too: see [Writing a sender](/api/sender-rules). ::: ## Next steps * [Quick start](/guide/quick-start): send your first event with curl. * [Core concepts](/guide/concepts): organizations, projects, environments, deployments and keys. * [Pick an SDK](/sdks/javascript), or [connect a tool you already use](/integrations/). --- --- url: https://simpledatalogs.com/docs/guide/quick-start.md description: >- Create a key and send your first event and log line with curl in about two minutes. --- # Quick start In this guide you create an ingest key, send one event and one log line with curl, and watch them arrive on the dashboard. It takes about two minutes. ## Pick your stack The fastest path is one command in your project folder. It detects the framework, shows a diff of the few lines it adds, and sends a test event: ```sh npx simpledatalogs init ``` Or follow the page for your stack: | Web | Mobile and games | Servers | |---|---|---| | [Next.js](/quickstarts/nextjs) · [React (Vite)](/quickstarts/react-vite) · [Vue and Nuxt](/quickstarts/vue-nuxt) · [SvelteKit](/quickstarts/sveltekit) · [Plain HTML](/quickstarts/html) | [React Native / Expo](/quickstarts/react-native) · [iOS and macOS](/quickstarts/ios-swift) · [Flutter](/quickstarts/flutter) · [Android](/quickstarts/android) · [Unity](/quickstarts/unity) | [Node](/quickstarts/node) · [Laravel](/quickstarts/laravel) · [Python](/quickstarts/python) · [Go](/quickstarts/go) · [Rails](/quickstarts/rails) (HTTP recipe) | On WordPress or Shopify, install the [WordPress plugin](/integrations/wordpress) or the [Shopify app](/integrations/shopify) instead. The rest of this page does the same with curl, so you can see exactly what goes over the wire. ## Before you begin You need an account at [simpledatalogs.com](https://simpledatalogs.com) and a terminal with `curl`. The Free plan needs no card. ::: info Early access While sign-up is closed, accounts are by invitation: choose **Ask for early access** on the site. ::: ## 1. Create a project and an environment 1. Sign in and choose **New project**. Name it after your app, for example `Screenshotter`. 2. Open the project and choose **New environment**, for example `production` or `development`. Every environment starts with one deployment called `default`. You can add more later, one per build or source (see [Core concepts](/guide/concepts#deployments)). ## 2. Create an ingest key 1. Open the environment and go to the **Deployments & keys** tab. 2. Choose **Create key**, keep the `default` deployment, and pick a kind: * **Public** (`sdl_pub_…`) for apps and websites. It is write-only and safe to ship. * **Secret** (`sdl_sec_…`) for servers. 3. Choose **Create key** and copy the key. ::: warning Copy it now Only a hash of the key is stored, so the dialog is the only time you see the full key. A new key starts working within a few seconds. ::: ## 3. Send your first event Replace `sdl_pub_…` with your key and run: ```sh curl -X POST https://edge.simpledatalogs.com/v1/ingest \ -H "Authorization: Bearer sdl_pub_…" \ -H "Content-Type: application/json" \ -d '{"event": "hello_world", "device_id": "my-laptop"}' ``` You should see: ```json {"accepted":"0199f0c4-…"} ``` A `202` with an `accepted` id means the event is durably stored. If you see `{"error":"invalid_key"}` instead, wait a few seconds and try again: a brand-new key takes a moment to go live. ## 4. Send a log line Log lines don't need a special format. Send plain text, one entry per line: ```sh curl -X POST https://edge.simpledatalogs.com/v1/ingest \ -H "Authorization: Bearer sdl_pub_…" \ -H "Content-Type: text/plain" \ --data-binary $'2026-10-17 09:14:00 ERROR Payment failed for order 7\n2026-10-17 09:14:02 INFO Retried order 7' ``` Each line becomes a row with a time, a level and a message. See [Text log lines](/api/log-lines) for every format that is recognized. ## 5. Watch it arrive Open the environment's **Dashboard**. The first event shows up live, with a "first event received" note. `hello_world` appears in the events table, `my-laptop` counts as one active device, and the two log lines are on the **Logs** tab. ## Make it retry-safe The first example is fine for a test. In a real app, give every event an `event_id` and a `ts` when it happens, so a retried request never counts twice: ```sh curl -X POST https://edge.simpledatalogs.com/v1/ingest \ -H "Authorization: Bearer sdl_pub_…" \ -H "Content-Type: application/json" \ -d '{ "event": "signup", "event_id": "'"$(uuidgen)"'", "ts": "'"$(date -u +%Y-%m-%dT%H:%M:%SZ)"'", "device_id": "my-laptop", "props": {"plan": "pro", "is_trial": true} }' ``` Send the same request twice and the dashboard still counts one `signup`. ::: tip Let an SDK do this The SDKs for [JavaScript](/sdks/javascript), [Swift](/sdks/swift), [Laravel](/sdks/laravel), [Flutter](/sdks/flutter), [Android](/sdks/android), [Unity](/sdks/unity), [Python](/sdks/python) and [Go](/sdks/go) set `event_id` and `ts` for you, batch, keep an outbox and retry. Or paste the setup prompt from the key dialog into your [coding agent](/agents/). ::: ## Next steps * [Core concepts](/guide/concepts): how keys, environments and deployments fit together. * [Events and log objects](/api/events): every field you can send. * [Responses](/api/responses): what each status code means and what your client should do. --- --- url: https://simpledatalogs.com/docs/guide/concepts.md description: >- Organizations, projects, environments, deployments, ingest keys, and the difference between events and log lines. --- # Core concepts A few ideas explain the whole product. Once you know them, every page in the dashboard and every field in the API makes sense. ## The hierarchy ``` Organization your team, its plan and billing └── Project one app, game or website └── Environment production, staging, development… └── Deployment one build or source: iOS, Android, web, API server └── Keys sdl_pub_… and sdl_sec_…, each belongs to one deployment ``` ### Organization Your team. It holds the members, the plan and the monthly event quota. Everything below it belongs to exactly one organization. People in it have one of three roles: | Role | Can | |---|---| | Owner | Everything, including billing, members, alerts, prices, public counters and erasing data | | Member | Read everything, edit dashboards and boards, work on issues, export and manage webhooks | | Viewer | Read dashboards, logs and events only; never keys or billing. Viewers are free on paid plans and don't use a seat | ### Project One product, such as your Mac app or your website. A project has a name, a logo or emoji, and a **time zone**: "today" on the dashboard means today in that zone. Times are always stored in UTC. ### Environment A separate data set inside a project, such as `production` and `development`. Each environment has its own dashboard, logs, devices, keys, alerts and retention. Data never mixes between environments. ### Deployments The builds or sources that send data into one environment. For example, production might have `ios`, `android`, `macos` and `web` deployments. * Every environment starts with one deployment called `default`. * Once there are two, the dashboard shows an **All** tab plus one tab per deployment. * A deployment has a name and a platform (`ios`, `android`, `macos`, `windows`, `linux`, `web`, `server` or `other`), used for its icon. An environment holds up to 20. * On the **Deployments & keys** tab, **Add deployment** picks a platform and a name; **Create key** on its card then gives it a key. * **Delete** in a deployment's menu removes it and its keys while none of them has stored data. A deployment with data, a webhook source or no sibling stays, and its menu says why. You never send a deployment name. **The key decides it.** ## Ingest keys Every request carries an ingest key, and the key alone decides the environment and the deployment. Your payloads never name either. | Kind | Prefix | Where it goes | Can it read data? | |---|---|---|---| | Public | `sdl_pub_…` | Apps, games and websites. Safe to ship in a binary or a web page | No, write-only | | Secret | `sdl_sec_…` | Servers, log shippers, CI | No, write-only | Both kinds only write. Neither can read your data, so a public key scraped from your website can at worst send junk events. If that happens, revoke it on the **Deployments & keys** tab and create a new one: other deployments keep working. ::: tip One key per build Give each build or source its own deployment and key. Your iOS and web numbers then get their own tabs, and you can rotate one key without touching the others. ::: A deployment can hold several keys at once, which is how you rotate: create the new key, ship it, then revoke the old one. A revoked key stops working within about 30 seconds. Reading data is separate. The dashboard uses your sign-in, and the [MCP server](/mcp/) uses OAuth or a personal `sdl_mcp_…` token. ## Events and log lines simpledatalogs does two jobs, and every row is one of two kinds. ### Events An event has a name and counts something that happened: ```json {"event": "screenshot_taken", "device_id": "c0ffee-42", "props": {"type": "area"}} ``` Events become counters, charts over time, breakdowns by property, and unique-device counts. ### Log lines A log line has a message and, usually, a level. It has no event name: ```json {"level": "error", "message": "Upload failed", "props": {"status": 503}} ``` Log lines land on the **Logs** tab, where you can filter by level, deployment and text, and they also feed the log-level chart. You can send them as JSON or as plain text in almost any [common format](/api/log-lines). The levels are a closed set: `debug`, `info`, `notice`, `warning`, `error` and `critical`. Common spellings such as `WARN` or `fatal` map onto them. ## Devices and identity Send an identity field, and the dashboard counts **active devices** and gives each device its own page. The fields are `device_id`, `install_id`, `anonymous_id`, `user_id` and `session_id`. Use a random install id or an anonymous id, not a name or an email. If an email does arrive in an identity field, it is stored as a stable pseudonym instead. See [Privacy for senders](/reference/privacy). ## Props `props` holds everything else worth charting: strings, numbers and booleans. Low-cardinality string props, such as `plan` or `platform`, become breakdowns automatically. Any top-level field the API doesn't know moves under `props`, so `{"event": "signup", "plan": "pro"}` and `{"event": "signup", "props": {"plan": "pro"}}` are stored the same way. ## Unparsed Anything that can't become a row, such as invalid JSON, a binary body or a line that doesn't fit your [text template](/api/templates), lands in the environment's **Unparsed** tab with a reason. It is kept for 7 days. When you change the template, or choose **Read again**, it is read with the current rules. ## Next steps * [Sending data](/api/ingest): the endpoint, headers and body shapes. * [Events and log objects](/api/events): every field and its rules. * [Privacy for senders](/reference/privacy): what not to send, and what is masked for you. --- --- url: https://simpledatalogs.com/docs/guide/boards.md description: >- Build dashboard boards that answer feature questions — how many users, what share of users, of those who did A how many did B — from the events your app already sends. --- # Boards Next to an environment's **Overview** you can add up to 8 **boards**: an emoji, a name and up to 12 widgets. Click **New board**, pick a suggested board, describe one in a sentence or start blank, then **Add widget** or **Ask** for more. **Add widget** opens on questions such as "What share of users used \_\_\_?" or "Of users who \_\_\_, how many \_\_\_?"; each fills only its blanks from the events you send. The kind tiles sit under **Custom shape**. Every number comes from your events, never from a model. The assistant only picks widgets; you can edit anything it builds. ## Widget kinds | Kind | Shows | |---|---| | Number | One value and its change: a count, active users, a sum or average, a **reach** or a **rate** | | Line | A trend over time; up to 4 lines on one chart | | Stacked bars | A trend split by a property or by log level | | Top list | The busiest users, devices, days, events or property values | | Donut | How rows, or users, split across a property's values | | Recent rows | The newest matching rows | | Conversion | Of users who did one thing, how many did the next | | Retention | Of users who started (installed, signed up) in the range, the share back on day 1, 7 and 30 | "Users" means devices: an app that sends only a device id has one user per device. Send `user_id` too and a conversion can count signed-in users instead. ## Three questions about any feature A worked example with a real app's events, step by step, is on [Feature discovery](/guide/feature-discovery). | Question | Widget | Example | |---|---|---| | Did users find it? | Number, measure **reach** | 18 % of users swiped the moon | | How did they get there? | Donut by the event's `source`, counting users | 52 % from the menu, 41 % by shortcut | | Did it work for them? | Number, measure **rate**, or a conversion | 31 % of feeling-good jams were quit within 5 s | **Reach** is the users who sent the event as a share of every active user in the range. **Rate** is the share of the event's rows that also meet extra conditions. Both show the percentage, the counts behind it ("412 of 1,084 users") and the change in percentage points against the previous range. ## Conditions Any widget can keep only rows that meet up to 3 conditions, all of which must hold: | Op | Means | Value | |---|---|---| | `eq`, `neq` | is, is not | text (booleans are `"true"` and `"false"`) | | `lt`, `lte`, `gt`, `gte` | <, ≤, >, ≥ | a number | A value that isn't a number never passes a comparison. Any property works, even one that isn't in the breakdowns yet. Conditions read raw events, so a long range may be refused; the card then asks for a shorter one. ::: tip Send buckets for splits Splits only list properties with a few values: * A property can be a split when it has at most 20 distinct values over the last 7 days. One with 21 or more (23 languages, say) is left out of the split lists, but still works in conditions. * At most 24 event-and-property pairs are offered; the busiest pairs win. When you want to split by a distance, a length or a long list, send a bucket (`distance_bucket: "1_12_months"`, `language_group: "devanagari"`) next to the raw value, and use the raw value in conditions. ::: ## Conversions A conversion has 2 to 4 steps, each an event with its own optional conditions. A user counts at a step only after doing the steps before it, in order, within the window: 1 hour, 24 hours (the default) or 7 days from the first step. Two steps show a percentage; three or four show a funnel with each step's share and the drop between steps. Conversions read raw events, so they cover at most 90 days; on Year or Lifetime they show the last 90 days and say so. ## Retention "Do users come back after \_\_\_?" builds a retention: the start event (say `app_installed`) and what counts as coming back (any event, or one such as `app_opened`). * The cohort is everyone whose first start event falls in the dashboard's range. * Day N is a local day in the project's time zone: a user counts on day 7 when they sent the return event on the seventh day after their start day. * Each day only counts users whose day N is already over, so a range of the last few days shows day 1 but not day 30 yet. * The headline is day 7, compared with the previous range. ## Shares "Which shares bring installs?" counts your app's shared links: shares, links opened and installs from shares, with the sharers who brought the most installs. It needs a `share_id` on the share event, the share page event and `app_installed`; see [Share links and installs](/guide/share-links). * **The app sends on a share:** the share event, such as `app_shared`, from this environment. * **Share page tracked in:** this environment, or another environment of the organization (your website project). * **Share page event:** such as `share_card_view`; leave it empty to skip opens. * **Rank:** the sharers who brought the most installs, or single shares with their sharer, opens and installs. * The caption says how many installs were matched by IP address, and how installs changed against the previous range. Installs from shares made before the range show as **Earlier shares**. * Shares cards read raw events, so they cover at most 90 days (the last 90 on Year or Lifetime). The assistant doesn't write them. ## The widget JSON The builder and the assistant write the same widget JSON: ```json { "kind": "number", "event": "jam_stopped", "measure": "rate", "filters": [{ "property": "source", "op": "eq", "value": "feeling_good" }], "rate_filters": [{ "property": "duration_s", "op": "lt", "value": 5 }], "title": "Feeling-good jams quit within 5 s" } { "kind": "line", "series": [ { "event": null, "measure": "unique", "title": "Active users" }, { "event": "screenshot_taken", "measure": "count", "title": "Screenshots" } ] } { "kind": "conversion", "steps": [{ "event": "list_view_opened" }, { "event": "date_picked" }], "window": "1h", "by": "$device" } ``` Lines measured differently (a count beside users, or a reach beside a count) get a second axis. ## Limits 8 boards per environment, 12 widgets per board, 3 conditions per list, 4 lines per chart and 4 steps per conversion. Board numbers refresh every minute. Custom dashboards per plan are on [Limits](/reference/limits). --- --- url: https://simpledatalogs.com/docs/guide/feature-discovery.md description: >- Find out whether users discover a feature, how they reach it and whether it works for them, with three question widgets and no setup, shown on a real Mac screenshot app. --- # Feature discovery Every feature raises three questions. A board answers each one with a single widget, picked as a question in plain words: | Question | In plain words | Widget question | |---|---|---| | **Reach** | Did users find it? | What share of users used \_\_\_? | | **Path** | How did they get there? | How is \_\_\_ split by \_\_\_? | | **Stick** | Did it work for them? | What share of \_\_\_ had \_\_\_? · Of users who \_\_\_, how many \_\_\_? | There are no cohorts, no event definitions and no query language. Your app sends plain events with plain properties, and every picker lists only what your environment has actually sent. "Users" means devices: an app that sends only a device id has one user per device. ## The example: a Mac screenshot app XKD Screenshotter is a menu bar app for macOS. It sends these events with the [Swift SDK](/sdks/swift): | Event | Properties | Sent when | |---|---|---| | `app_installed` | `source` (`website`, `homebrew`, `setapp`) | The first launch after install | | `app_launched` | `launch_source`, `cold_start_ms` | Every launch | | `screenshot_taken` | `type` (`area`, `full_screen`, `window`), `trigger` (`shortcut`, `menu_bar`, `history_panel`), `format`, `duration_ms` | Every capture | | `screenshot_saved` | `destination` (`clipboard`, `file`, `both`) | A capture is kept | | `history_panel_opened` | `source` (`menu_button`, `shortcut`, `dock_click`) | The History panel opens | | `ocr_used` | `source`, `found_text` (`true`, `false`), `chars_bucket` | Text is read from a capture | ```swift SimpleDataLogs.track("history_panel_opened", properties: ["source": "shortcut"]) SimpleDataLogs.track("ocr_used", properties: ["source": "capture_toolbar", "found_text": true, "chars_bucket": "50_500"]) ``` Two habits make every question below possible: * **Send a `source` (or `trigger`) property** on any feature that can be opened in more than one way. That property is the path. * **Send buckets, not raw numbers, for splits.** `chars_bucket: "50_500"` splits; a raw character count only works in conditions and averages. ## Reach: did users find it? On a board, click **Add widget**, then **What share of users used \_\_\_?** and pick `history_panel_opened`. The card shows the share of active users who opened History in the range, the counts behind it ("874 of 1,902 users") and the change in percentage points against the previous range. Pick `ocr_used` for a second card and you can see at a glance that OCR reaches far fewer users than History. To watch reach move after a change (a new onboarding tip, a new ad), add the same event under **More shapes** as a **Line** with the measure **Reach**. ## Path: how did they get there? **How is \_\_\_ split by \_\_\_?** with `history_panel_opened` and `source`, counting **Users**, shows how History is opened: for example 52 % from the menu button, 41 % by shortcut, 7 % from the Dock. The same question with `screenshot_taken` and `trigger` shows whether people capture with the keyboard shortcut or the menu bar. A property with up to 6 values draws a donut; more values draw a top list. ## Stick: did it work for them? **What share of \_\_\_ had \_\_\_?** measures one outcome among an event's rows. Pick `ocr_used`, then the condition `found_text` is `true`: the card says what share of OCR attempts found text. Conditions take a property, a comparison and a value. Text properties offer **is** and **is not** with the values your app has sent; numeric properties add **<**, **≤**, **>** and **≥**. Each list holds up to 3 conditions, and all must hold. **Of users who \_\_\_, how many \_\_\_?** follows users from one step to the next. With `app_installed` then `screenshot_taken` within 24 hours, the card shows how many new installs took a first screenshot that day. Add up to 4 steps for a funnel, and give any step its own conditions, such as `screenshot_taken` where `trigger` is `shortcut`. Conditions and conversions read raw events, so a long range may be refused; the card then asks for a shorter one. A conversion counts each user once. ## Suggested boards **New board** offers up to three boards built from what your environment sent in the range, with no AI build used: | Board | Holds | |---|---| | **Daily use** | Active users, your busiest event, both on one line per day, and the busiest days | | **Feature reach** | A reach card for each of your 6 busiest feature events (launches, installs, sessions and page views are left out), each beside a donut by its best few-valued property, preferring `source`, `trigger` or `mode` | | **Conversions** | Up to 4 conversions between events where at least 20 % of the first event's users also sent the second, and the second is the narrower one | Click one and it is added as a normal board: rename it, edit any widget, or remove what you don't need. For the screenshot app, Feature reach puts `screenshot_taken` by `trigger`, `history_panel_opened` by `source` and `ocr_used` by `source` side by side with their reach, which answers reach and path for every feature in one click. ## Next * [Boards](/guide/boards) lists every widget kind, the condition ops and the widget JSON the assistant writes. * [Swift SDK](/sdks/swift) shows how to send events and properties from a Mac or iOS app. --- --- url: https://simpledatalogs.com/docs/guide/web-analytics.md description: >- Visitors, visits, bounce rate, sources, journeys and funnels for your website, without cookies, next to your logs and events. --- # Web analytics Every environment has a **Sessions** tab. Once a site sends `page_view` events, it shows what Plausible or Fathom show, next to your logs and events. ## Set it up Add the script tag (see [Plain HTML](/quickstarts/html)) or turn on autocapture in the [JavaScript SDK](/sdks/javascript#autocapture). Any sender works too: a `page_view` event with a `path` prop, sent from the browser with a public key. ## What you see | Number | Meaning | |---|---| | Users | Distinct visitors in the range (see [Visitors without cookies](#visitors-without-cookies)) | | Sessions | Runs of activity with no 30-minute gap, without passive ones | | Active time | Time someone was using the app or site per session: gaps over 5 minutes don't count, and SDKs can send the exact time as `engaged_ms` | | Passive sessions | Sessions where every event came from the background (`app_state: "background"`), like a sync or an idle menu-bar app | | Pageviews | `page_view` events | | Views / visit | Pageviews per visit with at least one page view | | Bounce rate | Visits with one page view and no other event | | Live | Visitors with an event in the last 30 minutes | Below the chart: * **Pages:** top pages by views, entry pages and exit pages. * **Sources:** channels, referrers, UTM sources and UTM campaigns. * **Countries**, and **devices**, operating systems and browsers. * **Journeys:** the first three pages of each visit, as a flow chart. * **Funnels:** two to six steps in order, each a page (`/pricing`) or an event name (`signup`). A visitor counts at a step once they did the steps before it within a day. * **Recent visits:** open one to see its pages and events in order, with times. Click any row to filter the whole page by it, for example only visits from Google, or only phones. Filters stack; click a filter's chip to remove it. When every deployment of the environment is an app (iOS, Android, macOS, Windows, Linux or server), the page-only numbers and lists (pageviews, bounce, pages, sources, journeys, browsers) are left out. To mark background work, send `app_state: "background"` with the event; `"active"` or nothing means someone was using it. ## Visitors without cookies The script tag stores nothing in the browser. We count visitors on our side instead: * For each request, ingest hashes the sender's IP address and User-Agent together with your environment and a **salt that changes every day**. Only the hash is kept, never the address. * The salt of each day is deleted after it, so a visitor can't be followed from one day to the next, even by us. * A visitor therefore counts once per day. Over 30 days, someone who came on three days counts three times, as in Plausible. * If your app sets its own `device_id`, `install_id` or `anonymous_id`, that id is used instead, and counts exactly across days. Requests with a secret key, and server senders like log drains, never get a hash. ## Channels A visit's channel comes from its first page view: the UTM medium first, then the referrer. | Channel | When | |---|---| | Paid search, Paid social, Paid other | `utm_medium` is `cpc`, `ppc`, `paid`, `paid_social`, `display` … | | Email | `utm_medium=email` or `newsletter`, or a webmail referrer | | AI assistants | ChatGPT, Perplexity, Claude, Gemini, Copilot | | Organic search | Google, Bing, DuckDuckGo, Yahoo, Yandex, Baidu, Ecosia, Brave Search … | | Organic social | Facebook, Instagram, X, LinkedIn, Reddit, YouTube, Hacker News, Product Hunt … | | Referral | Any other site | | Campaign | A UTM source without a referrer | | Direct | Nothing at all | ## Good to know * **Bots don't count.** Crawlers, uptime monitors, headless browsers and HTTP libraries are recognized by their User-Agent. Their events stay on the **Logs** and **Dashboard** tabs. * **Numbers settle within a minute.** Visits are rebuilt from your stored events every minute, so late or retried events are counted once. * **Retention:** totals, the chart and the unfiltered lists are kept for good. Filters, journeys, funnels and recent visits cover the days your plan keeps raw events. * **Visitors over long ranges** come from a compact count that can differ from the exact number by well under 1 % on large sites. * Do Not Track and Global Privacy Control turn the script tag's automatic capture off. --- --- url: https://simpledatalogs.com/docs/guide/share-links.md description: >- Count how many people your users' shared links brought in, and which installs came from which share, with share_id and app_installed. --- # Share links and installs When your app lets people share a link, simpledatalogs can tell you how many shares there were, how many links were opened, and which installs came from a share. ## What to send | Where | Event | Props | |---|---|---| | Your app, when someone shares | `app_shared` (any name you like) | `share_id`: a random id you make per share, also put on the link | | Your share page, in the browser | any event, such as `share_card_view` | `share_id` read from the link | | Your app, on its first launch | `app_installed` | nothing extra, or `install_referrer` on Android | Make the share id short and random, such as 8 letters and digits (`A-Z`, `a-z`, `0-9`, `_`, `-`, up to 64). Never use a device id or a user id as the share id. ```text https://example.com/share/?s=K7F2mQ9x ``` ## How an install finds its share simpledatalogs writes `share_id` and `attribution` onto `app_installed` the moment it arrives. The first match wins: | `attribution` | When | How exact | |---|---|---| | `link` | Your app already put `share_id` on `app_installed`, for example from a deep link | Exact | | `referrer` | Android: the Play install referrer carries `share_id` (see below) | Exact | | `ip` | A browser opened a link with that `share_id` from the same IP address within the last 24 hours | A good guess | The `ip` match works across the projects and environments of one organization, so a share page tracked in your website project matches an install in your app project. It is a guess: people on the same Wi-Fi or mobile network share an address, and an install more than a day after the click isn't matched. Link previews (Messages, Slack, WhatsApp) are bots and never count as opens. ## Android: the Play install referrer Put the share id in the Play Store link's `referrer`: ```text https://play.google.com/store/apps/details?id=com.example.app&referrer=share_id%3DK7F2mQ9x ``` On the first launch, read it with the SDK and send it on `app_installed`: ```kotlin SimpleDataLogs.installReferrer(context) { installReferrer -> SimpleDataLogs.track("app_installed", mapOf("install_referrer" to installReferrer)) } ``` simpledatalogs reads `share_id` out of the referrer. ## On a board Add a card with **Which shares bring installs?** (see [Boards](/guide/boards#shares)). It shows the totals in one line, such as "20 shares by 5 users · 12 opened · 2 installs (1 by IP match)", and a table of the sharers who brought the most installs. Other cards work too: * **Installs from shares:** a Number of `app_installed` where `share_id` is set, broken down by `attribution`. * **Best shares:** `app_installed` broken down by `share_id`. ## Privacy * The IP address is never stored. Ingest keeps only a hash of it, under a salt that changes every day and is deleted the next day. * That hash is kept for two days, only for browser visits that carry a `share_id`, and never in your events. * Projects with **No device IDs** turned on get no hash, so their installs are matched only by `link` or `referrer`. * Say in your privacy policy that an install can be matched to the shared link that led to it. See [Privacy for senders](/reference/privacy). --- --- url: https://simpledatalogs.com/docs/guide/observability.md description: >- Requests, jobs, scheduled tasks, slow queries, outgoing calls, traces and grouped exceptions for your app, next to its logs and events. --- # App and Issues Once your app sends telemetry, two tabs appear on its environment: **App** and **Issues**. With the [Laravel SDK](/sdks/laravel#telemetry) that happens as soon as a key is set; other stacks can send [telemetry objects](/api/telemetry) directly. ## App | Section | What it shows | |---|---| | Overview | Requests, p95, error rate, exceptions, failed jobs and slow queries, each with its change; throughput and latency (p50, p95, p99) against the previous period; slowest routes, top issues, busiest queries, failing jobs, outgoing hosts | | Requests | Every route pattern with count, p95, error rate and trend; a route opens its own page | | Jobs · Schedule · Commands | Runs, failures and durations per job class, task and command; Schedule marks a task **Late** when its last run is older than one and a half intervals | | Queries | Normalized SQL with count, total and slowest time | | Outgoing | Calls by host, then by `METHOD host` | | Traces | Search by route or job, status, duration, errors, user or trace id; a row opens the waterfall | Sections appear only when your app sends that kind of work. p95 and the other percentiles are within about 1 %; counts are exact. ## Traces A trace shows one request or job as a waterfall: every query, outgoing call, cache step and mail in time order, with repeated calls folded as `×37`. A job dispatched by a request shows in the same trace, so you can follow a checkout from the click to the invoice email. **Open logs** lists the log lines written during it. ## Issues Exceptions with the same cause group into one issue. They come from [telemetry objects](/api/telemetry#exceptions), and from errors sent through [Sentry SDKs](/integrations/sentry) or OpenTelemetry logs with an `exception.type`. * Filter by Open, Resolved, Ignored, Regressions and Assigned to me; search and sort by last seen, occurrences or users. * **Assign**, **Resolve** (with a note, or "in next release"), **Ignore** and **Reopen**, one at a time or in bulk. Viewers can read but not change issues. * A resolved issue that happens again in a newer release reopens as a **regression**. * The issue page shows the stack trace with your own frames in bold, occurrences over time, breakdowns by route, release, OS, browser and country, and each occurrence's trace. ## What it costs A request with everything inside it is usually one event. See [What counts toward your plan](/api/telemetry#what-counts-toward-your-plan). --- --- url: https://simpledatalogs.com/docs/guide/alerts.md description: >- Threshold alerts by email, and on paid plans in Slack, Discord or a signed webhook, with the payload and how to check its signature. --- # Alerts and channels An alert watches one count in an environment: an event name, a level, or both, **above** or **below** a number in a window (5 minutes to 24 hours). "Below 1 in an hour" catches silence. You get one message when an alert fires and one when it's back to OK. Alerts that change in the same check are grouped into one message. Snoozing an alert silences it for 24 hours; its state keeps updating. | | Free | Starter | Pro | Scale | |---|---|---|---|---| | Checked every | 5 minutes | Minute | Minute | Minute | | Email to owners | ✓ | ✓ | ✓ | ✓ | | Slack, Discord, webhooks | — | ✓ | ✓ | ✓ | ## Channels Owners add channels on an environment's **Alerts** page, under **Channels**. A channel belongs to the organization, so every environment can use it. Pick the channels each alert sends to in the New alert dialog or in the alert's menu under **Channels**. Email to the owners always goes out too. * **Slack:** create an app with an [incoming webhook](https://api.slack.com/messaging/webhooks) and paste its `https://hooks.slack.com/services/…` URL. * **Discord:** in the channel's settings, open **Integrations → Webhooks**, create one and paste its `https://discord.com/api/webhooks/…` URL. * **Webhook:** any public HTTPS URL. We sign every request (below). **Send test** posts a test message right away and shows whether it worked. Each channel shows **Working** or **Failing** after its last delivery. Webhook URLs and signing secrets are stored encrypted. Only owners see a channel's host and can test, delete or read the signing secret. After a downgrade to Free, channels stay but pause; email keeps working. ## Delivery * A message is sent within a minute of the check, never holding up the email. * A timeout, `408`, `429` or `5xx` is retried with backoff (30 seconds, doubling, up to an hour) for about two hours. * Any other `4xx` stops the retries. Fix or re-add the channel. * Redirects are not followed. Each request has 5 seconds to connect and 10 to answer. * A webhook host must resolve only to public addresses, checked before every send. Private, loopback and link-local addresses are refused. * One message lists at most 20 alerts, then "…and N more". ## Webhook payload A `POST` with `content-type: application/json`: ```json { "type": "alerts", "delivery_id": "01928f3e-5c1a-7b2e-9f00-3c4d5e6f7a8b", "organization_id": "01928f3e-0000-7000-8000-000000000001", "created_at": "2026-10-19T14:05:00.123456Z", "alerts": [ { "id": "01928f3e-0000-7000-8000-0000000000aa", "name": "Errors", "state": "firing", "environment_id": "01928f3e-0000-7000-8000-0000000000e1", "event": null, "level": "error", "comparison": "above", "threshold": 50, "window_minutes": 15, "value": 73, "firing_since": "2026-10-19T14:05:00Z", "url": "https://simpledatalogs.com/environments/01928f3e-0000-7000-8000-0000000000e1/alerts" } ], "more_count": 0 } ``` * `state` is `firing` or `ok` (resolved). `firing_since` is `null` once resolved. * `delivery_id` stays the same across retries; use it to drop duplicates. * **Send test** posts `"type": "test"` with an empty `alerts` list. * Answer with any `2xx` quickly; the body is ignored. ## Checking the signature Every webhook request carries three headers: | Header | Value | |---|---| | `x-sdl-timestamp` | Unix seconds when the request was signed | | `x-sdl-signature` | `v1=` and the hex HMAC-SHA256 of `timestamp.body` | | `x-sdl-delivery` | The payload's `delivery_id` | The key is the channel's signing secret (`whsec_…`), shown when you add the channel and again from its menu under **Signing secret**. Compute the HMAC over the **raw** body, before any JSON parsing, compare in constant time, and refuse timestamps older than 5 minutes. ::: code-group ```js [Node] import crypto from 'node:crypto' export function isValidSignature(rawBody, headers, secret) { const timestamp = headers['x-sdl-timestamp'] const signature = headers['x-sdl-signature'] ?? '' if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false const expected = 'v1=' + crypto.createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex') return signature.length === expected.length && crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected)) } ``` ```python [Python] import hashlib, hmac, time def is_valid_signature(raw_body: bytes, headers, secret: str) -> bool: timestamp = headers["x-sdl-timestamp"] if abs(time.time() - int(timestamp)) > 300: return False signed = timestamp.encode() + b"." + raw_body expected = "v1=" + hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest() return hmac.compare_digest(headers.get("x-sdl-signature", ""), expected) ``` ```php [PHP] function isValidSignature(string $rawBody, array $headers, string $secret): bool { $timestamp = $headers['x-sdl-timestamp']; if (abs(time() - (int) $timestamp) > 300) { return false; } $expected = 'v1=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret); return hash_equals($expected, $headers['x-sdl-signature'] ?? ''); } ``` ::: ## Limits | | Every paid plan | |---|---| | Channels per organization | 20 | | Alerts per message | 20 | | Connect / answer timeout | 5 s / 10 s | | Retries | About 2 hours | Alert limits per plan are on [Plans and limits](/reference/limits). --- --- url: https://simpledatalogs.com/docs/guide/spend.md description: >- Price what you pay per call, such as an LLM or SMS API, and see the spend by day, deployment and price line from exact counts. --- # Spend Your events often stand for something you pay for: a call to an LLM, an SMS, a lookup in a paid API. The **Spend** tab turns those events into money, so you can see what you spent, when, and where it came from. For example: $4 spent in the last 4 days across web and apps, with the iOS app 60% of it. ## Set prices Owners set prices in the **Prices** dialog on an environment's **Spend** tab. Before any price exists, the tab shows **Add a price**; afterwards, the **Prices** button beside the range opens the same dialog. Each price table has one event, split by one or two of its properties, and one line per value: * **Event:** the event you pay for, such as `llm_call`, or **Any event** to price log lines and events alike. * **Split by:** the property that decides the cost, such as `model`. Use **+ add a second split** for a second property, such as `country` for `sms_sent`. The lists show the properties the environment has seen; before there are any, type the name. * **Lines:** pick a value, then type its cost per call. For `llm_call` split by `model`, that is `large` at `0.002` and `small` at `0.0004`. **Other value…** lets you type a value the environment hasn't sent yet. **+ Add a line** adds another. * **Currency:** the small list next to **Save prices**. USD is the default. The dialog opens with the busiest event, its first property and its busiest value filled in, so most tables only need a cost. A line missing its value or cost is marked when you save, and nothing changes until every line reads. Each value, or pair of values, has one line, and a table holds up to 500 lines. Values match exactly. Calls whose values have no line are counted as "no price" and cost nothing; **Add one** under **By price line** opens the dialog with a new line for the busiest of them. Members and viewers open the same dialog to read the prices, but can't change them. ## Read the spend The tab follows the range and deployment tabs like the dashboard: * **Spend** for the range, with its change against the previous period of the same length. * **Calls:** the priced calls in the range. * **Biggest driver:** the deployment with the most spend on the All tab, or the price line with the most spend on a deployment's tab, with its share. * **Spend per day** (per hour for Today) as bars. * **By deployment** on the All tab: the top five deployments by spend and their share of the total. * **By price line:** each line's values, calls, spend and share, and how many calls had no price. **How it works** next to the Prices button plays a short demo of the steps above. Spend is exact: each combination's deduplicated count times its unit cost, added up without rounding. The same numbers are available to AI assistants through the [`spend_summary`](/mcp/tools#spend-summary) tool. --- --- url: https://simpledatalogs.com/docs/guide/public-counters.md description: >- Publish one number from your events, like app installs all time or screenshots taken in the last 7 days, as JSON or an embeddable badge. --- # Public counters Show one number on your own site or README: "app installs, all time" or "screenshots taken in the last 7 days". Nothing else about your events or users is shown. ## Publish a counter Owners publish on an environment's **Counters** tab. Click **Publish a counter**, then pick: * **Event:** any event name the environment has seen. Start typing to search. * **Window:** All time, Last 7 days, Last 30 days or Today. * **Public label:** filled in from the event and window, e.g. "screenshot taken in the last 7 days". Change it to anything up to 60 characters. The dialog previews the badge with the current count. Each environment can publish 10 counters. Days follow the project's time zone, as on the dashboard. **Last 7 days** is today and the 6 days before it; **Today** starts at local midnight. The count is the same number the dashboard shows for that range. **Edit** changes a counter's window and label; the event and link stay the same. **Unpublish** stops the link and badge within a minute. Members and viewers see the counters but can't publish, edit or unpublish them. ## JSON `GET https://simpledatalogs.com/c/` ```json { "label": "screenshots taken in the last 7 days", "window": "7d", "count": 1204, "display": "1.2K", "as_of": "2026-10-20T09:15:00Z", "powered_by": "simpledatalogs", "powered_by_url": "https://simpledatalogs.com/?ref=counter" } ``` `window` is one of `all_time`, `7d`, `30d` or `today`. `display` is the short form the badge uses, rounded down. ## Badge `GET https://simpledatalogs.com/c//badge.svg` draws `[label | 1.2K | simpledatalogs]`. The Counters tab has the HTML to paste, linking back to simpledatalogs. ## Caching and limits * Any site may fetch both URLs (`Access-Control-Allow-Origin: *`). * Answers are cached for 60 seconds, so the number refreshes once a minute. * Each address may make 300 requests a minute; more answer `429` with `Retry-After`. * An unknown or unpublished token answers `404`. --- --- url: https://simpledatalogs.com/docs/guide/ga4-import.md description: >- Bring your Google Analytics 4 history into simpledatalogs by CSV or Google sign-in, shown beside your own numbers and marked as imported. --- # Import from GA4 Leaving Google Analytics 4? Bring its history along, so your charts don't start at zero. Imported numbers sit beside your own, always marked **Imported from GA4**, and never count twice. Owners import on an environment's **Import** tab. Members see the imported numbers; viewers see them on the dashboard. ## What comes over Daily totals, not single events: | From GA4 | Shows as | |---|---| | Event count by event name | Added to the **Events** card, the events table (with a **GA4** badge) and a separate "Imported from GA4" series in the chart | | Views, sessions | The **Imported from GA4** card | | Top pages, sources / mediums, countries, device categories | Lists on the same card | GA4 users are not devices, so the **Active devices** card never includes them. Imported numbers show on the All tab of 7-, 30- and 90-day ranges, not on deployment tabs, Logs or device pages. Imported numbers **don't count toward your plan**. ## The cutoff day Imported days show only **before** the cutoff day, so they never overlap the events you send us. It starts at the day of your first own event (or the day of your first import, if you have none yet). Change it on the Import tab. ## Upload a CSV 1. In GA4, open **Explore** and start a **Free form** exploration. 2. **Rows:** `Date`, plus at most one of `Event name`, `Page path and screen class`, `Session source / medium`, `Country`, `Device category`. 3. **Values:** any of `Event count`, `Views`, `Sessions`, `Active users`. 4. Set the date range, then **Export data → CSV**. 5. On the Import tab, click **Import CSV** and pick the file. One file per report, up to 20 MB. The card's page view and session totals come from a report with `Date` alone as its row; the events card uses the `Event name` report when there is one. We check the file before importing it and say which line is wrong, if one is. GA4's `#` header lines, the totals row and anything after the first table are skipped. A standard report's CSV download works too, as long as its first table has a `Date` column. Uploading the same report again replaces its numbers; it never adds them twice. Per day, the 500 busiest values of a dimension are kept. ## Connect Google Analytics Where it's switched on, **Connect** on the Import tab signs you in with Google and asks for read-only access to your Analytics reports (`analytics.readonly`). Pick a property and a start day (two years back by default), then **Import**. * The import reads six daily reports in 90-day windows and shows its progress on the page. A small property takes a few minutes. * If GA4's daily quota runs out, it waits an hour and carries on where it stopped. * If GA4 hid small numbers (thresholding) or folded rare values into `(other)`, the import says so. * **Disconnect** revokes our access at Google. Imported numbers stay until you remove them. We never ask to change anything in your Google Analytics. ## Remove imported numbers **Remove** on the Import tab deletes every imported number of the environment. Your own events are not touched. --- --- url: https://simpledatalogs.com/docs/guide/exports.md description: >- Download events and log lines as gzipped CSV or JSON lines, and on Scale write them every hour or day to your own S3, R2, B2 or MinIO bucket. --- # Exports Take your raw rows with you: download them, or have them written to your own bucket on a schedule. | | Free | Starter | Pro | Scale | |---|---|---|---|---| | CSV and JSON lines downloads | — | ✓ | ✓ | ✓ | | Scheduled exports to your bucket | — | — | — | ✓ | Owners and members export on an environment's **Exports** tab. Viewers can't: exports move data out. Only owners set up a bucket. ## Downloads Click **New export**, then pick: * **Format:** CSV, or JSON lines (one JSON object per line, also called NDJSON). * **Rows:** everything, log lines only, events only, or one event name. * **From and To:** days in your time zone. Days past your plan's retention are already gone. * **Level, deployment and message text**, as on the Logs page. The **Export** button on the Logs page and on an event's page opens the same dialog with that page's filters. The file is built in the background. We email you when it's ready, and the list shows **Ready** with a **Download** button. Files are gzipped (`.csv.gz`, `.ndjson.gz`) and kept for 3 days. Each download link works for 5 minutes, so it can't be passed around for long. Every export is logged with who asked, the filters, the row count and each download. ## Columns Both formats have the same columns, in this order. Times are UTC, ISO 8601 with milliseconds. | Column | What it holds | |---|---| | `event_id` | The row's id; a re-sent row keeps it, so it's safe to dedupe on | | `ts` | When it happened, as the sender said | | `received_at` | When we received it | | `name` | Event name; empty for a log line | | `level` | Log level, or empty | | `message` | The log message, or empty | | `device` | The device or visitor id | | `user_id` | The signed-in user, if sent | | `country` | Two-letter country, from the sender's address | | `deployment` | The deployment whose key sent it | | `props` | Properties: a JSON object in JSON lines, JSON text in CSV | In CSV, a cell that starts with `=`, `+`, `-` or `@` gets a leading `'`, so a spreadsheet never runs it as a formula. Use JSON lines when you need values byte for byte. ## Scheduled exports (Scale) An owner clicks **Set up** under **Scheduled to your bucket** and fills in: * **Endpoint:** your storage's `https://` URL, host only. * **Bucket, region** and an optional **folder** (a key prefix). * **Access key ID and secret key.** The secret is stored encrypted and never shown again; leave it blank when editing to keep it. * **Every:** hour or day. **Format:** JSON lines or CSV. * **Path-style URLs:** on for MinIO and some R2 or B2 setups. **Test connection** writes a small file and deletes it, so you know the keys can write before you save. ### How files are cut * Each file covers one hour, or one UTC day, of **when we received** the rows. Every row is in exactly one file, even when its `ts` is days old. * A window is written an hour after it closes, so rows still on their way make it in. * Copies of a re-sent row are written once when both arrive before their window is written. A later copy can land in a second file, so dedupe on `event_id`. * Files land at `////
/--0001.ndjson.gz`, e.g. `sdl/0192…/2026/10/19/20261019T0000Z-20261020T0000Z-0001.ndjson.gz`. A window over 1,000,000 rows is split into `-0002`, `-0003` and so on. * An empty window writes no file. * The first file starts with the hour or day you set it up in. ### When something fails The next run starts where the last good one ended, so nothing is skipped and a retried window overwrites the same files. The status shows **Failing** with the last error, and after 3 failures in a row the owners get one email. **Pause** stops the runs; **Resume** catches up from where it stopped, up to 48 windows per run. Scheduled exports pause while the organization is over its monthly quota or off Scale, and pick up again from the same point. A row that reaches us more than an hour after it was received (only during an outage on our side) is stored but not added to a file that's already written. Its `event_id` lets you find it in a download. ## Bucket setup ### AWS S3 1. Create a bucket, e.g. `acme-simpledatalogs` in `us-east-1`. 2. Create an IAM user with this policy, then an access key for it: ```json { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": ["s3:PutObject", "s3:DeleteObject"], "Resource": "arn:aws:s3:::acme-simpledatalogs/sdl/*" } ] } ``` 3. Endpoint `https://s3.us-east-1.amazonaws.com`, region `us-east-1`, folder `sdl`, path-style off. `DeleteObject` is only used by **Test connection**, on its own test file. ### Cloudflare R2 1. Create a bucket, then an **R2 API token** with **Object Read & Write** on that bucket. 2. Endpoint `https://.r2.cloudflarestorage.com`, region `auto`, path-style on. ### Backblaze B2 1. Create a private bucket and an application key with write access to it. 2. Endpoint `https://s3..backblazeb2.com` (e.g. `us-west-004`), the same region, path-style on. ### MinIO 1. Create a bucket and an access key whose policy allows `s3:PutObject` and `s3:DeleteObject` on it. 2. Endpoint: your server's public `https://` URL, any region (e.g. `us-east-1`), path-style on. The endpoint must resolve only to public addresses; private, loopback and link-local addresses are refused, checked again before every run. ## Limits | Limit | Value | |---|---| | Rows per download | 5,000,000 (more is refused; pick fewer days or add a filter) | | Uncompressed size per download | 2 GB | | Downloads queued or running per organization | 2 | | Download files kept | 3 days | | Download link lifetime | 5 minutes | | Scheduled file part | 1,000,000 rows | | Scheduled windows per run | 48 | | Wait after a window closes | 1 hour | --- --- url: https://simpledatalogs.com/docs/quickstarts/nextjs.md description: >- Add simpledatalogs to a Next.js app in one minute, with page views, sessions and errors captured by one line. --- # Next.js ## Install ```sh npx simpledatalogs init ``` The command finds your Next.js app, adds the key to `.env.local`, writes `instrumentation-client.ts`, shows you the diff first, and sends a test event. Then install the package: ```sh npm install simpledatalogs ``` ## Or by hand ```sh # .env.local NEXT_PUBLIC_SIMPLEDATALOGS_KEY=sdl_pub_… ``` ```ts // instrumentation-client.ts (next to app/, or in src/) import { init } from 'simpledatalogs' init({ key: process.env.NEXT_PUBLIC_SIMPLEDATALOGS_KEY, autocapture: true }) ``` `instrumentation-client` runs in the browser before your app (Next.js 15.3 or later). On older versions, call `init()` once in a client component rendered by your root layout. ## Send everything, or only what you choose * **Everything:** `autocapture: true`, as above. * **Only your own events:** leave `autocapture` out and call `track()`: ```ts import { init } from 'simpledatalogs' export const sdl = init({ key: process.env.NEXT_PUBLIC_SIMPLEDATALOGS_KEY }) sdl.track('signup', { plan: 'pro' }) ``` ## In the first minute * `session_start`, then a `page_view` with `path`, `title`, `referrer` and any `utm_*` parameters. * A `page_view` for every client-side navigation (``, `router.push`, back and forward). * Uncaught errors and unhandled promise rejections on the **Logs** tab, with their stack. ## Autocapture defaults | Feature | `autocapture: true` | |---|---| | Page views (App and Pages Router navigation) | on | | Sessions (`session_id`, 30 idle minutes) | on | | Errors and unhandled rejections | on | | Outbound link clicks | off (`autocapture: { outboundLinks: true }`) | | Do Not Track / Global Privacy Control | respected: nothing automatic is sent | No cookies: the session id lives in `sessionStorage`. Every option is in the [JavaScript SDK](/sdks/javascript#autocapture) reference. --- --- url: https://simpledatalogs.com/docs/quickstarts/react-vite.md description: >- Add simpledatalogs to a React app on Vite in one minute, with page views, sessions and errors captured by one line. --- # React (Vite) ## Install ```sh npx simpledatalogs init npm install simpledatalogs ``` `init` adds the key to `.env.local` and two lines to `src/main.jsx` (or `.tsx`), after showing you the diff. ## Or by hand ```sh # .env.local VITE_SIMPLEDATALOGS_KEY=sdl_pub_… ``` ```jsx // src/main.jsx, after the other imports import { init } from 'simpledatalogs' init({ key: import.meta.env.VITE_SIMPLEDATALOGS_KEY, autocapture: true }) ``` ## Send everything, or only what you choose * **Everything:** `autocapture: true`. * **Only your own events:** drop it and track what matters: ```js // analytics.js import { init } from 'simpledatalogs' export const sdl = init({ key: import.meta.env.VITE_SIMPLEDATALOGS_KEY }) ``` ```jsx sdl.track('checkout_started', { items: cart.length }) ``` ## In the first minute * `session_start` and a `page_view` with `path`, `title`, `referrer` and `utm_*`. * A `page_view` on every React Router (or any `history.pushState`) navigation that changes the path. * Uncaught errors and unhandled rejections on the **Logs** tab. ## Autocapture defaults | Feature | `autocapture: true` | |---|---| | Page views, including SPA routes | on | | Sessions | on | | Errors and unhandled rejections | on | | Outbound link clicks | off | | Do Not Track / Global Privacy Control | respected | See the [JavaScript SDK](/sdks/javascript#autocapture) for every flag. --- --- url: https://simpledatalogs.com/docs/quickstarts/vue-nuxt.md description: >- Add simpledatalogs to a Vue (Vite) or Nuxt app in one minute, with page views, sessions and errors captured by one line. --- # Vue and Nuxt ## Install ```sh npx simpledatalogs init npm install simpledatalogs ``` `init` tells Vue and Nuxt apart and shows the diff before writing. ## Vue on Vite ```sh # .env.local VITE_SIMPLEDATALOGS_KEY=sdl_pub_… ``` ```ts // src/main.ts, after the other imports import { init } from 'simpledatalogs' init({ key: import.meta.env.VITE_SIMPLEDATALOGS_KEY, autocapture: true }) ``` ## Nuxt A client-only plugin (`app/plugins/` on Nuxt 4, `plugins/` before): ```ts // app/plugins/simpledatalogs.client.ts import { init } from 'simpledatalogs' export default defineNuxtPlugin(() => { init({ key: 'sdl_pub_…', autocapture: true }) }) ``` A public key is write-only and safe in the browser, so it can sit in the file. To keep it in the environment instead, add `runtimeConfig.public.simpledatalogsKey` to `nuxt.config` and read it with `useRuntimeConfig()`. ## Only your own events Leave out `autocapture` and call `track()`: ```ts const sdl = init({ key: 'sdl_pub_…' }) sdl.track('report_exported', { format: 'csv' }) ``` ## In the first minute * `session_start` and a `page_view` with `path`, `title`, `referrer` and `utm_*`. * A `page_view` on every Vue Router navigation that changes the path. * Uncaught errors and unhandled rejections on the **Logs** tab. Errors Vue catches itself go to `app.config.errorHandler`; forward them with `sdl.log('error', error)`. ## Autocapture defaults | Feature | `autocapture: true` | |---|---| | Page views, including router navigation | on | | Sessions | on | | Errors and unhandled rejections | on | | Outbound link clicks | off | | Do Not Track / Global Privacy Control | respected | --- --- url: https://simpledatalogs.com/docs/quickstarts/sveltekit.md description: >- Add simpledatalogs to a SvelteKit app in one minute, with page views, sessions and errors captured by one line. --- # SvelteKit ## Install ```sh npx simpledatalogs init npm install simpledatalogs ``` ## Or by hand ```sh # .env PUBLIC_SIMPLEDATALOGS_KEY=sdl_pub_… ``` ```js // src/hooks.client.js import { init } from 'simpledatalogs' import { PUBLIC_SIMPLEDATALOGS_KEY } from '$env/static/public' init({ key: PUBLIC_SIMPLEDATALOGS_KEY, autocapture: true }) ``` `hooks.client` runs once in the browser, so the client is created once. ## Only your own events ```js // src/lib/analytics.js import { init } from 'simpledatalogs' import { PUBLIC_SIMPLEDATALOGS_KEY } from '$env/static/public' export const sdl = init({ key: PUBLIC_SIMPLEDATALOGS_KEY }) ``` Import `sdl` only from browser code (components, `+page.svelte`), not from server load functions. ## In the first minute * `session_start` and a `page_view` with `path`, `title`, `referrer` and `utm_*`. * A `page_view` on every client-side navigation. * Uncaught errors and unhandled rejections on the **Logs** tab. ## Autocapture defaults | Feature | `autocapture: true` | |---|---| | Page views, including client navigation | on | | Sessions | on | | Errors and unhandled rejections | on | | Outbound link clicks | off | | Do Not Track / Global Privacy Control | respected | --- --- url: https://simpledatalogs.com/docs/quickstarts/html.md description: >- Add simpledatalogs to any website with one script tag; visitors, page views, clicks and errors are captured automatically, without cookies. --- # Plain HTML (script tag) ## Add the tag Put one tag in your page's ``: ```html ``` Or let `npx simpledatalogs init` add it to `index.html` for you. ::: info Early access The CDN URL works once the package is published to npm with the public launch. ::: The tag is the opt-in: it captures page views, outbound clicks, `data-track` clicks and errors, with no cookies and no browser storage. Add `data-autocapture="false"` to send only your own events. ## Your own events ```html ``` `simpledatalogs` is the global client; `simpledatalogs.log('error', 'Checkout failed')` sends a log line. Or mark the element and skip the code: ```html ``` ## In the first minute * A `page_view` with `path`, `title`, `referrer` and `utm_*`, and the visit on the **Sessions** tab. * One `page_view` per page load, and per path change on single-page sites. * Uncaught errors and unhandled rejections on the **Logs** tab. ## Attributes | Attribute | Default | Meaning | |---|---|---| | `data-key` | — | Your public key (required) | | `data-endpoint` | `https://edge.simpledatalogs.com` | Another ingest host | | `data-autocapture` | on | `"false"` for manual calls only | | `data-outbound-links` | on | `"false"` stops tracking clicks on links to other sites | | `data-storage` | off | `"true"` keeps unsent events and a tab session in browser storage | Do Not Track and Global Privacy Control are respected: with either on, nothing is captured automatically. There are no cookies and nothing in browser storage. The file is about 9 KB gzipped. --- --- url: https://simpledatalogs.com/docs/quickstarts/node.md description: >- Send events, logs and crashes from Node to simpledatalogs in one minute, with pino and winston recipes. --- # Node ## Install ```sh npm install simpledatalogs npx simpledatalogs init # adds SIMPLEDATALOGS_KEY to .env and writes simpledatalogs.js (.mjs in CommonJS projects) ``` Use a **secret key** (`sdl_sec_…`) on servers. ## Add it ```js // simpledatalogs.js import { init } from 'simpledatalogs' export const sdl = init({ key: process.env.SIMPLEDATALOGS_KEY, autocapture: true }) ``` ```js import { sdl } from './simpledatalogs.js' sdl.track('job_finished', { queue: 'emails', duration_ms: 812 }) sdl.log('error', 'Payment failed', { order_id: 7 }) process.on('SIGTERM', async () => { await sdl.shutdown() // sends the last batch process.exit(0) }) ``` In Node, `autocapture: true` means one thing: an **uncaught exception** is sent as a `critical` log line with its stack, then the process exits with code 1, as Node would have. Leave it out to send only your own calls. There are no page views or sessions on a server. ## pino A second stream turns each pino line into a log line: ```js import pino from 'pino' import { Writable } from 'node:stream' import { sdl } from './simpledatalogs.js' // pino's numeric levels to simpledatalogs levels. const levelNames = { 10: 'debug', 20: 'debug', 30: 'info', 40: 'warning', 50: 'error', 60: 'critical' } const toSimpleDataLogs = new Writable({ write(chunk, _encoding, callback) { const { level, msg, time, pid, hostname, ...fields } = JSON.parse(chunk) sdl.log(levelNames[level] ?? 'info', msg ?? '', fields) callback() }, }) export const logger = pino({}, pino.multistream([{ stream: process.stdout }, { stream: toSimpleDataLogs }])) ``` ## winston ```js import winston from 'winston' import Transport from 'winston-transport' import { sdl } from './simpledatalogs.js' // winston's npm levels to simpledatalogs levels. const levelNames = { error: 'error', warn: 'warning', info: 'info', http: 'debug', verbose: 'debug', debug: 'debug', silly: 'debug' } class SimpleDataLogsTransport extends Transport { log(info, callback) { const { level, message, ...fields } = info sdl.log(levelNames[level] ?? 'info', message, fields) callback() } } export const logger = winston.createLogger({ transports: [new winston.transports.Console(), new SimpleDataLogsTransport()] }) ``` Both recipes go through the SDK, so lines batch, retry and carry an `event_id`. Fields must be strings, numbers or booleans; nested objects are skipped with one warning. ## In the first minute * Your `track()` events on the dashboard, and log lines on the **Logs** tab. * With `autocapture: true`, a crash shows up as a `critical` line with `stack`. * Lines go out every 10 seconds (5 with `NODE_ENV=development`) or at 50; `await sdl.flush()` sends them now. ## Autocapture defaults (Node) | Feature | `autocapture: true` | |---|---| | Uncaught exceptions | on: logged, flushed for up to 2 s, then exit code 1 | | Page views, sessions, browser errors | not in Node | --- --- url: https://simpledatalogs.com/docs/quickstarts/react-native.md description: >- Send events, sessions and crashes from a React Native or Expo app to simpledatalogs with the JavaScript SDK. --- # React Native / Expo The JavaScript SDK detects React Native and runs in app mode: no browser autocapture, sessions driven by `AppState`, and the platform's own User-Agent. ## Install ```sh npx expo install simpledatalogs @react-native-async-storage/async-storage npx simpledatalogs init # adds EXPO_PUBLIC_SIMPLEDATALOGS_KEY and simpledatalogs.ts ``` ## Add it ```ts // simpledatalogs.ts import AsyncStorage from '@react-native-async-storage/async-storage' import { AppState } from 'react-native' import { init } from 'simpledatalogs' export const sdl = init({ key: process.env.EXPO_PUBLIC_SIMPLEDATALOGS_KEY, storage: AsyncStorage, // unsent events survive the app being killed appState: AppState, // flush on background, sessions autocapture: true, defaultProperties: { app_version: '1.4.0' }, }) // Crashes and uncaught errors, as unhandled exceptions. sdl.installGlobalHandlers() ``` ```ts import { sdl } from './simpledatalogs' sdl.setDeviceId(installId) // an anonymous id you keep, for active devices sdl.track('level_complete', { level: 3 }) sdl.track('screen_view', { screen: 'Settings' }) ``` ## What each option does | Option | What you get | |---|---| | `storage: AsyncStorage` | The outbox is saved at most once a second, and at once when the app goes to the background. Events left from the last launch go out after the next one starts. Without it, the outbox is in memory and lost if the app is killed | | `appState: AppState` | Going to the `background` saves and flushes. Without it, call `sdl.handleAppStateChange(state)` from your own listener | | `autocapture: true` | A session from launch: `session_start`, a `session_id` on every event, and a new session when the app returns after 30 minutes or more in the background. `session_end` is stamped when the app left the foreground. A launch in the background (a background task) starts no session until the app is active | | `installGlobalHandlers()` | Chains `ErrorUtils`: each uncaught error is sent with its Hermes stack frames, and a fatal one waits up to 1 second for the flush before the app's previous handler runs | Any storage with `getItem`, `setItem` and `removeItem` works, whether they return Promises or values. ## One key per platform Create an iOS deployment and an Android deployment in the same environment, and pick the public key at runtime, so the dashboard splits the platforms: ```ts import { Platform } from 'react-native' const key = Platform.OS === 'ios' ? process.env.EXPO_PUBLIC_SIMPLEDATALOGS_KEY_IOS : process.env.EXPO_PUBLIC_SIMPLEDATALOGS_KEY_ANDROID export const sdl = init({ key, storage: AsyncStorage, appState: AppState, autocapture: true }) ``` ## Errors you catch ```ts try { await purchase() } catch (error) { sdl.captureException(error, { props: { sku: 'pro_monthly' } }) } ``` ::: tip Native crashes The SDK sees JavaScript errors only. Crashes in Swift, Kotlin or C++ code, and out-of-memory kills, never reach the JS thread, so a JS-only sender can't report them. ::: ## Re-sending safely An event you queue yourself (an offline purchase log, for example) can keep its own id, so sending it twice counts once: ```ts sdl.track('purchase', { sku }, { eventId: purchase.uuid, ts: purchase.createdAt }) ``` ## In the first minute * Your first events on the dashboard within a second or two, or at once with `await sdl.flush()`. * With `setDeviceId`, the device counts in **active devices**. * Track screens with `screen_view` events, for example from your navigation container's `onStateChange`. ## Against a local ingest * **Android emulator:** the Mac's `localhost` is `10.0.2.2`, so use `endpoint: 'http://10.0.2.2:4001'`. * **iOS simulator:** `http://localhost:4001` works as is. * **Phone over USB:** run `adb reverse tcp:4001 tcp:4001` and keep `localhost`, or use the Mac's LAN address. --- --- url: https://simpledatalogs.com/docs/quickstarts/ios-swift.md description: >- Add simpledatalogs to an iOS or macOS app with the Swift SDK, with launches, sessions and crashes captured by one argument. --- # iOS and macOS (Swift) ## Install In Xcode, **File → Add Package Dependencies…** and add the `SimpleDataLogs` library. Until the public launch the package comes with your invitation as a local package (**Add Local…**, pick the package folder). ## Add it ```swift import SimpleDataLogs // At launch, e.g. in your App's init: SimpleDataLogs.configure(ingestKey: "sdl_pub_…", autocapture: .all) // Anywhere: SimpleDataLogs.track("screenshot_taken", properties: ["type": "area"]) ``` ## Send everything, or only what you choose * **Everything:** `autocapture: .all` sends launches, sessions and crashes. * **Pick:** `autocapture: [.sessions, .crashes]`. * **Only your own events:** leave `autocapture` out. Screen views are opt-in per screen: ```swift SettingsView() .trackScreenView("Settings") ``` ## In the first minute * `session_start` and `app_launched` (with `is_first_launch`) as soon as the app starts. * Every event carries a `session_id`; `session_end` (with `duration_s`) follows once the app has been away 30 minutes. * After a crash from an uncaught `NSException`, `app_crashed` with `exception_name`, `reason` and `stack` on the next launch. ## Autocapture defaults | Option | With `.all` | |---|---| | `.appLaunches` | on | | `.sessions` (iOS foreground/background, macOS activation) | on | | `.crashes` (uncaught `NSException`) | on | | Screen views | per screen, with `.trackScreenView` or `trackScreen(_:)` | Swift traps (`fatalError`, force unwraps) are not `NSException`s and are not captured. Everything else is in the [Swift SDK](/sdks/swift) reference. --- --- url: https://simpledatalogs.com/docs/quickstarts/laravel.md description: >- Send events, Eloquent model changes, sign-ins and logs from Laravel to simpledatalogs in one minute, without cron. --- # Laravel ## Install ```sh composer require simpledatalogs/laravel ``` ```sh # .env SIMPLEDATALOGS_KEY=sdl_sec_… ``` `npx simpledatalogs init` in the project folder adds the key line for you and sends a test event. ## Add it ```php use SimpleDataLogs\Laravel\Facades\SimpleDataLogs; SimpleDataLogs::track('signup', ['plan' => 'pro']); ``` Events leave after each response; no cron needed. ## Send more with one line each ```php // A model: order_created, order_updated (changed attribute names only), order_deleted, order_restored. class Order extends Model { use \SimpleDataLogs\Laravel\TracksModelEvents; } ``` ```sh # Sign-ins, sign-outs and sign-ups, and the user's id on every event. SIMPLEDATALOGS_TRACK_AUTH=true ``` ```php // A log channel, in config/logging.php. 'tap' => [\SimpleDataLogs\Laravel\ForwardToSimpleDataLogs::class . ':daily'], ``` ## In the first minute * Your `track()` events, and model events with `model`, `model_id` and `changed`. * `user_logged_in` with a `user_id`, once auth tracking is on. * Forwarded log lines on the **Logs** tab. * One telemetry bundle per request, job, scheduled task and Artisan command, with its queries, outgoing calls and exceptions, on the **App** and **Issues** tabs. ## Defaults | Feature | Default | |---|---| | Model events | per model, with the trait; values never sent unless listed in `$trackedAttributeValues` | | Auth events | off (`SIMPLEDATALOGS_TRACK_AUTH`) | | Telemetry | on once the key is set; `SIMPLEDATALOGS_TELEMETRY=false` turns it off | | Sending | after each response (`SIMPLEDATALOGS_DELIVERY=terminate`); `queue` or `schedule` instead | Everything else is in the [Laravel SDK](/sdks/laravel) reference. --- --- url: https://simpledatalogs.com/docs/quickstarts/flutter.md description: >- Add simpledatalogs to a Flutter app with the simpledatalogs_flutter package, with launches, sessions, errors and screen views captured by one argument. --- # Flutter ## Install ```sh flutter pub add simpledatalogs_flutter ``` Until the package is on pub.dev, add it from GitHub as shown in the [Flutter SDK](/sdks/flutter#install) page. ## Add it ```dart // lib/main.dart import 'package:flutter/material.dart'; import 'package:simpledatalogs_flutter/simpledatalogs_flutter.dart'; Future main() async { await SimpleDataLogsFlutter.configure( ingestKey: const String.fromEnvironment('SIMPLEDATALOGS_KEY'), autocapture: AutocaptureOptions.all, ); runApp(MaterialApp( navigatorObservers: [SimpleDataLogsNavigatorObserver()], home: const HomePage(), )); } ``` ```dart // Anywhere: SimpleDataLogs.track('signup', {'plan': 'pro'}); ``` Run with `flutter run --dart-define=SIMPLEDATALOGS_KEY=sdl_pub_…`. Events wait in a crash-safe outbox until a `202`, so nothing is lost if the app is killed. ## Send everything, or only what you choose * **Everything:** `AutocaptureOptions.all` sends launches, sessions and uncaught errors. * **Pick:** `AutocaptureOptions(sessions: true, errors: true)`. * **Only your own events:** leave `autocapture` out. ## In the first minute * `session_start` and `app_launched` within 10 seconds (5 in debug builds), or as soon as the app is hidden. * A `screen_view` for every named route you open. * With `SimpleDataLogs.setDeviceId(anonymousId)` before `configure`, the device counts in **active devices**. ## Autocapture defaults | Option | With `AutocaptureOptions.all` | |---|---| | `appLaunches` | on | | `sessions` (30 minutes away ends one) | on | | `errors` (uncaught Flutter and platform errors, as `app_error`) | on | | Screen views | named page routes, with `SimpleDataLogsNavigatorObserver` | Dart servers use the pure Dart package. Everything else is in the [Flutter SDK](/sdks/flutter) reference. --- --- url: https://simpledatalogs.com/docs/quickstarts/android.md description: >- Add simpledatalogs to an Android app with the Kotlin SDK, with launches, sessions, crashes and screen views captured by one argument. --- # Android (Kotlin) ## Install In your app module's `build.gradle.kts`: ```kotlin dependencies { implementation("com.xkdlabs:simpledatalogs:0.1.0") } ``` It needs minSdk 21 and adds the `INTERNET` permission for you. Until the library is on Maven Central, build it locally as shown in the [Android SDK](/sdks/android#install) page. ## Add it ```kotlin import com.xkdlabs.simpledatalogs.Autocapture import com.xkdlabs.simpledatalogs.SimpleDataLogs class MyApplication : Application() { override fun onCreate() { super.onCreate() SimpleDataLogs.configure(this, ingestKey = "sdl_pub_…", autocapture = Autocapture.ALL) } } // Anywhere, from any thread: SimpleDataLogs.track("signup", mapOf("plan" to "pro")) ``` Register `MyApplication` with `android:name` on the `` tag, and ship a public `sdl_pub_` key. ## Send everything, or only what you choose * **Everything:** `Autocapture.ALL` sends launches, sessions, crashes and screen views. * **Pick:** `Autocapture.SESSIONS + Autocapture.CRASHES`. * **Only your own events:** leave `autocapture` out. Compose destinations and fragments are not activities, so send their screen views yourself: ```kotlin SimpleDataLogs.trackScreen("Settings") ``` ## In the first minute * `session_start` and `app_launched` (with `is_first_launch`) as soon as the first activity starts. * A `screen_view` each time an activity resumes. * After an uncaught exception, `app_crashed` on the next launch. * Your events within 10 seconds (5 while the app is debuggable), and at once when the app goes to the background. ## Autocapture defaults | Option | With `Autocapture.ALL` | |---|---| | `APP_LAUNCHES` | on | | `SESSIONS` (30 minutes in the background ends one) | on | | `CRASHES` (uncaught JVM exceptions) | on | | `SCREEN_VIEWS` (activity resumed, `screen` = class name) | on | Native (NDK) crashes and ANRs are not captured. Everything else, including the opt-out and WorkManager, is in the [Android SDK](/sdks/android) reference. --- --- url: https://simpledatalogs.com/docs/quickstarts/unity.md description: >- Add simpledatalogs to a Unity game with the Unity package, with launches, sessions, scene views and errors captured by one argument. --- # Unity ## Install In **Window → Package Manager**, click **+**, choose **Add package from git URL…** and enter: ```text https://github.com/XKD-Labs/simpledatalogs-unity.git ``` ## Add it ```csharp using System.Collections.Generic; using UnityEngine; using XKDLabs.SimpleDataLogs; public sealed class Analytics : MonoBehaviour { // Once, at launch, on the main thread. private void Awake() { SimpleDataLogs.Configure("sdl_pub_…", autocapture: AutocaptureOptions.All); } } ``` ```csharp // Anywhere: SimpleDataLogs.Track("level_complete", new Dictionary { ["level"] = 3, ["score"] = 1200 }); ``` Use a public `sdl_pub_` key. Events wait in a crash-safe outbox until a `202`, so nothing is lost if the game is killed. ## Send everything, or only what you choose * **Everything:** `AutocaptureOptions.All` sends launches, sessions, scene views and errors. * **Pick:** `AutocaptureOptions.Sessions | AutocaptureOptions.SceneViews`. * **Only your own events:** leave `autocapture` out. Screens that are not scenes, such as a shop panel: ```csharp SimpleDataLogs.TrackScreen("Shop"); ``` ## In the first minute * `session_start` and `app_launched` (with `is_first_launch`) within 10 seconds (5 in the Editor and development builds), or at once with `SimpleDataLogs.Flush()`. * A `screen_view` each time a scene loads. * Pausing the game or switching away also sends what is waiting. ## Autocapture defaults | Option | With `AutocaptureOptions.All` | |---|---| | `AppLaunches` | on | | `Sessions` (30 minutes away ends one) | on | | `SceneViews` (`screen` = the scene name) | on | | `Errors` (logged exceptions and errors, as `app_error`) | on | Everything else, including the opt-out and WebGL notes, is in the [Unity SDK](/sdks/unity) reference. --- --- url: https://simpledatalogs.com/docs/quickstarts/python.md description: >- Send events and log lines from Django, Flask, FastAPI or any Python process to simpledatalogs in one minute. --- # Python (Django, Flask and FastAPI) ## Install ```sh pip install simpledatalogs ``` ```sh # .env SIMPLEDATALOGS_KEY=sdl_sec_… ``` Until the package is on PyPI, install it from GitHub as shown in the [Python SDK](/sdks/python#install) page. ## Add it ```python import simpledatalogs sdl = simpledatalogs.init() sdl.track("signup", {"plan": "pro"}, user_id=42) ``` A background thread sends every 5 seconds; whatever is left goes out when the process exits. ## Send your logs ```python # Django: settings.py import simpledatalogs simpledatalogs.init() LOGGING = { "version": 1, "handlers": {"simpledatalogs": {"class": "simpledatalogs.SimpleDataLogsHandler", "level": "WARNING"}}, "root": {"handlers": ["simpledatalogs"], "level": "INFO"}, } ``` ```python # Flask import logging app.logger.addHandler(simpledatalogs.SimpleDataLogsHandler(level=logging.WARNING)) ``` For FastAPI, use `simpledatalogs.AsyncClient()` and `await sdl.aclose()` in the lifespan. ## In the first minute * Your `track()` events within 5 seconds. * Warnings and errors on the **Logs** tab, with `exception.type`, `exception.message` and `stacktrace` for exceptions. ## Defaults | Feature | Default | |---|---| | Sending | every 5 s, or at 50 lines; retried until a `202` | | Queue | memory, at most 10,000 lines or 10 MB; `outbox_dir=` keeps unsent lines on disk | | Exit | flushes for up to 5 s | | Autocapture | none: only what you track and what your loggers emit at the handler's level | Everything else is in the [Python SDK](/sdks/python) reference. --- --- url: https://simpledatalogs.com/docs/quickstarts/go.md description: Send events and slog records from Go to simpledatalogs in one minute. --- # Go ## Install ```sh go get github.com/XKD-Labs/simpledatalogs-go ``` ```sh # .env SIMPLEDATALOGS_KEY=sdl_sec_… ``` Until the first tagged release, use `go get github.com/XKD-Labs/simpledatalogs-go@main`. ## Add it ```go import simpledatalogs "github.com/XKD-Labs/simpledatalogs-go" sdl, err := simpledatalogs.New(simpledatalogs.Config{}) if err != nil { log.Printf("simpledatalogs is off: %v", err) } defer sdl.Close(context.Background()) sdl.Track("signup", simpledatalogs.Props{"plan": "pro"}, simpledatalogs.WithUserID("42")) ``` A background goroutine sends every 5 seconds; `Close` sends what is left. ## Send your logs ```go logger := slog.New(simpledatalogs.NewHandler(sdl, &simpledatalogs.HandlerOptions{Level: slog.LevelWarn})) slog.SetDefault(logger) slog.Error("Payment failed", "order_id", 7, "err", err) ``` ## In the first minute * Your `Track` events within 5 seconds. * Warnings and errors on the **Logs** tab, with attributes as props (groups as dotted names). ## Defaults | Feature | Default | |---|---| | Sending | every 5 s, or at 50 lines; retried until a `202` | | Queue | memory, at most 10,000 lines or 10 MB | | Shutdown | `Close(ctx)` flushes, retrying until the context's deadline | | Autocapture | none: only what you track and what your loggers emit at the handler's level | Everything else is in the [Go SDK](/sdks/go) reference. --- --- url: https://simpledatalogs.com/docs/quickstarts/rails.md description: >- Send events and Rails log lines to simpledatalogs with a small HTTP recipe and a logger device. --- # Rails ::: warning HTTP recipe There is no Ruby gem yet. This page posts to the [ingest API](/api/ingest) with `Net::HTTP`. It batches in memory and drops a batch it can't send; for guaranteed delivery of log files, use [Vector or Fluent Bit](/integrations/log-shippers). ::: ## Install Nothing to install. Add the key to your environment: ```sh SIMPLEDATALOGS_KEY=sdl_sec_… ``` ## Add it ```ruby # config/initializers/simpledatalogs.rb require "json" require "net/http" require "securerandom" module SimpleDataLogs INGEST_URL = URI(ENV.fetch("SIMPLEDATALOGS_URL", "https://edge.simpledatalogs.com/v1/ingest")) PENDING = Queue.new def self.track(event, props = {}, user_id: nil) line = { event: event, event_id: SecureRandom.uuid, ts: Time.now.utc.iso8601(3), props: props } line[:user_id] = user_id.to_s if user_id PENDING << line.to_json end def self.flush lines = [] lines << PENDING.pop(true) while lines.size < 50 && !PENDING.empty? return if lines.empty? Net::HTTP.post(INGEST_URL, lines.join("\n"), "Authorization" => "Bearer #{ENV.fetch('SIMPLEDATALOGS_KEY')}", "Content-Type" => "application/x-ndjson") rescue StandardError => error warn "[simpledatalogs] batch dropped: #{error.message}" end # A logger device: each formatted line is already one JSON log line. class LogDevice def write(line) = PENDING << line.chomp def close; end end def self.logger(level: :warn) logger = ActiveSupport::Logger.new(LogDevice.new, level: level) logger.formatter = proc do |severity, time, _progname, message| { event_id: SecureRandom.uuid, ts: time.utc.iso8601(3), level: severity.downcase, message: message.to_s }.to_json + "\n" end logger end Thread.new { loop { sleep 5; flush } } at_exit { flush } end # Rails 7.1+: warnings and errors also go to simpledatalogs. Rails.logger.broadcast_to(SimpleDataLogs.logger) ``` ```ruby SimpleDataLogs.track("signup", { plan: "pro" }, user_id: current_user.id) ``` ## In the first minute * Your `track()` events within 5 seconds. * Rails warnings and errors on the **Logs** tab (`WARN` reads as `warning`, `FATAL` as `critical`). ## Autocapture None: this recipe sends only what you track and what the logger writes at its level. With several Puma workers, each process sends its own batches. --- --- url: https://simpledatalogs.com/docs/api/ingest.md description: >- The ingest endpoint, authentication, Content-Type and Content-Encoding, body shapes, browsers and CORS, and the other paths ingest serves. --- # Sending data Everything you send goes to one host, `edge.simpledatalogs.com`. This page covers the native endpoint, `/v1/ingest`, which takes events and log lines in JSON, NDJSON or plain text. ## The endpoint ```http POST https://edge.simpledatalogs.com/v1/ingest Authorization: Bearer Content-Type: application/json | application/x-ndjson | text/plain Content-Encoding: gzip (optional) User-Agent: / ``` | Header | Required | Notes | |---|---|---| | `Authorization` | Yes | `Bearer sdl_pub_…` or `Bearer sdl_sec_…`. The key alone decides the environment and the deployment | | `Content-Type` | Recommended | Decides how the body is read; see [Body shapes](#body-shapes). Without one, the body lands in Unparsed | | `Content-Encoding` | No | Absent, `identity`, `gzip` or `x-gzip`. Anything else is answered `415` | | `User-Agent` | Recommended | Name your SDK or app and its version, such as `my-game/1.4.0`. It is kept with every request | A request is at most **512 KB as sent**. A gzip body counts its compressed bytes. Aim for 256 KB or less per request; see [Writing a sender](/api/sender-rules). ## Body shapes Ingest stores the body exactly as sent and answers. The parser reads it afterwards, based on the `Content-Type`. | Content-Type | Body | |---|---| | `application/json` | One event object, a JSON array of event objects, or a `batch` / `events` wrapper | | `application/x-ndjson` | One event object per line | | `text/plain` | Log lines in any common format, one entry per line, stack traces included | | Anything else | Stored as is, and shown on the **Unparsed** tab with its reason | The same two events, three ways: ::: code-group ```sh [JSON array] curl -X POST https://edge.simpledatalogs.com/v1/ingest \ -H "Authorization: Bearer sdl_sec_…" \ -H "Content-Type: application/json" \ -d '[ {"event": "order_paid", "event_id": "0199f0c4-6c1e-7a3b-9d2e-5b8f1a2c3d4e", "ts": "2026-10-17T09:14:00Z", "props": {"amount": 29}}, {"level": "error", "message": "Card declined", "event_id": "0199f0c4-6c1f-7c55-8e10-2a9b3c4d5e6f", "ts": "2026-10-17T09:14:02Z"} ]' ``` ```sh [NDJSON] curl -X POST https://edge.simpledatalogs.com/v1/ingest \ -H "Authorization: Bearer sdl_sec_…" \ -H "Content-Type: application/x-ndjson" \ --data-binary $'{"event":"order_paid","event_id":"0199f0c4-6c1e-7a3b-9d2e-5b8f1a2c3d4e","ts":"2026-10-17T09:14:00Z","props":{"amount":29}}\n{"level":"error","message":"Card declined","event_id":"0199f0c4-6c1f-7c55-8e10-2a9b3c4d5e6f","ts":"2026-10-17T09:14:02Z"}' ``` ```sh [Plain text] curl -X POST https://edge.simpledatalogs.com/v1/ingest \ -H "Authorization: Bearer sdl_sec_…" \ -H "Content-Type: text/plain" \ --data-binary $'level=info event=order_paid amount=29\n2026-10-17 09:14:02 ERROR Card declined' ``` ::: The fields of each object are on [Events and log objects](/api/events), and the text formats on [Text log lines](/api/log-lines). ::: tip Prefer NDJSON for servers NDJSON with `event_id` and `ts` is the most robust shape for batches: one bad line goes to Unparsed alone, and retries never count twice. Plain text lines carry no `event_id`, so a retried text request counts twice. ::: ## Compressing with gzip Gzip is worth it for anything bigger than a few events. Set `Content-Encoding: gzip` and send the compressed bytes: ```sh gzip -c events.ndjson | curl -X POST https://edge.simpledatalogs.com/v1/ingest \ -H "Authorization: Bearer sdl_sec_…" \ -H "Content-Type: application/x-ndjson" \ -H "Content-Encoding: gzip" \ --data-binary @- ``` The 512 KB limit applies to the compressed bytes. A body may inflate to at most 8 MB; a larger one, or a corrupt one, goes to Unparsed whole (`decompressed_too_large`, `invalid_gzip`). `br`, `zstd` and `deflate` are refused with `415`. ## A successful answer ```http HTTP/1.1 202 Accepted {"accepted":"0199f0c4-7d20-7e8a-b1c2-d3e4f5a6b7c8"} ``` `202` is the only success. It is sent only once the body is durably stored. Anything else means it was not stored, so keep the data and retry; see [Responses](/api/responses). ## Browsers and CORS Public keys are meant for websites, so ingest answers browsers on every path. * Every answer, refusals included, carries `Access-Control-Allow-Origin: *` and exposes `Retry-After`. * `OPTIONS` on any path is a preflight. It answers `204` without a key and allows the `Authorization`, `Content-Type` and `Content-Encoding` headers. * There is no credentials mode: ingest never uses cookies. ```js await fetch('https://edge.simpledatalogs.com/v1/ingest', { method: 'POST', headers: { Authorization: 'Bearer sdl_pub_…', 'Content-Type': 'application/json' }, body: JSON.stringify({ event: 'page_view', event_id: crypto.randomUUID(), ts: new Date().toISOString() }), }) ``` ::: warning Never put a secret key in a web page Only `sdl_pub_…` keys belong in browsers and apps. The [JavaScript SDK](/sdks/javascript) handles batching, retries and page hide for you. ::: ## Other paths Ingest also speaks the wire protocols of tools you may already use, so they switch by changing an endpoint and a key. Each path shares `/v1/ingest`'s rules: the key check, the rate limit, the 512 KB body limit, gzip, and success only once the body is stored. | Path | For | Guide | |---|---|---| | `POST /v1/logs` | OpenTelemetry logs (OTLP/HTTP, protobuf or JSON) | [OpenTelemetry](/integrations/opentelemetry) | | `POST /v1/batch`, `/v1/track`, `/v1/page`, `/v1/screen`, `/v1/identify`, `/v1/group`, `/v1/alias` | Segment HTTP Tracking API | [Segment](/integrations/segment) | | `POST /api//envelope/`, `/api//store/` | Sentry SDKs | [Sentry](/integrations/sentry) | | `POST /api/v2/logs` | Datadog logs intake | [Datadog logs](/integrations/datadog) | | `POST /loki/api/v1/push`, `/api/prom/push`, `/otlp/v1/logs` | Loki push API | [Loki](/integrations/loki) | | `POST /services/collector[/event][/1.0]`, `/services/collector/raw[/1.0]` | Splunk HEC | [Splunk HEC](/integrations/splunk) | | `POST /vercel/v1/logs` | Vercel Log Drains, signed with the ingest key | [Vercel](/integrations/vercel) | | `POST /heroku/v1/logs` | Heroku HTTPS log drains | [Heroku](/integrations/heroku) | | `POST /v1/hooks/` | Webhooks from Stripe, GitHub and other services; the URL token replaces the key | [Inbound webhooks](/integrations/webhooks) | | `POST /openrouter/v1/traces` | OpenRouter Broadcast traces (OTLP/JSON), one `llm_call` event per LLM call | [OpenRouter](/integrations/openrouter) | | `GET /up` | Health check, answers `200 ok` | — | Other OpenTelemetry traces and metrics (`/v1/traces`, `/v1/metrics`) are not stored. They always answer `404` with a message naming the exporter setting to turn off. ## Next steps * [Events and log objects](/api/events): every field and its limits. * [Responses](/api/responses): every status code and what to do about it. * [Writing a sender](/api/sender-rules): batching, the outbox and retries. --- --- 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`. --- --- url: https://simpledatalogs.com/docs/api/telemetry.md description: >- Send requests, jobs, scheduled tasks and commands with their queries, outgoing calls and exceptions as telemetry objects on the same ingest endpoint. --- # Telemetry objects Telemetry rides the same endpoint as events: `POST /v1/ingest`, NDJSON, one object per line, the same keys and responses. An object with `kind` set to `request`, `job`, `task`, `command`, `exception` or `span` is telemetry; anything else is an event or a log line. The [Laravel SDK](/sdks/laravel#telemetry) sends all of this for you. Use this page to write your own sender. ## A bundle One object per request, job run, scheduled task run or command: the root, its children, a summary and its exceptions. ```json {"kind":"request","event_id":"0192f0c4-7b1e-7cc1-9a51-3f1c2d4e5a6b","ts":"2026-10-20T09:14:03.410Z", "trace_id":"4bf92f3577b34da6a3ce929d0e0e4736","span_id":"00f067aa0ba902b7", "name":"GET /orders/{order}","duration_ms":182.4,"status":"ok","http_status":200,"method":"GET", "user_id":"42","release":"2026.10.3", "spans":[ {"kind":"query","span_id":"a1b2c3d4e5f60718","start_ms":20.0,"duration_ms":61.0,"name":"select * from order_lines where order_id = ?","count":37}, {"kind":"http","span_id":"b2c3d4e5f6071829","start_ms":90.3,"duration_ms":40.1,"name":"POST api.stripe.com","http_status":200}], "summary":{"queries":{"count":38,"duration_ms":66.9}}, "exceptions":[{"type":"App\\Exceptions\\PaymentDeclined","message":"Card declined","handled":true, "frames":[{"file":"app/Services/Payments.php","line":88,"function":"charge","in_app":true}]}]} ``` | Field | Root (`request` `job` `task` `command`) | Child (`query` `http` `cache` `mail` `notification` `span`) | |---|---|---| | `event_id`, `ts` | Required; `ts` is the start | — | | `trace_id` | Required, 32 hex | Inherited | | `span_id` | Required, 16 hex | 16 hex | | `parent_span_id` | Optional; set it on a job dispatched by a request so both show in one trace | Optional, else the root | | `name` | Route pattern, job class, task command or command name | SQL with `?` for values, `METHOD host`, `hit`/`miss`, a class name or free text | | `duration_ms` | Required | Optional | | `start_ms` | — | Offset from the root's `ts` | | `status` | `ok` or `error` (a 5xx counts as `error`) | Same | | `http_status`, `method`, `route` | Requests | `http_status` on `http` children | | `count`, `max_ms` | — | Identical calls folded into one, and the slowest | | `summary` | Exact totals per kind, kept even when you send no children | — | | `exceptions` | Up to 5 | — | | `user_id`, `release`, `props` | Optional | `props` optional | ### Limits * 64 KB per object as JSON; a bigger one is kept in Unparsed as `bundle_too_large`, never stored in part. * 60 children after folding identical ones; the slowest are kept and the rest add up as one `(other)` child per kind. * 5 exceptions, 50 frames and 8 KB per stack, 100 props. * Names up to 512 bytes, SQL up to 2 KB. * Up to 1,000 distinct names per kind per day; later new names count under `(other)`. ### What we clean up * Route names with ids in them (`/orders/8812`) become patterns (`/orders/{id}`); query strings are dropped. * SQL literals become `?`. Send the SQL your framework already shows with placeholders; never send bound values. * `http` names keep `METHOD host` only. ## Exceptions Inside a bundle, or on their own with `kind: exception`: ```json {"kind":"exception","event_id":"0192f0c4-8a2b-7cc1-9a51-3f1c2d4e5a6c","ts":"2026-10-20T09:15:00Z", "type":"NSRangeException","message":"index 7 beyond bounds [0 .. 4]","handled":false, "frames":[{"file":"Feed/FeedViewModel.swift","line":120,"function":"apply(update:)","in_app":true}], "release":"3.2.0 (411)"} ``` * They are stored as events named `exception` (level `error`, or `critical` when `handled` is false), so Logs, boards and alerts see them. * They group into **issues** by type, the message with numbers, ids and quoted text masked, and the first in-app frame. Send your own `fingerprint` to group differently. ## Logs in a trace Add `trace_id` (32 hex) and `span_id` (16 hex) to any log line or event. The Logs page links it to the trace. ## What counts toward your plan A bundle counts one event per started 4 KB of JSON, or one per exception inside it if that is more: a typical request, with or without its queries, is **one event**. A standalone span counts one event per started 4 KB; a standalone exception counts one. --- --- url: https://simpledatalogs.com/docs/api/log-lines.md description: >- Send text/plain log lines in any common format; JSON, Monolog, syslog, access logs, logfmt and timestamp-first lines are recognized, and stack traces join their entry. --- # Text log lines You don't need to change how your app logs. Send the lines as `text/plain`, one entry per line, and each line is recognized on its own. ```sh tail -n 200 storage/logs/laravel.log | curl -X POST https://edge.simpledatalogs.com/v1/ingest \ -H "Authorization: Bearer sdl_sec_…" \ -H "Content-Type: text/plain" \ --data-binary @- ``` The body must be UTF-8. Anything else goes to Unparsed whole (`invalid_utf8`). ## Recognized formats Each line gets the first format that matches, in this order: | Format | Example | Becomes | |---|---|---| | JSON object | `{"level":"error","msg":"boom","time":1792232040}` | Exactly like an NDJSON line, [aliases](/api/events#field-aliases) included | | Laravel / Monolog | `[2026-10-13 12:00:01] production.ERROR: Payment failed {"order_id":7} {"request_id":"r-1"}` | Time, level, message; props `channel`, plus `context` and `extra` when they decode | | Syslog RFC 5424 | `<34>1 2026-10-13T12:00:01.123Z web-1 app 1234 ID47 [sd@1 a="b"] message` | Level from the PRI; props `host`, `app`, `pid`, `msgid`, `structured_data` | | Syslog RFC 3164 | `<34>Oct 13 12:00:01 web-1 app[123]: message` | Needs `TAG:` or `TAG[pid]:`; without a PRI there is no level | | Access log (nginx, Apache) | `203.0.113.9 - - [13/Oct/2026:12:00:01 +0000] "GET /pricing?plan=pro HTTP/1.1" 200 2326 "-" "Mozilla/5.0" 0.042` | Message `GET /pricing 200`; error for 5xx, warning for 4xx, else info; props `method`, `path`, `status`, `bytes`, `protocol`, `referrer`, `user_agent`, `duration_ms` | | logfmt | `ts=2026-10-13T12:00:01Z level=warn msg="disk almost full" free_mb=120` | Every token `key=value`, at least two. Known keys fill fields; the rest are typed props | | Timestamp first | `2026-10-13 12:00:01,123 - ERROR - Payment failed` | A time, an optional level word (`ERROR`, `[warn]`, `INFO:`), then the message | | Anything else | `Payment failed for order 7` | A plain log line: the whole entry is the message, the time is the receive time, the level is empty | A line that matches nothing is still kept, as a plain log line. It never goes to Unparsed. * **Times without an offset are UTC.** * In logfmt, `event=…` makes the line an event, and identity keys such as `device_id=…` fill the identity. * A recognized line that fails a check, such as one with over 100 props, is kept as a plain line. ## Stack traces Stack traces join the entry they belong to, so one exception is one row. * After a timestamp-first or plain line, these lines join it: indented lines, `#0 …`, `at …`, `Caused by…`, `Stack trace:`, `[stacktrace]` and `Next …Exception`. * A Laravel entry runs until the next Laravel header, so its `[stacktrace]`, `#0 …` and closing `"}` lines join it. * JSON, syslog, access-log and logfmt lines are one line each. ```text 2026-10-13 12:00:01 ERROR Payment failed: java.lang.IllegalStateException: card declined at com.example.Checkout.pay(Checkout.java:42) at com.example.Checkout.run(Checkout.java:17) Caused by: java.net.SocketTimeoutException: read timed out ``` That body is one `error` row whose message holds all four lines. An entry holds at most 200 lines and 64 KB; the rest starts a new plain entry. Entries never join across requests, so send a whole trace in one request. ## Masking Text lines often carry things you never meant to log. Before a text line is stored, these are masked in the message and in string props: | Found | Stored as | |---|---| | Public IPv4 and IPv6 addresses | `[ip]` | | Email addresses | `[email]` | | Bearer credentials, JWTs, well-known API keys, values under secret names such as `password`, `api_key`, `token` | `[token]` | | Card numbers | `[card]` | | Phone numbers | `[phone]` | Private, loopback and link-local addresses stay. Access logs never keep the client IP, the ident or user fields, or any query string. ::: info JSON is stored as sent Masking applies to text lines and to the Datadog, Loki and Splunk drop-ins. JSON events, NDJSON and OTLP are structured data you chose to send, so they are stored as sent. ::: ## Retries count twice Text lines carry no `event_id`, so a text request retried after a lost `202` is stored twice. If you need exact counts, send [NDJSON](/api/ingest#body-shapes) with `event_id` and `ts` instead. The [Laravel SDK](/sdks/laravel) does that for you. ## Your own format If your lines follow a fixed layout that none of these formats covers, such as `api.stripe.com/v1/charges 1 3`, set a [text template](/api/templates) on the deployment, and its keys' text bodies are read with it. ## Next steps * [Text templates and Unparsed](/api/templates): typed templates for your own line layout. * [Vector and Fluent Bit](/integrations/log-shippers): ship log files continuously. * [Laravel SDK](/sdks/laravel): forward Laravel log channels with exact counts. --- --- url: https://simpledatalogs.com/docs/api/templates.md description: >- Read your own text line layout with a typed per-deployment template, and use the Unparsed bin to fix and re-read data that didn't fit. --- # Text templates and Unparsed The [recognized formats](/api/log-lines) cover most logs. When your lines have their own fixed layout, a **text template** turns each one into typed fields, without any code. ## An example Say your API logs one line per call it makes to another service: ```text api.openai.com/v1/chat/completions 1 3 api.stripe.com/v1/payment_intents 0 5 ``` The template ```text {endpoint} {success:bool} {attempts:int} ``` turns the first line into: ```json {"message": "api.openai.com/v1/chat/completions 1 3", "props": {"endpoint": "api.openai.com/v1/chat/completions", "success": true, "attempts": 3}} ``` Now the dashboard can chart calls per endpoint, the success rate and the average number of attempts. ## Set a template 1. Open the environment's **Keys** tab. 2. On the deployment's card, choose **Set a template** (or **Text templates** in its menu). 3. Pick an example under **Start from**, or type your own template. 4. Check **Try it on**: it starts with the lines waiting in Unparsed, or paste a few. The preview shows each line's fields, or **Doesn't fit**. With several templates, a badge says which line each sample fit. 5. Save. On the **Unparsed** tab, a row a template can fix has a **Fix with a template** link. It opens the template for that row's deployment, with its waiting lines as samples. From then on, `text/plain` bodies sent with that deployment's keys are read with the template instead of the automatic formats. ## Several layouts: one template per line When one deployment sends lines in more than one layout, write one template per line. Each entry is tried against them in order, and the first that fits wins. ```text {endpoint} {success:bool} {attempts:int} [{ts:datetime}] {level}: {message} ``` * Put specific templates above general ones: a catch-all such as `{message}` on line 1 would fit everything. * Up to 10 templates. Blank lines are ignored. * An entry that fits none goes to Unparsed, as below. ## Placeholders | Placeholder | Captures | Becomes | |---|---|---| | `{name}` | As little text as the rest of the template allows | A string | | `{name:int}` | Digits | A number | | `{name:float}` | A decimal | A number | | `{name:bool}` | `true`, `false`, `yes`, `no`, `1`, `0` | A boolean | | `{name:datetime}` | Any time `ts` accepts as text (no offset means UTC) | A time | | `{name:json}` | An object or array | Nested props | | `{_}` | Anything | Skipped | * The names `ts`, `level`, `message`, `event` and the identity fields (`device_id` …) fill their own fields. Every other name is a prop, and `a.b` nests. * Without `message` or `event`, the whole entry is the message. * Literal text matches as written, and any run of spaces matches any run of whitespace. * Stack-trace lines join their entry, as with the automatic formats. * Each template is at most 1,000 characters with 30 placeholders. Names use letters, digits, `_` and `.`. A fuller template, for a Monolog-style line with JSON context: ```text [{ts:datetime}] {env}.{level}: {endpoint} {success:bool} {attempts:int} {context:json} ``` ::: tip Masking still applies Every captured value is masked like any text line: emails become `[email]`, public IPs `[ip]`, and so on. ::: ## Lines that don't fit go to Unparsed With templates set, a line that fits none of them is **not** stored as a plain line. It goes to the **Unparsed** tab as `template_mismatch`, so nothing is half-read. When you change the templates, every line waiting in Unparsed for that deployment is read again with the new set. Lines that now fit become rows, at their original times. ## The Unparsed tab Unparsed is the safety net for everything that couldn't become a row. Nothing in it is lost: each item is kept for 7 days with its raw body and a reason, and you get an email when data starts landing there. Common reasons: | Reason | What happened | What to do | |---|---|---| | `invalid_json` | A JSON body or NDJSON line didn't parse | Fix the sender's JSON | | `invalid_utf8` | A `text/plain` body wasn't UTF-8 | Send UTF-8 | | `template_mismatch` | A line didn't fit the deployment's template | **Fix with a template**; Unparsed is read again | | `invalid_gzip`, `decompressed_too_large` | A gzip body was corrupt, or inflated past 8 MB | Fix the compression, or send smaller batches | | `too_many_events` | One request held more than 10,000 events or 20,000 lines | Send smaller batches | | `invalid_ts`, `ts_out_of_range`, `too_many_props` … | One object failed a [field check](/api/events#what-gets-refused) | Fix that field in the sender | The **Read again** button re-reads everything waiting, with the current rules and template. ### Find patterns Not sure what template to write? **Find patterns** on the Unparsed tab groups the held lines that share a shape, per deployment, with numbers, times and ids shown as blanks. **Turn into template** opens the template dialog with the proposed template added as a new line under your saved ones, and lines from that group, so you can rename fields and check the preview before saving. With 10 templates already saved, it offers to replace them instead. Nothing changes until you save; emails, tokens and public IPs are masked in everything shown. ## Next steps * [Text log lines](/api/log-lines): the formats read without a template. * [Laravel SDK](/sdks/laravel): its `message_fields` setting does the same split on the sender side. * [Troubleshooting](/reference/troubleshooting): when data doesn't show up. --- --- url: https://simpledatalogs.com/docs/api/responses.md description: >- Every status code ingest answers, its body, what it means, and exactly what your client must do next. --- # Responses Ingest answers every request with one of a handful of statuses. Your client only has to know one rule: ::: tip Rule of thumb Anything not answered `202` was not stored by us, so keep it and retry. ::: ## `/v1/ingest` | Status | Body | Meaning | Your client should | |---|---|---|---| | **`202`** | `{"accepted":""}` | Durably stored. **The only success** | Delete the batch from its outbox | | `401` | `{"error":"invalid_key"}` | Unknown, revoked or brand-new key | **Pause** sending, report a configuration error, **keep the outbox**, and re-check every ~10 minutes and on the next launch | | `413` | `{"error":"payload_too_large","max_bytes":524288}` | The body is over 512 KB as sent | Split the batch and resend. Never resend it unchanged | | `408` | `{"error":"request_timeout"}` | The body didn't finish arriving within 10 seconds | Retry | | `429` | `{"error":"rate_limited"}` + `Retry-After` | This key is over its rate limit, or your address sent too many unknown keys this second | Retry after `Retry-After` | | `429` | `{"error":"over_quota"}` + `Retry-After: 3600` | Your organization sent twice its monthly quota | Keep the outbox and retry after `Retry-After`. An upgrade or the next month lifts it | | `503` | `{"error":"busy"}` + `Retry-After` | Ingest is overloaded or storage is unavailable | Retry after `Retry-After` | | `415` | `{"error":"unsupported_content_encoding"}` | A `Content-Encoding` other than `gzip` or `identity` | Fix the client. Never resend unchanged | | Anything else, a timeout or a network error | — | Unknown whether it was stored | Retry. `event_id` makes any copy harmless | ### `401`: a new key takes a few seconds A key you just created works within a few seconds. Until then it may answer `401`. That is why a client should pause and re-probe on `401` rather than drop data: the first re-probe usually succeeds. ### `413`: split, don't drop Halve the batch and send each half. Repeat until it fits. A single event that is still too large can never be accepted, and is the only thing a client may drop. ::: warning Large bodies may see a reset Refusals of large or slow bodies close the connection, so a client streaming far past 512 KB may see a connection reset instead of the `413`. Keep requests under 256 KB and you never will. ::: ### `429` and `503`: honor `Retry-After` `Retry-After` is in seconds. Wait at least that long, then retry with your usual backoff. See [Writing a sender](/api/sender-rules#retries). ## `/v1/logs` (OpenTelemetry) `/openrouter/v1/traces` answers the same way. OTLP answers follow the OTLP/HTTP spec, in the request's encoding: protobuf for `application/x-protobuf`, JSON otherwise. * **Success is `200`** with an empty `ExportLogsServiceResponse`: an empty protobuf body, or `{}` in JSON. Like `202`, it is sent only once the body is stored. * **Errors carry a `google.rpc.Status`**, such as `{"code":16,"message":"invalid ingest key; …"}`. | Status | gRPC code | When | OpenTelemetry exporters | |---|---|---|---| | `401` | 16 `UNAUTHENTICATED` | Unknown, revoked or not-yet-published key | Drop the batch | | `413` | 3 `INVALID_ARGUMENT` | Body over 512 KB as sent | Drop the batch | | `415` | 3 `INVALID_ARGUMENT` | An unsupported Content-Type or Content-Encoding | Drop the batch | | `400` | 3 `INVALID_ARGUMENT` | The body couldn't be read | Drop the batch | | `429` + `Retry-After` | 8 `RESOURCE_EXHAUSTED` | This key is over its limit | Retry | | `503` + `Retry-After` | 14 `UNAVAILABLE` | Overloaded, storage unavailable, or the body didn't arrive within 10 seconds | Retry | | `404` | 12 `UNIMPLEMENTED` | `/v1/traces` or `/v1/metrics`; the message names the setting to turn off | Drop, and log the message | ::: warning Exporters drop on 401 and 413 OTLP exporters retry only `429`, `502`, `503` and `504`. A wrong key or an oversized export loses that batch, so check the key before going live, and keep exports under 512 KB (see [OpenTelemetry](/integrations/opentelemetry#keep-exports-under-512-kb)). ::: ## Segment paths * **Success is `200 {"success":true}`**, sent only once the body is stored. * **Errors** use `/v1/ingest`'s statuses and `Retry-After`, with the body `{"success":false,"error":"","code":"","message":""}`. A `413` carries no `max_bytes`. * A Content-Type other than `application/json` or `text/plain`, or none at all, is `415 unsupported_content_type`. ## Sentry, Datadog, Loki and Splunk Each drop-in answers in its own protocol, with `/v1/ingest`'s statuses and `Retry-After` unless noted. Success is sent only once the body is stored. | Protocol | Success | Errors | Differences | |---|---|---|---| | Sentry | `200 {}` | `{"detail":"…"}` | Every answer carries `X-Sentry-Rate-Limits` naming the item types we don't store, so the SDK stops sending them. A `429` adds `::key` | | Datadog logs | `202 {}` | `{"errors":["…"]}` | An unknown key is `403`, as Datadog answers | | Loki | `204` | Plain text | A slow body is `503` + `Retry-After` | | Splunk HEC | `200 {"text":"Success","code":0}` | `{"text":"…","code":}` | No key `401` code 2, a wrong key `403` code 4, `429` and `503` code 9, unreadable or too large code 6 | ## Vercel and Heroku drains * **Success is `200 {"accepted":""}`**, sent only once the whole delivery is stored. * **Errors** use `/v1/ingest`'s statuses, bodies and `Retry-After`. * Vercel only: `403 {"error":"missing_signature"}` or `403 {"error":"invalid_signature"}` when `x-vercel-signature` doesn't match the ingest key. ## Next steps * [Writing a sender](/api/sender-rules): batching, the outbox and backoff, built on these answers. * [Troubleshooting](/reference/troubleshooting): what each answer usually means in practice. --- --- url: https://simpledatalogs.com/docs/api/sender-rules.md description: >- The six rules every client follows (batching, a durable outbox, backoff with jitter, event_id and ts, failing open, a User-Agent), with a reference implementation. --- # Writing a sender If there is no SDK for your platform, writing your own client takes an afternoon. Follow these six rules, and your client gets the same guarantees as ours: nothing is lost, nothing counts twice, and your app never slows down. ## The six rules 1. **Batch.** Apps flush up to 50 events, or every 10 seconds (5 seconds in debug builds). Servers flush every ~5 seconds. Keep each request under 256 KB. 2. **Keep a durable outbox.** A file, a table or `localStorage`. An event leaves it only after a `202`. 3. **Retry with exponential backoff and full jitter**, from 1 second up to 5 minutes, honoring `Retry-After`. On `401`, pause and re-probe slowly. On `413`, split. 4. **Give every event a UUID `event_id` and a `ts`**, both set when the event happens, so a retry never counts twice. 5. **Fail open.** Never block, slow down or crash the host app because of analytics. 6. **Send a `User-Agent`** that names your client and its version. ## Batching Small requests waste battery and connections; huge ones risk the 512 KB limit. Flush when either comes first: * the batch reaches 50 events (apps) or the flush interval passes (10 s for apps, 5 s in their debug builds, ~5 s for servers); * the request would pass 256 KB. Apps should also flush when they move to the background or the page is hidden. ## The outbox Write each event to durable storage **as it is tracked**, not when it is sent. Then a crash, a reload or a closed laptop loses nothing. * Remove events only after a `202`. * Bound it. Pick a cap that suits the platform (our browser SDK keeps 1,000 events or 2 MB; Swift 10,000 events or 10 MB) and drop the oldest only when it fills, with a warning. * Resend whatever is left on the next launch. `event_id` dedupes anything that had in fact been stored. ## Retries Retry everything that isn't a `202`, with exponential backoff and **full jitter**: ```js // Waits a random time up to the backoff ceiling, never sooner than Retry-After. function retryDelayMs(failureCount, retryAfterSeconds) { const ceilingMs = Math.min(300_000, 1_000 * 2 ** failureCount) const jitteredMs = Math.random() * ceilingMs return Math.max(jitteredMs, (retryAfterSeconds ?? 0) * 1_000, 250) } ``` Three answers need special handling: | Answer | Do this | |---|---| | `401 invalid_key` | Stop sending and keep the outbox. Re-probe with a small batch after a minute, then every 10 minutes, and on the next launch | | `413 payload_too_large` | Split the batch in half and resend each half. A single event that still doesn't fit is the only thing you drop | | `415` | The request can never succeed unchanged. Set that batch aside, log it once, and move on | See [Responses](/api/responses) for every answer. ## A minimal client This Node client follows all six rules in under 80 lines. It keeps the outbox in a file; swap that for whatever storage your platform has. ```js import { appendFileSync, readFileSync, writeFileSync, existsSync } from 'node:fs' import { randomUUID } from 'node:crypto' const INGEST_URL = 'https://edge.simpledatalogs.com/v1/ingest' const INGEST_KEY = process.env.SIMPLEDATALOGS_KEY const OUTBOX_PATH = './simpledatalogs-outbox.ndjson' let failureCount = 0 let pausedUntilMs = 0 // Records one event; stamps its id and time now, and appends it to the outbox. export function track(eventName, props = {}) { try { const event = { event: eventName, event_id: randomUUID(), ts: new Date().toISOString(), props } appendFileSync(OUTBOX_PATH, JSON.stringify(event) + '\n') } catch (error) { console.warn('simpledatalogs: could not write the outbox', error) } } // Sends up to 50 outbox lines; removes them only after a 202. async function flush() { if (Date.now() < pausedUntilMs || !existsSync(OUTBOX_PATH)) return const outboxLines = readFileSync(OUTBOX_PATH, 'utf8').split('\n').filter(Boolean) const batchLines = outboxLines.slice(0, 50) if (batchLines.length === 0) return try { const response = await fetch(INGEST_URL, { method: 'POST', headers: { Authorization: `Bearer ${INGEST_KEY}`, 'Content-Type': 'application/x-ndjson', 'User-Agent': 'my-node-client/1.0.0', }, body: batchLines.join('\n'), signal: AbortSignal.timeout(15_000), }) // Success: re-read the outbox, since track() may have appended meanwhile, and drop only the sent lines. if (response.status === 202) { failureCount = 0 const currentLines = readFileSync(OUTBOX_PATH, 'utf8').split('\n').filter(Boolean) writeFileSync(OUTBOX_PATH, currentLines.slice(batchLines.length).map((line) => line + '\n').join('')) return } // A bad key pauses sending for 10 minutes; everything stays in the outbox. if (response.status === 401) { pausedUntilMs = Date.now() + 600_000 console.warn('simpledatalogs: invalid ingest key; pausing') return } // Anything else waits for the backoff, never sooner than Retry-After. const retryAfterSeconds = Number(response.headers.get('retry-after')) || 0 failureCount += 1 pausedUntilMs = Date.now() + retryDelayMs(failureCount, retryAfterSeconds) } catch { // A timeout or network error: unknown whether it was stored, so retry later. failureCount += 1 pausedUntilMs = Date.now() + retryDelayMs(failureCount, 0) } } // Waits a random time up to the backoff ceiling, never sooner than Retry-After. function retryDelayMs(failureCount, retryAfterSeconds) { const ceilingMs = Math.min(300_000, 1_000 * 2 ** failureCount) return Math.max(Math.random() * ceilingMs, retryAfterSeconds * 1_000, 250) } // Flushes every 5 seconds, the server cadence, one request at a time. async function flushLoop() { await flush() setTimeout(flushLoop, 5_000).unref() } flushLoop() ``` It leaves out what a production client also needs: splitting on `413`, a size cap on the outbox, and a lock if several processes share one file. The [JavaScript SDK](/sdks/javascript) has all of that. ::: tip Let your coding agent write it The [setup prompt](/agents/) gives an agent these same rules, so it can write a client like this one in your app's language. ::: ## Next steps * [Responses](/api/responses): the full table of answers. * [JavaScript](/sdks/javascript), [Swift](/sdks/swift), [Laravel](/sdks/laravel), [Flutter](/sdks/flutter), [Android](/sdks/android), [Unity](/sdks/unity), [Python](/sdks/python) and [Go](/sdks/go): SDKs that already follow these rules. * [Vector and Fluent Bit](/integrations/log-shippers): ship log files without writing a client. --- --- url: https://simpledatalogs.com/docs/integrations.md description: >- Switch from OpenTelemetry, Segment, Sentry, Datadog logs, Loki, Splunk HEC, Vector or Fluent Bit by changing an endpoint and a key, drain Vercel, Heroku, Fly.io and Cloudflare logs, turn webhooks into events, or count OpenRouter's LLM calls. --- # Integrations If your code already sends data somewhere, you probably don't need to change it. Ingest speaks the wire protocols of the common tools, so you switch by changing **an endpoint and a key** in configuration. | You use | Change | Guide | |---|---|---| | OpenTelemetry SDK or Collector (logs) | The OTLP logs endpoint and an `authorization` header | [OpenTelemetry](/integrations/opentelemetry) | | Vector, Fluent Bit or another log shipper | An HTTP sink to `/v1/ingest` | [Vector and Fluent Bit](/integrations/log-shippers) | | Segment SDKs | The API host, and the ingest key as the write key | [Segment](/integrations/segment) | | Sentry SDKs | Only the DSN | [Sentry](/integrations/sentry) | | Datadog Agent, Vector or Fluent Bit to Datadog logs | The logs intake URL and the API key | [Datadog logs](/integrations/datadog) | | Promtail, Grafana Alloy, Vector or Fluent Bit to Loki | The Loki URL and the token or password | [Loki](/integrations/loki) | | Splunk HEC senders | The HEC URL and the token | [Splunk HEC](/integrations/splunk) | | PostHog, Mixpanel or Amplitude payloads | Post their event shapes to `/v1/ingest` | [Payload shapes](/integrations/payload-shapes) | | Vercel | Add a Log Drain, signed with the ingest key | [Vercel](/integrations/vercel) | | Heroku | Add an HTTPS log drain with the key in its URL | [Heroku](/integrations/heroku) | | Fly.io | Add a sink file to fly-log-shipper | [Fly.io](/integrations/fly) | | Cloudflare | Point a Logpush job at `/v1/ingest` | [Cloudflare](/integrations/cloudflare) | | OpenRouter | Add a Webhook destination under Broadcast | [OpenRouter](/integrations/openrouter) | | Stripe, Buy Me a Coffee, Lemon Squeezy, Paddle, Gumroad, Ko-fi, Patreon, GitHub, Typeform, Tally or any JSON webhook | Paste a webhook URL from the Webhooks tab | [Inbound webhooks](/integrations/webhooks) | On WordPress or Shopify, there's nothing to change in code: install the [WordPress plugin](/integrations/wordpress) or the [Shopify app](/integrations/shopify) and paste a public key. Every recipe works with a public (`sdl_pub_…`) or secret (`sdl_sec_…`) key. Use a secret key for servers and shippers, and give each source its own deployment and key so its numbers get their own tab. ## Limits that shape every recipe * **512 KB per request as sent.** Gzip counts compressed bytes. Aim for 256 KB or less. * **Hosting logs count too.** Every request log a drain sends is an event, so pick its sources and sample it (each page says how). * **Exact counts need `event_id` and `ts`.** OpenTelemetry, Segment, Sentry, Datadog, Loki, Splunk, Vercel and Heroku get ids automatically, from the content or the message id. NDJSON senders must set both before the first send attempt. * **Retry everything that isn't a success**, with backoff. Each path answers success only once the data is stored. * **Gzip is the only compression**, except that Loki's snappy protobuf is accepted. `br`, `zstd` and `deflate` are refused with `415`. ## What isn't stored The drop-ins carry events and log lines. Other parts of each protocol are answered clearly, never silently dropped: * OpenTelemetry `/v1/traces` and `/v1/metrics` answer `404` with a message naming the setting to turn off. OpenRouter's traces are the exception: each LLM call becomes an event. * Sentry transactions, spans, sessions, replays, attachments and profiles are skipped, and every answer tells the SDK to stop sending them. * Datadog metrics, APM and events, Loki queries and Splunk acknowledgments answer `404`. Errors from Sentry, and OpenTelemetry records with an `exception.type`, group into [Issues](/guide/observability#issues). Requests, jobs and traces come from [telemetry objects](/api/telemetry) on `/v1/ingest`, which the [Laravel SDK](/sdks/laravel#telemetry) sends for you. ## Next steps Pick your tool in the table above, or [use an SDK](/sdks/javascript) for new code. --- --- url: https://simpledatalogs.com/docs/integrations/opentelemetry.md description: >- Send OpenTelemetry logs over OTLP/HTTP (protobuf or JSON, gzip) from any OpenTelemetry SDK or the Collector. --- # OpenTelemetry logs Any OpenTelemetry SDK or Collector can send its logs to simpledatalogs over OTLP/HTTP. You change environment variables or exporter settings, not code. ``` POST https://edge.simpledatalogs.com/v1/logs Authorization: Bearer Content-Type: application/x-protobuf | application/json Content-Encoding: gzip (recommended) ``` ::: warning Logs only Traces and metrics are not stored: `/v1/traces` and `/v1/metrics` always answer `404`. Turn those exporters off, or use `logs_endpoint` in the Collector. ::: ## SDKs, with environment variables ```sh export OTEL_EXPORTER_OTLP_LOGS_ENDPOINT=https://edge.simpledatalogs.com/v1/logs export OTEL_EXPORTER_OTLP_LOGS_HEADERS="authorization=Bearer%20sdl_sec_…" export OTEL_EXPORTER_OTLP_LOGS_PROTOCOL=http/protobuf export OTEL_EXPORTER_OTLP_LOGS_COMPRESSION=gzip export OTEL_LOGS_EXPORTER=otlp export OTEL_TRACES_EXPORTER=none export OTEL_METRICS_EXPORTER=none export OTEL_SERVICE_NAME=checkout # Lower this if records average over ~1 KB, so each export stays under 512 KB. export OTEL_BLRP_MAX_EXPORT_BATCH_SIZE=512 ``` * The header value is percent-encoded (`%20` for the space), as the OpenTelemetry spec requires. The Python SDK also accepts a literal space. * `http/json` works too, in SDKs that support it. * With the base endpoint instead (`OTEL_EXPORTER_OTLP_ENDPOINT=https://edge.simpledatalogs.com`), keep `OTEL_TRACES_EXPORTER=none` and `OTEL_METRICS_EXPORTER=none`. * A `device.id` or `user.id` attribute, on the resource or the record, fills the identity, so per-device views work. Tested with the Python SDK (`opentelemetry-sdk` 1.29.0), protobuf and gzip. ## The Collector ```yaml processors: batch: send_batch_size: 256 send_batch_max_size: 512 # a hard cap per export, keeps requests under 512 KB once gzipped timeout: 5s exporters: otlphttp/simpledatalogs: logs_endpoint: https://edge.simpledatalogs.com/v1/logs encoding: proto # or json compression: gzip # the default; keep it headers: authorization: Bearer sdl_sec_… retry_on_failure: enabled: true initial_interval: 1s max_interval: 60s max_elapsed_time: 0s # never give up on 429 or 503 sending_queue: enabled: true queue_size: 1000 service: pipelines: logs: receivers: [otlp] # or filelog, journald… processors: [batch] exporters: [otlphttp/simpledatalogs] ``` ::: tip Use `logs_endpoint`, not `endpoint` With `endpoint`, the exporter also derives `/v1/traces` and `/v1/metrics`, which answer `404`. ::: Tested with collector-contrib 0.115.1, fed by telemetrygen. ## OTLP/JSON by hand ```sh gzip -c logs.json | curl -sS https://edge.simpledatalogs.com/v1/logs \ -H "Authorization: Bearer sdl_sec_…" \ -H "Content-Type: application/json" \ -H "Content-Encoding: gzip" \ --data-binary @- # → 200 {} ``` The same export sent twice stores each record once. ## How records map Each LogRecord becomes one row. | Field | From the LogRecord | |---|---| | `ts` | `time_unix_nano`, else `observed_time_unix_nano`, else the receive time | | `level` | `severity_number` 1–8 debug, 9–12 info, 13–16 warning, 17–20 error, 21–24 critical; otherwise `severity_text` | | `message` | The body. A map body's `message` or `msg` key, with its other keys in `props.body` | | `event` | `event_name`, else the `event.name` attribute; without either the record is a log line | | identity | `device.id`, `user.id` then `enduser.id`, `session.id`, from the record, else the resource | | `event_id` | Derived from the record's content when it has a time, so an exporter's retry never counts twice | | `props` | The record's attributes (dotted keys stay flat, such as `http.route`), common resource attributes such as `service.name`, `host.name` and `deployment.environment`, the scope, and `trace_id` / `span_id` as hex | Uncommon resource attributes are left out and counted in `props.otel.dropped_resource_attributes`, so a large resource never refuses a record. OTLP is stored as sent, without masking. ## Keep exports under 512 KB OTLP exporters drop a batch answered `401` or `413`; they retry only `429`, `502`, `503` and `504`. So: * Check the key before going live. * Turn on gzip (`OTEL_EXPORTER_OTLP_LOGS_COMPRESSION=gzip`; the Collector's `otlphttp` exporter gzips by default). * The SDK default of 512 records per export fits while records average under ~1 KB uncompressed. Lower `OTEL_BLRP_MAX_EXPORT_BATCH_SIZE`, or the Collector's `send_batch_max_size`, for larger records. A slow body answers `503`, not `408`, so exporters retry it. ## Next steps * [Responses](/api/responses#v1-logs-opentelemetry): every OTLP answer and its gRPC code. * [Vector and Fluent Bit](/integrations/log-shippers): ship log files without OpenTelemetry. --- --- url: https://simpledatalogs.com/docs/integrations/log-shippers.md description: >- Ship log files with Vector or Fluent Bit to /v1/ingest as gzipped NDJSON, with event ids so retries count once. --- # Vector and Fluent Bit Log shippers tail your files and post them in batches. Point their HTTP output at `/v1/ingest`, send NDJSON with gzip, and you're done. ## Vector ```yaml transforms: add_ids: type: remap inputs: [app_logs] source: | .event_id = uuid_v4() .ts = format_timestamp!(.timestamp, format: "%+") del(.timestamp) sinks: simpledatalogs: type: http inputs: [add_ids] uri: https://edge.simpledatalogs.com/v1/ingest method: post compression: gzip encoding: codec: json framing: method: newline_delimited request: headers: Content-Type: application/x-ndjson retry_attempts: 1000000 auth: strategy: bearer token: sdl_sec_… batch: max_bytes: 262144 timeout_secs: 5 ``` * The ids are set in a transform, **before** the sink, so Vector's retries resend the same ids and never count twice. * `level`, `message`, `device_id` and the other [documented fields](/api/events#fields) fill their columns; everything else becomes props. Tested with Vector 0.43.1. ## Fluent Bit ```ini [OUTPUT] Name http Match * Host edge.simpledatalogs.com Port 443 tls On URI /v1/ingest Format json_lines Header Authorization Bearer sdl_sec_… Header Content-Type application/x-ndjson Json_date_key ts Json_date_format iso8601 Compress gzip Retry_Limit no_limits ``` ::: warning Fluent Bit retries can count twice Fluent Bit has no built-in id generator, so a retried batch is stored again. If exact counts matter, add an `event_id` to each record in a Lua filter. ::: Tested with Fluent Bit 3.2.2. ## Plain text instead A shipper that can only post raw lines can send them as `text/plain`. Each line is [recognized on its own](/api/log-lines), stack traces join their entry, and personal data is masked. Text lines carry no `event_id`, so retries count twice. ## Next steps * [Text log lines](/api/log-lines): the formats read without setup. * [OpenTelemetry](/integrations/opentelemetry): if you already run a Collector. * [Loki](/integrations/loki) and [Datadog logs](/integrations/datadog): if your shipper already speaks those. --- --- url: https://simpledatalogs.com/docs/integrations/segment.md description: >- Point Segment's server SDKs at simpledatalogs by changing the API host and using the ingest key as the write key. --- # Segment Segment's HTTP Tracking API is served as is. Change the SDK's host and use your ingest key as the write key; your `track`, `page` and `identify` calls stay the same. ::: code-group ```python [Python] import segment.analytics as analytics analytics.host = "https://edge.simpledatalogs.com" analytics.write_key = "sdl_sec_…" analytics.track("user-42", "order_completed", {"total": 29}) ``` ```js [Node] import { Analytics } from '@segment/analytics-node' const analytics = new Analytics({ writeKey: 'sdl_sec_…', host: 'https://edge.simpledatalogs.com' }) analytics.track({ userId: 'user-42', event: 'order_completed', properties: { total: 29 } }) ``` ```go [Go] client, _ := analytics.NewWithConfig("sdl_sec_…", analytics.Config{ Endpoint: "https://edge.simpledatalogs.com", }) ``` ```ruby [Ruby] analytics = Segment::Analytics.new(write_key: "sdl_sec_…", host: "edge.simpledatalogs.com") ``` ```swift [Swift] let configuration = Configuration(writeKey: "sdl_pub_…") .apiHost("edge.simpledatalogs.com/v1") ``` ::: | SDK | Status | |---|---| | Python (`segment-analytics-python` 2.x) | Tested (2.3.3) | | Node (`@segment/analytics-node`) | Tested (2.1.2) | | Go, Ruby, Swift, Kotlin | Written from their docs; not yet tested | | Browser (analytics.js) | Not drop-in: it posts to Segment's short paths, which ingest doesn't serve. Use the [JavaScript SDK](/sdks/javascript) | Segment SDKs send the write key as HTTP Basic auth, which ingest reads. Python 2.x sends it only inside the JSON body; on the Segment paths ingest reads it from there, as long as the body isn't gzipped. ## Paths `POST /v1/batch`, `/v1/track`, `/v1/page`, `/v1/screen`, `/v1/identify`, `/v1/group` and `/v1/alias`, with `application/json` (or `text/plain` with a JSON body). ## How messages map | Field | From the message | |---|---| | `event` | track: its `event`; page `page_view`; screen `screen_view`; identify `identify`; group `group`; alias `alias` | | `user_id`, `anonymous_id` | `userId`, `anonymousId` | | `event_id` | `messageId`, so a retried batch never counts twice | | `ts` | `timestamp`, else `originalTimestamp` | | `props` | `properties`, plus `name` and `category` (page, screen), `traits` (identify, group), `group_id`, `previous_id`, and `context` | `writeKey`, `integrations`, `sentAt` and `context.ip` are never stored. ## Answers Success is `200 {"success":true}`, sent only once the batch is stored. Errors use the [usual statuses](/api/responses#segment-paths) with Segment's error body. ## Next steps * [Payload shapes](/integrations/payload-shapes): PostHog, Mixpanel and Amplitude events on `/v1/ingest`. * [Core concepts](/guide/concepts#devices-and-identity): how `userId` and `anonymousId` count as devices. --- --- url: https://simpledatalogs.com/docs/integrations/sentry.md description: >- Change only the Sentry DSN, and errors, messages and structured logs from any Sentry SDK arrive as countable log lines, with errors grouped into Issues. --- # Sentry Every Sentry SDK can send to simpledatalogs. Change only the **DSN**: its public key is your ingest key, its host is ingest, and the project id is any number. ```text https://sdl_pub_…@edge.simpledatalogs.com/1 ``` ::: code-group ```python [Python] import sentry_sdk sentry_sdk.init( dsn="https://sdl_sec_…@edge.simpledatalogs.com/1", enable_logs=True, traces_sample_rate=0, ) ``` ```js [Browser] import * as Sentry from '@sentry/browser' Sentry.init({ dsn: 'https://sdl_pub_…@edge.simpledatalogs.com/1', tracesSampleRate: 0, }) ``` ```js [Node] import * as Sentry from '@sentry/node' Sentry.init({ dsn: 'https://sdl_pub_…@edge.simpledatalogs.com/1', tracesSampleRate: 0, }) ``` ```sh [Laravel .env] SENTRY_LARAVEL_DSN=https://sdl_sec_…@edge.simpledatalogs.com/1 SENTRY_TRACES_SAMPLE_RATE=0 ``` ::: It works the same in every Sentry SDK (JavaScript, Python, PHP, Ruby, Go, Cocoa, Android …): they all post envelopes to `/api//envelope/`, with the key in `X-Sentry-Auth` or the `sentry_key` query parameter. The JavaScript SDKs, Node included, put the key in the query, and a URL only takes a **public** key (`sdl_pub_…`): a secret key there is refused with `401 secret_key_in_url`. Tested with `sentry-sdk` 2.35.0 and `@sentry/node` and `@sentry/browser` 8.47.0. ## What you get Errors and messages become **log lines**, so you can count, filter and alert on them. Errors with an exception also group into [Issues](/guide/observability#issues), by type, message and release (without stack frames). Sentry's performance data and replays aren't stored. | Field | From a Sentry event | |---|---| | `level` | `fatal` → critical, `error`, `warning`, `info`, `debug`; anything else → error | | `message` | The formatted message, else the last exception's `Type: value` | | `ts` | `timestamp` | | `event_id` | Sentry's `event_id`, so a resend never counts twice | | `user_id` | `user.id` | | `props` | `exception.type`, `exception.value`, `exception.mechanism`, `stacktrace` (the last exception's frames as text, up to 50 frames), `platform`, `environment`, `release`, `server_name`, `transaction`, `request.method`, `request.url` (without its query), `os.*`, `browser.name`, `device.model`, `trace_id`, and up to 30 tags as `tag.` | Sentry's structured logs (`enable_logs`) map one to one: each record becomes a log line with its level, body and attributes. ## What isn't stored * Transactions, spans, sessions, replays, attachments and profiles are skipped. Every answer names them in `X-Sentry-Rate-Limits`, so the SDK stops sending them. Set the traces sample rate to 0 anyway. * The user's IP address, email and username, request headers, cookies, query strings, breadcrumbs and `extra` are never stored. ## Tips ::: warning Give Sentry its own key Sentry SDKs never retry a `429` or `503`. Give Sentry its own deployment and key, so another sender's burst can't rate-limit it, and keep it under 200 requests a second. ::: ::: warning Keep compression at gzip sentry-python uses brotli when the `brotli` package is installed, and ingest refuses brotli with `415`. In that case pass `_experiments={"transport_compression_algo": "gzip"}` to `sentry_sdk.init`. ::: ## Next steps * [Core concepts](/guide/concepts#deployments): one deployment per source. * [Responses](/api/responses#sentry-datadog-loki-and-splunk): Sentry's answers. --- --- url: https://simpledatalogs.com/docs/integrations/datadog.md description: >- Point the Datadog Agent, Vector or Fluent Bit logs output at simpledatalogs and use the ingest key as the API key. --- # Datadog logs Anything that sends to Datadog's logs intake can send to simpledatalogs. Point the logs URL at ingest and use your ingest key as the API key. ::: code-group ```yaml [Datadog Agent] # datadog.yaml api_key: sdl_sec_… logs_enabled: true logs_config: logs_dd_url: edge.simpledatalogs.com:443 force_use_http: true use_compression: true compression_kind: gzip # Metrics and APM aren't stored, so turn them off. enable_payloads: series: false events: false service_checks: false sketches: false apm_config: enabled: false ``` ```yaml [Vector] sinks: simpledatalogs: type: datadog_logs inputs: [app_logs] endpoint: https://edge.simpledatalogs.com default_api_key: sdl_sec_… compression: gzip ``` ```ini [Fluent Bit] [OUTPUT] Name datadog Match * Host edge.simpledatalogs.com TLS On apikey sdl_sec_… compress gzip ``` ::: Tested with Datadog Agent 7.60.0, Vector 0.43.1 and Fluent Bit 3.2.2. * The key travels in `DD-API-KEY`, or the `dd-api-key` query parameter. The query parameter takes only a public key (`sdl_pub_…`); a secret key there is refused with `401`. `GET /api/v1/validate` answers the sender's key check. * `zstd` and `deflate` bodies are refused with `415`; set compression to gzip. * Datadog metrics, APM and events answer `404`. ## How logs map | Field | From the log | |---|---| | `message` | `message`, masked like a text line | | `level` | `status`, else `level`, else `severity` | | `ts` | `timestamp`, else `date` | | `user_id` | `usr.id` | | `event_id` | Derived from the log's content when it has a time, so a retried batch never counts twice | | `props` | Every other attribute (`service`, `hostname`, `ddsource` …), `ddtags` as `tag.`, and `usr.*` fields except email and name | Success is `202 {}`. An unknown key is `403`, as Datadog answers. ## Next steps * [Loki](/integrations/loki) and [Splunk HEC](/integrations/splunk): other drop-ins. * [Text log lines](/api/log-lines#masking): what masking does. --- --- url: https://simpledatalogs.com/docs/integrations/loki.md description: >- Use simpledatalogs as a Loki push URL from Promtail, Grafana Alloy, Vector or Fluent Bit, with the ingest key as a bearer token or password. --- # Loki Ingest serves Loki's push API. Use it as the Loki URL, and pass your ingest key as a bearer token, or as the basic-auth password with any username, the way Grafana Cloud takes a token. ::: code-group ```hcl [Grafana Alloy] loki.write "simpledatalogs" { endpoint { url = "https://edge.simpledatalogs.com/loki/api/v1/push" bearer_token = "sdl_sec_…" } } ``` ```yaml [Promtail] clients: - url: https://edge.simpledatalogs.com/loki/api/v1/push bearer_token: sdl_sec_… ``` ```yaml [Vector] sinks: simpledatalogs: type: loki inputs: [app_logs] endpoint: https://edge.simpledatalogs.com encoding: codec: text labels: app: checkout auth: strategy: bearer token: sdl_sec_… ``` ```ini [Fluent Bit] [OUTPUT] Name loki Match * Host edge.simpledatalogs.com Port 443 tls On http_user any http_passwd sdl_sec_… ``` ::: Tested with Grafana Alloy 1.5.1, Vector 0.43.1 and Fluent Bit 3.2.2. Promtail sends the same protobuf as Alloy. * Snappy protobuf (Promtail, Alloy and Vector's default) and JSON both work. * `/api/prom/push` and Loki's OTLP path, `/otlp/v1/logs`, work too. ## How entries map | Field | From the entry | |---|---| | `message` | The line, masked like a text line | | `level` | The first of the `level`, `detected_level`, `severity` or `lvl` labels or metadata | | `ts` | The entry's time | | `user_id`, `device_id` | Labels or metadata of those names | | `event_id` | Derived from the time, labels, metadata and line, so a client retry never counts twice | | `props` | Stream labels and structured metadata, masked, up to 90 | An empty line without a level is skipped. Success is `204`. ## Next steps * [Datadog logs](/integrations/datadog) and [Splunk HEC](/integrations/splunk): other drop-ins. * [Vector and Fluent Bit](/integrations/log-shippers): ship NDJSON with your own event ids instead. --- --- url: https://simpledatalogs.com/docs/integrations/splunk.md description: >- Use simpledatalogs as a Splunk HEC endpoint from Vector, the OpenTelemetry Collector or Fluent Bit, with the ingest key as the HEC token. --- # Splunk HEC Ingest serves Splunk's HTTP Event Collector. Use it as the HEC URL and your ingest key as the HEC token. ::: code-group ```yaml [Vector] sinks: simpledatalogs: type: splunk_hec_logs inputs: [app_logs] endpoint: https://edge.simpledatalogs.com default_token: sdl_sec_… compression: gzip encoding: codec: json acknowledgements: indexer_acknowledgements_enabled: false ``` ```yaml [OpenTelemetry Collector] exporters: splunk_hec: endpoint: https://edge.simpledatalogs.com/services/collector token: sdl_sec_… disable_compression: false ``` ```ini [Fluent Bit] [OUTPUT] Name splunk Match * Host edge.simpledatalogs.com Port 443 tls On Splunk_Token sdl_sec_… ``` ::: Tested with Vector 0.43.1, Fluent Bit 3.2.2 and collector-contrib 0.115.1. ::: warning Turn indexer acknowledgment off `/services/collector/ack` answers `404`. Success already means the data is stored. ::: ## Paths * `/services/collector/event` (and `/services/collector`) takes HEC events: concatenated JSON objects, `{…}{…}`. * `/services/collector/raw` takes raw lines, [read like any text body](/api/log-lines), or with the deployment's [template](/api/templates). Raw query parameters (`sourcetype`, `host` …) aren't kept. * `/services/collector/health` answers the sender's health check. ## How events map | Field | From the HEC event | |---|---| | `message` | A text `event`, masked like a text line; an object event's `message`, `msg` or `log` | | `level` | `level`, `severity`, `otel.log.severity.text` or `log_level`, in the event or in `fields` | | `ts` | `time` (epoch seconds) | | `event_id` | Derived from the content when it has a time, so a retried batch never counts twice | | `props` | `host`, `source`, `sourcetype`, an object event's other keys, and `fields`. `index` is never kept | Metric events are skipped. Success is `200 {"text":"Success","code":0}`. ## Next steps * [Loki](/integrations/loki) and [Datadog logs](/integrations/datadog): other drop-ins. * [Responses](/api/responses#sentry-datadog-loki-and-splunk): HEC error codes. --- --- url: https://simpledatalogs.com/docs/integrations/payload-shapes.md description: >- PostHog, Mixpanel and Amplitude event shapes are understood on /v1/ingest; post them with an ingest key. --- # PostHog, Mixpanel and Amplitude These tools' SDKs put their key inside the request body, so they can't simply be pointed at simpledatalogs. Their **event shapes** are understood, though: post them to `/v1/ingest` with an ingest key, and the fields map for you. ```sh curl -sS https://edge.simpledatalogs.com/v1/ingest \ -H "Authorization: Bearer sdl_sec_…" \ -H "Content-Type: application/json" \ -d '{"batch":[{"event":"$pageview","distinct_id":"d-1","uuid":"0199f0c4-6c1e-7a3b-9d2e-5b8f1a2c3d4e","timestamp":"2026-10-15T10:00:00Z","properties":{"$current_url":"/pricing"}}]}' ``` This is useful when you replay exported events, or forward them from your own backend. ## How shapes map | Shape | Recognized by | Mapping | |---|---|---| | Wrapper | An object with a `batch` array (Segment, PostHog) or an `events` array (Amplitude), and no `event` | Each entry is one event; the wrapper's own fields (`api_key`, `sentAt` …) aren't stored | | PostHog, Mixpanel | `event` with a `properties` object, or a top-level `distinct_id` | `properties` → props; `distinct_id` → `anonymous_id`; `uuid`, else `properties.$insert_id` → `event_id`; `timestamp`, else `properties.time` → `ts` | | Amplitude | `event_type` and no `event` | `event_type` → `event`; `event_properties` → props; `user_properties` → `props.user_properties`; `insert_id` → `event_id`; `user_id`, `device_id`, `session_id` as they are; `time` (ms) → `ts` | | Segment | A `type` of track, page, screen, identify, group or alias | As on [Segment](/integrations/segment#how-messages-map) | ::: warning Send the ids PostHog's id is `uuid`, Mixpanel's `properties.$insert_id`, Amplitude's `insert_id`. Without them, a retried request counts twice. Amplitude's integer `event_id` is a per-device counter, so it is kept as `props.event_id`, not used as the id. ::: * Documented fields always win: if an object also sends `event_id`, `anonymous_id`, `ts` or `props`, the vendor field stays a prop. * Client IPs (`context.ip`, `$ip`, `ip`) and body keys (`api_key`, `token`, `writeKey`) are never stored. ## Next steps * [Events and log objects](/api/events): the native shape. * [Segment](/integrations/segment): a true drop-in for Segment's SDKs. --- --- url: https://simpledatalogs.com/docs/integrations/vercel.md description: >- Send Vercel runtime, build and request logs to simpledatalogs with a signed Log Drain, deduped on Vercel's own log ids. --- # Vercel A Vercel Log Drain posts your deployments' logs to ingest. Ingest checks Vercel's signature, and each entry becomes a log line. Drains need a Vercel Pro or Enterprise team. ## Set it up 1. In Vercel, open **Team Settings → Drains → Add Drain** and choose **Logs**. 2. Pick the projects, sources and environments (see [Keep the volume down](#keep-the-volume-down)). 3. Under **Custom Endpoint**: | Field | Value | |---|---| | Endpoint URL | `https://edge.simpledatalogs.com/vercel/v1/logs` | | Format | JSON or NDJSON | | Signature Verification Secret | Your ingest key, `sdl_sec_…` | | Custom Headers | `Authorization: Bearer sdl_sec_…` (the same key) | 4. Click **Create Drain**. Vercel tests the endpoint first; the **Test** button sends sample entries any time. ::: warning Replace the generated secret Vercel fills in a random signature secret. Replace it with your ingest key, or every delivery is refused with `403 invalid_signature`. ::: If Vercel asks you to verify the endpoint, add its code to the URL: `https://edge.simpledatalogs.com/vercel/v1/logs?verify=`. Ingest echoes it back in `x-vercel-verify`. ## What's stored | Field | From the Vercel entry | |---|---| | `message` | `message`, masked like a text line; a request log without one reads `GET /about 200` | | `level` | `level` (`info`, `warning`, `error`; `fatal` is `critical`) | | `ts` | `timestamp` | | `event_id` | Derived from Vercel's `id`, so a retried delivery never counts twice | | `props` | `source`, `host`, `path`, `statusCode`, `requestId`, `deploymentId`, `projectId`, `projectName`, `environment`, `branch`, `type`, `entrypoint`, `traceId`, `spanId` and more; `method` and `region`; the request as `proxy.path`, `proxy.statusCode`, `proxy.vercelCache`, `proxy.userAgent` … | The client IP (`proxy.clientIp`) and the TLS fingerprints (`ja3Digest`, `ja4Digest`) are never stored. Vercel batches up to 1 MB. Ingest splits a batch over 512 KB at entry boundaries, so nothing is refused for size. ## Keep the volume down ::: warning Every request log is an event With the `static` and `edge` sources on, every page view, image and asset request is a row, and counts against your monthly events. ::: * Choose only the **sources** you need. `lambda` and `build` are usually enough. * Choose **Production** only, unless you debug previews here. * Add a **sampling rule**, such as 10% of `/`, and 100% of `/api`. * Filter by `source` in Logs to see one kind at a time. ## Responses * `200 {"accepted":""}` once the whole delivery is stored. * `403 missing_signature` or `403 invalid_signature` when the signature doesn't match the key. * Other refusals are [`/v1/ingest`'s](/api/responses#v1-ingest), with `Retry-After`. Vercel retries failed deliveries. ## Next steps * [Heroku](/integrations/heroku): the other hosting drain. * [Logs](/api/log-lines): how log lines are read and masked. --- --- url: https://simpledatalogs.com/docs/integrations/heroku.md description: >- Send Heroku app, router and platform logs to simpledatalogs with an HTTPS log drain, router lines as structured fields. --- # Heroku A Heroku HTTPS log drain posts every line your app, the router and the platform write. Each line becomes a log line. ## Set it up Heroku drains can't send headers, so the key goes in the URL: ```sh heroku drains:add "https://sdl:sdl_sec_…@edge.simpledatalogs.com/heroku/v1/logs" -a your-app ``` * The key is the basic-auth password; the username can be anything. * Or put a public key last in the path: `https://edge.simpledatalogs.com/heroku/v1/logs/sdl_pub_…`. A secret key in the path is refused, because URL paths end up in proxy logs. * Use a secret key in the basic-auth form, and a drain per app. `drain_token` tells drains apart. Check it with `heroku drains -a your-app`; lines arrive within seconds. ## What's stored | Field | From the Logplex frame | |---|---| | `message` | The line, masked like a text line; JSON and logfmt lines are read like [any text line](/api/log-lines) | | `level` | The line's own level, else the syslog priority | | `ts` | The line's own time, else the frame's | | `event_id` | Derived from Heroku's frame id and the line's position, so a redelivered frame never counts twice | | `props` | `source` (`app` or `heroku`), `dyno` (`web.1`, `router`), `process` (`web`), `drain_token`, and the line's own fields | Router lines read as `GET /pricing 200`, with `method`, `path`, `status`, `service_ms`, `connect_ms`, `bytes`, `request_id`, `host`, `protocol` and `target_dyno` as props. `at=error` lines (H12, H13 …) are errors, with `code` and `desc`. The client IP (`fwd`) is never stored. ## Keep the volume down ::: warning Every router line is an event The router writes one line per request. A busy app sends far more router lines than app lines, and each counts against your monthly events. ::: * Filter by `source` = `app` in Logs to hide platform lines. * To skip router lines entirely, log requests from your app instead, and use a separate drain only where you need them. ## Responses * `200 {"accepted":""}` once stored. * Other refusals are [`/v1/ingest`'s](/api/responses#v1-ingest). Logplex buffers lines while ingest refuses them, and drops the oldest once its buffer is full. ## Next steps * [Vercel](/integrations/vercel): the other hosting drain. * [Fly.io](/integrations/fly) and [Cloudflare](/integrations/cloudflare): recipes on existing paths. --- --- url: https://simpledatalogs.com/docs/integrations/fly.md description: >- Ship Fly.io app logs to simpledatalogs with fly-log-shipper and a Vector sink, with ids so retries count once. --- # Fly.io Fly's [log shipper](https://github.com/superfly/fly-log-shipper) is a Vector app that reads your organization's logs. Add one sink file and it posts them to `/v1/ingest`. ## Set it up 1. Create a folder with this `Dockerfile`: ```dockerfile FROM flyio/log-shipper:latest COPY simpledatalogs.toml /etc/vector/sinks/simpledatalogs.toml ``` 2. Next to it, `simpledatalogs.toml`: ```toml [transforms.simpledatalogs_fields] type = "remap" inputs = ["log_json"] source = ''' .event_id = uuid_v4() .ts = .timestamp .level = .log.level .props.app = .fly.app.name .props.instance = .fly.app.instance .props.region = .fly.region .props.provider = .event.provider del(.timestamp) del(.log) del(.fly) del(.event) ''' [sinks.simpledatalogs] type = "http" inputs = ["simpledatalogs_fields"] uri = "https://edge.simpledatalogs.com/v1/ingest" method = "post" compression = "gzip" encoding.codec = "json" framing.method = "newline_delimited" request.headers.Content-Type = "application/x-ndjson" request.retry_attempts = 1000000 auth.strategy = "bearer" auth.token = "${SDL_INGEST_KEY}" batch.max_bytes = 262144 batch.timeout_secs = 5 ``` 3. Launch it and set its secrets: ```sh fly launch --no-public-ips --no-deploy fly secrets set ORG=personal ACCESS_TOKEN="$(fly tokens create readonly personal)" SDL_INGEST_KEY=sdl_sec_… fly deploy ``` ## What's stored * `message`, `level` and `ts` from Fly's log line. * `app`, `instance`, `region`, `provider` and `host` as props. * The `event_id` is set before the sink, so Vector's retries never count twice. The transform matters: Fly's raw line has an `event` object, which [`/v1/ingest`](/api/events#fields) would refuse as an event name. Tested with Vector 0.43.1 and the shipper's own `log_json` transform. ## Keep the volume down Every line from every app in the organization is an event. To ship one app, set `SUBJECT=logs.your-app.>` with `fly secrets set`. ## Next steps * [Vector and Fluent Bit](/integrations/log-shippers): the same sink for any host. --- --- url: https://simpledatalogs.com/docs/integrations/cloudflare.md description: >- Send Cloudflare HTTP request logs and Workers trace events to simpledatalogs with a Logpush job to an HTTP destination. --- # Cloudflare Cloudflare Logpush posts batches of logs to an HTTP destination. Point a job at `/v1/ingest`, and each record becomes an event. Logpush needs an Enterprise zone for HTTP requests, or a Workers Paid plan for Workers trace events. ## The destination ```text https://edge.simpledatalogs.com/v1/ingest?header_Authorization=Bearer%20sdl_sec_…&header_Content-Type=application%2Fx-ndjson&header_Content-Encoding=gzip ``` * `header_…` parameters become request headers. Logpush always gzips, and the last one says so. * Logpush tests the destination with one small upload. It shows up once in Unparsed as `missing_event`. ## HTTP requests Create the job with a `record_template`, so each request gets an id and a time: ```sh curl "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/logpush/jobs" \ --header "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \ --json '{ "name": "simpledatalogs", "dataset": "http_requests", "destination_conf": "https://edge.simpledatalogs.com/v1/ingest?header_Authorization=Bearer%20sdl_sec_…&header_Content-Type=application%2Fx-ndjson&header_Content-Encoding=gzip", "max_upload_records": 1000, "output_options": { "field_names": ["RayID", "EdgeStartTimestamp", "ClientRequestHost", "ClientRequestMethod", "ClientRequestPath", "EdgeResponseStatus", "CacheCacheStatus", "EdgeColoCode"], "timestamp_format": "rfc3339ms", "record_template": "\"event\":\"http_request\",\"event_id\":{{.RayID}},\"ts\":{{.EdgeStartTimestamp}},\"host\":{{.ClientRequestHost}},\"method\":{{.ClientRequestMethod}},\"path\":{{.ClientRequestPath}},\"status\":{{.EdgeResponseStatus}},\"cache\":{{.CacheCacheStatus}},\"colo\":{{.EdgeColoCode}}" }, "enabled": true }' ``` * Each request is an `http_request` event, deduped on its Ray ID. * Leave `ClientIP` out of `field_names`; it would be stored as sent. ## Workers trace events Use the account endpoint (`/accounts/$ACCOUNT_ID/logpush/jobs`) with `"dataset": "workers_trace_events"` and: ```json "output_options": { "field_names": ["EventTimestampMs", "ScriptName", "Outcome", "Logs", "Exceptions"], "record_template": "\"event\":\"worker_invocation\",\"ts\":{{.EventTimestampMs}},\"script\":{{.ScriptName}},\"outcome\":{{.Outcome}},\"logs\":{{.Logs}},\"exceptions\":{{.Exceptions}}" } ``` Each invocation is a `worker_invocation` event, with its `console.log` lines under `logs`. These records carry no id, so a retried batch can count twice. ## Keep the volume down ::: warning Every request is an event An HTTP requests job sends one record per request to the zone. Use `"sample_rate": 0.1` in `output_options`, or a `filter` on the job, to send fewer. ::: * Keep `max_upload_records` at 1000, so each gzipped batch stays well under the 512 KB limit. Tested by posting Logpush-shaped batches (gzip NDJSON, sent twice) to the local stack, not against a live Logpush job. ## Next steps * [Payload shapes](/integrations/payload-shapes): other tools' events on `/v1/ingest`. --- --- url: https://simpledatalogs.com/docs/integrations/webhooks.md description: >- Turn Stripe, Buy Me a Coffee, Lemon Squeezy, Paddle, Gumroad, Ko-fi, Patreon, GitHub, Typeform, Tally or any JSON webhook into events, signed and deduped. --- # Inbound webhooks Services you already use can post their webhooks to simpledatalogs. Each delivery becomes one event in your environment, so "someone bought me a coffee" or "a checkout completed" shows up next to your app's own events. Webhooks are free on every plan. Each delivery that becomes an event counts toward your monthly quota. ## Add a source 1. Open your environment, then **Webhooks → New source**. 2. Pick the service, name it, and pick the deployment its events belong to. 3. Paste the service's signing secret (where to find it is below). You can add it later under **Edit**. 4. Click **Create source** and copy the URL. It's shown once: ```text https://edge.simpledatalogs.com/v1/hooks/sdl_hook_… ``` 5. Paste the URL into the service. The source shows its last delivery and any refused ones. **New URL** replaces the URL; deliveries to the old one are refused for a week and listed as "Old URL used". **Delete** stops the URL. Events already stored stay either way. Owners and members manage sources. Viewers see the events but never the URLs or secrets. ## What every event gets | Field | Value | |---|---| | Name | The service's event type, lower case, with `.`, `:` and spaces as `_`: `donation.created` → `donation_created` | | `event_id` | From the service's own event id, so a retried delivery is stored once | | `ts` | The service's own time for the event | | `user_id` | The payer's or respondent's email, stored as an `email:` pseudonym unless your project keeps emails readable | | `props.webhook` | The service, e.g. `stripe` | | Other props | Amount (in the currency's own unit, not cents), currency, status, product, plan, country and so on, per service | Names, addresses, messages, form answers, IP addresses and card details are never stored. With a secret pasted, a delivery without a valid signature is refused with `401`, and the service retries or shows the failure. Without one, the URL alone authenticates, so keep it private. ## Buy Me a Coffee **Settings → Webhooks → Add webhook.** Paste the URL, pick the events, and copy its **signing secret** into the source. | Event | Name | Props | |---|---|---| | `donation.created` | `donation_created` | `amount`, `currency`, `status`, `coffee_count`, `support_type` | | `membership.started` | `membership_started` | `amount`, `currency`, `plan`, `interval`, `status` | | `extra_purchase.created` | `extra_purchase_created` | `amount`, `currency`, `product` | Every other type (refunds, commissions, wishlist, recurring donations) is named the same way. Test events have `test: true`. ## Stripe **Developers → Webhooks → Add destination.** Paste the URL, pick the events, and copy the `whsec_…` **signing secret**. | Event | Name | Props | |---|---|---| | `checkout.session.completed` | `checkout_session_completed` | `amount`, `currency`, `status`, `mode`, `country` | | `invoice.paid` | `invoice_paid` | `amount`, `currency`, `status`, `plan`, `interval` | | `customer.subscription.created` | `customer_subscription_created` | `status`, `plan`, `interval` | Signatures older than 5 minutes are refused, so a captured delivery can't be replayed. Test-mode events have `test: true`. ## Lemon Squeezy **Settings → Webhooks → +.** Paste the URL, type a **signing secret** of your own, and paste the same secret into the source. `order_created`, `subscription_created`, `subscription_payment_success` and the rest keep their names; props are `amount`, `currency`, `status`, `product`, `plan` (the variant) and `test`. ## Paddle **Developer tools → Notifications → New destination.** Paste the URL, pick the events, and copy the destination's **secret key**. `transaction.completed` becomes `transaction_completed`, `subscription.created` becomes `subscription_created`, and so on; props are `amount` (the grand total), `currency`, `status`, `product` and `interval`. Signatures older than 5 minutes are refused. ## Gumroad **Settings → Advanced → Ping.** Paste the URL and save. Gumroad doesn't sign pings, so the URL is the secret. Each sale is a `sale` event (`refund` when the ping says it was refunded) with `amount`, `currency`, `product`, `quantity`, `plan` (the recurrence), `country` and `test`. Card details in the ping are dropped. ## Ko-fi **More → API → Webhooks.** Paste the URL, then open **Advanced** and copy the **verification token** into the source. Types become `donation`, `subscription`, `commission` and `shop_order`, with `amount`, `currency`, `plan` (the tier) and the subscription flags. ## Patreon **Creator portal → My webhooks.** Paste the URL, pick triggers, and copy the webhook's **secret**. Triggers become names: `members:pledge:create` → `members_pledge_create`. Props are `amount` (the entitled amount), `status`, `charge_status`, `plan` (the tier) and `interval_months`. Patreon sends no event id, so the id comes from the member and its last charge. ## GitHub **Repository (or organization) → Settings → Webhooks → Add webhook.** Paste the URL, choose content type `application/json`, and type a **secret** there and in the source. The name is the event plus its action: `issues_opened`, `pull_request_closed`, `star_created`, `push`, `release_published`. Props are `repository`, `action`, `ref`, `number`, `state`, `merged`, `conclusion`, `tag`, `commits` and `stars`. GitHub's `ping` marks the source as delivered but isn't an event. ## Typeform **Form → Connect → Webhooks.** Add the URL, then **Edit → Secret** and type a secret there and in the source. Each submission is a `form_response` event with `form_id`, `form` (its title), `answers` (how many) and `score`. Answers themselves are never stored; an email answer becomes the `user_id` pseudonym. ## Tally **Form → Integrations → Webhooks.** Paste the URL and add a **signing secret**, then paste the same secret into the source. Each submission is a `form_response` event with `form_id`, `form` and `fields` (how many). Field values are never stored; an email field becomes the `user_id` pseudonym. ## Any other service Pick **Other (JSON)** for anything that POSTs a JSON object. | Field | Default | Set it when | |---|---|---| | Name | `event`, `type`, `event_type` or `name` | The name lives elsewhere, e.g. `data.kind` | | ID | `id`, `event_id` or `uuid` | Retries should dedupe on another field | | Time | `ts`, `timestamp`, `created_at` or `time` | The time lives elsewhere | Fields are dotted paths into the body (`data.object.id`; a number picks a list item). Other values become props, flattened two levels deep (`service.region`), up to 50; lists and keys that look personal (`email`, `name`, `phone`, `address`, `ip` …) are left out. Signed services work when they use either: * **Standard Webhooks** (`webhook-id`, `webhook-timestamp`, `webhook-signature` headers; Svix, Resend, Clerk and others): paste the `whsec_…` secret. * **An HMAC-SHA256 of the body** in `X-Signature`, `X-Webhook-Signature` or `X-Hub-Signature-256`, as hex or base64, with or without `sha256=`. A `GET` with `?challenge=` (or `?hub.challenge=`) to the URL echoes the challenge, for services that verify the URL first. ## Answers | Status | When | |---|---| | `200` `{"accepted": ""}` | Stored. Only this means delivered | | `401` `invalid_signature`, `missing_signature`, `stale_signature` | The secret doesn't match, no signature came, or it's older than 5 minutes | | `404` `unknown_webhook` | No such URL, or a replaced one | | `413` | The body is over 512 KB | | `429` + `Retry-After` | Too many deliveries, or the organization is past 200 % of its quota | | `503` + `Retry-After` | Busy; the service retries | Every service above retries non-`200` answers on its own schedule. ## Limits * 512 KB per delivery; GitHub pushes with thousands of commits can be larger and are refused with `413`. * 20 sources per environment. * Each delivery that becomes an event counts toward your quota. Pings and refused deliveries don't. --- --- url: https://simpledatalogs.com/docs/integrations/openrouter.md description: >- Send OpenRouter's Broadcast traces to simpledatalogs, so every LLM call becomes an llm_call event with model, provider, tokens, cost and latency. --- # OpenRouter OpenRouter's **Broadcast** sends a trace for every API request you make through OpenRouter. Point it at simpledatalogs and each LLM call becomes one `llm_call` event, with the model, provider, tokens, cost in USD and latency as properties. Your app's code doesn't change. Prompts and completions are never stored. ## Set it up 1. In simpledatalogs, create a **secret** key (`sdl_sec_…`) on the deployment the calls should count under, such as a new deployment named `openrouter`. 2. In OpenRouter, open **Settings → Observability** and turn on **Enable Broadcast**. In an organization, you need the admin role. 3. Click the edit icon next to **Webhook** and fill in: | Field | Value | |---|---| | URL | `https://edge.simpledatalogs.com/openrouter/v1/traces` | | Method | `POST` | | Headers | `{"Authorization": "Bearer sdl_sec_…"}` | 4. Under **Privacy**, turn on **Privacy Mode**. simpledatalogs drops prompts and completions either way, and with Privacy Mode they never leave OpenRouter. ::: warning Keep Privacy Mode on A trace over 512 KB is refused and lost. Long prompts and agent transcripts get there quickly; without them, a trace is a few KB. ::: 5. Optional: under **Additional generation metadata**, turn on **Cost** to add the billed tier, BYOK and estimate flags. Costs and tokens come without it. 6. Click **Test Connection**, then save. OpenRouter saves only when the test passes. 7. Make one request through OpenRouter. An `llm_call` event shows up in Logs within a minute. OpenRouter's test trace (a made-up GPT-4 Turbo call costing $0.02) arrives as `llm_call_test` instead, so it never adds to your `llm_call` spend. ::: tip OpenTelemetry Collector works too OpenRouter's **OpenTelemetry Collector** destination sends the same traces. Use the same URL as its **Endpoint** and the same headers. ::: To send only some keys' calls, pick them under the destination's **API keys**, or exclude the ones to leave out. Sampling under 1.0 sends fewer traces, so counts and spend drop with it. ## What's stored One `llm_call` event per generation. Provider retries and fallbacks inside one call don't count twice. | Property | What | |---|---| | `model` | The model that answered, such as `openai/gpt-4o-2024-08-06` | | `requested_model` | The model you asked for, only when routing picked another | | `provider`, `provider_slug` | Who served it, such as `OpenAI` and `openai` | | `input_tokens`, `output_tokens`, `total_tokens` | Token counts | | `cached_tokens`, `cache_write_tokens`, `reasoning_tokens` | When the provider reports them | | `cost_usd` | What OpenRouter charged for the call, in USD, fees included | | `input_cost_usd`, `output_cost_usd` | The prompt and completion parts of the cost | | `input_unit_price_usd`, `output_unit_price_usd` | The price per token OpenRouter charged | | `duration_ms`, `ttft_ms`, `inter_token_latency_ms` | Total time, time to first token, and the mean gap between tokens | | `status`, `error`, `finish_reason` | `ok` or `error`, the error message, and why the answer ended (`stop`, `length` …) | | `generation_id` | OpenRouter's generation id, to look the call up in OpenRouter | | `api_key_name` | Which OpenRouter key made the call | | `service_tier`, `is_byok`, `usage_is_estimated`, `cancelled` | With **Cost** on: the tier it was billed at, whether it used your own provider key, whether OpenRouter had to estimate usage, and whether the call was cancelled | | `span_name`, `trace_id`, `span_id` | The trace's names and ids | | `gateway` | Always `openrouter` | * A failed call is an `error`-level event, so it shows in error counts and alerts. * The request's `user` becomes the event's user, and `session_id` its session. An email as `user` is pseudonymized like any email user id. * Flat values in the request's `trace` object (`"trace": {"feature": "support"}`) become properties too, up to 20. * Prompts, completions, messages, tool definitions and the provider's raw usage are always dropped. * A resent trace lands on the same event, so nothing counts twice. ## Chart your spend Add a board with these widgets: * **Number**, event `llm_call`, measure **sum** of `cost_usd`: what you spent in the range. * **Stacked bars**, event `llm_call`, split by `model` (or `provider`): calls per model over time. * **Number**, measure **avg** of `duration_ms`: how slow calls are. * **Number**, measure **rate**, condition `status` is `error`: the share of calls that failed. The [Spend](/guide/spend) tab prices calls by a fixed unit cost. OpenRouter already sends the real cost, so chart `cost_usd` on a board instead. ## Responses * `200 {}` once the trace is stored, including for Test Connection's empty payload. * `401` with a wrong or missing key. * Other refusals are [OTLP's](/api/responses#v1-logs-opentelemetry), with `Retry-After`. Each LLM call counts as one event. ## Next steps * [Boards](/guide/boards): build the spend board. * [Alerts](/guide/alerts): get told when `llm_call` errors spike. --- --- url: https://simpledatalogs.com/docs/integrations/wordpress.md description: >- The simpledatalogs WordPress plugin; cookie-free page views, opt-in autocapture, server-side events for users, comments and WooCommerce, and how it handles privacy. --- # WordPress The simpledatalogs plugin connects a WordPress site in a minute: paste a public key, and page views start arriving. The browser SDK is bundled with the plugin; nothing loads from a CDN. * WordPress 6.3+ and PHP 7.4+. * No cookies. * Never tracks wp-admin. ## Install 1. In WordPress, go to **Plugins → Add New** and search for **simpledatalogs**. 2. Install and activate it. ::: info Early access The plugin isn't on wordpress.org yet. Until it is, download the ZIP from [GitHub](https://github.com/XKD-Labs/simpledatalogs-wordpress) (**Code → Download ZIP**) and upload it under **Plugins → Add New → Upload Plugin**. ::: ## Set it up 1. Open **Settings → simpledatalogs**. 2. Paste a public key (`sdl_pub_…`) and save. Secret keys are refused, because they must never appear in web pages. 3. Press **Send test event**. A success notice means `setup_test` was accepted. ## What it sends | Setting | Default | What you get | |---|---|---| | Page views | on | `page_view` on every public page, with `path` and `title` | | Autocapture | off | Page views, sessions and JavaScript errors, from the bundled [JavaScript SDK](/sdks/javascript#autocapture) | | Server-side events | off | Events sent from your server, picked one by one (below) | Server-side events, once turned on: | Event | When | Props | |---|---|---| | `user_registered` | A new user registers | `role` | | `user_logged_in` | A user logs in | `role` | | `comment_posted` | A comment is posted | `status`, `post_type` | | `order_completed` | A WooCommerce order completes | `total`, `currency`, `item_count`, `payment_method` | | `add_to_cart` | A WooCommerce product is added to the cart | `product_id`, `quantity` | Each server event gets its `event_id` and `ts` when it happens, and all of a request's events go in one non-blocking batch. ## Your own events Your theme can send its own events through the global client: ```js simpledatalogs.track('newsletter_signup', { form: 'footer' }) ``` ## Privacy and consent * **No cookies.** Unsent events wait in the browser's `localStorage`, and autocapture keeps its session id in the tab's session storage. * **Do Not Track and Global Privacy Control** turn tracking off, including your theme's own calls. This is on by default. * **Roles.** Logged-in users with the roles you pick are never tracked, in the browser or on the server. Administrators are excluded by default. * **No personal data.** No names, emails or IP addresses are put in events. * Nothing is sent until you save a key. ## Page caching The plugin works with page caches. The script reads its settings from the page, and the privacy checks run in the browser. Role exclusion applies to logged-in users, whose pages caches usually skip. ## Troubleshooting | Test result | What to do | |---|---| | `401` | The key was refused. Check that it's a current public key; a new key takes a few seconds to go live | | Could not be reached | Your server can't reach the ingest host. Leave **Ingest host** empty unless support gave you another | | Anything else | Try again in a minute | ## Next steps * [JavaScript SDK](/sdks/javascript): the bundled client is version 0.3.0, so web mode and `data-track` clicks come with a later plugin release. * [Privacy for senders](/reference/privacy): what to keep out of events. --- --- url: https://simpledatalogs.com/docs/integrations/shopify.md description: >- The simpledatalogs Shopify app; a web pixel that sends storefront and checkout events with no theme edits, only with analytics consent and without personal data. --- # Shopify The simpledatalogs Shopify app sends storefront and checkout events with no theme edits. You paste a public key, and a Shopify web pixel starts sending. * Runs in Shopify's strict pixel sandbox, on the storefront and in checkout. * Loads only with the shopper's analytics consent. * Sends no personal data. ::: info Early access The app isn't in the Shopify App Store yet. Until it is, you can run it from its [GitHub repo](https://github.com/XKD-Labs/simpledatalogs-shopify) with a Shopify Partner account and `shopify app dev`; the README has the steps. ::: ## Set it up 1. Install the app on your store and open it from the Shopify admin. 2. Paste a public key (`sdl_pub_…`). Secret keys are refused. 3. Pick the event groups to send, and save. Saving connects the pixel. 4. Press **Send test event**. 5. Check **Settings → Customer events**: the simpledatalogs pixel shows as connected. ## What it sends | Group | Events | Props | |---|---|---| | Page views | `page_view` | `title`; an external `referrer` and `referrer_host`; `utm_*` | | Product, collection and search views | `product_viewed`, `collection_viewed`, `search_submitted` | Product and variant ids, titles, type, vendor, `sku`, `price_amount`, `currency`; collection id, title and `product_count`; search `query` and `result_count` | | Add to cart | `product_added_to_cart` | Product and variant props, `quantity`, `total_amount`, `currency` | | Checkout started, payment info and purchases | `checkout_started`, `payment_info_submitted`, `checkout_completed` | `currency`, `total_amount`, `subtotal_amount`, `tax_amount`, `shipping_amount`, `discount_amount`, `line_item_count`, `item_quantity`; purchases add `order_id` and `is_first_order` | * Every event also carries `source` (`shopify`), `shop`, `path` and `shopify_event_id`. * Shopify's `clientId` is sent as `anonymous_id`. * `page_view` matches the [JavaScript SDK](/sdks/javascript)'s name, so the same boards work for Shopify and other sites. ## Privacy and consent * The pixel runs only with analytics consent. When a shopper withdraws consent, it clears its unsent events. * **Never sent:** emails, names, phone numbers, addresses, customer ids, checkout tokens, query strings, URL fragments or IPs. * A search query that looks like an email or phone number is dropped. * The app stores your shop's settings, nothing about shoppers. Uninstalling deletes them. ## Delivery * Unsent events wait in the pixel's local storage, at most 500 events or 512 KB; the oldest go first. * Batches of up to 50 events and 256 KB go out about a second after the first waiting event. Leftovers from an earlier page are resent on the next. * Failures back off with full jitter from 1 second to 5 minutes, honoring `Retry-After`. A `401` pauses sending for 10 minutes; a `413` splits the batch, and a `415` drops it. ## Next steps * [Core concepts](/guide/concepts#deployments): give the store its own deployment and key. * [Privacy for senders](/reference/privacy): what to keep out of events. --- --- url: https://simpledatalogs.com/docs/sdks/javascript.md description: >- The simpledatalogs JavaScript SDK for browsers, extensions, React Native and Node; install, track events, log lines and exceptions, identity, flushing, offline behaviour, caps and every option. --- # JavaScript The `simpledatalogs` package sends events, log lines and exceptions from browsers, browser extensions, React Native and Node. It is one ES module of about 9 KB gzipped, with no dependencies and with TypeScript types, and it follows every [sender rule](/api/sender-rules) for you. ## Install ::: code-group ```sh [npm] npm install simpledatalogs ``` ```sh [pnpm] pnpm add simpledatalogs ``` ```sh [yarn] yarn add simpledatalogs ``` ::: Node 18 or later, any modern browser, a Manifest V3 extension or React Native. The package is ESM only, and its types ship in the package. ::: info Early access The package is published with the public launch. Until then, your invitation includes the package source. ::: ## Set up with one command ```sh npx simpledatalogs init ``` It detects Next.js, Nuxt, SvelteKit, React or Vue on Vite, React Native/Expo, plain HTML, Node and Laravel, asks for the key (or takes `--key`), shows a diff of the few lines it adds, writes them only after you say yes, sends a `setup_test` event and prints the dashboard link. Browser frameworks get the one-line autocapture; flags: `--framework`, `--endpoint`, `--app-url`, `--dashboard-url`, `--dir`, `--yes`, `--no-test`. The onboarding page and the new-key dialog show this command with your key and `--dashboard-url` filled in, so the link it prints opens your environment's dashboard. The link is only printed; nothing is sent with it. ## Track your first event ```js import { createClient } from 'simpledatalogs' const sdl = createClient({ ingestKey: 'sdl_pub_…' }) sdl.track('signup', { plan: 'pro' }) ``` `track()` only queues: it returns at once and never throws. Every event gets a UUID `event_id` and a `ts` the moment you call it, so retries never count twice. Create the client **once** per key, for example in a module you import everywhere: ```js // analytics.js import { createClient } from 'simpledatalogs' export const sdl = createClient({ ingestKey: import.meta.env.VITE_SIMPLEDATALOGS_KEY }) ``` ## Event options ```js const sdl = createClient({ ingestKey: 'sdl_pub_…', defaultProperties: { app_version: '1.4.0' } }) sdl.track('purchase', { order_id: 7 }, { eventId: storedUuid, ts: purchasedAt }) ``` * **Default properties** go into every event, log line and exception. `setDefaultProperties(props)` replaces them; the event's own props win on a clash. * **`eventId`** is a UUID you keep, so re-sending the same event, after an app restart for example, is counted once. Anything that isn't a UUID gets a new id, with one warning. * **`ts`** says when it happened: a `Date`, epoch milliseconds or ISO 8601 text from the last 10 years. Anything else becomes now, with one warning. * `log(level, message, props, { eventId, ts })` takes the same options. ## Autocapture One option sends page views, sessions and errors, with no `track()` calls. It is off until you turn it on. ```js import { init } from 'simpledatalogs' const sdl = init({ key: 'sdl_pub_…', autocapture: true }) ``` `init({ key })` is the short form of `createClient({ ingestKey })`; both take every option below. | Flag | With `autocapture: true` | What you get | |---|---|---| | `pageViews` | on | `page_view` on load and on every SPA route change that changes the path. Props: `path` (never the query), `title`; a session's first page view adds `referrer`, `referrer_host` and `utm_source` … `utm_content` | | `sessions` | on | `session_start`, a `session_id` on every event, and `session_end` after 30 idle minutes (`duration_s`, `page_views`) | | `errors` | on | Uncaught errors and unhandled promise rejections as `error` log lines with `stack`, `file`, `line` and `path` | | `outboundLinks` | off | `outbound_click` (`url`, `host`) for links to other sites | | `trackedClicks` | on | A click on an element with `data-track="signup"` sends that event, with its `data-track-*` attributes as props and `path` | | `uncaughtExceptions` | on, Node only | A `critical` log line, then exit code 1 as Node would, after up to 2 s of flushing | | `respectDoNotTrack` | on | Do Not Track or Global Privacy Control turns automatic capture off; your own calls still go (see [Do Not Track](#do-not-track)) | Pick features with an object: ```js init({ key: 'sdl_pub_…', autocapture: { errors: false, outboundLinks: true } }) ``` * **No cookies.** The session id lives in the tab's `sessionStorage`, so each tab has its own session. A closed tab sends no `session_end`; its last event marks the end. * **Bounded.** At most 5 copies of one error message and 50 errors per page load. * SPA routes are caught by wrapping `history.pushState` and `history.replaceState`; `shutdown()` restores them. ## Active and background In pages and React Native apps, every event tracked now says whether someone was using it, so the [Sessions tab](/guide/web-analytics) can tell active sessions from passive ones: * `app_state` is `active` while the page is visible or the app is in front, else `background`. * `engaged_ms` is the time in front since the previous event. * Mark background work yourself with `track('sync_finished', { app_state: 'background' })`; your value wins. * `activity: 'off'` sends neither. Back-dated events, log lines, Node and workers never carry them. ## Log lines ```js sdl.log('error', 'Payment failed', { order_id: 7 }) sdl.log('info', 'Checkout opened') try { await pay() } catch (error) { sdl.log('error', error) // stored as "TypeError: card declined" } ``` The level is one of `debug`, `info`, `notice`, `warning`, `error` or `critical`. An `Error` becomes its `Name: message`. ## Exceptions ```js try { await pay() } catch (error) { sdl.captureException(error, { props: { order_id: 7 } }) } sdl.installGlobalHandlers() // uncaught errors and unhandled rejections ``` `captureException` sends an [exception object](/api/events) with `type`, `message`, `handled` and stack `frames`, so errors group into issues by type, message and the first frame of your own code. | Option | Meaning | |---|---| | `props` | Props for this exception, on top of the default properties | | `handled` | `true` by default; `false` marks a crash nobody caught, stored as `critical` | | `fingerprint` | Your own grouping key, up to 128 characters | | `eventId`, `ts` | As for `track()` | * Stacks from Chrome, Node, Safari, Firefox and Hermes are read into at most 50 frames. Query strings are dropped from file names; `node_modules`, `node:` and native frames don't count as your code. * The client's `release` option is sent with every exception. * `installGlobalHandlers()` reports as `handled: false`, at most 5 copies of one message and 50 in all, and returns a function that removes the handlers. * **Page or worker:** listens for `error` and `unhandledrejection`; your own handlers keep working. * **React Native:** chains `ErrorUtils`. A fatal error waits up to 1 second for the flush, then goes on to the previous handler. * **Node:** listens for `uncaughtException`. When nothing else listens, it prints the error and exits with code 1 after up to 2 seconds, as Node would. * Autocapture's `errors` flag sends browser errors as `error` log lines instead. Use one or the other. ## Identity Set an anonymous device id to unlock active devices and per-device pages: ```js sdl.setDeviceId(anonymousInstallId) sdl.setUserId(accountId) // optional sdl.setDeviceId(null) // clears it ``` Both apply to events tracked afterwards. You can also pass `deviceId` and `userId` to `createClient`. `sdl.setSessionId(id)` sets your own `session_id` the same way; a new one starts a new visit. Never use an email or a name; see [Privacy for senders](/reference/privacy). ## Props Props must be strings, finite numbers or booleans: ```js sdl.track('export_finished', { format: 'png', duration_ms: 412, is_pro: true }) ``` Any other value (an object, `NaN`, `undefined`) is skipped, with one warning. ## In the browser ### Script tag For a site without a bundler, one tag does everything. The tag is the opt-in, so page views, outbound clicks, `data-track` clicks and errors are captured: ```html ``` The tag runs in **web mode**: no cookies and no browser storage. Events wait in memory and go out when the page hides, and visitors and visits are counted on our side (see [Web analytics](/guide/web-analytics)). | Attribute | Meaning | |---|---| | `data-key` | Your public key (required) | | `data-endpoint` | Another ingest host, e.g. `http://localhost:4001` | | `data-autocapture="false"` | Manual calls only | | `data-outbound-links="false"` | Stop tracking clicks on links to other sites | | `data-storage="true"` | Keep unsent events in `localStorage` and a tab session in `sessionStorage`, as the npm package does | Your own calls use the global client: `simpledatalogs.track('signup')`, or no code at all: ```html ``` unpkg serves the same file at `https://unpkg.com/simpledatalogs@0.5.0/dist/simpledatalogs.min.js`. ::: info Early access The CDN URLs work once the package is published to npm with the public launch. ::: ### With a bundler ```js import { init } from 'simpledatalogs' export const sdl = init({ key: import.meta.env.VITE_SIMPLEDATALOGS_KEY, autocapture: true }) ``` * **Use a public key** (`sdl_pub_…`). The SDK warns if it sees a secret key in a web page. * **Unsent events survive reloads.** The outbox lives in `localStorage`, so a closed tab sends its events on the next page load. * **Page hide flushes.** On `pagehide` or a hidden tab, one `fetch` with `keepalive` sends up to 60 KB. Anything unconfirmed stays stored and is resent later. * **Cross-origin works.** Ingest answers CORS preflights, and there are no cookies. ## In a browser extension In a Manifest V3 service worker the SDK runs in worker mode, not as Node: ```js // background.js import { init } from 'simpledatalogs' export const sdl = init({ key: 'sdl_pub_…' }) sdl.installGlobalHandlers() ``` * **The outbox survives the worker stopping.** With the `storage` permission in your manifest, it lives in `chrome.storage.local`; without it, in memory. * **Sends every 10 seconds**, since Chrome stops an idle worker after 30 seconds. Events still waiting are sent when the worker next starts. * **No `User-Agent` header**, and a warning if you ship a secret key. * Popups, options pages and content scripts can share the storage with a prefix of their own: ```js import { chromeStorageAdapter, init } from 'simpledatalogs' const sdl = init({ key: 'sdl_pub_…', storage: chromeStorageAdapter(chrome.storage.local, 'popup:') }) ``` ## In React Native The SDK detects React Native and skips browser autocapture. Pass `AsyncStorage` to keep unsent events across launches and `AppState` to flush on background and run sessions; see [React Native / Expo](/quickstarts/react-native). ## In Node ```js import { createClient } from 'simpledatalogs' const sdl = createClient({ ingestKey: process.env.SIMPLEDATALOGS_KEY }) // sdl_sec_… sdl.track('job_finished', { queue: 'emails', duration_ms: 812 }) // Before the process exits: process.on('SIGTERM', async () => { await sdl.shutdown() process.exit(0) }) ``` * **Use a secret key** (`sdl_sec_…`) on servers. * **Call `await sdl.shutdown()`** before the process exits. In Node the outbox is kept in memory, so this is what sends the last batch. * Batches go one at a time over one reused keep-alive connection. ## Flushing Events go out every 10 seconds (every 5 while you develop: a local site, an unpacked extension, React Native's `__DEV__` or `NODE_ENV=development`), or at once when 50 are waiting. The first batch goes 1 second after the client starts. You rarely need to flush by hand, but you can: ```js await sdl.flush() // sends everything now; never rejects console.log(sdl.pendingCount) // events not yet confirmed by a 202 ``` `flush()` resolves when the attempt ends, whether or not it succeeded. Anything undelivered stays in the outbox. ## Opt-out ```js sdl.setEnabled(false) // deletes every unsent event and ignores new ones sdl.setEnabled(true) // collects again ``` The SDK doesn't remember the choice. Your app's own setting is the source of truth, so apply it on every page load. ## Do Not Track By default, Do Not Track and Global Privacy Control turn off **automatic capture only**. Your own `track()`, `log()` and `captureException()` calls still go. To honour them for every send, pass `respectDoNotTrack: true`: ```js const sdl = init({ key: 'sdl_pub_…', autocapture: true, respectDoNotTrack: true }) ``` With the signal on, the client starts opted out, as if you called `setEnabled(false)`, so unsent events are deleted. Calling `setEnabled(true)` after your own consent prompt overrides it. ## Offline and errors The SDK handles every [answer](/api/responses) for you: | Answer | What the SDK does | |---|---| | `202` | Removes the batch from the outbox | | `401` | Keeps everything, warns once, and re-checks the key after 1 minute, then every 10 minutes | | `413` | Halves the batch and resends; each `202` doubles it back to 50. A single event over 256 KB is dropped | | `415` | Drops that batch, with one warning | | Anything else, a timeout, offline | Backs off with full jitter from 1 second to 5 minutes, never sooner than `Retry-After` | Problems are logged once each with `console.warn`. No call ever throws into your app. ## Caps Storage is small and shared with your page or app, so the outbox is bounded: * at most **1,000 events or 2 MB** (`maxOutboxEvents`, `maxOutboxBytes`); * only when that fills, for example after hours with a revoked key, are the oldest unsent events dropped, with a warning; * a stored outbox older than 30 days is deleted, whatever key wrote it. `track()` never writes storage itself. The outbox is saved once per turn of the event loop. ## Options ```js const sdl = createClient({ ingestKey: 'sdl_pub_…', // required endpoint: 'https://edge.simpledatalogs.com', // the ingest host deviceId: null, userId: null, flushIntervalMs: 10_000, firstFlushDelayMs: 1_000, maxBatchEvents: 50, maxRequestBytes: 256_000, maxKeepaliveBytes: 60_000, maxOutboxEvents: 1_000, maxOutboxBytes: 2_000_000, requestTimeoutMs: 15_000, autocapture: false, defaultProperties: {}, release: null, respectDoNotTrack: false, }) ``` | Option | Default | Meaning | |---|---|---| | `ingestKey` | — | Your `sdl_pub_…` or `sdl_sec_…` key. Without one, nothing is sent | | `endpoint` | `https://edge.simpledatalogs.com` | The ingest host; `/v1/ingest` is added | | `deviceId`, `userId` | `null` | Identity on every event | | `defaultProperties` | `{}` | Props merged into every event, log line and exception | | `release` | `null` | Your app's release, sent with exceptions | | `flushIntervalMs` | `10000` (`5000` while you develop) | How long a partial batch waits | | `firstFlushDelayMs` | `1000` | Wait before the first batch, so a new setup shows data at once | | `maxBatchEvents` | `50` | Events per request | | `maxRequestBytes` | `256000` | Bytes per request | | `maxKeepaliveBytes` | `60000` | Bytes in the page-hide request | | `maxOutboxEvents`, `maxOutboxBytes` | `1000`, `2000000` | The outbox caps | | `requestTimeoutMs` | `15000` | One request's timeout | | `autocapture` | `false` | `true`, or an object of flags; see [Autocapture](#autocapture) | | `respectDoNotTrack` | `false` | Makes Do Not Track and Global Privacy Control an opt-out for every send | | `storage` | `localStorage` in browsers, `chrome.storage.local` in an extension worker, none elsewhere | `localStorage`, or any object whose `getItem`, `setItem` and `removeItem` may return Promises (AsyncStorage); `null` keeps the outbox in memory | | `appState` | — | React Native's `AppState`: flushes on background and drives sessions | | `runtime` | detected | `browser`, `worker`, `react-native` or `node`; `sdl.runtime` says which is in use | | `logger` | `console` | Where warnings go; `null` silences them | | `sendUserAgent` | `true` in Node only | Whether to send `simpledatalogs-js/` as the User-Agent | ## Next steps * [Events and log objects](/api/events): what the SDK sends. * [Privacy for senders](/reference/privacy): what to keep out of props. * [Prompt recipes](/agents/recipes): let your coding agent add tracking for you. --- --- url: https://simpledatalogs.com/docs/sdks/swift.md description: >- The SimpleDataLogs Swift package for macOS and iOS apps; install, configure, track, back-dated events, opt-out, flush and disable, sessions, crash redaction, the crash-safe outbox, lifecycle and caps. --- # Swift The `SimpleDataLogs` Swift package tracks events from macOS and iOS apps. It has no dependencies, uses Swift 6 strict concurrency, and keeps a crash-safe outbox on disk, so an event is never lost to a crash or a closed laptop. * macOS 13+ and iOS 16+. * Swift Package Manager. * Ships a privacy manifest (`PrivacyInfo.xcprivacy`) for App Store review. ## Install In Xcode, choose **File → Add Package Dependencies…**, enter `https://github.com/XKD-Labs/simpledatalogs-swift`, and add the `SimpleDataLogs` library to your app target. Or in `Package.swift`: ```swift dependencies: [ .package(url: "https://github.com/XKD-Labs/simpledatalogs-swift", from: "0.4.0"), ], targets: [ .target(name: "MyApp", dependencies: [.product(name: "SimpleDataLogs", package: "simpledatalogs-swift")]), ] ``` ::: info Early access The package URL is published with the public launch. Until then, your invitation includes the package source: add it with **Add Local…** in Xcode, or `.package(path: "…")` in `Package.swift`. ::: ## Quick start ```swift import SimpleDataLogs // At launch: the opt-out first, then identity and default properties, then the key. SimpleDataLogs.setEnabled(userAllowsUsageStatistics) SimpleDataLogs.setDeviceID(anonymousDeviceID) SimpleDataLogs.setDefaultProperties(["app_version": "1.1.0", "distribution": "website"]) SimpleDataLogs.configure(ingestKey: "sdl_pub_…") // Anywhere, from any thread or actor; it returns at once. SimpleDataLogs.track("screenshot_taken", properties: ["type": "area"]) ``` Use a **public key** (`sdl_pub_…`) in apps. The SDK logs a warning if it sees a secret key. ## Autocapture One argument sends launches, sessions and crashes without any `track` call. It is off until you pass it. ```swift SimpleDataLogs.configure(ingestKey: "sdl_pub_…", autocapture: .all) ``` | Option | What you get | |---|---| | `.appLaunches` | `app_launched` on every launch, with `is_first_launch` | | `.sessions` | `session_start`, a `session_id` on every event, and `session_end` (`duration_s`) when the session ends; see [session modes](#session-modes) | | `.crashes` | An uncaught `NSException` is saved at the crash and sent as `app_crashed` (`exception_name`, `reason`, `stack`) on the next launch | `.all` is all three; pass a set to pick, e.g. `[.sessions, .crashes]`. ### Session modes | `sessionMode:` | A session ends after | Suits | |---|---|---| | `.activation` (default) | 30 minutes away; macOS counts activation, iOS foreground and background | Regular windowed and iOS apps | | `.inactivityTimeout(minutes: 30)` | That many minutes without a `track` call | Menu-bar and accessory apps, which are rarely frontmost | ```swift SimpleDataLogs.configure(ingestKey: "sdl_pub_…", autocapture: .all, sessionMode: .inactivityTimeout(minutes: 30)) ``` * In `.inactivityTimeout`, the first `track` after a quiet gap sends `session_end` (stamped at the last activity) and `session_start` before the event itself. * Back-dated events never move sessions. * An app quit mid-session ends that session on the next launch, at its last activity. ### Crash reasons By default the reason is sent word for word, up to 4,000 characters. Reasons can carry file paths, record ids or emails, so you can redact them: ```swift SimpleDataLogs.configure(ingestKey: "sdl_pub_…", autocapture: .all, crashReasonRedaction: .standard) ``` * `.standard` replaces digits with `#`, UUIDs and hex values with `` and ``, paths and file URLs with ``, and emails with ``, then cuts the reason at 200 characters. * Pick parts with `CrashReasonRedaction(shouldStripNumbers:shouldStripPaths:shouldStripEmails:maxLength:)`. * File paths in `stack` frames are always cut to the file or module name. * **Screen views** are opt-in per screen: `.trackScreenView("Settings")` on a SwiftUI view, or `SimpleDataLogs.trackScreen("Settings")` from UIKit/AppKit. Both send `screen_view` with a `screen` prop. * Swift runtime traps (`fatalError`, force unwraps) and signals are not `NSException`s, so they are not captured. * The opt-out applies: with `setEnabled(false)`, nothing is captured and a saved crash is discarded. ### Active and background Every event tracked now says whether someone was using the app, so the [Sessions tab](/guide/web-analytics) can tell active sessions from passive ones: * `app_state` is `active` while the app is in front, or on a Mac when there was keyboard or mouse input in the last 10 seconds (a menu-bar hotkey counts); otherwise `background`. * `engaged_ms` is the time the app spent in front since the previous event. * Mark background work yourself with `track("sync_finished", properties: ["app_state": "background"])`; your value wins. * Turn it off with `configure(ingestKey: …, activity: .off)`. Back-dated events carry neither. ## API | Call | What it does | |---|---| | `configure(ingestKey:endpoint:autocapture:sessionMode:crashReasonRedaction:)` | Starts the SDK once; `autocapture` defaults to none. `endpoint` defaults to `https://edge.simpledatalogs.com`; later calls are ignored | | `trackScreen(_:properties:)` | Records `screen_view` with a `screen` prop. | | `track(_:properties:)` | Records one event. `event_id` and `ts` are stamped now, when the event happens | | `track(_:properties:at:eventID:)` | Records an event that happened earlier; see [back-dated events](#back-dated-events) | | `track(_:properties:eventID:)` | Records an event now with your own `event_id`, so a resend is counted once | | `setDeviceID(_:)` | Sets, or clears with `nil`, the `device_id` on every later event | | `setDefaultProperties(_:)` | Replaces the properties merged into every event; the event's own properties win on a clash | | `setEnabled(_:)` | The opt-out. `false` ignores new events and **deletes every unsent event**; `true` collects again | | `flush() async` | Sends everything now and returns when that attempt ends. Undelivered events stay on disk | | `disable()` | The same as `setEnabled(false)` | | `flushAndDisable(timeLimit:)` | Ignores new events at once, sends what is queued (10 s by default), then turns collection off and deletes the rest. Async, or with a `completion:` callback | ### Properties `PropertyValue` is a string, integer, double or boolean, and literals work directly: ```swift SimpleDataLogs.track("export_finished", properties: ["format": "png", "duration_ms": 412, "is_pro": true]) ``` Coming from Firebase or another loosely typed API? `PropertyValue(bridging:)` converts an `Any`, and returns `nil` for anything else: ```swift SimpleDataLogs.track(eventName, properties: parameters.compactMapValues(PropertyValue.init(bridging:))) ``` `NaN` and infinity are dropped before the event is stamped. ### Back-dated events Keep events from before the user agreed to share usage, such as onboarding steps, and send them later with their real time: ```swift for step in savedOnboardingSteps { SimpleDataLogs.track("onboarding_step", properties: ["step": .string(step.name)], at: step.completedAt, eventID: step.id) } ``` * `at` may be up to 3,650 days back, inside the ingest API's 10-year `ts` window. Older events are dropped with a log line; a future time is sent as now. * `eventID` is optional. Pass a stable UUID when the same event might be sent twice, so it counts once. ## The opt-out Call `setEnabled(_:)` **before** `configure`. A user who opted out then never sends a byte, and whatever an earlier launch left on disk is deleted instead of sent. ```swift SimpleDataLogs.setEnabled(settings.isUsageSharingOn) SimpleDataLogs.configure(ingestKey: "sdl_pub_…") // Later, when the user flips the toggle: SimpleDataLogs.setEnabled(newValue) ``` The SDK doesn't persist the choice. Your app's setting is the source of truth, so pass it on every launch. To send what was already collected before stopping, use `flushAndDisable()` instead: ```swift await SimpleDataLogs.flushAndDisable() ``` It ignores new events at once, skips any pending backoff for one last send, then deletes whatever did not make it within the time limit. ## How it behaves ### Batching Events go out in batches of 50, or 10 seconds after the first event of a partial batch (5 seconds in debug builds). Every request stays under 256 KB. ### A crash-safe outbox * Each event is appended to a journal on disk as it is tracked. * Batches are sealed into files and deleted only after a `202`. * The next launch resends anything left over, including a journal cut short by a crash. * Files live in `Application Support//SimpleDataLogs/` (inside the container for sandboxed apps) and are excluded from backups. * On iOS, the files use the `completeUntilFirstUserAuthentication` protection class, so background work on a locked device can still track and send. ### Lifecycle The SDK flushes when the app terminates and when the Mac goes to sleep (macOS), or when the app moves to the background (iOS). On iOS it asks for background time for that send, for at most 20 seconds. Anything a flush can't deliver in time stays on disk for the next launch. Events tracked in `applicationWillTerminate` are kept and usually sent before the app quits: * Once termination begins, the SDK holds the main thread until every queued event is in the journal (at most 1 second). * Then it sends them, holding the quit until the send ends or 2 seconds have passed since termination began. * Before termination, `track` never waits. ### Errors and offline | Answer | What the SDK does | |---|---| | `202` | Deletes the batch file | | `401` | Pauses sending and logs one configuration error. Re-probes one minute later, then every 10 minutes, and on the next launch. A new key that needs ~30 s to go live is picked up by the first re-probe | | `413` | Splits the batch in half and resends. A single event still too large is dropped, with a log line | | `415` | Sets the batch aside as `.unsupported` and moves on | | Anything else, a timeout, offline | Backs off with full jitter from 1 second to 5 minutes, honoring `Retry-After` | ### Caps * The outbox keeps at most **10,000 events, 10 MB or 500 batch files**. Past that, the oldest batches are dropped, with a log line. * If more than 5,000 events wait to be written (a hot loop faster than the disk), new ones are skipped, with one log line, until it catches up. Nothing throws or crashes your app. ## Debugging The SDK logs through `os.Logger` with the subsystem `com.simpledatalogs.sdk`: ```sh log stream --predicate 'subsystem == "com.simpledatalogs.sdk"' --level debug ``` For local development, point it at a local ingest: ```swift SimpleDataLogs.configure(ingestKey: "sdl_pub_…", endpoint: URL(string: "http://localhost:4001")!) ``` ::: tip Sandboxed Mac apps A sandboxed app needs the `com.apple.security.network.client` entitlement to send. ::: ## Next steps * [Core concepts](/guide/concepts#deployments): one deployment per build, such as `macos` and `macos-appstore`. * [Privacy for senders](/reference/privacy): device ids, not emails. * [Prompt recipes](/agents/recipes): have your coding agent wire it up. --- --- url: https://simpledatalogs.com/docs/sdks/laravel.md description: >- Track events, Eloquent model changes and sign-ins, forward Laravel log channels, and record requests, jobs, queries and exceptions in simpledatalogs through a disk outbox, with event ids so everything counts once. --- # Laravel The `simpledatalogs/laravel` package sends events, model changes, sign-ins, your Laravel log channels and telemetry (requests, jobs, scheduled tasks, commands, queries, outgoing HTTP and exceptions) to simpledatalogs. Everything only appends to a disk outbox, shipped in batches after each response. Your requests never wait on the network, and every line carries an `event_id` and `ts`, so retries count once. * PHP 8.2+, Laravel 11, 12 or 13 (13 needs PHP 8.3+), Monolog 3. ## Install ```sh composer require simpledatalogs/laravel php artisan vendor:publish --tag=simpledatalogs-config ``` Add a **secret key** to `.env`: ```sh SIMPLEDATALOGS_KEY=sdl_sec_… ``` ::: info Early access The package is published on Packagist with the public launch. Until then, your invitation includes the package source, which you can add as a Composer `path` repository. ::: ## Track events ```php use SimpleDataLogs\Laravel\Facades\SimpleDataLogs; SimpleDataLogs::track('signup', ['plan' => 'pro', 'seats' => 3]); ``` Each event gets an `event_id` and `ts` now, `props.app_env`, and the user's id when one is known (`userId:` argument, `SimpleDataLogs::setUserId()`, or the auth listener below). Props must be strings, numbers or booleans (backed enums send their value); anything else is skipped. `track()` only appends to the disk outbox and never throws. Events tracked from a queue worker, an Artisan command or the scheduler also carry `props.app_state: background`, so the [Sessions tab](/guide/web-analytics) counts them as passive; pass your own `app_state` to override it. Web requests and Octane carry none. ## Model events (opt-in) ```php use SimpleDataLogs\Laravel\TracksModelEvents; class Order extends Model { use TracksModelEvents; protected array $trackedAttributeValues = ['status']; // optional: values allowed out } ``` | Event | Props | |---|---| | `order_created`, `order_deleted`, `order_restored` (soft deletes) | `model` (morph class), `model_id` | | `order_updated` | The same, plus `changed`: the changed attribute **names**, e.g. `status,note`; an update that only touched timestamps sends nothing | Values are never sent, except `attributes.` for attributes listed in `$trackedAttributeValues` and not `$hidden`. Override `trackedEventName($action)` to rename. ## Auth events (opt-in) ```sh SIMPLEDATALOGS_TRACK_AUTH=true ``` Sends `user_logged_in`, `user_logged_out` and `user_registered`, and puts the signed-in user's id (`getAuthIdentifier()`) as `user_id` on every event of the request, including model events. It reads the user only when the guard already resolved one, so it adds no query. ## Forward a channel Add a tap to any channel in `config/logging.php`. The channel keeps writing its own file; the tap adds a copy for simpledatalogs: ```php 'partner_api' => [ 'driver' => 'daily', 'path' => storage_path('logs/partner-api.log'), 'level' => 'debug', 'days' => 90, 'tap' => [\SimpleDataLogs\Laravel\ForwardToSimpleDataLogs::class . ':partner_api'], ], ``` The tap's parameter (`:partner_api`) names the channel, because Laravel names every Monolog logger after `APP_ENV`. It becomes the `channel` prop on each line. Add the same tap, with its own name, to every channel you want forwarded, such as `daily`. ## Telemetry Telemetry is **on by default** from version 1.0 once `SIMPLEDATALOGS_KEY` is set. With no code to add, the package records every request, queued job, scheduled task and Artisan command. Each one becomes one line, a *bundle*, written to the outbox when it ends, with its queries, outgoing HTTP calls, cache counts, mail, notifications and exceptions inside it. A request with everything inside it usually counts as one event. To turn it all off: ```dotenv SIMPLEDATALOGS_TELEMETRY=false ``` | Signal | What is sent | Switch | |---|---|---| | Requests | Method, route pattern (`GET /orders/{order}`), route name, status, duration, peak memory, user id | `SIMPLEDATALOGS_TELEMETRY_REQUESTS` | | Queries | SQL with `?` placeholders and the connection; the same SQL run 37 times is one child with `count: 37`, the summed time and `max_ms` | `SIMPLEDATALOGS_TELEMETRY_QUERIES` | | Outgoing HTTP | `METHOD host`, status and duration, for Laravel's HTTP client | `SIMPLEDATALOGS_TELEMETRY_HTTP` | | Cache | Hit, miss, write and forget counts per store | `SIMPLEDATALOGS_TELEMETRY_CACHE` | | Mail and notifications | Class name, channel and duration | `SIMPLEDATALOGS_TELEMETRY_MAIL`, `SIMPLEDATALOGS_TELEMETRY_NOTIFICATIONS` | | Jobs | Class, queue, connection, attempts, duration and status, linked to the request that dispatched the job | `SIMPLEDATALOGS_TELEMETRY_JOBS` | | Scheduled tasks | Command line, cron expression, interval, duration, status and skipped runs | `SIMPLEDATALOGS_TELEMETRY_TASKS` | | Commands | Name, exit code and duration | `SIMPLEDATALOGS_TELEMETRY_COMMANDS` | | Exceptions | Type, message, whether the app handled it, and up to 50 stack frames | `SIMPLEDATALOGS_TELEMETRY_EXCEPTIONS` | | Log links | `trace_id` and `span_id` on every log record | `SIMPLEDATALOGS_TELEMETRY_LOGS` | `simpledatalogs:ship`, `schedule:run` and long-running workers (`queue:work`, `horizon`, `octane:start` and the like) are not recorded as commands. The lists are `telemetry.ignore_commands` and `telemetry.ignore_jobs` in `config/simpledatalogs.php`. ### What never leaves * Query bindings and SQL literals: `where email = 'a@b.com'` is sent as `where email = ?`. * Headers, cookies, request and response bodies, and the URL path and query string of outgoing calls. * Mail addresses, subjects and bodies, notification payloads, and command arguments. * Route parameters: the route pattern keeps `{order}`, never the value. * Cache keys, unless you set `SIMPLEDATALOGS_TELEMETRY_CACHE_KEYS=true`. Even then, digits and hashes are sent as `*` (`user:*:profile`). `drop_context_keys` applies to telemetry props too. ### Traces Each request, job, task and command gets a trace id. If a request arrives with a W3C `traceparent` header, it joins the caller's trace. A job dispatched during a request carries the trace in its payload, so it appears under that request. Log lines written meanwhile carry the same `trace_id`, so you can go from a log line to its request. Wrap your own work in a span: ```php use SimpleDataLogs\Laravel\Facades\SimpleDataLogs; $pdf = SimpleDataLogs::span('render-pdf', fn () => $renderer->render($invoice), ['pages' => 3]); ``` ### Sampling Every request, job, task and command is sent, with exact totals and its exceptions. Its children (each query and call) are sent when it was slow, when it failed, or one time in ten: | Variable | Default | Meaning | |---|---|---| | `SIMPLEDATALOGS_SLOW_REQUEST_MS` | `500` | Requests slower than this always send their children | | `SIMPLEDATALOGS_SLOW_JOB_MS` | `5000` | The same for jobs, tasks and commands | | `SIMPLEDATALOGS_DETAIL_EVERY` | `10` | Other roots send their children one time in this many | | `SIMPLEDATALOGS_SAMPLE_RATE` | `1` | Below 1, keeps only that share of roots, each with a `sample_weight`; slow and failed ones are always kept | ### Release and grouping Every bundle carries a `release`: `SIMPLEDATALOGS_RELEASE` if set, else the deployed git SHA (from a `REVISION` file or `.git`), else `APP_VERSION`. To group exceptions your own way, set `fingerprint` in `config/simpledatalogs.php` to a callable that takes the exception and returns a string. ### Limits and cost * At most 60 children per bundle; the rest fold into one `(other)` child per kind, so totals stay right. * At most 5 exceptions, 50 frames and 8 KB per stack, and 100 props. * At most 64 KB per bundle. A bigger one drops its children and keeps its totals. * Recording only appends to memory, then writes one line when the request ends. It costs well under half a millisecond for a request with 20 queries, and it never throws. Octane works as is. Queued jobs write their bundle as soon as they finish; for busy workers, schedule `simpledatalogs:ship` as shown below. ## Sending: no cron needed By default the package ships after each response: once Laravel has sent the response, it sends what is waiting for up to 3 seconds. Another process already shipping holds a lock, so busy apps still batch. | `SIMPLEDATALOGS_DELIVERY` | What happens | |---|---| | `terminate` (default) | Ships after each response or Artisan command | | `queue` | Queues one `ShipOutboxJob` (unique for 60 s) on `SIMPLEDATALOGS_QUEUE` | | `schedule` | Leaves it to the scheduled command below | Long-running queue workers terminate only when they stop, so for busy workers also schedule the ship command, which works with every mode: ```php // routes/console.php use Illuminate\Support\Facades\Schedule; Schedule::command('simpledatalogs:ship')->everyMinute()->withoutOverlapping(); ``` Each run ships for about 55 seconds, so lines leave within about 5 seconds. Run one pass by hand with: ```sh php artisan simpledatalogs:ship --once ``` ## What each line holds | Field | From | |---|---| | `event_id` | A UUID set when the record is written | | `ts` | The record's time, with milliseconds and offset | | `level` | The Monolog level, lowercased | | `message` | The record's message | | `props` | The context (minus `drop_context_keys`, or only `context_allowlist`), `channel`, `app_env`, `extra`, and the typed `message_fields` | | `trace_id`, `span_id` | The request, job, task or command the line was written in, when telemetry is on | So `Log::channel('partner_api')->error('Payment failed', ['order_id' => 7])` arrives as an `error` log line with `props.order_id = 7` and `props.channel = "partner_api"`. ## Split the message into fields If a channel logs space-separated values, `message_fields` turns them into typed props. For a line such as `/catalog/products/get_by_sku 1 3`: ```php // config/simpledatalogs.php 'message_fields' => ['endpoint', 'success:bool', 'attempts:int'], ``` Types are `string` (the default), `int`, `float` and `bool`. A message with a different number of tokens is sent without them. To do the same on the server side for any sender, use a [text template](/api/templates). ## Keep personal data out Context arrays often carry more than you'd like. Two settings control what leaves your server: ```php // config/simpledatalogs.php 'drop_context_keys' => ['queries', 'email', 'password'], // never sent 'context_allowlist' => null, // or ['order_id', 'plan'] to send only these ``` `queries` is dropped by default. NDJSON lines are stored as sent, so filter here; see [Privacy for senders](/reference/privacy). ## Configuration Every setting can come from the environment: | Variable | Default | Meaning | |---|---|---| | `SIMPLEDATALOGS_KEY` | — | The deployment's secret ingest key | | `SIMPLEDATALOGS_URL` | `https://edge.simpledatalogs.com/v1/ingest` | The ingest endpoint | | `SIMPLEDATALOGS_OUTBOX_PATH` | `storage/simpledatalogs/outbox` | The disk outbox folder | | `SIMPLEDATALOGS_MAX_OUTBOX_BYTES` | `536870912` (512 MB) | The outbox's disk cap | | `SIMPLEDATALOGS_MAX_FILE_BYTES` | `67108864` (64 MB) | A pending file this big is handed to the shipper | | `SIMPLEDATALOGS_BATCH_LINES` | `50` | Lines per request | | `SIMPLEDATALOGS_BATCH_BYTES` | `262144` | Bytes per request | | `SIMPLEDATALOGS_SHIP_SECONDS` | `55` | How long one ship run keeps shipping | | `SIMPLEDATALOGS_DELIVERY` | `terminate` | `terminate`, `queue` or `schedule` | | `SIMPLEDATALOGS_TERMINATE_SHIP_SECONDS` | `3` | How long shipping after a response may take | | `SIMPLEDATALOGS_QUEUE` | default queue | The queue for `ShipOutboxJob` | | `SIMPLEDATALOGS_TRACK_AUTH` | `false` | The auth listener | | `SIMPLEDATALOGS_TELEMETRY` | `true` | Telemetry as a whole (needs the key) | | `SIMPLEDATALOGS_RELEASE` | git SHA, else `APP_VERSION` | The release on every bundle | `message_fields`, `drop_context_keys`, `context_allowlist`, `fingerprint` and the telemetry lists are set in `config/simpledatalogs.php`. The per-signal and sampling variables are in [Telemetry](#telemetry). ## Delivery * A batch leaves the outbox only after a `202`. At most 50 lines and 256 KB per request, gzipped. * `413` halves the batch, and each `202` doubles it back. A single line too large on its own moves to `outbox/rejected/`. * `415` moves the batch to `outbox/rejected/`, with one error-log line. * `401` pauses shipping for 10 minutes and keeps everything. * `429`, `503` and network errors back off with full jitter, 1 second to 5 minutes, never sooner than `Retry-After`. * Big backlogs stream one batch at a time, so memory stays at about one batch, and a crash resends only the batch in flight. * One shipper per outbox at a time (`flock`). Logging never throws: a failed append goes to PHP's error log. ### Disk caps The outbox never takes more than `SIMPLEDATALOGS_MAX_OUTBOX_BYTES`. Past it, the oldest unsent file goes, with one warning per process. A line over 200 KB is cut down to its `channel`, `app_env` and the first 8 KB of its message. `outbox/rejected/` rotates at 16 MB. ## Next steps * [Text templates](/api/templates): server-side parsing for any sender. * [Sentry](/integrations/sentry): send exceptions from `sentry/sentry-laravel` too. * [Prompt recipes](/agents/recipes#add-simpledatalogs-to-a-laravel-app): let your coding agent set it up. --- --- url: https://simpledatalogs.com/docs/sdks/flutter.md description: >- The simpledatalogs packages for Flutter apps and Dart servers; install, configure, track, screen views, errors, the opt-out, the crash-safe outbox and caps. --- # Flutter and Dart Two packages track events from Flutter apps, Dart servers and scripts. Both keep a crash-safe outbox, so an event is never lost to a crash or a killed app. * `simpledatalogs`: the pure Dart core. Runs on the Dart VM, Flutter (mobile, desktop, web) and servers. One dependency: `http`. * `simpledatalogs_flutter`: optional Flutter wiring. A disk outbox in the app support folder, flushes when the app is hidden, sessions, error capture and screen views. * Dart 3.5+, Flutter 3.24+. ## Install Flutter apps: ```sh flutter pub add simpledatalogs_flutter ``` Dart servers and scripts: ```sh dart pub add simpledatalogs ``` ::: info Early access The packages aren't on pub.dev yet. Until they are, add them from GitHub in `pubspec.yaml`: ```yaml dependencies: simpledatalogs_flutter: git: url: https://github.com/XKD-Labs/simpledatalogs-dart.git path: flutter # The Flutter package asks for the core from pub.dev; point it at GitHub too. dependency_overrides: simpledatalogs: git: url: https://github.com/XKD-Labs/simpledatalogs-dart.git ``` For a Dart server, list only `simpledatalogs` with the same `git` URL under `dependencies`. ::: ## Quick start ```dart import 'package:flutter/material.dart'; import 'package:simpledatalogs_flutter/simpledatalogs_flutter.dart'; Future main() async { // The opt-out first, then identity and default properties, then the key. SimpleDataLogs.setEnabled(userAllowsUsageStatistics); SimpleDataLogs.setDeviceId(anonymousDeviceId); SimpleDataLogs.setDefaultProperties({'app_version': '1.1.0'}); await SimpleDataLogsFlutter.configure(ingestKey: 'sdl_pub_…'); runApp(const MyApp()); } ``` Then, anywhere. It returns at once and never throws: ```dart SimpleDataLogs.track('level_complete', {'level': 3, 'duration_s': 42}); ``` Use a **public key** (`sdl_pub_…`) in apps. The Flutter package logs a warning if it sees a secret key. ## Autocapture One argument sends launches, sessions and errors without any `track` call. It is off until you pass it. ```dart await SimpleDataLogsFlutter.configure( ingestKey: 'sdl_pub_…', autocapture: AutocaptureOptions.all, ); ``` | Option | What you get | |---|---| | `appLaunches` | `app_launched` once per `configure`, with `is_first_launch` | | `sessions` | `session_start`, a `session_id` on every event, and `session_end` (`duration_s`) once the app has been away 30 minutes; a relaunch within 30 minutes resumes the session | | `errors` | Uncaught Flutter and platform errors as `app_error` (`source` = `flutter` or `platform`), after your own handlers | `AutocaptureOptions.all` is all three; pick with `AutocaptureOptions(sessions: true, errors: true)`. * **Screen views:** add the observer to your app. Named page routes send `screen_view` on push, replace and pop; pass `screenNameExtractor` to name them your own way. ```dart MaterialApp( navigatorObservers: [SimpleDataLogsNavigatorObserver()], home: const HomePage(), ) ``` * **By hand:** `SimpleDataLogs.trackScreen('Settings')` and `SimpleDataLogs.trackError(error, stackTrace)`. * The opt-out applies: with `setEnabled(false)`, nothing is captured. ## Active and background With `SimpleDataLogsFlutter.configure`, every event tracked now says whether someone was using the app, so the [Sessions tab](/guide/web-analytics) can tell active sessions from passive ones: * `app_state` is `active` while the app is on screen, else `background`. * `engaged_ms` is the time on screen since the previous event. * Mark background work yourself with `SimpleDataLogs.track('sync_finished', {'app_state': 'background'})`; your value wins. * `reportsActivity: false` sends neither. Plain Dart never does. ## API | Call | What it does | |---|---| | `SimpleDataLogsFlutter.configure(ingestKey:, endpoint:, autocapture:, httpClient:)` | Flutter: starts the SDK with a disk outbox and lifecycle flushes. Later calls are ignored | | `SimpleDataLogs.configure(ingestKey:, endpoint:, autocapture:, storage:, batchInterval:, httpClient:)` | Pure Dart: starts the SDK once. `storage` defaults to memory; `batchInterval` to 10 s in release and AOT builds, 5 s otherwise (debug, profile, `dart run`); `httpClient` swaps the HTTP client (proxies, tests) | | `track(eventName, [properties])` | Records one event. `event_id` and `ts` are stamped now, when the event happens | | `trackScreen(screenName, [properties])` | Records `screen_view` with a `screen` prop | | `trackError(error, [stackTrace, properties])` | Records `app_error` (`exception_type`, `message`, `stack`), at most 5 per message and 50 per run | | `setDeviceId(id)` | Sets, or clears with `null`, the `device_id` on every later event | | `setDefaultProperties(map)` | Replaces the properties merged into every event; the event's own properties win | | `setEnabled(bool)` | The opt-out. `false` ignores new events and **deletes every unsent event**; `true` collects again | | `flush()` | Sends everything now; completes when that attempt ends. Undelivered events stay in the outbox | | `close()` | Stops timers so a script can exit | Property values are strings, integers, finite doubles or booleans. Anything else (null, NaN, lists, maps) is dropped with a log line. For several keys in one process, create `SimpleDataLogsClient` instances; they take the same arguments. ## Dart servers and scripts Pass a disk outbox and a shorter batch interval: ```dart import 'dart:io'; import 'package:simpledatalogs/io.dart'; import 'package:simpledatalogs/simpledatalogs.dart'; Future main() async { SimpleDataLogs.configure( ingestKey: Platform.environment['SIMPLEDATALOGS_KEY']!, storage: FileOutboxStorage(Directory('/var/lib/my-app/simpledatalogs')), batchInterval: const Duration(seconds: 5), ); SimpleDataLogs.track('report_generated', {'rows': 120}); // Scripts send before exiting; anything undelivered stays on disk for the next run. await SimpleDataLogs.flush(); await SimpleDataLogs.close(); } ``` Servers may use a secret key (`sdl_sec_…`). In pure Dart, sessions follow `client.appBecameActive()` and `client.appWentToBackground()`, which the Flutter package calls for you. ## The opt-out Call `setEnabled` **before** `configure`. A user who opted out then never sends a byte, and whatever an earlier run left on disk is deleted instead of sent. The SDK doesn't persist the choice. Your app's setting is the source of truth, so pass it on every launch. ## Privacy * No identifiers are collected for you. `device_id` is only what you set: an anonymous id you keep, never an email or advertising id. * Events are kept in the app's support folder (Flutter) or the folder you choose (Dart), never in shared storage. * The opt-out deletes everything not yet sent. ## How it behaves ### Batching Events go out in batches of 50, or 10 seconds after the first event of a partial batch (5 seconds in debug and profile builds). Every request stays under 256 KB, and bodies of 1 KB or more are gzipped (not on the web). ### A crash-safe outbox * Each event is appended to a journal as it is tracked. * Batches are sealed into files and deleted only after a `202`. * The next run resends anything left over, including a journal cut short by a crash. * A batch that fails to read three times in a row is renamed `*.unreadable` and skipped. * Flutter web keeps the outbox in memory. ### Errors and offline | Answer | What the SDK does | |---|---| | `202` | Deletes the batch file | | `401` | Pauses sending with one error line; nothing is lost. Re-probes after 1 minute, then every 10 minutes, and on the next run | | `413` | Splits the batch in half and resends. A single event still too large is dropped, with a log line | | `415` | Sets the batch aside as `*.unsupported` | | Anything else, a timeout, offline | Backs off with full jitter from 1 second to 5 minutes, honoring `Retry-After` | ### Caps * The outbox keeps at most **10,000 events, 10 MB or 500 batch files**. Past that, the oldest batches are dropped, with a log line. * If more than 5,000 events wait to be written, new ones are skipped, with one log line. Nothing throws into your app. ## Troubleshooting The SDK logs through `dart:developer` under the name `simpledatalogs`. Set `SdkLog.handler` to route its lines into your own logger. For local development, point it at a local ingest: ```dart await SimpleDataLogsFlutter.configure(ingestKey: 'sdl_pub_…', endpoint: Uri.parse('http://localhost:4001')); ``` ## Next steps * [Flutter quickstart](/quickstarts/flutter): the shortest path. * [Core concepts](/guide/concepts#deployments): one deployment per build, such as `android` and `ios`. * [Privacy for senders](/reference/privacy): device ids, not emails. --- --- url: https://simpledatalogs.com/docs/sdks/android.md description: >- The simpledatalogs Android and Kotlin SDK; install, configure, track, autocapture, the opt-out, WorkManager, the JVM server client, the crash-safe outbox and caps. --- # Android The SimpleDataLogs Android SDK tracks events from Android apps in Kotlin or Java. It has no dependencies besides the Kotlin standard library, and keeps a crash-safe outbox on disk, so an event is never lost to a crash or a killed process. * Android 5.0+ (minSdk 21). * Two artifacts: `com.xkdlabs:simpledatalogs` (the Android library) and `com.xkdlabs:simpledatalogs-core` (pure Kotlin/JVM, for servers). * No AndroidX, no OkHttp, no JSON library. ## Install In your app module's `build.gradle.kts`: ```kotlin dependencies { implementation("com.xkdlabs:simpledatalogs:0.1.0") } ``` It comes from Maven Central. The library's manifest adds the `INTERNET` permission. ::: info Early access The library isn't on Maven Central yet. Until it is, build it from GitHub into your local Maven repository: ```sh git clone https://github.com/XKD-Labs/simpledatalogs-android.git cd simpledatalogs-android ./gradlew publishToMavenLocal ``` Then add `mavenLocal()` to your repositories in `settings.gradle.kts`, and keep the `implementation` line above. ::: ## Quick start ```kotlin import com.xkdlabs.simpledatalogs.SimpleDataLogs class MyApplication : Application() { override fun onCreate() { super.onCreate() // The opt-out first, then identity and default properties, then the key. SimpleDataLogs.setEnabled(userAllowsUsageStatistics) SimpleDataLogs.setDeviceId(anonymousDeviceId) SimpleDataLogs.setDefaultProperties(mapOf("app_version" to BuildConfig.VERSION_NAME)) SimpleDataLogs.configure(this, ingestKey = "sdl_pub_…") } } // Anywhere, from any thread; it returns at once. SimpleDataLogs.track("level_complete", mapOf("level" to 3, "is_pro" to true)) ``` Register `MyApplication` with `android:name` on the `` tag. Use a **public key** (`sdl_pub_…`) in apps. From Java, every method is static: `SimpleDataLogs.track("signup", Collections.singletonMap("plan", "pro"));`. ## Autocapture One argument sends launches, sessions, crashes and screen views without any `track` call. It is off until you pass it. ```kotlin SimpleDataLogs.configure(this, ingestKey = "sdl_pub_…", autocapture = Autocapture.ALL) ``` | Option | What you get | |---|---| | `Autocapture.APP_LAUNCHES` | `app_launched` once per process start that shows UI, with `is_first_launch` on the very first | | `Autocapture.SESSIONS` | `session_start`, a `session_id` on every event, and `session_end` (`duration_s`) once the app has been in the background 30 minutes | | `Autocapture.CRASHES` | An uncaught exception is saved at the crash and sent as `app_crashed` (`exception_name`, `reason`, `stack`) on the next launch; your crash reporter still runs | | `Autocapture.SCREEN_VIEWS` | `screen_view` with `screen` = the activity's class name each time an activity resumes | `Autocapture.ALL` is all four; combine with `+` to pick, e.g. `Autocapture.SESSIONS + Autocapture.CRASHES`. * **Screen views by hand:** `SimpleDataLogs.trackScreen("Settings")`. Use it for Compose destinations and fragments, which activity callbacks can't see. * A rotation is not a trip to the background, so it neither flushes nor touches the session. * Native (NDK) crashes and ANRs are not captured. * The opt-out applies: with `setEnabled(false)`, nothing is captured and a saved crash is discarded. ## Active and background Every event tracked now says whether someone was using the app, so the [Sessions tab](/guide/web-analytics) can tell active sessions from passive ones: * `app_state` is `active` while one of your activities is started, else `background` (a worker, a service, a receiver). * `engaged_ms` is the time in front since the previous event. * Mark background work yourself with `track("sync_finished", mapOf("app_state" to "background"))`; your value wins. * `configure(..., activity = ActivityReporting.OFF)` sends neither. The plain JVM client never does. ## API | Call | What it does | |---|---| | `configure(context, ingestKey, endpoint, autocapture)` | Starts the SDK once; `autocapture` defaults to `Autocapture.NONE`, `endpoint` to `https://edge.simpledatalogs.com`. Later calls are ignored with a warning | | `track(eventName, properties)` | Records one event. `event_id` and `ts` are stamped now, when the event happens | | `trackScreen(screenName, properties)` | Records `screen_view` with a `screen` prop | | `setDeviceId(deviceId)` | Sets, or clears with `null`, the `device_id` on every later event | | `setDefaultProperties(properties)` | Replaces the properties merged into every event; the event's own properties win on a clash | | `setEnabled(isEnabled)` | The opt-out. `false` ignores new events and **deletes every unsent event**; `true` collects again | | `flush()` | Starts sending everything now, without waiting | | `flushBlocking(timeoutMillis)` | Sends everything and waits; `true` when nothing is left. On the main thread it returns `false` at once | | `installReferrer(context, callback)` | Reads the Play Store install referrer once and calls back on the main thread with it, or `null`. Needs `com.android.installreferrer:installreferrer:2.2` in your app | Property values are `String`, `Int`, `Long`, `Short`, `Byte`, `Float`, `Double` or `Boolean`. Anything else, and NaN or infinity, is dropped with a log line. ## The opt-out Call `setEnabled` **before** `configure`. A user who opted out then never sends a byte, and whatever an earlier launch left on disk is deleted instead of sent. The SDK doesn't persist the choice. Your app's setting is the source of truth, so pass it on every launch. ## Installs from shared links To learn which shared link led to an install, add `com.android.installreferrer:installreferrer:2.2` to your app, then on the first launch: ```kotlin SimpleDataLogs.installReferrer(context) { installReferrer -> SimpleDataLogs.track("app_installed", mapOf("install_referrer" to installReferrer)) } ``` A `null` referrer is left out of the props, so the event still counts. See [Share links and installs](/guide/share-links). ## Sending from WorkManager The SDK flushes when the app goes to the background and resends leftovers on the next launch. To also deliver while the app stays closed, flush from a worker (add `androidx.work:work-runtime-ktx` yourself): ```kotlin class FlushEventsWorker(context: Context, parameters: WorkerParameters) : Worker(context, parameters) { override fun doWork(): Result { return if (SimpleDataLogs.flushBlocking(20_000)) Result.success() else Result.retry() } } ``` Schedule it as unique periodic work, for example every 6 hours with a network constraint. ## Servers and other JVM apps `simpledatalogs-core` is pure Kotlin/JVM (Java 8 bytecode) with the same outbox and rules, and sends every 5 seconds: ```kotlin dependencies { implementation("com.xkdlabs:simpledatalogs-core:0.1.0") } ``` ```kotlin import com.xkdlabs.simpledatalogs.SimpleDataLogsClient val analytics = SimpleDataLogsClient( ingestKey = System.getenv("SIMPLEDATALOGS_KEY"), storageDirectory = File("/var/lib/myapp/simpledatalogs"), ) analytics.track("invoice_paid", mapOf("plan" to "pro", "amount_cents" to 4_900)) // On shutdown: a last flush of up to 5 s; undelivered events stay on disk. Runtime.getRuntime().addShutdownHook(Thread { analytics.close() }) ``` Use a secret key (`sdl_sec_…`) on servers, and give each process its own `storageDirectory`. The constructor also takes `endpoint` and `batchIntervalMillis` (5,000 by default); `shutdown(timeoutMillis)` is `close()` with your own flush limit. ## Privacy * The SDK sends only the events you track and the autocapture you turn on. It reads no advertising id, Android id, location, contacts or installed apps. * `device_id` is sent only if you set one; use an anonymous id you generate. * The outbox lives in `noBackupFilesDir/simpledatalogs/`, so Auto Backup never copies unsent events. * Crash reports hold the exception class, its message and up to 40 stack frames. If messages can hold personal data, leave `CRASHES` off. ## How it behaves ### Batching Events go out in batches of 50, or 10 seconds after the first event of a partial batch (5 seconds while the app is debuggable). Every request stays under 256 KB, and bodies of 1 KB or more are gzipped. `track` hands the event to one background thread; disk and network never run on the caller's thread. ### A crash-safe outbox * Each event is appended to a journal on disk as it is tracked. * Batches are sealed into files and deleted only after a `202`. * The next launch resends anything left over, including a journal cut short by a crash. * A batch file that fails to read three times in a row is renamed `*.unreadable` and skipped. ### Errors and offline | Answer | What the SDK does | |---|---| | `202` | Deletes the batch file | | `401` | Pauses sending and logs one configuration error; nothing is lost. Re-probes after 1 minute, then every 10 minutes, and on the next launch | | `413` | Splits the batch in half and resends. A single event still too large is dropped, with a log line | | `415` | Sets the batch aside as `*.unsupported` and moves on | | Anything else, a timeout, offline | Backs off with full jitter from 1 second to 5 minutes, honoring `Retry-After` | ### Caps * The outbox keeps at most **10,000 events, 10 MB or 500 batch files**. Past that, the oldest batches are dropped, with a log line. * If more than 5,000 events wait to be written, new ones are skipped, with one log line. No public method throws, and nothing blocks your app. ## Troubleshooting The SDK logs to Logcat under the tag `simpledatalogs`: ```sh adb logcat -s simpledatalogs ``` To send to a local ingest from the emulator: ```kotlin SimpleDataLogs.configure(this, ingestKey = "sdl_pub_…", endpoint = "http://10.0.2.2:4001") ``` Plain `http` needs a network security config that allows cleartext to that host. ## Next steps * [Android quickstart](/quickstarts/android): the shortest path. * [Core concepts](/guide/concepts#deployments): one deployment per build, such as `play` and `beta`. * [Privacy for senders](/reference/privacy): device ids, not emails. --- --- url: https://simpledatalogs.com/docs/sdks/unity.md description: >- The simpledatalogs Unity package for games and apps; install by git URL or OpenUPM, configure, track game events, autocapture sessions, scenes and errors, the opt-out, the outbox and caps. --- # Unity The `com.xkdlabs.simpledatalogs` package tracks events from Unity games and apps. It has no dependencies, uses no reflection, and keeps a crash-safe outbox on disk, so an event is never lost to a crash or a killed app. * Unity 2021.3 or newer. * Android, iOS, desktop and WebGL players, on Mono and IL2CPP. ## Install In Unity, open **Window → Package Manager**, click **+**, choose **Add package from git URL…** and enter: ```text https://github.com/XKD-Labs/simpledatalogs-unity.git#v0.1.0 ``` Or with [OpenUPM](https://openupm.com): ```sh openupm add com.xkdlabs.simpledatalogs ``` The Package Manager also offers a **Quick start** sample with a ready-made MonoBehaviour. ::: info Early access The package isn't on OpenUPM yet, and the `v0.1.0` tag comes with the first release. Until then, add the git URL without the tag: ```text https://github.com/XKD-Labs/simpledatalogs-unity.git ``` ::: ## Quick start Configure once at launch, on the main thread, for example in `Awake` of a bootstrap object: ```csharp using System.Collections.Generic; using UnityEngine; using XKDLabs.SimpleDataLogs; public sealed class Analytics : MonoBehaviour { private void Awake() { // The opt-out first, then identity and default properties, then the key. SimpleDataLogs.SetEnabled(userAllowsUsageStatistics); SimpleDataLogs.SetDeviceId(anonymousDeviceId); SimpleDataLogs.SetDefaultProperties(new Dictionary { ["app_version"] = Application.version, }); SimpleDataLogs.Configure("sdl_pub_…"); } } ``` Then track from anywhere, on any thread. It returns at once and never throws: ```csharp SimpleDataLogs.Track("level_complete", new Dictionary { ["level"] = 3, ["duration_s"] = 41, ["score"] = 1200 }); ``` Use a **public key** (`sdl_pub_…`) in games. The SDK logs a warning if it sees a secret key. ## Events for games ```csharp SimpleDataLogs.Track("level_start", new Dictionary { ["level"] = 3 }); SimpleDataLogs.Track("purchase", new Dictionary { ["sku"] = "gem_pack_small", ["price"] = 0.99, ["currency"] = "USD" }); SimpleDataLogs.Track("tutorial_step", new Dictionary { ["step"] = 2 }); // A screen that is not its own scene, such as a menu panel. SimpleDataLogs.TrackScreen("Shop"); ``` Property values are strings, integers, floats, doubles or booleans. Anything else (null, lists, dictionaries, NaN, infinity) is dropped with a console line. ## Autocapture One argument sends launches, sessions, scene views and errors without any `Track` call. It is off until you pass it. ```csharp SimpleDataLogs.Configure("sdl_pub_…", autocapture: AutocaptureOptions.All); ``` | Option | What you get | |---|---| | `AppLaunches` | `app_launched` on every launch, with `is_first_launch` | | `Sessions` | `session_start`, a `session_id` on every event, and `session_end` (`duration_s`) once the game has been away 30 minutes; pause and focus loss count as away | | `SceneViews` | `screen_view` with `screen` = the scene name, each time a scene loads (additive loads too) | | `Errors` | `app_error` (`message`, `stack`, `log_type`) for logged exceptions and errors; at most 5 per message and 50 per session | `All` is all four; combine flags to pick, e.g. `AutocaptureOptions.Sessions | AutocaptureOptions.SceneViews`. * Nothing starts on its own: nothing is sent before you call `Configure`. * The opt-out applies: with `SetEnabled(false)`, nothing is captured. ## Active and background Every event tracked now says whether someone was playing, so the [Sessions tab](/guide/web-analytics) can tell active sessions from passive ones: * `app_state` is `active` while the game is unpaused and focused, else `background`. * `engaged_ms` is the time in front since the previous event. * Mark background work yourself with an `app_state` of `background`; your value wins. * `Configure(key, reportsActivity: false)` sends neither. ## API | Call | What it does | |---|---| | `Configure(ingestKey, endpoint, autocapture)` | Starts the SDK once; `autocapture` defaults to none, `endpoint` to `https://edge.simpledatalogs.com`. Later calls are ignored | | `Track(eventName, properties)` | Records one event. `event_id` and `ts` are stamped now, when the event happens | | `TrackScreen(screenName, properties)` | Records `screen_view` with a `screen` prop | | `SetDeviceId(deviceId)` | Sets, or clears with `null`, the `device_id` on every later event | | `SetDefaultProperties(properties)` | Replaces the properties merged into every event; the event's own properties win on a clash | | `SetEnabled(isEnabled)` | The opt-out. `false` ignores new events and **deletes every unsent event**; `true` collects again | | `Flush()` | Sends everything now. Undelivered events stay on disk | ## The opt-out Call `SetEnabled` **before** `Configure`. A player who opted out then never sends a byte, and whatever an earlier launch left on disk is deleted instead of sent. The SDK doesn't persist the choice. Your game's setting is the source of truth, so pass it on every launch. ## Privacy * The SDK sends only what you track, plus the device id and session id when you set or enable them. * It never reads `SystemInfo.deviceUniqueIdentifier` or advertising ids. Pick your own anonymous id; the sample keeps a random one in `PlayerPrefs`. * Error autocapture sends log messages as they are, so keep personal data out of them, or leave `Errors` off. ## How it behaves ### Batching Events go out in batches of 50, or 10 seconds after the first event of a partial batch (5 seconds in the Editor and development builds). Every request stays under 256 KB, and bodies of 1 KB or more are gzipped (except on WebGL). ### A crash-safe outbox * Each event is appended to a journal on disk as it is tracked. * Batches are sealed into files and deleted only after a `202`. * The next launch resends anything left over, including a journal cut short by a crash. * Files live in `Application.persistentDataPath/simpledatalogs/`. ### Lifecycle A hidden `DontDestroyOnLoad` object ticks the SDK a few times a second (ignoring `Time.timeScale`) and flushes when the game pauses, loses focus or quits. Anything a flush can't deliver in time stays on disk for the next launch. ### Errors and offline | Answer | What the SDK does | |---|---| | `202` | Deletes the batch file | | `401` | Pauses sending and logs one configuration error; nothing is lost. Re-probes after 1 minute, then every 10 minutes, and on the next launch | | `413` | Splits the batch in half and resends. A single event still too large is dropped, with a console line | | `415` | Sets the batch aside as `.unsupported` and moves on | | Anything else, a timeout, offline | Backs off with full jitter from 1 second to 5 minutes, honoring `Retry-After` | ### Caps The outbox keeps at most **10,000 events, 10 MB or 500 batch files**. Past that, the oldest batches are dropped, with a console line. No call throws into your game. ## Platform notes * **IL2CPP and code stripping.** No reflection and a hand-written JSON encoder, so no `link.xml` is needed. * **WebGL.** Requests go through UnityWebRequest, which uses the browser's networking, so there's no gzip or `User-Agent`. The outbox lives in the browser's IndexedDB-backed `persistentDataPath`. * **Enter Play Mode Options.** With domain reload off, the SDK resets when play mode starts, so call `Configure` in each play session. ## Troubleshooting Problems are logged to the Unity console with the prefix `[simpledatalogs]`. A `401` there means the key is wrong or not live yet; events are kept and sent once it works. For local development, point it at a local ingest: ```csharp SimpleDataLogs.Configure("sdl_pub_…", endpoint: "http://localhost:4001"); ``` ## Next steps * [Unity quickstart](/quickstarts/unity): the shortest path. * [Core concepts](/guide/concepts#deployments): one deployment per build, such as `steam` and `ios`. * [Privacy for senders](/reference/privacy): device ids, not emails. --- --- url: https://simpledatalogs.com/docs/sdks/python.md description: >- The simpledatalogs Python package for servers, workers and scripts; install, track events, send log records, Django, Flask, FastAPI, the disk outbox, delivery and caps. --- # Python The `simpledatalogs` package sends events and log lines from Python servers, workers and scripts. It uses only the standard library, is typed, and never raises into your app. * Python 3.9+. * A `logging.Handler` for Django, Flask or any logger. * An `AsyncClient` for FastAPI and other asyncio apps. ## Install ```sh pip install simpledatalogs ``` ::: info Early access The package isn't on PyPI yet. Until it is, install it from GitHub: ```sh pip install "git+https://github.com/XKD-Labs/simpledatalogs-python.git" ``` ::: ## Quick start ```python import simpledatalogs sdl = simpledatalogs.init() # reads SIMPLEDATALOGS_KEY sdl.track("signup", {"plan": "pro"}, user_id=42) sdl.log("error", "Payment failed", {"order_id": 7}) ``` A background thread sends every 5 seconds, and whatever is left goes out when the process exits. Use a **secret key** (`sdl_sec_…`) on servers. `simpledatalogs.init()` creates the process-wide client; `simpledatalogs.Client(key, **options)` creates one you hold yourself. Without a key the client is off and says so once. ## Events ```python sdl.track("checkout", {"plan": "pro", "seats": 3, "trial": False}, user_id=user.id) sdl.track("import_done", {"rows": 1200}, timestamp=finished_at) # a datetime or epoch seconds ``` * Every line gets a UUID `event_id` and a `ts` when you call `track()` or `log()`, so a retry never counts twice. * Props are strings, finite numbers or booleans (enums send their value); anything else is skipped with a warning. At most 100 props. ## Logs `SimpleDataLogsHandler` is a `logging.Handler`. Each record becomes one log line with its level and time. ```python import logging import simpledatalogs simpledatalogs.init() logging.getLogger().addHandler(simpledatalogs.SimpleDataLogsHandler(level=logging.INFO)) logging.getLogger("payments").error("Payment failed", extra={"order_id": 7, "user_id": 42}) ``` * Props: `logger`, scalar `extra=` values, and for exceptions `exception.type`, `exception.message` and `stacktrace` (the last 8 KB). * Levels map to `debug`, `info`, `warning`, `error` and `critical`. Pass `include_extras=False` to send no extras. * The handler only queues; it never waits on the network. ### Django ```python # settings.py import simpledatalogs simpledatalogs.init() LOGGING = { "version": 1, "handlers": {"simpledatalogs": {"class": "simpledatalogs.SimpleDataLogsHandler", "level": "WARNING"}}, "root": {"handlers": ["simpledatalogs"], "level": "INFO"}, } ``` With gunicorn or uWSGI, each worker process gets its own sending thread after the fork. ### Flask ```python import logging import simpledatalogs from flask import Flask sdl = simpledatalogs.init() app = Flask(__name__) app.logger.addHandler(simpledatalogs.SimpleDataLogsHandler(sdl, level=logging.WARNING)) ``` ### FastAPI `AsyncClient` takes the same options. Its sends run in a worker thread, so the event loop never waits on the network. ```python from contextlib import asynccontextmanager import simpledatalogs from fastapi import FastAPI sdl = simpledatalogs.AsyncClient() @asynccontextmanager async def lifespan(app: FastAPI): yield await sdl.aclose() app = FastAPI(lifespan=lifespan) ``` `await sdl.flush()` sends now. ## Users Identity is per call: `user_id`, `device_id`, `session_id` and `anonymous_id`, on `track()` and `log()`. In log records, the same names in `extra=` fill the identity fields. ## The opt-out ```python sdl.set_enabled(False) # deletes every unsent line, from disk too, and ignores new ones sdl.set_enabled(True) # collects again ``` To keep a client silent in tests or local runs, pass `enabled=False`. ## Options | Option | Default | What it does | |---|---|---| | `key` | `SIMPLEDATALOGS_KEY` | The ingest key | | `endpoint` | `SIMPLEDATALOGS_ENDPOINT`, else `https://edge.simpledatalogs.com` | The ingest host; `/v1/ingest` is added | | `flush_interval_seconds` | `5` | How often the background thread sends | | `max_batch_events` | `50` | Lines per request | | `max_request_bytes` | `256000` | Bytes per request before gzip | | `max_queue_events` / `max_queue_bytes` | `10000` / `10000000` | The queue's caps | | `outbox_dir` | none | A directory for the disk outbox | | `default_props` | none | Props added to every line, under the call's own | | `gzip` | `True` | Gzips bodies of 1 KB and more | | `request_timeout_seconds` | `15` | Per request | | `flush_at_exit` / `exit_timeout_seconds` | `True` / `5` | Flush at interpreter exit, for at most this long | | `enabled` | `True` | `False` keeps the client silent | ## Privacy * The SDK sends only what you pass: no IP addresses, hostnames, environment variables or request data. * `extra=` values reach ingest as you logged them. Leave out personal data, or pass `include_extras=False`. * See [Privacy for senders](/reference/privacy) for what belongs in `user_id`. ## How it behaves ### Batching Every 5 seconds, or at once when 50 lines wait. Each request stays under 256 KB before gzip. ### Flush and exit * `sdl.flush(timeout=5)` sends now and returns `True` when nothing is left. * `sdl.close()` flushes and stops the thread. * At interpreter exit the client flushes for up to 5 seconds. Call `close()` yourself in workers that exit through `os._exit`. ### Disk outbox The queue lives in memory unless you give it a directory: ```python sdl = simpledatalogs.init(outbox_dir="/var/lib/myapp/simpledatalogs") ``` * Each line is appended to a file as it is tracked and leaves after a `202`, so a crash or a deploy loses nothing. * Each process keeps its own locked file. A file whose process is gone is picked up by the next client on the same directory. * It needs POSIX file locks (Linux, macOS); elsewhere the client warns and keeps lines in memory. ### Errors and offline | Answer | What the SDK does | |---|---| | `202` | Removes the lines | | `401` | Keeps every line and re-checks the key after 1 minute, then every 10 minutes | | `413` | Halves the batch; a single line still too large is dropped | | `415` | Drops the batch, with one warning | | Anything else, a timeout, offline | Backs off with full jitter from 1 second to 5 minutes, never sooner than `Retry-After` | ### Caps The queue keeps at most **10,000 lines or 10 MB**. Past that, the oldest unsent lines are dropped, with a warning. ## Troubleshooting Problems are logged once each on the `simpledatalogs` logger. For local development, set `SIMPLEDATALOGS_ENDPOINT=http://localhost:4001`. ## Next steps * [Python quickstart](/quickstarts/python): the shortest path. * [Text log lines](/api/log-lines): how levels and messages are stored. * [Privacy for senders](/reference/privacy): ids, not emails. --- --- url: https://simpledatalogs.com/docs/sdks/go.md description: >- The simpledatalogs Go module for services, workers and CLIs; install, track events, a slog handler, Flush and Close, delivery and caps. --- # Go The `github.com/XKD-Labs/simpledatalogs-go` module sends events and log lines from Go services, workers and CLIs. It uses only the standard library and never panics into your app. * Go 1.22+. * A `slog.Handler` for your logs. * A nil `*Client` is a no-op, so a missing key never crashes the app. ## Install ```sh go get github.com/XKD-Labs/simpledatalogs-go ``` ::: info Early access The module has no tagged release yet. Until it does, fetch the latest commit: ```sh go get github.com/XKD-Labs/simpledatalogs-go@main ``` ::: ## Quick start ```go import simpledatalogs "github.com/XKD-Labs/simpledatalogs-go" sdl, err := simpledatalogs.New(simpledatalogs.Config{}) // reads SIMPLEDATALOGS_KEY if err != nil { log.Printf("simpledatalogs is off: %v", err) } defer sdl.Close(context.Background()) sdl.Track("signup", simpledatalogs.Props{"plan": "pro"}, simpledatalogs.WithUserID("42")) sdl.Log(simpledatalogs.LevelError, "Payment failed", simpledatalogs.Props{"order_id": 7}) ``` A background goroutine sends every 5 seconds. `Close` sends what is left; call it before `main` returns. Use a **secret key** (`sdl_sec_…`) on servers. `New` returns an error only for a missing key or a malformed endpoint, and then a nil `*Client` whose methods do nothing. ## Events ```go sdl.Track("checkout", simpledatalogs.Props{"plan": "pro", "seats": 3, "trial": false}, simpledatalogs.WithUserID(strconv.Itoa(user.ID)), simpledatalogs.WithTime(finishedAt), ) ``` * Every line gets a UUID `event_id` and a `ts` when you call `Track` or `Log`, so a retry never counts twice. * Props are strings, numbers, booleans and named types over them; `fmt.Stringer`s send their text. Anything else is skipped with a warning. At most 100 props. ## Logs (slog) ```go logger := slog.New(simpledatalogs.NewHandler(sdl, &simpledatalogs.HandlerOptions{Level: slog.LevelWarn})) logger.Error("Payment failed", "order_id", 7, "user_id", user.ID, "err", err) ``` * Each record becomes one log line with its level, time and message. * Attributes become props; groups become dotted names (`http.status`). Errors and `Stringer`s send their text. * Levels map to `debug`, `info`, `warning`, `error`, and `critical` from `slog.LevelError+4`. * To keep your current output too, fan out to both handlers. `sdl.Log` takes `LevelDebug`, `LevelInfo`, `LevelNotice`, `LevelWarning`, `LevelError` or `LevelCritical`. ## Users Identity is per call: `WithUserID`, `WithDeviceID`, `WithSessionID` and `WithAnonymousID`. In slog records, top-level `user_id`, `device_id`, `session_id` and `anonymous_id` attributes fill the same fields. ## The opt-out ```go sdl.SetEnabled(false) // deletes every unsent line and ignores new ones sdl.SetEnabled(true) // collects again ``` To keep a client silent in tests or local runs, set `Disabled: true` in `Config`. ## Flush and Close ```go ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := sdl.Close(ctx); err != nil { log.Printf("simpledatalogs: %v", err) // errors.Is(err, simpledatalogs.ErrUndelivered) } ``` * With a deadline, `Flush` and `Close` retry until the queue is empty or the deadline passes. * Without one, they try once and stop at the first failure. * In an HTTP server, call `Close` after `server.Shutdown`, so the last requests' events go too. * `sdl.Pending()` counts lines not yet confirmed. ## Configuration | `Config` field | Default | What it does | |---|---|---| | `Key` | `SIMPLEDATALOGS_KEY` | The ingest key | | `Endpoint` | `SIMPLEDATALOGS_ENDPOINT`, else `https://edge.simpledatalogs.com` | The ingest host; `/v1/ingest` is added | | `FlushInterval` | `5s` | How often the background goroutine sends | | `MaxBatchEvents` | `50` | Lines per request | | `MaxRequestBytes` | `256000` | Bytes per request before gzip | | `MaxQueueEvents` / `MaxQueueBytes` | `10000` / `10000000` | The queue's caps | | `RequestTimeout` | `15s` | Per request | | `DisableGzip` | `false` | Bodies of 1 KB and more are gzipped unless set | | `DefaultProps` | none | Props added to every line, under the call's own | | `HTTPClient` | its own | Copied; redirects are never followed | | `Logger` | text on stderr | Where the SDK's own warnings go, once per kind | | `Disabled` | `false` | Keeps the client silent | ## Privacy * The SDK sends only what you pass: no IP addresses, hostnames, environment variables or request data. * slog attributes reach ingest as you logged them, so leave personal data out of them. * See [Privacy for senders](/reference/privacy) for what belongs in `user_id`. ## How it behaves ### Batching Every 5 seconds, or at once when 50 lines wait. Each request stays under 256 KB before gzip. No call waits on the network. ### Memory only The queue lives in memory, so lines still queued when the process dies are lost. For log files that must survive crashes, ship the files with [Vector or Fluent Bit](/integrations/log-shippers). ### Errors and offline | Answer | What the SDK does | |---|---| | `202` | Removes the lines | | `401` | Keeps every line and re-checks the key after 1 minute, then every 10 minutes | | `413` | Halves the batch; a single line still too large is dropped | | `415` | Drops the batch, with one warning | | Anything else, a timeout, offline | Backs off with full jitter from 1 second to 5 minutes, never sooner than `Retry-After` | ### Caps The queue keeps at most **10,000 lines or 10 MB**. Past that, the oldest unsent lines are dropped, with a warning. ## Troubleshooting The SDK's warnings go to stderr, once per kind; set `Config.Logger` to route them elsewhere. For local development, set `SIMPLEDATALOGS_ENDPOINT=http://localhost:4001`. Package docs are on [pkg.go.dev](https://pkg.go.dev/github.com/XKD-Labs/simpledatalogs-go). ## Next steps * [Go quickstart](/quickstarts/go): the shortest path. * [Text log lines](/api/log-lines): how levels and messages are stored. * [Privacy for senders](/reference/privacy): ids, not emails. --- --- url: https://simpledatalogs.com/docs/mcp.md description: >- The simpledatalogs MCP server lets Claude, ChatGPT, Grok, Cursor and other AI assistants read your event counts and logs, and lets a coding agent set up projects and keys. --- # MCP server The simpledatalogs MCP server lets your AI assistant read your data. Connect it once, then ask in plain language: > How many signups did the iOS app get this week, compared with last week? > > Show me the last 20 error logs from production. > > Which events grew the most in the last 7 days? The assistant calls the server's tools, gets exact numbers from the same queries the dashboard uses, and answers. It never guesses a number. ## The server | | | |---|---| | URL | `https://simpledatalogs.com/mcp` | | Transport | Streamable HTTP, JSON answers (no SSE stream, no sessions) | | Protocol | `2025-11-25`, `2025-06-18` and `2025-03-26` | | Access | **Read-only**, and it reads only what you can. A [setup token](#setup-tokens-for-a-first-setup) can also create projects and keys | | Plans | Starter, Pro and Scale. Organizations on Free are left out of every tool | ## Two ways to sign in Which one you use depends on your client. ### OAuth, for chat apps Claude (web, desktop and mobile), ChatGPT and Grok connect with OAuth. You paste the URL, and the app sends you to simpledatalogs to approve: 1. In the app, add a custom connector with the URL `https://simpledatalogs.com/mcp`. 2. Choose **Connect**. A simpledatalogs page opens; sign in if you aren't already. 3. Pick the organization the app may read, if you belong to more than one, and choose **Approve**. That's it. The app now reads that organization's data on your behalf, read-only. See [Connecting clients](/mcp/clients) for each app's exact steps. ### A personal token, for coding tools Claude Code, Cursor, the xAI API and your own scripts send a header instead. Create a personal token, `sdl_mcp_…`, and pass it as `Authorization: Bearer sdl_mcp_…`: 1. In the dashboard, open **Settings → MCP and agents** and choose **Manage tokens**. 2. Name the token, for example `Laptop agent`, and choose **Create token**. Creating one asks you to have signed in recently. 3. Copy it now: only a hash is stored. An **Install in your agent** dialog opens with the token filled into ready-made snippets for Claude Code, Cursor, VS Code, Codex, Windsurf, any other client and curl. ```sh claude mcp add --transport http simpledatalogs https://simpledatalogs.com/mcp \ --header "Authorization: Bearer sdl_mcp_…" ``` You can hold up to 10 tokens. Each one reads everything you can read. ### Setup tokens, for a first setup Tick **Create a temporary setup token instead** when you create a token, and your coding agent can also set the app up for you: create the project, its environments and deployments, make an ingest key and write it to the project's `.env`. Ask it, for example: > Set up simpledatalogs for this app: make a project, a key for production, and wire the SDK. A setup token is deliberately narrow: * It creates projects, environments, deployments and keys. It never edits or deletes anything, invites people, or touches billing, alerts or exports. * It can revoke only keys it made itself, for example one that leaked. * It expires 24 hours after you make it. Make another for the next setup. * It makes at most 100 projects, 5 environments per project, 10 deployments per environment and 2 active keys per deployment, at 10 setup calls a minute. Your plan's own limits apply too, and every agent that uses the token shares them. * Every setup answer carries `setup_budget` (`projects_left`, `environments_per_project`, `deployments_per_environment`, `keys_per_deployment`, `calls_per_minute`), and the server's instructions tell agents the limits before they start, so they plan instead of running into them. * It works only where you can create: not as a viewer, and not in organizations on Free. * Chat apps connected with OAuth never get it. Each create takes an `idempotency_key`, so a retried call never makes a second project or key. Keys a setup token made show **via MCP** and the token's name on the environment's Deployments & keys tab. ::: tip Which should I use? Use OAuth wherever the client offers it. Use a token for clients that only take headers, or for scripts. The two never mix: an OAuth token isn't a personal token, and the other way round. ::: ## What it can and can't read The server reads events, log lines, totals and series for the environments you are a member of. It never reads existing ingest keys, billing or team settings, and only a setup token can create anything. * Every answer is bounded: at most 90 days back and 100 rows. * Each user can make 60 calls a minute; past that the server answers `429` with `Retry-After: 60`. * Browsers may only call it from the dashboard's own origin. Agents and chat apps send no `Origin`, so they are unaffected. ## Revoking access * **A chat app:** open **Settings → MCP and agents** in the dashboard, choose **Connected apps**, find the app, and choose **Disconnect**. Its tokens stop working at once. Removing the connector inside the app alone doesn't revoke it. * **A personal token:** open **Settings → MCP and agents → Manage tokens** and revoke it. Any client using it gets `401` on its next call. * Leaving an organization stops every read of it at once. ## Next steps * [Connecting clients](/mcp/clients): step-by-step for Claude, ChatGPT, Grok, Claude Code, Cursor and others. * [Tools reference](/mcp/tools): every tool and its inputs. * [Coding agents](/agents/): have an agent add tracking to your app, then read the numbers back. --- --- url: https://simpledatalogs.com/docs/mcp/clients.md description: >- Step-by-step setup of the simpledatalogs MCP server in Claude, ChatGPT, Grok, Claude Code, Cursor, any mcp.json client and the xAI API. --- # Connecting clients Every client uses the same URL: ```text https://simpledatalogs.com/mcp ``` Chat apps sign in with **OAuth**: you paste the URL and approve. Coding tools and APIs send a **personal token** in a header. See [the overview](/mcp/#two-ways-to-sign-in) for the difference. ::: info Menu names change Chat apps rename their settings pages from time to time. If a step below doesn't match what you see, look for "connectors", "apps" or "MCP" in the app's settings, or its help pages. ::: ## Chat apps (OAuth) ### Claude Works in Claude on the web, the desktop app and mobile. Connectors you add on the web or desktop also appear on your phone. 1. Open **Settings → Connectors** and choose **Add custom connector**. 2. Name it `simpledatalogs` and paste `https://simpledatalogs.com/mcp` as the URL. Leave the advanced OAuth fields empty. 3. Choose **Add**, then **Connect**. 4. On the simpledatalogs page, sign in if asked, pick the organization, and choose **Approve**. In a chat, turn the connector on from the tools menu and ask away. On Team and Enterprise plans, an owner may need to add the connector for the organization first. ### ChatGPT Custom connectors live in ChatGPT's developer mode, on the plans that offer it. 1. Open **Settings → Apps & Connectors** and, under **Advanced settings**, turn on **Developer mode**. 2. Choose **Create** (a new connector or app). 3. Name it `simpledatalogs`, paste `https://simpledatalogs.com/mcp` as the URL, and choose **OAuth** as the authentication. 4. Create it, then sign in and choose **Approve** on the simpledatalogs page. Every tool is marked read-only, so ChatGPT runs them without asking you to confirm each call. ### Grok 1. Go to [grok.com/connectors](https://grok.com/connectors) and add a new **custom** connector. 2. Paste `https://simpledatalogs.com/mcp` as the server URL. 3. Sign in and choose **Approve** in the pop-up. Allow pop-ups from grok.com if your browser blocks it. ## Coding tools (personal token) First create a token in the dashboard under **Settings → MCP and agents → Manage tokens**. The **Install in your agent** dialog then shows each snippet below with your token filled in. The examples here use `sdl_mcp_…`; replace it with yours. ::: warning Keep tokens out of git A token reads everything you can read. Put it in an environment variable or your tool's user-level config, never in a file you commit. ::: ### Claude Code ```sh claude mcp add --transport http simpledatalogs https://simpledatalogs.com/mcp \ --header "Authorization: Bearer sdl_mcp_…" ``` Add `--scope user` to make it available in every project. Check it with `claude mcp list`, or `/mcp` inside a session. ### Cursor Add the server to `~/.cursor/mcp.json` (all projects) or `.cursor/mcp.json` (one project): ```json { "mcpServers": { "simpledatalogs": { "url": "https://simpledatalogs.com/mcp", "headers": { "Authorization": "Bearer sdl_mcp_…" } } } } ``` ### VS Code Add the server to `.vscode/mcp.json`. VS Code asks for the token once and keeps it out of the file: ```json { "servers": { "simpledatalogs": { "type": "http", "url": "https://simpledatalogs.com/mcp", "headers": { "Authorization": "Bearer ${input:sdl-mcp-token}" } } }, "inputs": [ { "type": "promptString", "id": "sdl-mcp-token", "description": "simpledatalogs MCP token", "password": true } ] } ``` ### Codex CLI Add the server to `~/.codex/config.toml`, and put the token in your shell profile: ```toml [mcp_servers.simpledatalogs] url = "https://simpledatalogs.com/mcp" bearer_token_env_var = "SDL_MCP_TOKEN" ``` ```sh export SDL_MCP_TOKEN=sdl_mcp_… ``` ### Windsurf Add the server to `~/.codeium/windsurf/mcp_config.json`: ```json { "mcpServers": { "simpledatalogs": { "serverUrl": "https://simpledatalogs.com/mcp", "headers": { "Authorization": "Bearer sdl_mcp_…" } } } } ``` ### Any `mcp.json` client Most clients that take a remote server with headers use the same shape: VS Code, Windsurf, Zed, Cline and others. The key names differ slightly between them (`url` or `serverUrl`, `mcpServers` or `servers`), so check your client's docs: ```json { "mcpServers": { "simpledatalogs": { "type": "http", "url": "https://simpledatalogs.com/mcp", "headers": { "Authorization": "Bearer sdl_mcp_…" } } } } ``` A client that only speaks stdio can use a bridge such as `mcp-remote`: ```json { "mcpServers": { "simpledatalogs": { "command": "npx", "args": ["-y", "mcp-remote", "https://simpledatalogs.com/mcp", "--header", "Authorization:Bearer ${SDL_MCP_TOKEN}"], "env": { "SDL_MCP_TOKEN": "sdl_mcp_…" } } } } ``` ### The xAI API Grok models can call the server through xAI's remote MCP tools. Pass the token in the tool's `headers`: ```sh curl https://api.x.ai/v1/responses \ -H "Authorization: Bearer $XAI_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "grok-4.7", "input": "How many signups did production get in the last 7 days?", "tools": [{ "type": "mcp", "server_url": "https://simpledatalogs.com/mcp", "server_label": "simpledatalogs", "headers": { "Authorization": "Bearer sdl_mcp_…" } }] }' ``` Use whichever Grok model your account has. In xAI's Python SDK, the same header goes in `extra_headers`. ### By hand The server takes one JSON-RPC message per `POST`. You can try it with curl: ```sh curl -sS https://simpledatalogs.com/mcp \ -H "Authorization: Bearer sdl_mcp_…" \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_environments","arguments":{}}}' ``` Batches (JSON arrays) are refused, and `GET` and `DELETE` answer `405`: there is no event stream and no session. ## When something goes wrong | You see | Why | Fix | |---|---|---| | `401` | A missing, revoked or mistyped token | Create a new token, or reconnect the app | | `403 Origin not allowed.` | A browser page on another site called the server | Call it from an agent or a server, not a web page | | `429 Too many calls; wait a minute.` | Over 60 calls a minute | Wait 60 seconds | | `400 Unsupported MCP-Protocol-Version` | The client sent a protocol version the server doesn't speak | Update the client | | `list_environments` is empty | The organization is on Free, which has no MCP | Upgrade to Starter or above | | A tool answers "No environment with that id" | The id isn't one of yours | Ask the assistant to call `list_environments` first | ## Next steps * [Tools reference](/mcp/tools): what each tool reads. * [Prompt recipes](/agents/recipes#read-your-numbers-back): questions that work well. --- --- url: https://simpledatalogs.com/docs/mcp/tools.md description: >- Every tool on the simpledatalogs MCP server, the read-only ones and the setup tools, with their inputs, outputs and limits. --- # Tools reference Every tool is read-only and marked with `readOnlyHint`, except the [setup tools](#setup-tools), which only a setup token sees. Each one reads only environments you are a member of, at most 90 days back and 100 rows per answer. Results come back as JSON, both as text content and as `structuredContent`. You rarely call these yourself. Your assistant picks them; this page helps you know what it can ask. ::: tip Start with `list_environments` Every other tool takes an `environment_id`. Assistants call `list_environments` first to find it, so you can just say "production of the Mac app". ::: ## Shared inputs | Input | Type | Default | Meaning | |---|---|---|---| | `environment_id` | string | — | An id from `list_environments`. Required by every tool but `list_projects` and `list_environments` | | `range` | `1h`, `24h`, `7d`, `30d`, `90d` | `24h` | How far back from now | | `name` | string | — | Only this event name | ## `list_environments` Every environment you can read, with its project and organization. No inputs. ```json {"environments": [{"id": "0199…", "name": "production", "project": "Screenshotter", "organization": "XKD"}]} ``` ## `event_totals` Event counts and unique devices over a range, optionally per event name and level; busiest groups first. | Input | Type | Default | |---|---|---| | `environment_id`, `range`, `name` | see above | | | `group_by` | array of `name`, `level` | none (one total) | | `limit` | 1–100 | `20` | With `"group_by": ["name"]`: ```json {"totals": [{"name": "screenshot_taken", "events": 4210, "devices": 388}, {"name": "app_launched", "events": 1022, "devices": 391}]} ``` ## `event_series` Counts per hour or per day over a range, in the project's time zone. | Input | Type | Default | |---|---|---| | `environment_id`, `range`, `name` | see above | | | `bucket` | `hour` or `day` | `day` | Hourly series go back at most 7 days (`1h`, `24h`, `7d`). ```json {"time_zone": "America/Los_Angeles", "points": [{"bucket_start": "2026-10-16T07:00:00Z", "events": 612, "devices": 140}]} ``` ## `search_logs` The newest events and log lines in a range, filtered by text, level or name. | Input | Type | Default | |---|---|---| | `environment_id`, `range`, `name` | see above | | | `query` | string | — a substring of the message | | `level` | `debug`, `info`, `notice`, `warning`, `error`, `critical` | — | | `limit` | 1–100 | `50` | ```json {"events": [{"ts": "2026-10-17T09:14:02.000Z", "name": "", "level": "error", "message": "Card declined", "device": "c0ffee-42", "props": {"status": 402}}]} ``` A long message is cut to 4,000 characters, and very large props stay as text. ## `list_projects` Every project you can read, with its organization, time zone and environments. No inputs. ## `list_deployments` An environment's deployments (one per build or source), with their platform. | Input | Type | Default | |---|---|---| | `environment_id` | see above | | ## `prop_breakdown` How many events had each value of one property, most common first. Ask for one event name to split just that event. | Input | Type | Default | |---|---|---| | `environment_id`, `range`, `name` | see above | | | `property` | string, required | — a property path such as `plan` or `device.os` | | `limit` | 1–100 | `20` | ## `device_stats` Unique devices in a range, and the most recently active devices with their busiest events. | Input | Type | Default | |---|---|---| | `environment_id`, `range` | see above | | | `query` | string | — a substring of the device id | | `limit` | 1–100 | `20` | ## `unparsed_summary` How much input couldn't be parsed (kept 7 days in Unparsed), with recent samples and their reasons. | Input | Type | Default | |---|---|---| | `environment_id`, `range` | see above | | | `limit` | 1–20 samples | `5` | ## `list_counters` An environment's published [public counters](/guide/public-counters): label, event, window, public URL and the count over that window. | Input | Type | Default | |---|---|---| | `environment_id` | see above | | ## `spend_summary` The estimated spend from the environment's price table over a range: the total, the costliest combinations, and events with no price. | Input | Type | Default | |---|---|---| | `environment_id`, `range` | see above | | | `limit` | 1–100 | `20` | ## `list_alerts` An environment's threshold alerts and their state: ok or firing, since when, the last value, snoozes, and the chat channels (Slack, Discord, webhook) each sends to besides email. | Input | Type | Default | |---|---|---| | `environment_id` | see above | | ## `list_dashboards` The environment's saved dashboard: the Overview's sections and custom widgets in display order, with hidden and pinned flags, and each custom board (name, emoji and widgets). | Input | Type | Default | |---|---|---| | `environment_id` | see above | | ::: tip Top events There's no separate top-events tool: `event_totals` with `"group_by": ["name"]` lists event names busiest first. ::: ## Setup tools Only a [setup token](/mcp/#setup-tokens-for-a-first-setup) lists and runs these. Every create needs an `idempotency_key` (any unique text, up to 100 characters); sending the same one again answers the first result instead of creating twice. | Tool | Inputs | Returns | |---|---|---| | `create_project` | `name`, `idempotency_key`; optional `organization_id` (when you can create in several), `environments` (1 to 5 names, default `production` and `local` as the plan allows), `emoji`, `logo_base64` (PNG, JPEG or WebP as base64 or a data: URL, at most 256 KB and 1024 × 1024 px, never SVG), `deployments` (1 to 10 `{"name", "platform"}` objects) | The project (with `emoji` and `has_logo`) and its environments, each with the given deployments, else one named `default` | | `create_environment` | `project_id`, `name`, `idempotency_key`; optional `deployments` (as above) | The environment with the given deployments, else one named `default` | | `create_deployment` | `environment_id`, `name`, `idempotency_key`; optional `platform` (`ios`, `android`, `macos`, `windows`, `linux`, `web`, `server`, `other`) | The deployment. The first one in an environment takes over its untouched `default` deployment, so no empty default is left behind | | `create_key` | `deployment_id`, `idempotency_key`; optional `kind` (`public` or `secret`, default secret for a `server` deployment, else public) | The key's full value once, its id, kind, the variable name `SIMPLEDATALOGS_KEY` and the ingest URL | | `revoke_key` | `key_id` | `{"revoked": true}`; only for a key this same token made | ```json {"key": "sdl_pub_…", "key_id": "0199…", "kind": "public", "variable_name": "SIMPLEDATALOGS_KEY", "ingest_url": "https://edge.simpledatalogs.com"} ``` List every thing that sends (each app build, site and server) up front and pass it as `deployments`, so each environment gets exactly those and no empty `default`: ```json {"name": "Tithi", "deployments": [{"name": "Website", "platform": "web"}, {"name": "iOS app", "platform": "ios"}], "idempotency_key": "tithi-1"} ``` A bad logo or deployments list refuses the whole call before anything is created, so fix it and retry with the same `idempotency_key`. Every setup answer also carries `setup_budget`: the projects this token may still create (`projects_left`), the per-project, per-environment and per-deployment limits, and its `calls_per_minute`. A repeated `create_key` answers `"is_repeat": true` without the key: its full value shows only once. ## Errors A tool that can't answer returns `isError: true` with a sentence the model can act on: | Message | Meaning | |---|---| | "No environment with that id among yours; call list_environments." | A wrong or foreign id | | "range must be one of 1h, 24h, 7d, 30d, 90d." | An unknown range | | "Hourly series go back at most 7 days; use bucket "day"." | `bucket: hour` with a longer range | | "Too many rows for this range; pick a shorter range or a name." | The query was too large | | "The event store didn't answer; try again in a moment." | A temporary outage | | "This token can only read. …" | A setup tool called with a read token or a chat app | | "This deployment already has 2 active keys, the setup limit. …" | A setup limit was reached (also 100 projects, 5 environments, 10 deployments); create the rest in the dashboard | | "Too many setup calls (10 a minute); wait a minute, then repeat this call with the same idempotency_key." | Over the setup call rate; the retry with the same key makes nothing twice | | "This setup token has expired or was revoked." | A setup token past its 24 hours | ## Next steps * [Connecting clients](/mcp/clients): set it up in your assistant. * [Prompt recipes](/agents/recipes#read-your-numbers-back): questions that make good use of these tools. --- --- url: https://simpledatalogs.com/docs/agents.md description: >- Let a coding agent add simpledatalogs to your app with the setup prompt, /llms.txt and these docs as Markdown. --- # Set up with a coding agent Coding agents such as Claude Code, Cursor, Codex or Copilot can wire simpledatalogs into your app for you. Give them the contract, and they pick the right SDK or write a small client that follows the [sender rules](/api/sender-rules). There are three ways to hand them the contract, from quickest to most thorough. ::: tip For your coding agent Every environment and project page has a **For your coding agent** button. It has two tabs: * **Integrate simpledatalogs:** pick what to send and your stack, make a key right there, and copy a prompt that has your agent wire it into your app. * **Ask about your data:** connect the read-only MCP server to Claude Code, Cursor, VS Code, Codex or a chat app. No project yet? A [setup token](/mcp/#setup-tokens-for-a-first-setup) lets your agent create the project and key itself and write the key to `.env`. ::: ## 1. The setup prompt When you create a key, the dialog shows a **Setup prompt** under "Or paste this into your coding agent". It already holds your key and your environment's name. 1. Open the environment's **Deployments & keys** tab and choose **Create key**. 2. Copy the **Setup prompt** from the dialog. 3. Paste it into your agent, in your app's repository. The prompt tells the agent to: 1. Find your app's language and framework, and use our SDK for it ([JavaScript](/sdks/javascript), [Swift](/sdks/swift), [Laravel](/sdks/laravel), [Flutter](/sdks/flutter), [Android](/sdks/android), [Unity](/sdks/unity), [Python](/sdks/python) or [Go](/sdks/go)), the [WordPress plugin](/integrations/wordpress), the [Shopify app](/integrations/shopify) or a [drop-in](/integrations/) where one fits, or else write a small client that follows the rules. 2. Track a few events that answer real questions (app launched, the main action done, errors), with `snake_case` names and a handful of props each. 3. Send one test event and confirm the answer is `202`. It ends with the full ingest contract: the endpoint, the event shape, every answer and what to do about it, and the six sender rules. ::: tip Keep the key in config The prompt asks the agent to keep the key in configuration, not in code. A `sdl_pub_…` key is safe in client apps; a `sdl_sec_…` key belongs only on servers. ::: ## 2. `/llms.txt` The dashboard serves the same contract, without a key, at: ```text https://simpledatalogs.com/llms.txt ``` It follows the [llms.txt](https://llmstxt.org) convention: a short summary, the ingest contract, the drop-in alternatives and the MCP server. Point any agent at it: ```text Read https://simpledatalogs.com/llms.txt, then add simpledatalogs event tracking to this app. Use the key from the SIMPLEDATALOGS_KEY environment variable. ``` ## 3. These docs, as Markdown This site publishes itself for agents too: | URL | What | |---|---| | [`/llms.txt`](https://simpledatalogs.com/docs/llms.txt) | An index of every page with a one-line description | | [`/llms-full.txt`](https://simpledatalogs.com/docs/llms-full.txt) | Every page in one Markdown file | | Any page + `.md` | That page as Markdown, such as [`/sdks/laravel.md`](https://simpledatalogs.com/docs/sdks/laravel.md) | Every page also has a **Copy page** button at the top, to paste it straight into a chat. ## Then read the numbers back Once data flows, connect the [MCP server](/mcp/) to the same agent, and it can check its own work: ```text Send a test event, then use the simpledatalogs MCP server to confirm it arrived in development. ``` ## Next steps * [AGENTS.md snippet](/agents/agents-md): keep the rules in your repo, so every future change follows them. * [Prompt recipes](/agents/recipes): prompts for Laravel, iOS, a website, Node and more. --- --- url: https://simpledatalogs.com/docs/agents/agents-md.md description: >- A snippet to paste into AGENTS.md or CLAUDE.md, so every coding agent in your repo tracks events the simpledatalogs way. --- # AGENTS.md snippet The setup prompt gets tracking in once. To keep every later change consistent, add the rules to the file your agents read on every task: `AGENTS.md` (Codex, Cursor, Copilot, Jules and others) or `CLAUDE.md` (Claude Code). Copy this section into it, and adjust the first three lines to your app: ```md ## Analytics and logs: simpledatalogs - We send events and log lines to simpledatalogs. Docs: https://simpledatalogs.com/docs (agents: https://simpledatalogs.com/docs/llms-full.txt). - Client: , set up in . - The ingest key comes from . Never hard-code it. `sdl_pub_` keys may ship in apps and web pages; `sdl_sec_` keys stay on servers. ### Naming events - Event names are `snake_case`, past tense, object then action: `screenshot_taken`, `order_paid`, `export_failed`. - Reuse an existing event name before adding a new one; search the code for the tracking call first. - Props are flat strings, numbers and booleans, at most a handful per event: `{ "plan": "pro", "duration_ms": 412 }`. - Low-cardinality values only in string props (a plan, a format, a platform); never ids, URLs with ids, or free text. ### Privacy - Never send emails, names, phone numbers, IP addresses, tokens, passwords or free-text user input, in props or messages. - Identify devices with a random install id (`device_id`), never with an email or account name. ### Sending - Use the client above; don't call the HTTP API directly from feature code. - If you must write a sender: POST to https://edge.simpledatalogs.com/v1/ingest with `Authorization: Bearer `; set `event_id` (UUID) and `ts` when the event happens; batch (≤ 50 events, ≤ 256 KB); keep a durable outbox and delete only after `202`; retry with backoff and jitter honoring `Retry-After`; on `401` pause and keep data; on `413` split; never block or crash the app. ### Logs - Use log levels `debug`, `info`, `notice`, `warning`, `error`, `critical`. - Log messages should read as sentences without personal data: "Payment failed for order 7", not "Payment failed for jane@example.com". ``` ::: tip Why put it in the repo? Agents follow repository instructions on every task, not just the first. With this section in place, "add an event when the export finishes" produces a well-named event with clean props, in the client you already use. ::: ## Next steps * [Prompt recipes](/agents/recipes): ready-made prompts for common stacks. * [Privacy for senders](/reference/privacy): the reasoning behind the privacy rules. --- --- url: https://simpledatalogs.com/docs/agents/recipes.md description: >- Ready-to-paste prompts for coding agents to add simpledatalogs to Laravel, iOS and macOS, websites, Node, OpenTelemetry and Sentry setups, and to read numbers back over MCP. --- # Prompt recipes Paste one of these into your coding agent, in your app's repository. Each assumes the key is in an environment variable or your build config; replace `<…>` with your details. ::: tip Give the agent the docs Every recipe starts by pointing the agent at the docs as Markdown. Agents that can't fetch URLs work just as well if you paste the linked page, using the **Copy page** button at the top of it. ::: ## Add simpledatalogs to a Laravel app ```text Read https://simpledatalogs.com/docs/sdks/laravel.md. Add simpledatalogs to this Laravel app: 1. Install simpledatalogs/laravel and publish its config. 2. Add SIMPLEDATALOGS_KEY to .env.example (empty) and tell me to set it to my sdl_sec_ key. 3. Tap the `daily` channel in config/logging.php with ForwardToSimpleDataLogs, named "app". 4. Schedule `simpledatalogs:ship` every minute without overlapping. 5. Add any context keys that could hold personal data to drop_context_keys. Then show me the diff. ``` ## Add event tracking to an iOS or macOS app ```text Read https://simpledatalogs.com/docs/sdks/swift.md. Add the SimpleDataLogs Swift package to this app and track: - app_launched (props: app_version) - the main action of the app, named _ - errors the user sees, as _failed with a `reason` prop Configure it once at launch in the app delegate, after setEnabled(). Use a random install id stored in UserDefaults as the device id. The key comes from . ``` ## Add analytics to a website ```text Read https://simpledatalogs.com/docs/sdks/javascript.md. Add simpledatalogs to this site with the `simpledatalogs` package and a public key from . Track page_view on every route change (props: path without query string), signup_started, signup_completed. Create the client once in a shared module. Don't track anything before the cookie/consent choice allows it; call setEnabled(false) when it doesn't. ``` ## Add logging to a Node service ```text Read https://simpledatalogs.com/docs/sdks/javascript.md. In this Node service, create one simpledatalogs client with the secret key from SIMPLEDATALOGS_KEY. Send our existing error logs with sdl.log('error', error, { route, status }) next to the current logger, track job_finished with queue and duration_ms, and call `await sdl.shutdown()` on SIGTERM. ``` ## Switch an OpenTelemetry setup ```text Read https://simpledatalogs.com/docs/integrations/opentelemetry.md. Point this service's OpenTelemetry logs at simpledatalogs using environment variables only. Turn off the traces and metrics exporters, use gzip, and put the key in the deployment config, not in code. ``` ## Switch from Sentry ```text Read https://simpledatalogs.com/docs/integrations/sentry.md. Change our Sentry DSN to send to simpledatalogs instead, set the traces sample rate to 0, and make sure the transport uses gzip. Don't change any capture calls. ``` ## Write a sender for another language ```text Read https://simpledatalogs.com/docs/api/sender-rules.md and https://simpledatalogs.com/docs/api/responses.md. There is no SDK for . Write a small simpledatalogs client for this app that follows all six sender rules: batching, a durable outbox in , backoff with full jitter honoring Retry-After, event_id and ts set at track time, failing open, and a User-Agent. Handle 401 (pause, keep), 413 (split) and 415 (set aside). Add tests with a fake HTTP server. ``` ## Review tracking for personal data ```text Read https://simpledatalogs.com/docs/reference/privacy.md. Find every simpledatalogs tracking and logging call in this repo and list any that could send personal data (emails, names, IPs, tokens, free text, ids in URLs). Propose a fix for each. ``` ## Read your numbers back With the [MCP server](/mcp/) connected, ask questions like these in any chat or coding agent: ```text Using simpledatalogs, how many screenshot_taken events did production get each day this week, and how many devices? ``` ```text List the error logs from the last 24 hours in production, grouped by message. Which one is new? ``` ```text I just added the export_finished event. Check development: has it arrived, and with which props? ``` ```text Compare the last 7 days with the 7 before for every event in production, and tell me what changed the most. ``` ## Next steps * [AGENTS.md snippet](/agents/agents-md): keep the rules in your repo. * [Tools reference](/mcp/tools): what the MCP server can answer. --- --- url: https://simpledatalogs.com/docs/reference/privacy.md description: >- What senders should never send, what simpledatalogs masks or drops for you, how emails in identity fields become pseudonyms, and how to erase data. --- # Privacy for senders You decide what leaves your app. simpledatalogs adds a safety net, but the cleanest data is data you never sent. This page covers what to keep out, what we do for you, and how to erase what slipped through. Under data-protection law, you are the controller of your users' data and simpledatalogs is your processor. See the [privacy policy](https://simpledatalogs.com/privacy) and the [data processing terms](https://simpledatalogs.com/data-processing). ## Never send these * Email addresses, names, usernames and phone numbers. * IP addresses, precise locations and advertising ids. * Passwords, tokens, API keys and session cookies. * Free text a user typed: search queries, messages, form fields. * URLs with personal data in them, such as `/users/jane@example.com` or `?token=…`. ```js // Avoid sdl.track('signup', { email: user.email, referrer_url: document.referrer }) // Prefer sdl.track('signup', { plan: 'pro', referrer: 'newsletter' }) ``` ::: warning JSON is stored as sent Events and log objects you send as JSON, NDJSON or OTLP are structured data you chose to send, so they are **stored exactly as sent**. Masking only runs on text log lines. Filter personal data out before it leaves your app. ::: ## Identify devices, not people To count active devices, send a **random install id** as `device_id`: a UUID generated on first launch and kept in local storage. It identifies an installation, not a person, and it resets on reinstall. For apps that must avoid persistent identifiers, such as games aimed at children, don't send an identity field, and turn on **No device IDs** in the project's settings (owners only). Then, for new events: * `device_id`, `install_id`, `anonymous_id`, `user_id` and `session_id` are dropped, from the columns and from props. * The raw User-Agent is dropped; only the browser, OS and device type read from it are kept. * Websites get no daily visitor hash, so the **Sessions** tab counts no visitors or visits. Event counts, log lines, countries and browsers still work; device counts, device pages and visits don't. Events stored before the change keep what they had. ### Emails in identity fields become pseudonyms If an email address does arrive in an identity field (`user_id`, `device_id` and the others, or OTLP's `user.id`), it isn't stored. It becomes a stable pseudonym: ```text jane@example.com → email:3f9a1c0be27d4a6f81c2 ``` The pseudonym is `email:` plus a keyed hash of the environment and the lower-cased address. Each user still counts once, and we never hold the address. The same email gives a different pseudonym in each environment. A project's owner can choose to keep emails readable, in the New project dialog or the project's settings, after a warning. That applies to new events only, and means simpledatalogs then stores those addresses. ### Share links and installs If your share page sends events with a `share_id`, ingest keeps a daily hash of the visitor's IP address for two days, so a later `app_installed` from the same address can be matched to that share. The address itself is never stored, and the hash never reaches your events. See [Share links and installs](/guide/share-links). ## What we mask or drop for you ### Text log lines Before a text line is stored, these are masked in the message and in string props: | Found | Stored as | |---|---| | Public IPv4 and IPv6 addresses | `[ip]` | | Email addresses | `[email]` | | Bearer credentials, JWTs, well-known API keys, values under secret names (`password`, `api_key`, `token` …) | `[token]` | | Card numbers | `[card]` | | Phone numbers | `[phone]` | Access logs never keep the client IP, the user fields or any query string. The same masking applies to the Datadog, Loki and Splunk drop-ins, and to values captured by [text templates](/api/templates). ### Vendor payloads * **Segment, PostHog, Mixpanel, Amplitude:** client IPs (`context.ip`, `$ip`, `ip`) and the vendor's keys (`writeKey`, `api_key`, `token`) are never stored. * **Sentry:** the user's IP address, email and username, request headers, cookies, query strings, breadcrumbs and `extra` are never stored. * **Datadog:** `usr.email` and `usr.name` are dropped. ### Request metadata Ingest never stores your users' IP addresses. The address a request comes from is used in memory for rate limiting and a country lookup; only the country is kept. The `User-Agent` header is kept with each request. The country comes from [IP Geolocation by DB-IP](https://db-ip.com), used under the CC BY 4.0 license. ## Erasing data An organization owner can erase data from the dashboard: * **One device:** open its page under **Devices** and erase every row of that identity. * **One event name:** open the event's page and erase every event of that name. Both need a recent sign-in and the exact id or name typed again, and both are audited. Rows go at once; copies in the 3-day recovery log and the 14-day backups age out, so an erasure is complete within 14 days. ## Your own users' consent simpledatalogs uses no cookies. The script tag stores nothing in the browser: unsent events wait in memory and go out when the page is hidden. The npm package keeps unsent events in `localStorage` until they are delivered, and a session id in the tab's `sessionStorage`. Neither stores an identifier of its own; a `device_id` is only sent if you set one. Whether your site or app needs consent depends on what you send and where your users are, so check with your own counsel. Use the opt-out in the [JavaScript](/sdks/javascript#opt-out), [Swift](/sdks/swift#the-opt-out), [Flutter](/sdks/flutter#the-opt-out), [Android](/sdks/android#the-opt-out), [Unity](/sdks/unity#the-opt-out), [Python](/sdks/python#the-opt-out) and [Go](/sdks/go#the-opt-out) SDKs to honour an opt-out. It also deletes everything not yet sent. ## Next steps * [AGENTS.md snippet](/agents/agents-md): put these rules where your coding agents read them. * [Laravel SDK](/sdks/laravel#keep-personal-data-out): drop context keys before they leave your server. --- --- url: https://simpledatalogs.com/docs/reference/limits.md description: >- Plans, monthly quotas and what happens past them, retention, and every request, event and MCP limit in one place. --- # Plans and limits ## Plans | | Free | Starter | Pro | Scale | |---|---|---|---|---| | Price | $0 | $10 / month | $50 / month | $150 / month | | Events and log lines per month | 20,000 | 250,000 | 5,000,000 | 50,000,000 | | Raw event retention | 7 days | 6 months | 1 year | 1 year | | Counter totals | Forever | Forever | Forever | Forever | | Extra events | — | Upgrade | $6 per million, opt-in | $4 per million, opt-in | | Projects | 1 | 6 | Unlimited | Unlimited | | Environments per project | 2 | 3 | Unlimited | Unlimited | | Dashboard ranges | Today to 90 days | Today to 90 days | Today to 90 days | Plus Year and Lifetime | | Custom dashboards | — | 3 | 10 | 50 | | AI dashboard builds / month | — | 3 | 10 | 50 | | MCP | — | ✓ | ✓ | ✓ | | Alerts | 2 | 10 | 25 | 100 | | Alert channels | Email | Email, Slack, Discord, webhook (20) | Same | Same | | Members (owners and members) | 1 | 3 | 10 | Unlimited | | Viewers (read-only) | — | Unlimited | Unlimited | Unlimited | | Exports | — | CSV and JSON lines | CSV and JSON lines | Plus scheduled to your bucket | Viewers read dashboards, logs and events but never change anything, see keys or open billing, and don't use a seat. A full team upgrades; there are no paid extra seats. **Coming soon:** AI overviews of dashboards, a paid opt-in; price later. Extra raw retention is an add-on: $10 a year per 100 million events. Write to us to add it; it applies to events stored from then on. An AI build is one ask for a dashboard or board: the assistant gets up to 3 tries to fit your data. A build that still fails counts; one where the assistant never answered doesn't. Builds reset on the 1st (UTC). Custom dashboards count across every project in the organization; the Overview never counts. After a downgrade, everything over a limit stays; only new projects, environments or dashboards are refused. Every event and every log line counts toward the monthly quota, including each [inbound webhook](/integrations/webhooks) delivery that becomes an event (webhooks themselves are free on every plan). Daily and all-time counters are kept forever on every plan, even after the raw rows expire. ## Billing * **Annual:** pay for 10 months and get 12. * **Trial:** Starter starts with a 30-day trial. It takes a card, and we email you 7 days before the first charge; cancelling drops you to Free. AI builds unlock with the first payment. * **A failed payment:** your plan keeps running for 7 days while the card is retried. Billing shows the last day and asks for a new card. After that, the organization moves to Free until a payment goes through. * **Refunds:** the first payment of a new plan is refundable within 14 days. See [Refunds](https://simpledatalogs.com/refunds). * Free needs no card. If the [pricing page](https://simpledatalogs.com/pricing) says **Opens soon**, paid plans aren't open yet. ## Past your quota You're never surprise-billed, and nothing is dropped at the quota: 1. Owners get an email at **50 %, 80 % and 100 %** of the month's quota. 2. **Between 100 % and 200 %**, events are still stored, but left out of every dashboard and page. A banner says to upgrade; upgrading shows them at once. 3. **Past 200 %**, ingest answers `429 {"error":"over_quota"}` with `Retry-After: 3600`. Our SDKs keep the data in their outbox and retry, so an upgrade or the next month lets it through. **Extra events on Pro and Scale (opt-in).** An owner can switch them on in Billing with a monthly cap in dollars. Events past the quota then show as usual and cost $6 (Pro) or $4 (Scale) per million, never more than the cap, charged once after the month ends. Steps 2 and 3 then start from quota + what the cap buys. ## Request limits | Limit | Value | Past it | |---|---|---| | Request body, as sent | 512 KB (gzip counts compressed bytes) | `413` | | Recommended request size | 256 KB or less | — | | Body upload time | 10 seconds | `408` (`503` on OTLP and Loki) | | Decompressed gzip body | 8 MB | Unparsed (`decompressed_too_large`) | | Requests per second, per key | 200 | `429` + `Retry-After: 1` | | Requests per second with unknown keys, per client address | 20 | `429` | | Events per request | 10,000 (20,000 text lines) | The rest kept in Unparsed (`too_many_events`) | ## Event limits | Limit | Value | Past it | |---|---|---| | Event name | 200 bytes | Unparsed (`invalid_event`) | | Props keys per event, nested included | 100 | Unparsed (`too_many_props`) | | Props key length | 128 bytes | Unparsed (`props_key_too_long`) | | Props nesting | 32 levels | Unparsed (`props_too_deep`) | | `ts` window | Up to 10 years back, until 2095 | Unparsed (`ts_out_of_range`) | | Text entry | 200 lines and 64 KB | The rest starts a new entry | | Unparsed retention | 7 days | Deleted | ## Organization limits | Limit | Value | |---|---| | Deployments per environment | 20 | | Public counters per environment | 10 | | Webhook sources per environment | 20 | ## Export limits | Limit | Value | |---|---| | Rows per download | 5,000,000 | | Uncompressed size per download | 2 GB | | Downloads queued or running per organization | 2 | | Download files kept | 3 days | | Download link lifetime | 5 minutes | | Scheduled file part | 1,000,000 rows | See [Exports](/guide/exports) for formats, columns and bucket setup. ## MCP limits | Limit | Value | |---|---| | Calls per user | 60 a minute, then `429` + `Retry-After: 60` | | Personal tokens per user | 10 | | Range a tool can read | 90 days | | Rows per answer | 100 | | Hourly series | 7 days | ## Next steps * [Responses](/api/responses): what each refusal means for your client. * [Troubleshooting](/reference/troubleshooting): when numbers look wrong. --- --- url: https://simpledatalogs.com/docs/reference/troubleshooting.md description: >- Fix common problems by the answer you get from ingest, and by what you see, or don't see, on the dashboard. --- # Troubleshooting Start with the answer your request got. Every SDK logs it: `console.warn` in JavaScript, `os.Logger` (subsystem `com.simpledatalogs.sdk`) in Swift, and PHP's error log in Laravel. With curl, add `-i` to see the status. ## By response ### `401 invalid_key` The key is unknown, revoked, or brand new. * **Just created it?** A new key goes live within a few seconds. Wait and retry; our SDKs re-probe on their own. * **Copied it whole?** Keys start with `sdl_pub_` or `sdl_sec_`. Check for a cut-off end or a stray quote. * **Right header?** `Authorization: Bearer sdl_…` on `/v1/ingest`. Drop-ins put the key where their protocol does: the DSN for Sentry, `DD-API-KEY` for Datadog, `Authorization: Splunk …` for HEC. * **Revoked?** The **Keys** tab lists only active keys. Compare the key's prefix with the list; a revoked key is gone from it. ### `413 payload_too_large` The body was over 512 KB as sent. Send smaller batches, turn on gzip, and aim for 256 KB per request. Our SDKs split and resend on their own. ### `415` * `unsupported_content_encoding`: use gzip, or no compression. Brotli, zstd and deflate are refused. sentry-python picks brotli when the `brotli` package is installed; see [Sentry](/integrations/sentry#tips). * `unsupported_content_type`: a drop-in path got a Content-Type it doesn't take. Segment paths need `application/json`; OTLP needs `application/x-protobuf` or `application/json`. ### `429 rate_limited` One key sent over 200 requests a second. Batch more per request, or give busy sources their own keys. Honor `Retry-After`. ### `429 over_quota` Your organization sent twice its monthly quota. Data waits in your senders' outboxes; upgrade on the **Billing** page, or wait for the next month. See [Plans and limits](/reference/limits#past-your-quota). ### `503 busy` Ingest is overloaded or storage is briefly unavailable. Retry after `Retry-After`. Nothing is lost while your client keeps its outbox. [status.simpledatalogs.com](https://status.simpledatalogs.com) shows whether an incident is open. ### `404` on `/v1/traces` or `/v1/metrics` Only OpenTelemetry **logs** are stored. Set `OTEL_TRACES_EXPORTER=none` and `OTEL_METRICS_EXPORTER=none`, or use the Collector's `logs_endpoint`. ### A timeout or a connection reset Unknown whether it was stored, so retry; `event_id` makes a copy harmless. Resets often mean a body far past 512 KB, which ingest refuses by closing the connection. ## By symptom ### I got `202`, but nothing shows up 1. **Right environment?** The key decides the environment and the deployment. Open the **Keys** tab of the environment you're looking at and check the key's prefix. 2. **Right deployment tab?** With several deployments, check the **All** tab. 3. **Check Unparsed.** A body that couldn't be read is there, with a reason. See [the reasons](/api/templates#the-unparsed-tab). 4. **Old `ts`?** An event is counted on the day of its `ts`, not the day it arrived. An app that sends a backlog after days offline fills past days. 5. **Over quota?** Between 100 % and 200 % of the quota, events are stored but hidden, with a banner. ### Counts are higher than expected * **Retries without ids.** Events without `event_id` and `ts`, and all text lines, count again when a request is retried. Send NDJSON with both. * **`ts` set at send time.** If you stamp `ts` when sending rather than when tracking, each retry gets a new time and can't be deduplicated. * **Two senders.** An app and its server tracking the same action count it twice. Pick one. ### Active devices are zero No identity field arrived. Send a `device_id` (or `install_id`, `anonymous_id`, `user_id`, `session_id`) with each event, or call `setDeviceId` / `setDeviceID` in the SDK. ### A log line has no level The level wasn't one of the six, or a common spelling of one. The original value is kept as `props.level`. Map it in your logger, or set a [text template](/api/templates). ### My stack trace is split into several rows Only lines that look like stack-trace lines join the entry before them: indented lines, `at …`, `#0 …`, `Caused by…`. Make sure your logger indents trace lines, and send each trace in one request. See [Stack traces](/api/log-lines#stack-traces). ### A browser request fails with a CORS error Ingest answers every origin, so a CORS error usually hides another failure. Check the network tab for the real status, and make sure the page sends to `https://edge.simpledatalogs.com/v1/ingest` with a public key. ### The MCP server answers `401` The token was revoked or mistyped, or the chat app's connection was disconnected. Create a new token, or reconnect the app. See [MCP: when something goes wrong](/mcp/clients#when-something-goes-wrong). ## Still stuck? First check [status.simpledatalogs.com](https://status.simpledatalogs.com). It probes the dashboard, ingest in each region and these docs every 5 minutes, and lists open incidents. It runs apart from our servers, so it stays up when they don't. Then send the request with `curl -i`, and keep the `accepted` id or the error body. They tell us exactly what happened to it. ## Next steps * [Responses](/api/responses): the full table. * [Writing a sender](/api/sender-rules): the rules that prevent most of these.