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 to drainTimeout. It is async, so await it. It rejects with DrainTimeoutError if 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 to onDrop. Useful in tests and for graceful shutdown sequences. Always pass a signal with a timeout, such as AbortSignal.timeout(10_000): retries during an outage can take minutes. It rejects with signal.reason if the signal aborts first, and never cancels a send.
  • stats() returns dropped (only buffer_full drops), droppedByReason, flushed, flushErrs, retries, pending and bufferSize for 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);
  },
});
  • reason is buffer_full, rejected, retries_exhausted, shutdown or closed.
  • err is the last delivery error for rejected and retries_exhausted. For a buffer_full drop that happens when the middleware records the request event, it is the BufferFullError under "error" and the 15 s TimeoutError under "block". Otherwise it is undefined.
  • The callback must not block and must not call record, flush or close on 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 id is safe: the server deduplicates it.
  • Delivered events are immutable. Dropped events cannot be recovered, so route onDrop to 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.stringify cannot encode (a BigInt or a circular reference in metadata) fails with EncodeError and is handled like a 400: that event is dropped as rejected and the rest of the batch is delivered.
  • buffer_full and closed reach onDrop from inside record(). The other reasons arrive from the background loop or from close().
  • A custom inner recorder decides what is retried through its errors: anything with transient: true, or named TimeoutError, is retried, as is any attempt flushTimeout cut off, whatever the inner threw. Any other error, such as a plain TypeError, drops the batch as rejected. An inner recorder without recordBatch gets one record() 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 late shutdown report for the in-flight batch can arrive after that.
  • Calling close() again returns the first call's promise. flush() after close() 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.