Skip to content

Cap event capture requests at 100 events - #162

Merged
bpapillon merged 1 commit into
mainfrom
fix/cap-event-batch-size
Aug 20, 2026
Merged

Cap event capture requests at 100 events#162
bpapillon merged 1 commit into
mainfrom
fix/cap-event-batch-size

Conversation

@ryanechternacht

Copy link
Copy Markdown
Member

Problem

The capture service rejects any batch over 100 events with 400 {"error": "batch too large", "max_size": 100} (capture.go:73), but EventBuffer.flush() drained its entire backlog into a single sendBatch call. Any caller with more than 100 buffered events lost the whole batch, with only a log line to show for it.

Found by a daily cron in schematic-dashai that tracked 113 companies and then called close(). All 113 events went out as one request and 400'd, losing a full day of usage data.

Why maxSize did not already prevent this

maxSize gates the push-triggered flush, and only when a flush is not already running:

if (this.events.length >= this.maxSize && !this.flushing) {
    await this.flush();
}
this.events.push(event);

So it is a soft ceiling. The buffer grows past it whenever:

  1. A flush is in flight. flushing short-circuits the size check, and callers keep appending for the full duration of the request, retry backoff included.
  2. The caller does not await track(). Pushes interleave across the await this.flush() yield point inside push.

The new test never sends more than 100 events when pushes are not awaited reproduces case 1 and produces a 200-event request against the old code.

Changes

  • Chunk each flush into requests of at most 100 events, retrying each chunk independently. Per-chunk retries matter here: retrying the whole drained set would resend chunks that had already been delivered. The retry loop is unchanged, just extracted into sendChunk.
  • Lower the default maxSize from 1000 to 100 so the common path is one request per flush and the chunking is a safety net rather than the primary path.

Failure behavior is unchanged: a chunk that exhausts maxRetries is logged and dropped, and does not prevent later chunks from being attempted.

When this became reachable

The 1000 default predates the move to the capture service. Through 1.4.x the buffer flushed via the REST createEventBatch endpoint; 1.5.0 switched it to EventCaptureClient.sendBatch against c.schematichq.com/batch, which enforces the cap. The mismatch was latent before that. Anything on a caret range picked this up as a routine dependency bump, which is exactly how it reached the demo.

Cross-SDK status

I checked every SDK with an event buffer. schematic-go, schematic-java, and schematic-csharp already hard-cap the drain at 100 and are correct. schematic-python and schematic-ruby have the same unbounded-drain shape as this and get their own PRs. schematic-js sends events one at a time and is unaffected.

Testing

Four new tests in tests/unit/events.test.ts, all four confirmed failing without the source change:

  • splits a drained buffer into requests of at most 100 events
  • never sends more than 100 events when pushes are not awaited (the regression case, sends 200 without the fix)
  • does not resend a delivered chunk when a later chunk fails
  • still attempts later chunks after an earlier chunk fails

Full unit suite is unchanged against baseline: 83 failed / 820 passed before, 83 failed / 824 passed after. The pre-existing failures are the WASM and webpack suites, which need a build step, and are identical on a clean checkout.

Follow-ups not in this PR

  • stop() awaits flush(), but flush() returns immediately when flushing is already set, so a close() landing during an in-flight flush silently drops everything buffered after it.
  • The retry loop treats a deterministic 4xx as transient and burns maxRetries with backoff on a request that can never succeed.
  • maxSize is not exposed on SchematicOptions (only eventBufferInterval is), so there was no config-level workaround available to the affected caller.

🤖 Generated with Claude Code

The capture service rejects any batch over 100 events with
`400 {"error": "batch too large", "max_size": 100}`, but EventBuffer
drained its entire backlog into a single sendBatch call. Any caller with
more than 100 buffered events lost the whole batch, with only a log line
to show for it.

The buffer's maxSize did not bound this. It gates the push-triggered
flush, and only when a flush is not already running:

    if (this.events.length >= this.maxSize && !this.flushing) {
        await this.flush();
    }
    this.events.push(event);

so while a request is in flight the size check is skipped entirely and
callers keep appending for its full duration, retry backoff included.
A caller that does not await track() sees the same thing, since pushes
interleave across the await inside push.

Split each flush into chunks of at most 100 events, retrying each chunk
independently. Per-chunk retries matter: retrying the whole drained set
would resend chunks that had already been delivered.

Also lower the default maxSize from 1000 to 100 so the common path is
one request per flush. The 1000 default predates the move to the capture
service in 1.5.0, which is when this became reachable. Before that the
buffer flushed to the REST createEventBatch endpoint and the mismatch
was latent.

Reported by a daily cron that tracked 113 companies then called close().
All 113 events went out as one request and 400'd, losing a full day of
usage data.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@bpapillon
bpapillon merged commit c314f17 into main Aug 20, 2026
5 checks passed
@bpapillon
bpapillon deleted the fix/cap-event-batch-size branch August 20, 2026 05:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants