Skip to content

T-19629 Keep sending the batch when a log argument can't be serialized - #27

Merged
PetrHeinz merged 10 commits into
mainfrom
claude/t-19629-cyclic-args
Jul 28, 2026
Merged

T-19629 Keep sending the batch when a log argument can't be serialized#27
PetrHeinz merged 10 commits into
mainfrom
claude/t-19629-cyclic-args

Conversation

@PetrHeinz

@PetrHeinz PetrHeinz commented Jul 23, 2026

Copy link
Copy Markdown
Member

Fixes #26.

buildPostData() puts the raw SLF4J argument array into the payload and batchToJson() serializes the whole batch with a plain ObjectMapper, which has no cycle detection. One log line carrying a cyclic object graph — a JDBC Connection, which HikariCP logs on every pooled connection creation — makes serialization of the entire batch throw, and flushLogs() then sets retries = maxRetries and drops all of it.

The fix is a BestEffortSerialization Jackson module registered on the appender's mapper, so there is exactly one serialization pass and one way of representing data:

  • A BeanSerializerModifier wraps the bean, map, collection and array serializers with a cycle guard that keeps an identity set of the objects currently being serialized — the same approach as logtail-python's frame.py. A reference looping back into the graph is replaced with "<omitted circular reference>" and everything around it stays structured JSON: a cyclic Parent argument arrives as {"Child":{"Parent":"<omitted circular reference>"}}. Only true ancestors count, so the same object referenced twice in a DAG still serializes fully both times.
  • SLF4J arguments — the only place arbitrary objects enter the payload — are additionally guarded: each argument serializes once into a TokenBuffer that is then replayed into the output stream. If it fails for a reason other than a cycle (typically a getter that throws), the buffer is discarded and the whole argument is sent as "<omitted unserializable com.example.Foo>". This second layer also catches cycles threaded exclusively through serializers the modifier hooks cannot wrap (e.g. a self-referencing ObjectNode), which degrade to whole-argument omission instead of batch loss.

toString() is never called on logged objects, nothing is serialized twice, and happy-path output is byte-identical to before — verified down to @JsonUnwrapped flattening, @JsonInclude(NON_EMPTY) suppression and polymorphic default typing, each of which the guard wrappers must explicitly delegate to preserve. The one trade-off: a bean with one good getter and one throwing getter is omitted as a whole argument — there is no clean way to back out mid-object in a streaming write, and an explicit marker naming the class beats a half-object. Only exact final JDK scalar types skip the argument guard; an instanceof Number shortcut would let a subclass with a throwing toString take the whole batch down again.

Measured overhead of the always-on guard: ~0.2 ms per 1000-line batch (1.65x the bare serialization, ~200 ns per log line) on a realistic argument mix. A lazily-armed variant reaching 1.0x was prototyped and rejected: it only wins microseconds while reintroducing StackOverflowError as control flow on every batch that contains a cycle.

The first commit is the failing reproduction on its own, so the diff shows the bug reproducing before it shows it fixed. The second commit is an earlier sanitize-and-retry attempt kept in history; the third replaces it with the module. The remaining commits are a red-team pass on the module itself, each gap committed as a failing test before its fix.

Worth noting for anyone reproducing this: on the jackson-databind 2.13.5 this repo pins, the cycle surfaces as Infinite recursion (StackOverflowError), while on the reporter's 2.21.4 the same cycle trips the StreamWriteConstraints nesting-depth guard instead (Document nesting depth (1001) exceeds the maximum allowed, a guard that only exists from Jackson 2.15). With the cycle guard neither is ever thrown — the cycle is cut before the serializer can recurse.

The 9 integration tests that need BETTER_STACK_SOURCE_TOKEN fail identically on main when run without the secret — unrelated to this change.

🤖 Generated with Claude Code

