fix: preserve empty traces/resourceSpans arrays in RBAC-filtered trace responses - #934
Open
IshwarKanse wants to merge 1 commit into
Open
IshwarKanse wants to merge 1 commit into
IshwarKanse wants to merge 1 commit into
Conversation
IshwarKanse
force-pushed
the
fix-empty-search-response-traces-field
branch
from
September 23, 2026 08:18
14e81f1 to
c2ce996
Compare
IshwarKanse
force-pushed
the
fix-empty-search-response-traces-field
branch
from
September 23, 2026 09:12
c2ce996 to
a5dcd32
Compare
5 tasks
IshwarKanse
force-pushed
the
fix-empty-search-response-traces-field
branch
from
September 23, 2026 10:37
a5dcd32 to
2e054ee
Compare
andreasgerstmayr
approved these changes
Sep 23, 2026
andreasgerstmayr
left a comment
Contributor
There was a problem hiding this comment.
imo the e2e test is overkill for this change, as the unit test already covers it - otherwise LGTM 👍
IshwarKanse
force-pushed
the
fix-empty-search-response-traces-field
branch
from
September 23, 2026 10:52
2e054ee to
500a61e
Compare
Contributor
Author
|
@andreasgerstmayr agreed — removed both |
3 tasks
IshwarKanse
added a commit
to IshwarKanse/api
that referenced
this pull request
Sep 28, 2026
launchTestRequests fired requests from goroutines staggered only by a 1ms sleep, then stored each result by launch index. The shared rate limiter assigns Retry-After values in server-arrival order under a mutex, so a 1ms stagger doesn't guarantee arrival order matches launch order under CI load. This let TestWithSharedRateLimiter/one_rate_limiter_with_additional_retry_after_logic flake when requests arrived out of order (observed on CircleCI, ci/circleci: test, build 13935, on PR observatorium#934). Since the affected subtests assert a strict, deterministic Retry-After progression, true concurrency isn't needed: send each request synchronously, waiting for its response before firing the next, and accumulate results directly instead of through an intermediate slice.
IshwarKanse
added a commit
to IshwarKanse/api
that referenced
this pull request
Sep 28, 2026
launchTestRequests fired requests from goroutines staggered only by a 1ms sleep, then stored each result by launch index. The shared rate limiter assigns Retry-After values in server-arrival order under a mutex, so a 1ms stagger doesn't guarantee arrival order matches launch order under CI load. This let TestWithSharedRateLimiter/one_rate_limiter_with_additional_retry_after_logic flake when requests arrived out of order (observed on CircleCI, ci/circleci: test, build 13935, on PR observatorium#934). Since the affected subtests assert a strict, deterministic Retry-After progression, true concurrency isn't needed: send each request synchronously, waiting for its response before firing the next, and accumulate results directly instead of through an intermediate slice.
…e responses
/api/search and /api/v2/traces/{id} responses lose their "traces" or
"resourceSpans" key entirely when query RBAC is enabled
(WithTraceQLNamespaceSelectAndForbidOtherAPIs/responseRBACModifier), instead
of correctly returning an empty array. This crashes downstream consumers
that assume the field is always present when parsing a JSON response -- we
hit this via the OpenShift Distributed Tracing console plugin (using the
Grafana/Perses Tempo datasource), reported as TRACING-6841.
Root cause: responseRBACModifier's JSON path unmarshals and re-marshals the
response using github.com/golang/protobuf/jsonpb, a thin wrapper around
protojson. tempopb.SearchResponse and tempopb.TraceByIDResponse are both
gogo/protobuf-generated types (like tempopb.Trace, which already gets
special-cased via tempopb.MarshalToJSONV1/UnmarshalFromJSONV1 for exactly
this reason). The two jsonpb implementations disagree on whether a
populated-but-empty repeated field is distinct from an absent one:
golang/protobuf's jsonpb collapses an explicit empty array to a nil Go slice
on unmarshal, and omits a nil-or-zero-length slice on marshal regardless of
how it got that way, so the key never makes it back out. gogo/protobuf's
jsonpb preserves the distinction on both ends.
Fix: switch the shared unmarshal/marshal helpers to
github.com/gogo/protobuf/jsonpb for the JSON content-type path, since every
tempopb.* type they handle is gogo/protobuf-generated -- matching the
existing tempopb.Trace special-casing already in this file. This fixes both
/api/search and /api/v2/traces/{id} with one change. The protobuf
content-type path (added in observatorium#924 for the Grafana client) is unchanged and
unaffected: the wire format has no representation for "empty but present"
vs. "absent" to begin with, so proto.Marshal/proto.Unmarshal round-trip
correctly there regardless of library.
Testing:
- TestSearchResponseEmptyTracesPreserved and
TestTraceByIDResponseEmptyResourceSpansPreserved: direct round-trip tests
of unmarshal/marshal against explicit empty-array payloads for both
response types.
- TestResponseRBACModifier/search_endpoint_with_zero_results_keeps_traces_as_an_empty_array:
end-to-end test through the actual HTTP response modifier.
- TestTracesTempoSearchZeroResultsWithQueryRBAC (new, test/e2e): starts a
real Tempo instance and a real observatorium-api binary with
--traces.query-rbac=true, searches the empty instance, and asserts
"traces":[] survives the round trip.
- TestTracesTempoSearchNonEmptyWithQueryRBAC (new, test/e2e): ingests a real
trace with a k8s.namespace.name resource attribute, searches with query
RBAC enabled, and confirms both correct round-tripping of real data and
that RBAC redaction still strips unauthorized attributes correctly.
- Verified against tempo-operator's Gateway e2e/chainsaw tests: none assert
on raw JSON shape for /api/search (all use `jq ".traces | length"`, which
evaluates identically whether the key is missing or an empty array), so
this is not a breaking change for existing consumers.
- Verified on a real OpenShift cluster running the OpenShift Distributed
Tracing console plugin against a Gateway using this fix: the
previously-crashing zero-result TraceQL search now works correctly, and
RBAC-based attribute redaction is unaffected.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
IshwarKanse
force-pushed
the
fix-empty-search-response-traces-field
branch
from
September 28, 2026 17:36
500a61e to
d467c43
Compare
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.
Summary
/api/searchand/api/v2/traces/{id}responses lose their"traces"or"resourceSpans"key entirely when query RBAC is enabled (WithTraceQLNamespaceSelectAndForbidOtherAPIs/responseRBACModifier), instead of correctly returning an empty array. This crashes downstream consumers that assume the field is always present when parsing a JSON response — we hit this via the OpenShift Distributed Tracing console plugin (using the Grafana/Perses Tempo datasource), reported as TRACING-6841.Root cause
responseRBACModifier's JSON path unmarshals and re-marshals the response usinggithub.com/golang/protobuf/jsonpb, a thin wrapper aroundprotojson.tempopb.SearchResponseandtempopb.TraceByIDResponseare both gogo/protobuf-generated types (liketempopb.Trace, which already gets special-cased viatempopb.MarshalToJSONV1/UnmarshalFromJSONV1for exactly this reason — see the comments already in this file at therouteQueryV1case). The two jsonpb implementations disagree on whether a populated-but-empty repeated field is distinct from an absent one:github.com/golang/protobuf/jsonpb(viaprotojson) collapses an explicit empty array to anilGo slice on unmarshal, and omits a nil-or-zero-length slice on marshal regardless of how it got that way — so the key never makes it back out, even once.github.com/gogo/protobuf/jsonpbpreserves the nil vs. populated-but-empty distinction on both ends.Verified empirically by round-tripping raw empty-array payloads through all four combinations of
{gogo, golang/protobuf} x {unmarshal, marshal}: onlygogo-unmarshal -> gogo-marshalpreserves the empty array in the output — every combination touchinggolang/protobufon either end drops the key.Fix
Switch the shared
unmarshal/marshalhelpers togithub.com/gogo/protobuf/jsonpbfor the JSON content-type path, since everytempopb.*type they handle (SearchResponse,TraceByIDResponse) is gogo/protobuf-generated — matching the existingtempopb.Tracespecial-casing already in this file. This fixes both/api/searchand/api/v2/traces/{id}with one change. The protobuf content-type path (added in #924 for the Grafana client) is unchanged and unaffected — the wire format has no separate representation for "empty but present" vs. "absent" to begin with, soproto.Marshal/proto.Unmarshalround-trip correctly there regardless of library.Testing
Unit tests:
TestSearchResponseEmptyTracesPreservedandTestTraceByIDResponseEmptyResourceSpansPreserved: direct round-trip tests ofunmarshal/marshalagainst explicit empty-array payloads for both response types.TestResponseRBACModifier/search_endpoint_with_zero_results_keeps_traces_as_an_empty_array: end-to-end test through the actual HTTP response modifier with a realistic zero-result Tempo response body.TestResponseRBACModifier/v2_trace_endpoint: updated to assert the fixed (non-omitted) redacted-attribute shape.TestRBACSearchResult,TestResponseRBACModifier(search endpoint, non-empty), andTestResponseRBACModifierProtobuf(search endpoint protobuf) all still pass unmodified — RBAC filtering behavior and the protobuf path are unaffected.Real OpenShift cluster validation:
observatorium-apiimage and ran it as the Tempo Operator Gateway's image (TempoStack.spec.images.tempoGateway) on a real OpenShift 4.22 cluster, alongside the OpenShift Distributed Tracing console plugin.tempo-operator's own Gateway e2e/chainsaw tests (multitenancy-rbac) that RBAC-filtered trace generation, viewing, and redaction all work correctly with this fix in place.tempo-operator's Gateway e2e/chainsaw tests: none assert on raw JSON shape for/api/search(all usejq ".traces | length", which evaluates identically whether the key is missing or an empty array), so this is not a breaking change for existing consumers.Other checks:
go build ./...,go vet ./...,gofmt -l, and the fullgo test ./...suite pass.go mod tidyproduces no diff.Note on CI:
ci/circleci: testflaked on this PR a couple of times onTestWithSharedRateLimiter/one_rate_limiter_with_additional_retry_after_logicin the unrelatedratelimitpackage (confirmed via two separate CI runs producing two different scrambledRetry-Afterorderings, and a zero diff between this branch andmainforratelimit/). Root cause and fix are in #936.Update: per review feedback, removed the two
test/e2eRBAC tests — the unit test coverage above plus the real OpenShift cluster validation already exercise this change thoroughly, so the extra real-Tempo e2e tests weren't worth their added CI time.