Skip to content

Commit 542e8dc

Browse files
grichaclaude
andcommitted
feat(hybridcloud): Propagate ViewerContext through cross-silo RPC
Pack ViewerContext into the RPC meta dict on the sending side and restore it via viewer_context_scope on the receiving side. This closes the gap where cross-silo RPC handlers had no ViewerContext despite the original request having one. Uses the existing reserved meta key in the RPC wire format. Backwards compatible — old callers send empty meta, receivers treat missing viewer_context as None. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 8cafbe2 commit 542e8dc

File tree

4 files changed

+135
-2
lines changed

4 files changed

+135
-2
lines changed

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

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import contextlib
2+
13
import pydantic
24
import sentry_sdk
35
from rest_framework import status
@@ -17,6 +19,7 @@
1719
)
1820
from sentry.hybridcloud.rpc.sig import SerializableFunctionValueException
1921
from sentry.utils.env import in_test_environment
22+
from sentry.viewer_context import ViewerContext, viewer_context_scope
2023

2124

2225
@internal_all_silo_endpoint
@@ -61,8 +64,16 @@ def post(self, request: Request, service_name: str, method_name: str) -> Respons
6164
sentry_sdk.capture_exception()
6265
raise ParseError from e
6366

67+
meta = request.data.get("meta", {})
68+
vc_data = meta.get("viewer_context")
69+
vc_scope = (
70+
viewer_context_scope(ViewerContext.deserialize(vc_data))
71+
if vc_data
72+
else contextlib.nullcontext()
73+
)
74+
6475
try:
65-
with auth_context.applied_to_request(request):
76+
with vc_scope, 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: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -571,8 +571,15 @@ def get_method_timeout(self) -> float:
571571
return settings.RPC_TIMEOUT
572572

573573
def _send_to_remote_silo(self, use_test_client: bool) -> Any:
574+
from sentry.viewer_context import get_viewer_context
575+
576+
vc = get_viewer_context()
577+
meta: dict[str, Any] = {}
578+
if vc is not None:
579+
meta["viewer_context"] = vc.serialize()
580+
574581
request_body = {
575-
"meta": {}, # reserved for future use
582+
"meta": meta,
576583
"args": self.serial_arguments,
577584
}
578585
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: 102 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,103 @@ 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_none_when_meta_empty(self) -> None:
188+
"""No ViewerContext set when meta is empty."""
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+
assert captured_contexts[0] is None
212+
213+
def test_viewer_context_roundtrip_through_meta(self) -> None:
214+
"""ViewerContext set on the sending side arrives on the receiving side."""
215+
organization = self.create_organization()
216+
captured_contexts: list[ViewerContext | None] = []
217+
218+
original_dispatch = __import__(
219+
"sentry.hybridcloud.rpc.service", fromlist=["dispatch_to_local_service"]
220+
).dispatch_to_local_service
221+
222+
def capturing_dispatch(*args, **kwargs):
223+
captured_contexts.append(get_viewer_context())
224+
return original_dispatch(*args, **kwargs)
225+
226+
# Simulate what _send_to_remote_silo builds when ViewerContext is set
227+
ctx = ViewerContext(organization_id=organization.id, user_id=42, actor_type=ActorType.USER)
228+
path = self._get_path("organization", "get_organization_by_id")
229+
data = {
230+
"args": {"id": organization.id},
231+
"meta": {"viewer_context": ctx.serialize()},
232+
}
233+
234+
with patch(
235+
"sentry.api.endpoints.internal.rpc.dispatch_to_local_service",
236+
side_effect=capturing_dispatch,
237+
):
238+
response = self._send_post_request(path, data)
239+
240+
assert response.status_code == 200
241+
assert len(captured_contexts) == 1
242+
restored = captured_contexts[0]
243+
assert restored is not None
244+
assert restored.organization_id == ctx.organization_id
245+
assert restored.user_id == ctx.user_id
246+
assert restored.actor_type == ctx.actor_type

0 commit comments

Comments
 (0)