Repository navigation
T-20356 Deliver every record: batching, flush on Close, retries and error reporting - #3
Merged
Merged
Conversation
The handler sends each record in its own goroutine with no way to wait for it, ignores the response status and the error, retries nothing and has no limit on the goroutines it starts. Records logged right before the process exits are lost, a wrong token is never noticed, and an outage either drops batches silently or stacks up goroutines. These tests pin the behaviour the next commits implement, on top of the shape and option tests that stay as they are: - Close delivers what is still queued and Flush waits for delivery. - Records are batched by size and interval and gzip-compressed, with the JSON array body and the Marshaler contract unchanged. - Derived handlers share one queue. - 408, 429, 5xx and network errors are retried with backoff, honouring Retry-After; other 4xx are terminal. A 413 batch is split. - Delivery failures and drops are reported through OnError; the default reporter writes to stderr. Stats count every record. - A full queue drops instead of blocking the application; a shutdown timeout bounds Close; Timeout bounds each attempt. - Concurrent Handle calls on a derived handler do not race. The tests do not compile against the current API (no Close, Flush, Stats or the new Option fields), so this commit is expected to fail CI. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Records now go through a bounded queue to one sender goroutine that uploads them in gzip-compressed batches of 1000 or after a second, with up to five uploads in flight. 408, 429, 5xx and network errors are retried with exponential backoff and jitter, honouring Retry-After; other 4xx are terminal; a 413 batch is split. Every failure and every drop is reported through OnError, one line on stderr by default, and counted in Stats. NewBetterstackHandler returns the concrete *BetterstackHandler so that Close and Flush can be called; it still satisfies slog.Handler, so slog.New callers do not change. The API, the record shape (extra, logger.name, logger.version) and the Debug default stay as they were, and a custom Marshaler still receives the []map[string]any of a batch. Also: Timeout bounds each attempt instead of being capped by a hard-coded client timeout, Handle no longer appends into the attribute slice that derived handlers share (a data race under concurrent logging), the User-Agent carries the version, and the example closes the handler instead of sleeping. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ercase an error Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Without a source token the handler now reports the omission once through OnError, returns ErrMissingToken from Handle and drops every record, so an unset environment variable cannot take the application down. The constructor keeps its signature. The package quickstart and the example project set Level: slog.LevelInfo with a note that Debug is the default. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
PetrHeinz
marked this pull request as ready for review
October 6, 2026 13:50
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
An end-to-end run against an unreachable endpoint showed that a batch abandoned at the shutdown timeout reports only the timeout, never the connection error behind it. A run that kept logging after Close showed that those records are counted but never mentioned, because slog.Logger discards Handle's error. A negative Timeout was not treated as unset. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… Close A batch dropped at shutdown after at least one failed attempt now reports that attempt's error, so an outage is visible even when Close gives up on it. The first record logged after Close is reported once. A Timeout of zero or less means the default. OnError is documented as concurrent and non-blocking, and the constructor as one per process. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Sending a 12 MiB record to the real endpoint got a 2xx, and the record never appeared: the per-record limit of 10 MiB uncompressed is enforced after the request is accepted, so the client counted it as sent. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A batch whose JSON is over 10 MiB is split until the records that are over the limit on their own stand alone, and only those are dropped and reported; the rest is sent. The server would otherwise accept the request and discard the record silently. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The test named the new constant, so against the code it was written to fail on it did not compile instead of failing. A literal makes it fail for the right reason: the record is sent and counted. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The handler sent every record in a goroutine of its own, ignored the response status and the error, never retried and had no way to wait for delivery. Records logged just before the process exited were lost, a wrong token was never noticed, and an outage either dropped records silently or piled up goroutines. This PR replaces the per-record send with a transport that batches, retries, reports and can be flushed, behind the unchanged API and record shape.
NewBetterstackHandlerreturns a*BetterstackHandler(still aslog.Handler) withClose,FlushandStats.defer handler.Close()delivers what is still queued. A program that sleeps before exiting instead keeps working, because a partial batch is sent afterBatchInterval.Marshalerstill receives the[]map[string]anyof a batch. Up to 5 uploads run concurrently.Retry-After. Other 4xx are terminal. A 413 batch is split and resent; a single record too large on its own is dropped.OnError, by default one line on stderr: rejected batches, exhausted retries and dropped records, with queue-full drops summarised every 5 s instead of one line per record. Nothing is silent anymore and nothing is logged through the handler itself.Closewaits up toShutdownTimeout(15 s) and returns an error naming what was left.Timeoutnow bounds each attempt (a hard-coded client timeout used to cap it at 10 s), and a data race on the attribute slice that derived handlers share is fixed.OnError, returnsErrMissingTokenfromHandleand drops every record, so an unset environment variable cannot take the application down. The example project setsLevel: slog.LevelInfowith a comment that Debug is the default.Unchanged: the record shape (
extra,logger.name,logger.version), the Debug default, every existingOptionfield and the package settings. Upgrading from 1.4.4 is ago get; addingdefer handler.Close()is what makes the last records before exit safe, and the docs page needs to say so.Verified:
go test -racegreen three times in a row on Go 1.25, golangci-lint clean, statement coverage 93%. The race test reproduces the data race on the published 1.4.4 under the race detector (handler.go:90). End to end against a test source on the clanker team: the example project delivers its three records in 0.6 s withCloseand no sleep, the rows keep the exactextrashape of 1.4.4, and a wrong token printsslog-betterstack: Better Stack rejected 3 records: 401 Unauthorizedon stderr where 1.4.4 printed nothing.End to end against the real ingest endpoint, built with the race detector: 10 records, a burst of 25,000 from 8 goroutines (all delivered, Close in 0.2 s), 1000 records of 16 KiB each (16 MB, all delivered), the old sleep-only workaround (delivered), a wrong token (401 reported, nothing sent), an unresolvable host (6 attempts, "no such host" reported), an unreachable address (Close bounded by the shutdown timeout), 16,000 records logged while Close runs (every record either sent or counted as dropped, no race), an endpoint without a trailing slash. A process that exits with neither Close nor a sleep loses its records, as documented. The counts in ClickHouse match
Statsfor every scenario. Four gaps found by that run are fixed in the last four commits, tests first: a batch abandoned at shutdown now reports the error of its last attempt, records logged after Close are reported once, a negativeTimeoutmeans the default, and a record whose JSON is over the 10 MiB per-record limit is dropped and reported before sending. That last one came from a 12 MiB record the ingest endpoint answered with a 2xx and then replaced with a notice row in the source, its documented behaviour for records over the 10 MiB limit, so the client counted as sent what never landed; checking locally also saves uploading megabytes that cannot land.Thanks to @prochac, who reported that records logged before exit were lost and whose analysis of the official clients shaped the defaults and the drop accounting here, and to @alistairjevans for the first batching fork and the race fix.
The first commit holds the tests only and is expected to fail CI. The following commits make them pass.
🤖 Generated with Claude Code