Node SDK · BufferedRecorder
The default recorder buffers events in memory and flushes in batches:
const rec = es.newRecorder({ bufferSize: 2000, flushInterval: 2_000 });
// or
const rec = recorder.create(projectId, apiKey, { bufferSize: 2000 });
Options
| Option | Default | Purpose |
|---|---|---|
bufferSize |
1000 |
Capacity of the in-memory event buffer. |
flushSize |
100 |
Pending-event count that triggers an immediate flush, and the most events per send. <= 0 uses the default. |
flushInterval |
5000 |
Max ms between flushes when the size threshold isn't reached. |
flushTimeout |
30000 |
Timeout (ms) per delivery attempt against the inner recorder; each retry gets a fresh one. |
overflowPolicy |
"drop-newest" |
Behavior when record() finds the buffer full: "drop-newest", "block", or "error". |
drainTimeout |
30000 |
Max ms close() waits for pending events, plus up to 1000 ms after it cancels the in-flight send. |
initialBackoff |
500 |
Base wait (ms) before the first retry of a failed batch. |
maxBackoff |
30000 |
Cap (ms) on a single wait between retries. |
maxRetryDuration |
300000 |
How long (ms) one batch is retried before its events are dropped as retries_exhausted. |
onDrop |
none | Callback invoked with the events, a DropReason and an error whenever events are discarded. See What happens when delivery fails. |
requestTimeout |
10000 |
Per-request HTTP timeout (ms). |
logger |
console |
Logger for diagnostics (overflow warnings, flush errors). |
baseUrl |
production | Override the ingestion endpoint (tests, staging). |
fetch |
global | Custom fetch implementation. |
autoIdempotencyKey |
off | Copy event.id into event.idempotencyKey when the latter is empty. |
The backoff and retry options treat undefined or <= 0 as the default.
Overflow policies
"drop-newest" // drop the incoming event, log a warning (default)
"block" // wait for space, abort signal, or close()
"error" // throw BufferFullError
Close & flush
close()drains pending events, with retries, for up todrainTimeout. It is async, soawaitit. It rejects withDrainTimeoutErrorif the drain timed out, so log what it rejects with. Call it exactly once, on the path that actually runs at shutdown.flush(signal?)resolves once the events queued at call time are delivered or reported toonDrop. Useful in tests and for graceful shutdown sequences. Always pass a signal with a timeout, such asAbortSignal.timeout(10_000): retries during an outage can take minutes. It rejects withsignal.reasonif the signal aborts first, and never cancels a send.stats()returnsdropped(onlybuffer_fulldrops),droppedByReason,flushed,flushErrs,retries,pendingandbufferSizefor observability.
What happens when delivery fails
record() only puts the event on an in-memory buffer, so your request path never waits on the network. A background loop sends batches and handles failures:
| Failure | What happens |
|---|---|
Network error, timeout, 429 or 5xx |
Retried with jittered exponential backoff: 500 ms first wait, 30 s max wait, server Retry-After honored up to that cap. After 5 minutes the batch is dropped as retries_exhausted. Tune with initialBackoff, maxBackoff and maxRetryDuration. |
400, 409 or 413 |
The batch is split in half and each half retried, so one bad event does not sink the rest. An event the server still refuses is dropped as rejected. |
Any other 4xx |
The whole batch is dropped as rejected. |
| Buffer full | Depends on the overflow policy. "drop-newest" (default) drops the new event as buffer_full, "block" makes record() wait for space, "error" throws BufferFullError. |
close() drain timeout |
The in-flight send is canceled and what is left is dropped as shutdown. |
record() after close() |
The event is dropped as closed, as is a "block" wait that close() ends. |
Pass onDrop to hear about every dropped event:
const rec = es.newRecorder({
onDrop: (events, reason, err) => {
console.error(`everscribe: dropped ${events.length} events (${reason})`, err);
},
});
reasonisbuffer_full,rejected,retries_exhausted,shutdownorclosed.erris the last delivery error forrejectedandretries_exhausted. For abuffer_fulldrop that happens when the middleware records the request event, it is theBufferFullErrorunder"error"and the 15 sTimeoutErrorunder"block". Otherwise it isundefined.- The callback must not block and must not call
record,flushorcloseon the same recorder. A throw or a rejected promise is caught and logged. - A dropped event may have been delivered anyway, for example when the last request timed out. Resending it with its original
idis safe: the server deduplicates it. - Delivered events are immutable. Dropped events cannot be recovered, so route
onDropto your own logs or queue.
Always await rec.close() before the process exits. It drains the buffer, with retries, for up to drainTimeout (30 s by default). At the timeout it cancels the send, waits up to 1000 ms more, and rejects with DrainTimeoutError. Backoff timers don't keep the process alive, so a process that exits while a batch waits to retry loses that batch without an onDrop report.
Give flush a deadline. flush() resolves once the events queued at the call are delivered or reported to onDrop, or rejects with signal.reason if your signal aborts first. It does not reject with delivery errors and never cancels a send.
Watch stats(). droppedByReason counts drops per reason, retries counts retries, flushErrs counts failed attempts, and dropped counts only buffer_full drops.
For the split budget, custom recorders and middleware edge cases, see Delivery details.
Delivery details
- Splitting is limited to 32 sends per batch. Events left when that budget runs out are dropped as
rejected. - An event
JSON.stringifycannot encode (aBigIntor a circular reference in metadata) fails withEncodeErrorand is handled like a400: that event is dropped asrejectedand the rest of the batch is delivered. buffer_fullandclosedreachonDropfrom insiderecord(). The other reasons arrive from the background loop or fromclose().- A custom inner recorder decides what is retried through its errors: anything with
transient: true, or namedTimeoutError, is retried, as is any attemptflushTimeoutcut off, whatever the inner threw. Any other error, such as a plainTypeError, drops the batch asrejected. An inner recorder withoutrecordBatchgets onerecord()call per event, and a failure that isn't retried drops only that event. - A custom inner recorder must honor
opts.signal. If it ignores it,close()settles after the 1000 ms grace, and a lateshutdownreport for the in-flight batch can arrive after that. - Calling
close()again returns the first call's promise.flush()afterclose()resolves at once.
Drops of the middleware's event
The middleware records the request event when the response finishes, and nobody awaits that call. If the buffer is full then, the event is reported to onDrop as buffer_full. Under "error" the report carries the BufferFullError. Under "block" the middleware waits up to 15 s for space, then gives up and the report carries the timeout error.
Where close goes
A server never falls out of the bottom of its entry module, so a close() written after app.listen() runs immediately and drains an empty buffer. Hang it off the signal your process manager sends instead:
const server = app.listen(port);
async function shutdown() {
await new Promise<void>((resolve) => server.close(() => resolve()));
try {
await rec.close(); // runs, and drains
} catch (err) {
console.error("everscribe: drain incomplete", err);
}
process.exit(0);
}
process.once("SIGTERM", shutdown);
process.once("SIGINT", shutdown);
Stop the server first, then close the recorder: events recorded by in-flight requests have to reach the buffer before it drains. And because close() is async, process.on("exit") is not a place to call it. That handler only runs synchronous work, so the drain never gets awaited and the buffer is discarded.
The same goes for scripts and jobs: await rec.close() before the last line. Retry wait timers don't hold the process open, so an exit during a retry wait loses that batch.