Skip to content

Commit f72145f

Browse files
grichaclaude
andauthored
feat(hybridcloud): Propagate ViewerContext through cross-silo RPC (#112248)
Pack ViewerContext into the RPC `meta` dict on the sending side (`_send_to_remote_silo`) and restore it via `viewer_context_scope` on the receiving side (`InternalRpcServiceEndpoint`). Previously, cross-silo RPC handlers had no ViewerContext — the middleware saw RPC signature auth, not the original user. The real user identity was only available through `AuthenticationContext` (which most RPC calls don't pass). Now the contextvar is automatically propagated through `meta`, which was already reserved in the wire format for exactly this kind of use. Backwards compatible — old callers send `meta: {}`, receivers treat missing `viewer_context` as `None`. Also adds `ViewerContext.deserialize()` as the inverse of `serialize()`. Part of the [ViewerContext RFC](https://www.notion.so/sentry/RFC-Unified-ViewerContext-via-ContextVar-32f8b10e4b5d81988625cb5787035e02). --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 596ace8 commit f72145f

File tree

4 files changed

+138
-2
lines changed

4 files changed

+138
-2
lines changed

src/sentry/api/endpoints/internal/rpc.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
)
1818
from sentry.hybridcloud.rpc.sig import SerializableFunctionValueException
1919
from sentry.utils.env import in_test_environment
20+
from sentry.viewer_context import ViewerContext, viewer_context_scope
2021

2122

2223
@internal_all_silo_endpoint
@@ -61,8 +62,18 @@ def post(self, request: Request, service_name: str, method_name: str) -> Respons
6162
sentry_sdk.capture_exception()
6263
raise ParseError from e
6364

65+
meta = request.data.get("meta") or {}
66+
vc_data = meta.get("viewer_context")
67+
vc = ViewerContext()
68+
if vc_data:
69+
try:
70+
vc = ViewerContext.deserialize(vc_data)
71+
except Exception as e:
72+
sentry_sdk.capture_exception()
73+
raise ParseError from e
74+
6475
try:
65-
with auth_context.applied_to_request(request):
76+
with viewer_context_scope(vc), auth_context.applied_to_request(request):
6677
result = dispatch_to_local_service(service_name, method_name, arguments)
6778
except RpcValidationException as e:
6879
return Response(

src/sentry/hybridcloud/rpc/service.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
from sentry.types.cell import Cell, CellMappingNotFound
3636
from sentry.utils import json, metrics
3737
from sentry.utils.env import in_test_environment
38+
from sentry.viewer_context import get_viewer_context
3839

3940
if TYPE_CHECKING:
4041
from sentry.hybridcloud.rpc.resolvers import CellResolutionStrategy
@@ -571,8 +572,13 @@ def get_method_timeout(self) -> float:
571572
return settings.RPC_TIMEOUT
572573

573574
def _send_to_remote_silo(self, use_test_client: bool) -> Any:
575+
vc = get_viewer_context()
576+
meta: dict[str, Any] = {}
577+
if vc is not None:
578+
meta["viewer_context"] = vc.serialize()
579+
574580
request_body = {
575-
"meta": {}, # reserved for future use
581+
"meta": meta,
576582
"args": self.serial_arguments,
577583
}
578584
data = json.dumps(request_body).encode(_RPC_CONTENT_CHARSET)

src/sentry/viewer_context.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,19 @@ def serialize(self) -> dict[str, Any]:
6262
result["token"] = {"kind": self.token.kind, "scopes": list(self.token.get_scopes())}
6363
return result
6464

65+
@classmethod
66+
def deserialize(cls, data: dict[str, Any]) -> ViewerContext:
67+
"""Reconstruct from a serialized dict. Token is not deserialized."""
68+
try:
69+
actor_type = ActorType(data.get("actor_type", "unknown"))
70+
except ValueError:
71+
actor_type = ActorType.UNKNOWN
72+
return cls(
73+
organization_id=data.get("organization_id"),
74+
user_id=data.get("user_id"),
75+
actor_type=actor_type,
76+
)
77+
6578

6679
@contextlib.contextmanager
6780
def viewer_context_scope(ctx: ViewerContext) -> Generator[None]:

tests/sentry/api/endpoints/test_rpc.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
from typing import Any
4+
from unittest.mock import patch
45

56
import orjson
67
from django.test import override_settings
@@ -10,6 +11,7 @@
1011
from sentry.hybridcloud.rpc.service import generate_request_signature
1112
from sentry.organizations.services.organization import RpcUserOrganizationContext
1213
from sentry.testutils.cases import APITestCase
14+
from sentry.viewer_context import ActorType, ViewerContext, get_viewer_context
1315

1416

1517
@override_settings(RPC_SHARED_SECRET=["a-long-value-that-is-hard-to-guess"])
@@ -142,3 +144,107 @@ def test_with_invalid_arguments(self) -> None:
142144
assert response.data == {
143145
"detail": ErrorDetail(string="Malformed request.", code="parse_error")
144146
}
147+
148+
def test_viewer_context_propagated_from_meta(self) -> None:
149+
"""ViewerContext in meta is set as the contextvar during dispatch."""
150+
organization = self.create_organization()
151+
captured_contexts: list[ViewerContext | None] = []
152+
153+
original_dispatch = __import__(
154+
"sentry.hybridcloud.rpc.service", fromlist=["dispatch_to_local_service"]
155+
).dispatch_to_local_service
156+
157+
def capturing_dispatch(*args, **kwargs):
158+
captured_contexts.append(get_viewer_context())
159+
return original_dispatch(*args, **kwargs)
160+
161+
path = self._get_path("organization", "get_organization_by_id")
162+
data = {
163+
"args": {"id": organization.id},
164+
"meta": {
165+
"viewer_context": {
166+
"organization_id": organization.id,
167+
"user_id": 42,
168+
"actor_type": "user",
169+
}
170+
},
171+
}
172+
173+
with patch(
174+
"sentry.api.endpoints.internal.rpc.dispatch_to_local_service",
175+
side_effect=capturing_dispatch,
176+
):
177+
response = self._send_post_request(path, data)
178+
179+
assert response.status_code == 200
180+
assert len(captured_contexts) == 1
181+
ctx = captured_contexts[0]
182+
assert ctx is not None
183+
assert ctx.organization_id == organization.id
184+
assert ctx.user_id == 42
185+
assert ctx.actor_type == ActorType.USER
186+
187+
def test_viewer_context_unknown_when_meta_empty(self) -> None:
188+
"""Empty ViewerContext with UNKNOWN actor type when meta has no viewer_context."""
189+
organization = self.create_organization()
190+
captured_contexts: list[ViewerContext | None] = []
191+
192+
original_dispatch = __import__(
193+
"sentry.hybridcloud.rpc.service", fromlist=["dispatch_to_local_service"]
194+
).dispatch_to_local_service
195+
196+
def capturing_dispatch(*args, **kwargs):
197+
captured_contexts.append(get_viewer_context())
198+
return original_dispatch(*args, **kwargs)
199+
200+
path = self._get_path("organization", "get_organization_by_id")
201+
data = {"args": {"id": organization.id}, "meta": {}}
202+
203+
with patch(
204+
"sentry.api.endpoints.internal.rpc.dispatch_to_local_service",
205+
side_effect=capturing_dispatch,
206+
):
207+
response = self._send_post_request(path, data)
208+
209+
assert response.status_code == 200
210+
assert len(captured_contexts) == 1
211+
ctx = captured_contexts[0]
212+
assert ctx is not None
213+
assert ctx.user_id is None
214+
assert ctx.organization_id is None
215+
assert ctx.actor_type == ActorType.UNKNOWN
216+
217+
def test_viewer_context_roundtrip_through_meta(self) -> None:
218+
"""ViewerContext set on the sending side arrives on the receiving side."""
219+
organization = self.create_organization()
220+
captured_contexts: list[ViewerContext | None] = []
221+
222+
original_dispatch = __import__(
223+
"sentry.hybridcloud.rpc.service", fromlist=["dispatch_to_local_service"]
224+
).dispatch_to_local_service
225+
226+
def capturing_dispatch(*args, **kwargs):
227+
captured_contexts.append(get_viewer_context())
228+
return original_dispatch(*args, **kwargs)
229+
230+
# Simulate what _send_to_remote_silo builds when ViewerContext is set
231+
ctx = ViewerContext(organization_id=organization.id, user_id=42, actor_type=ActorType.USER)
232+
path = self._get_path("organization", "get_organization_by_id")
233+
data = {
234+
"args": {"id": organization.id},
235+
"meta": {"viewer_context": ctx.serialize()},
236+
}
237+
238+
with patch(
239+
"sentry.api.endpoints.internal.rpc.dispatch_to_local_service",
240+
side_effect=capturing_dispatch,
241+
):
242+
response = self._send_post_request(path, data)
243+
244+
assert response.status_code == 200
245+
assert len(captured_contexts) == 1
246+
restored = captured_contexts[0]
247+
assert restored is not None
248+
assert restored.organization_id == ctx.organization_id
249+
assert restored.user_id == ctx.user_id
250+
assert restored.actor_type == ctx.actor_type

0 commit comments

Comments
 (0)