Appearance
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
- 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.
- Keep a durable outbox. A file, a table or
localStorage. An event leaves it only after a202. - Retry with exponential backoff and full jitter, from 1 second up to 5 minutes, honoring
Retry-After. On401, pause and re-probe slowly. On413, split. - Give every event a UUID
event_idand ats, both set when the event happens, so a retry never counts twice. - Fail open. Never block, slow down or crash the host app because of analytics.
- Send a
User-Agentthat 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_iddedupes 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 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 has all of that.
Let your coding agent write it
The setup prompt gives an agent these same rules, so it can write a client like this one in your app's language.
Next steps
- Responses: the full table of answers.
- JavaScript, Swift, Laravel, Flutter, Android, Unity, Python and Go: SDKs that already follow these rules.
- Vector and Fluent Bit: ship log files without writing a client.