-
[OPIK-7333] [BE] server-side ingestion size guards (request 413 + document 4xx) (#7483)
发布于
2026-07-26 06:31:11 +00:00 - feat(backend): server-side ingestion size guards - request 413 + document 4xx (OPIK-7333, OPIK-7334)
Server-side half of the unbounded-payload OOM fix (parent OPIK-7312; client-side
per-span cap is OPIK-7335 / PR #7469).OPIK-7333 - RequestSizeLimitFilter: reject a request whose Content-Length exceeds
maxRequestSizeBytes with 413 before the body is read. Guicey auto-installs it as a JAX-RS
provider and injects JacksonConfig via the Guicey @Config sub-config binding (no explicit
module bind) - same style as TraceThreadConfig / OnlineScoringConfig injection. No
Content-Length (chunked) falls through to maxDocumentLength. Header parsed as long so2GB can't slip past an int overflow; malformed/negative -> 400.
OPIK-7334 - maxDocumentLength: cap the whole decompressed document on both the HTTP and
JsonUtils ObjectMappers via StreamReadConstraints (set directly alongside maxStringLength;
the builder normalizes <=0 to unlimited), so an oversized batch aborts mid-parse with 4xx
(via JsonProcessingExceptionMapper) before a multi-GB node tree is built.Limits (config-driven, env-overridable):
- maxStringLength = 100MB (per-field cap; unchanged; the documented "100MB per field")
- maxDocumentLength = 512MB self-hosted default; Comet cloud overrides to 256MB via
JACKSON_MAX_DOCUMENT_LENGTH (deployment-side, OPIK-7353) - maxRequestSizeBytes = 512MB self-hosted default; cloud overrides to 256MB via
JACKSON_MAX_REQUEST_SIZE_BYTES. Kept == maxDocumentLength so the
request filter never rejects a request the document guard would accept.
config-test.yml uses lower, payload-calibrated test caps (independent of shipped defaults)
so the guard tests trip at modest sizes; JacksonConfigTest verifies the real 512MB defaults.
NOTE (deployment): the cloud 256MB override lives in comet-helm/values-production-opik.yaml
(component.backend.env), NOT in this repo - tracked as OPIK-7353. Without it, cloud inherits 512MB.Tests (all green via mvn):
- Unit (11): RequestSizeLimitFilterTest (over->413, at-limit pass, no-CL pass, >2GB->413,
malformed/negative->400); JsonUtilsDocumentLengthTest (over-doc->StreamConstraintsException
via streamed InputStream, under parses, unlimited parses, maxStringLength still enforced);
JacksonConfigTest (512MB defaults + request==document invariant). - Integration (SpansResourceTest.LargePayloadTests, 4): ~93MB inline succeeds + strips;
253MB single string -> 400 (maxStringLength); 280MB total document -> 400 (maxDocumentLength);
buffered client (Grizzly + Expect:100-continue) with Content-Length over cap -> 413.
Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
- docs(backend): mirror jacksonConfig docblocks in config-test.yml
config-test.yml added maxDocumentLength/maxRequestSizeBytes without the per-key
Default:/Description: docblocks that config.yml carries. Mirror them verbatim so the
test config stays structurally in sync with config.yml, matching the sibling
maxStringLength entry.- feat(metrics): add ingestion size-guard rejection counter (OPIK-7333)
Emit ingestion_size_guard_rejections_total{guard,endpoint,workspace_id,
workspace_name} so the OPIK-7333/7334 size guards are observable in Grafana.
Neither rejection is otherwise captured: the request-size 413 aborts with a
Response (no throwable) and the document/string-length 4xx is handled by the
more-specific JsonProcessingExceptionMapper, so opik.errors.count never sees
them; the dedicated guard label also separates an oversized-payload reject from
an ordinary malformed-JSON 400.- IngestionSizeGuardMetrics: @Singleton OTel counter, reusing ErrorMetricsResolver
for endpoint + workspace (workspace_id falls back to unknown pre-auth on the 413
path; workspace_name falls back to workspace_id per metrics-instrumentation §2.3). - RequestSizeLimitFilter: emit guard=request_size on the 413 branch only (malformed/
negative Content-Length 400s are bad framing, not size rejections, and stay uncounted). - JsonProcessingExceptionMapper: emit guard=document_length/string_length when the
parse failure is a StreamConstraintsException, classified from its message.
Tests: RequestSizeLimitFilterTest asserts emit-on-reject / never-on-pass;
IngestionSizeGuardMetricsTest covers the message classifier. mvn -o compile clean.Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
- fix(backend): enforce maxDocumentLength >= maxStringLength + dedup stream-constraint setup (OPIK-7333)
Addresses two Baz review findings on #7483:
-
Cross-field validation (Logical Bug): JacksonConfig only had per-field @Min
checks, so maxDocumentLength could be set below maxStringLength and reject a
payload that already passed the string cap. Add an @AssertTrue invariant
(documentLength >= stringLength, or <= 0 for unlimited) so a misordered env
override fails fast at startup with a clear message. The config is @Valid, so
it's enforced on load. Covered by a new JacksonConfigTest case. -
Dedup: the StreamReadConstraints (maxStringLength + maxDocumentLength) build was
assembled independently in both OpikApplication (HTTP mapper) and
JsonUtils.createConfiguredMapper (internal mapper). Extract a single
JsonUtils.applyStreamReadConstraints(mapper, ...) helper used by both, so the
two mappers can't drift.
Verified: spotless clean, module compiles, JacksonConfigTest + JsonUtilsDocumentLengthTest green (Java 25).
Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
- fix(backend): address second Baz review round on ingestion size guards (OPIK-7333)
- Config (A): add an @AssertTrue enforcing maxRequestSizeBytes >= maxDocumentLength
(unless the document cap is <= 0 / unlimited), so a request-size cap set below the
document cap — which would 413 requests the parser would accept — fails fast at
startup. Mirrors the maxDocumentLength >= maxStringLength invariant. - Metrics (C): add a stable
componentdimension (request_filter | json_parser) to
ingestion_size_guard_rejections_total, so the 413-filter and 4xx-parser call sites
are distinguishable without overloadingguard(metrics-instrumentation skill §1.4). - Logging (D+E): split JsonProcessingExceptionMapper — expected size-guard
StreamConstraintsExceptions log at DEBUG (they're already counted, and were flooding
INFO on every oversized ingest); genuine malformed JSON logs at INFO with the
exception (stack trace/type) for diagnosis. - Test (B): RequestSizeLimitFilterTest asserts the exact UriInfo passed to
recordRequestSizeRejection instead of any(), catching a mislabeled endpoint.
Verified (Java 25): spotless clean, module compiles, 17/17 targeted tests pass
(JacksonConfigTest, RequestSizeLimitFilterTest, IngestionSizeGuardMetricsTest,
JsonUtilsDocumentLengthTest).Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
- fix(backend): drop the maxRequestSizeBytes >= maxDocumentLength invariant (OPIK-7333)
Revert the round-2 Finding-A validator. It was both wrong and boot-breaking:
- Semantically wrong: maxRequestSizeBytes bounds the on-the-wire (compressed)
Content-Length, while maxDocumentLength bounds the decompressed document. They
measure different axes, so a request cap below the document cap is a legitimate
configuration (e.g. cap edge uploads while still parsing larger decompressed
batches), not a misconfiguration. - Boot-breaking: config-test.yml deliberately sets maxRequestSizeBytes=50MB <
maxDocumentLength=256MB to exercise the 413 path cheaply. The @AssertTrue rejected
that at startup — SpansResourceTest.LargePayloadTests failed with
ConfigurationValidationException (caught by running the app-booting integration
test locally; the unit tests missed it).
Replaced with a comment explaining why there is intentionally no such cross-field
check. The maxDocumentLength >= maxStringLength invariant (real, same-axis) stays.The other round-2 fixes are unchanged: component metric dimension, log-level split,
and the exact-UriInfo filter test.Verified (Java 25, Docker): app boots, SpansResourceTest.LargePayloadTests 4/4 green
(413 + 400 end-to-end), plus JacksonConfig/RequestSizeLimitFilter/IngestionSizeGuard/
JsonUtilsDocumentLength unit tests — 20/20 total.Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
- fix(backend): count size-guard rejections wrapped in JsonMappingException (OPIK-7333)
The ingestion size-guard metric under-counted the real-world case. When
maxDocumentLength/maxStringLength trips while Jackson is binding a bean property
(a well-formed SpanBatch/TraceBatch whose input/output/metadata is oversized), the
parser-level StreamConstraintsException is re-wrapped in a JsonMappingException
(carrying the reference chain) by BeanDeserializer.wrapAndThrow. The mapper's
instanceof StreamConstraintsExceptioncheck then matched the wrapper, not the
constraint, so ingestion_size_guard_rejections_total was never incremented for it.
Net effect: the OPIK-7390 dashboard would read ~0 document/string-length rejections
in prod even while the backend actively rejects oversized SDK payloads. Only an
unknown top-level field (which Jackson skips, with no bean binding) propagates the
constraint raw, which is why synthetic bodies incremented the metric but real SDK
traffic did not.Fix: walk the exception cause chain (findStreamConstraint, bounded and
self-reference-safe) to find the underlying StreamConstraintsException, and pass the
unwrapped instance to recordStreamConstraintRejection so it still classifies
document- vs string-length from the clean message.Verified end-to-end against a local low-limit backend driven by old opik 2.2.0 (no
client-side truncation): for the same 10 rejected batches the counter went from 0 to
10, correctly split document_length/string_length and attributed to
/v1/private/{spans,traces}/batch. Added JsonProcessingExceptionMapperTest (5 cases:
wrapped-in-JsonMappingException, raw, deeply nested, plain malformed JSON no-op, and
a self-referencing cause chain).Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
- fix(backend): return 413 for ingestion size-guard rejections; tidy test imports (OPIK-7333)
Addresses two code-review comments on the size-guard mapper:
-
Size-guard rejections now return 413 REQUEST_ENTITY_TOO_LARGE instead of 400. When
Jackson's StreamConstraintsException trips during request deserialization (document-
or string-length over the cap) it is a payload-too-large rejection, not malformed
JSON, so JsonProcessingExceptionMapper surfaces it as 413 - consistent with
RequestSizeLimitFilter's pre-parse 413. Genuine malformed JSON still returns 400.
This is uniform across StreamConstraintsException, so an oversized single value
(maxStringLength) now also returns 413. -
Replace the inline fully-qualified Jersey/Jakarta client names in the 413 test with
imports (ClientBuilder, ClientConfig, ClientProperties, RequestEntityProcessing,
GrizzlyConnectorProvider).
Tests updated to match: JsonProcessingExceptionMapperTest (size-guard -> 413, malformed
-> 400), SpansResourceTest LargePayloadTests (oversized attachment + oversized document
-> 413), JsonUtilsDocumentLengthTest display name.Verified (Java 25, Docker): JsonProcessingExceptionMapperTest 5/5; SpansResourceTest
LargePayloadTests 4/4 - 413 confirmed end-to-end through the Jersey stack.Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
- fix(backend): address remaining CRA review findings on the ingestion size guards (OPIK-7333)
Verified locally: unit 19/19, SpansResourceTest LargePayloadTests 4/4 (integration), plus a
live dev-runner run driven by the old (pre-truncation) SDK - 10 rejections, all 413, with the
guard metric correctly split document_length/string_length by endpoint.- JacksonConfig: maxDocumentLength drops @Min (a non-positive value is a deliberate "unlimited"
escape hatch that @Min turned into a boot failure); its @AssertTrue now carries the floor
(>= maxStringLength) AND a 2GB ceiling. maxRequestSizeBytes gains @Max(2GB). Beyond ~2GB the
guard cannot protect the heap (Java 2^31 String/array limit), so an out-of-range override now
fails fast at startup instead of silently disabling the guard. - JsonUtils / JacksonConfig javadoc: clarify the document cap is enforced on stream-based reads
(the HTTP boundary), not fully in-memory String/byte[] reads. Read-back hardening is tracked
separately as OPIK-7426. - JsonProcessingExceptionMapper: include the workspace id in the genuine-malformed-JSON log.
- RequestSizeLimitFilter javadoc: it is a global filter capping every request with a
Content-Length (intentional memory protection), not ingestion-only. - IngestionSizeGuardMetrics: document that classifyStreamConstraint depends on Jackson message
substrings, now guarded by real-parse tests. - Tests: JsonUtilsDocumentLengthTest adds a typed-bean (SpanBatch) case reproducing the real
wrapped-JsonMappingException path (the metric-under-count blind spot); IngestionSizeGuardMetricsTest
adds real-Jackson classification tests; JacksonConfigTest covers the 2GB ceiling + @Max;
SpansResourceTest asserts the 413 body via JsonNode and documents the chunked-transfer dependency;
config-test.yml comments corrected (no stale cloud values).
Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
- fix(backend): stop echoing parser detail in error responses; trim javadoc (OPIK-7333)
Second review round:
- Response leak: JsonProcessingExceptionMapper returned "Unable to process JSON. " +
exception.getMessage(), leaking Jackson internals and fragments of the client's payload into the
HTTP error body. It now returns a stable generic message (413 "Request payload exceeds the maximum
allowed size."; 400 "Unable to process the request body: it is not valid JSON.") while the full
exception (with workspace context) stays in the server log. Added a leak-guard test. - JsonUtils javadoc no longer overstates internal coverage: it states maxDocumentLength is enforced
only on stream/reader reads (parser buffer refill), not fully in-memory String/byte[] reads
(dropped the misleading "already bounded at ingestion" claim). - Trimmed the verbose javadoc/comments from the previous round to minimal across JacksonConfig,
RequestSizeLimitFilter, IngestionSizeGuardMetrics, JsonUtils and the mapper. - Removed the deployment-configurable size numbers (50MB/256MB request/document, the 512MB/cloud
narrative) from code comments/javadoc, since they are set by config and drift; only the // 512MB
decode of the default literal remains. Customer-facing docs keep the numbers.
Verified (Java 25, Docker): unit 19/19; SpansResourceTest LargePayloadTests 4/4 (the 413 tests now
assert the generic message end-to-end).Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
- fix(backend): bound the exception cause-chain walk against cyclic causes (OPIK-7333)
findStreamConstraint only broke on a DIRECT self-cause (cause == cause.getCause()), so a
multi-node cause cycle (A -> B -> A, which Throwable.initCause permits) spun the walk forever
and pinned the JAX-RS request thread. Replace the fragile self-cause check with a hard depth
cap (MAX_CAUSE_DEPTH = 50, far beyond any real chain; no per-call allocation). Adds a
timeout-guarded regression test that reproduces the hang on the old code.Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
- fix(backend): keep informative 400 message for non-size deserialization errors (OPIK-7333)
The size-guard leak-fix genericized the WHOLE 400 branch of the global
JsonProcessingExceptionMapper, changing the error body for every malformed/invalid-JSON
request API-wide and breaking ~8 integration tests (spans, project metrics, workspaces,
filters, provider keys) that assert the specific 'Unable to process JSON. '
message. Scope the generic message to the 413 size-guard branch (where internal limits /
Jackson internals are actually exposed) and restore the informative message on the 400
branch (the caller's own payload + our field names). Status codes unchanged; mapper unit
test helper updated to match.Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
- refactor(backend): trim size-guard comments to the essentials (OPIK-7333)
Reduce the javadoc/comments we added for the ingestion size guards to the load-bearing
rationale only (2GB ceiling, no cross-field check, in-memory bypass, Jackson-message
fragility, 413-vs-400, do-not-genericize-400, cause-cycle depth cap). Drops restatement,
internal skill-doc citations, and redundant @param/@return. Pre-existing comments untouched.Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
- fix(backend): address Thiago review findings on ingestion size guards (OPIK-7333)
- Force application/json on the error response: a non-JSON Accept (e.g. OTel protobuf)
had no ErrorMessage writer, so the 413/400 was surfacing as a 500 (T2, real bug). - Replace the custom cause-chain walk + MAX_CAUSE_DEPTH with the cycle-safe
ExceptionUtils.getThrowableList (T3, code reduction). - Classify the stream constraint: size (document/string length) -> 413; a structural
limit (nesting/number depth) -> 400 instead of a misleading 413 (T4). - MAX_CONFIGURABLE_BYTES -> Integer.MAX_VALUE; the old 2^31 was one byte past the real
String/array limit (T5). - @Priority on RequestSizeLimitFilter so the size guard runs deterministically before
auth (413, not 401) (T6).
Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
- fix(backend): redact caller detail from deserialization error logs (OPIK-7333)
The deserialization-error log logged the full throwable, whose message carries the caller's
own body content (potential PII) - a log leak on ingestion endpoints (Baz medium). Log a
redacted summary instead: quoted workspace + exception type, no throwable. Also quote the
workspace placeholder per opik-backend/SKILL.md. Applies to both 400 log sites; the 413 line
stays at debug.Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
- refactor(backend): rename catch-all guard label stream_constraint -> unclassified (OPIK-7333)
The fallback bucket was labelled 'stream_constraint', which reads like a confirmed classification
even though it is only ever the catch-all for an unrecognized StreamConstraintsException - misleading
dashboards/alerts (Baz low). Rename the constant + emitted value to 'unclassified'.Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
- docs(backend): trim config.yml jackson comments; drop deployment-specific overrides (OPIK-7333)
Align maxDocumentLength/maxRequestSizeBytes comments with the file's Default/Description house
style and remove the self-hosted-vs-cloud (256MB) override prose - the override lives in the
deployment env, not this config's comments.Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
- refactor(backend): drop hardcoded byte-cap defaults from JacksonConfig; config.yml is the source (OPIK-7333)
maxDocumentLength/maxRequestSizeBytes no longer hard-code 512MB - the value is supplied by
config.yml (Thiago T1: avoid two competing sources of truth). Drops the JacksonConfigTest
assertions that checked those in-code defaults; maxStringLength (a symbolic default, not a
duplicated literal) is unchanged.Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
Co-authored-by: Claude Opus 4.8 (1M context) noreply@anthropic.com
下载附件