PetrHeinz and others added 2 commits July 23, 2026 10:13
A log argument with a cyclic object graph makes batchToJson() throw, which
makes flushLogs() give up on the entire batch. The test asserts that the
surrounding log lines still get serialized; it currently fails with
JsonMappingException: Infinite recursion (StackOverflowError).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
batchToJson() now falls back to sanitizing the batch when the ObjectMapper
throws: log lines that serialize fine are kept untouched, and only the values
Jackson chokes on are replaced with their string representation. A cyclic
object graph on one log line - HikariCP logs a pooled JDBC connection on every
connection creation - no longer costs the whole batch.

The fallback only runs after a failed serialization, so the happy path is
unchanged. StackOverflowError is caught alongside Exception because Jackson
only wraps it into JsonMappingException for bean serializers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@PetrHeinz PetrHeinz changed the title T-19629 Reproduction test: cyclic log argument drops the entire batch T-19629 Keep sending the batch when a log argument can't be serialized Jul 23, 2026
PetrHeinz and others added 7 commits July 27, 2026 19:46
Replaces the sanitize-and-retry fallback: there is now exactly one
serialization per batch and one way of representing data. A new
BestEffortSerialization Jackson module detects circular references
during serialization (identity set of ancestor objects, mirroring
logtail-python's frame.py) and replaces the reference back into the
graph with "<omitted circular reference>" while the rest of the object
stays structured JSON. SLF4J arguments - the only place arbitrary
objects enter the payload - are additionally guarded: an argument whose
serialization fails for any other reason (typically a getter that
throws) is buffered through a TokenBuffer and replaced as a whole with
"<omitted unserializable <class>>". toString() is never called on
logged objects and nothing is ever serialized twice.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three gaps, each with a failing test committed before its fix:

- guard() gives every Number instance a free pass, but Number is not
  final and Jackson stringifies unknown Number subclasses via toString -
  a broken subclass escapes both guards and drops the whole batch.
- CycleGuard does not delegate unwrappingSerializer, so @JsonUnwrapped
  properties silently serialize nested instead of flattened.
- CycleGuard does not delegate isEmpty, so @JsonInclude(NON_EMPTY)
  properties are emitted even when empty.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Number is not final and Jackson serializes unknown Number subclasses
via toString, so one with a throwing toString escaped guard() and threw
during batch serialization, dropping the whole batch - the exact bug
this branch exists to fix. Only exact final JDK scalar types skip the
guard now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
JsonSerializer.unwrappingSerializer returns the serializer itself
unless overridden, so the guard wrapper swallowed the delegate's
unwrapping variant and @JsonUnwrapped beans serialized nested. The
guard now wraps the delegate's unwrapping serializer instead, keeping
both the flattened shape and the cycle protection.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The guard wrapper inherited the default isEmpty (null check only), so
@JsonInclude(NON_EMPTY) properties were emitted even when empty.
Delegated to the wrapped serializer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ping

A cyclic JsonNode serializes through its own JsonSerializable path that
no serializer-modifier hook wraps - a regression test pins that such
cycles fall back to whole-argument omission instead of dropping the
batch. The polymorphic typing test fails: GuardedSerializer inherits a
serializeWithType that throws, so a mapper with default typing enabled
cannot serialize guarded arguments at all.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The guard wrapper is invisible in the output, so serializeWithType has
no type id of its own to write - it now defers to the plain serialize,
letting the wrapped value's serializer emit its type info inside the
buffer instead of inheriting the base implementation that throws.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@PetrHeinz
PetrHeinz requested a review from adikus July 28, 2026 10:27
@PetrHeinz
PetrHeinz marked this pull request as ready for review July 28, 2026 10:27
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@adikus adikus left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you!

@PetrHeinz
PetrHeinz merged commit 8a2d32c into main Jul 28, 2026
4 checks passed
@PetrHeinz
PetrHeinz deleted the claude/t-19629-cyclic-args branch July 28, 2026 11:14
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.

LogtailAppender crashes on any log argument with a cyclic object graph (e.g. a JDBC Connection), dropping the entire batch

2 participants