Skip to content

Commit e55b093

Browse files
committed
add support for RPC interceptors
allowing telemetry hooks so we can provide automatic traces when used in agents.
1 parent 11319e9 commit e55b093

5 files changed

Lines changed: 463 additions & 37 deletions

File tree

‎README.md‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,29 @@ except Exception as e:
223223

224224
You may find it useful to adjust the `response_timeout` parameter, which indicates the amount of time you will wait for a response. We recommend keeping this value as low as possible while still satisfying the constraints of your application.
225225

226+
#### Intercepting RPC calls
227+
228+
An `RpcInterceptor` wraps every RPC the local participant performs or handles, which is useful for logging, tracing, or attaching metadata to payloads. Each method receives the call and a `next` continuation; return what `next` returns. Interceptors run in the order they were added, the first being outermost, and errors from the remote side or from your handler flow through them unchanged.
229+
230+
```python
231+
class TimingInterceptor(rtc.RpcInterceptor):
232+
async def intercept_outgoing(self, call, next):
233+
start = time.perf_counter()
234+
try:
235+
return await next(call)
236+
finally:
237+
print(f"call {call.method} -> {call.destination_identity}: {time.perf_counter() - start:.3f}s")
238+
239+
async def intercept_incoming(self, invocation, next):
240+
start = time.perf_counter()
241+
try:
242+
return await next(invocation)
243+
finally:
244+
print(f"handled {invocation.method} from {invocation.caller_identity}: {time.perf_counter() - start:.3f}s")
245+
246+
room.local_participant.add_rpc_interceptor(TimingInterceptor())
247+
```
248+
226249
## Using local media devices
227250

228251
The `MediaDevices` class provides a high-level interface for working with local audio input (microphone) and output (speakers) devices. It's built on top of the `sounddevice` library and integrates seamlessly with LiveKit's audio processing features. In order to use `MediaDevices`, you must have the `sounddevice` library installed in your local Python environment, if it's not available, `MediaDevices` will not work.

‎livekit-rtc/livekit/rtc/__init__.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@
115115
except Exception: # pragma: no cover - optional dependency (sounddevice)
116116
_HAS_MEDIA_DEVICES = False
117117
from .utils import combine_audio_frames
118-
from .rpc import RpcError, RpcInvocationData
118+
from .rpc import RpcCallInfo, RpcError, RpcInterceptor, RpcInvocationData
119119
from .synchronizer import AVSynchronizer
120120
from .data_stream import (
121121
TextStreamInfo,
@@ -211,6 +211,8 @@
211211
"AudioResamplerQuality",
212212
"RpcError",
213213
"RpcInvocationData",
214+
"RpcCallInfo",
215+
"RpcInterceptor",
214216
"EventEmitter",
215217
"combine_audio_frames",
216218
"AVSynchronizer",

‎livekit-rtc/livekit/rtc/participant.py‎

Lines changed: 87 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,17 @@
4848
TrackPublication,
4949
)
5050
from .transcription import Transcription
51-
from .rpc import RpcError
51+
from .rpc import (
52+
RpcCallInfo,
53+
RpcError,
54+
RpcInterceptor,
55+
RpcInvocationData,
56+
_chain_incoming,
57+
_chain_outgoing,
58+
)
5259
from ._proto.rpc_pb2 import RpcMethodInvocationResponseRequest
5360
from .log import logger
5461

55-
from .rpc import RpcInvocationData
5662
from .data_stream import (
5763
TextStreamWriter,
5864
TextStreamInfo,
@@ -247,6 +253,7 @@ def __init__(
247253
self._room_queue = room_queue
248254
self._track_publications: dict[str, LocalTrackPublication] = {}
249255
self._rpc_handlers: Dict[str, RpcHandler] = {}
256+
self._rpc_interceptors: List[RpcInterceptor] = []
250257
# Handles of data stream writers that have been opened but not yet
251258
# closed, so the room can drop them at disconnect. The FFI close
252259
# request consumes the handle (take_handle), so an entry is removed as
@@ -427,15 +434,27 @@ async def perform_rpc(
427434
Raises:
428435
RpcError: On failure. Details in `message`.
429436
"""
437+
call = RpcCallInfo(
438+
destination_identity=destination_identity,
439+
method=method,
440+
payload=payload,
441+
response_timeout=response_timeout,
442+
max_round_trip_latency=max_round_trip_latency,
443+
)
444+
# snapshot the interceptor list so add/remove during a call is well defined
445+
perform = _chain_outgoing(list(self._rpc_interceptors), self._perform_rpc_ffi)
446+
return await perform(call)
447+
448+
async def _perform_rpc_ffi(self, call: RpcCallInfo) -> str:
430449
req = proto_ffi.FfiRequest()
431450
req.perform_rpc.local_participant_handle = self._ffi_handle.handle
432-
req.perform_rpc.destination_identity = destination_identity
433-
req.perform_rpc.method = method
434-
req.perform_rpc.payload = payload
435-
if response_timeout is not None:
436-
req.perform_rpc.response_timeout_ms = int(response_timeout * 1000)
437-
if max_round_trip_latency is not None:
438-
req.perform_rpc.max_round_trip_latency_ms = int(max_round_trip_latency * 1000)
451+
req.perform_rpc.destination_identity = call.destination_identity
452+
req.perform_rpc.method = call.method
453+
req.perform_rpc.payload = call.payload
454+
if call.response_timeout is not None:
455+
req.perform_rpc.response_timeout_ms = int(call.response_timeout * 1000)
456+
if call.max_round_trip_latency is not None:
457+
req.perform_rpc.max_round_trip_latency_ms = int(call.max_round_trip_latency * 1000)
439458

440459
queue = FfiClient.instance.queue.subscribe()
441460
try:
@@ -449,6 +468,29 @@ async def perform_rpc(
449468

450469
return cast(str, cb.perform_rpc.payload)
451470

471+
def add_rpc_interceptor(self, interceptor: RpcInterceptor) -> None:
472+
"""
473+
Add an :class:`RpcInterceptor` that wraps every RPC this participant performs or
474+
handles. Interceptors run in the order they were added, the first being outermost.
475+
Adding the same instance twice is a no-op.
476+
477+
Args:
478+
interceptor (RpcInterceptor): The interceptor to add.
479+
"""
480+
if interceptor not in self._rpc_interceptors:
481+
self._rpc_interceptors.append(interceptor)
482+
483+
def remove_rpc_interceptor(self, interceptor: RpcInterceptor) -> None:
484+
"""
485+
Remove a previously added :class:`RpcInterceptor`. Calls already in flight keep the
486+
chain they started with.
487+
488+
Args:
489+
interceptor (RpcInterceptor): The interceptor to remove.
490+
"""
491+
if interceptor in self._rpc_interceptors:
492+
self._rpc_interceptors.remove(interceptor)
493+
452494
def register_rpc_method(
453495
self,
454496
method_name: str,
@@ -552,33 +594,21 @@ async def _handle_rpc_method_invocation(
552594
response_error: Optional[RpcError] = None
553595
response_payload: Optional[str] = None
554596

555-
params = RpcInvocationData(request_id, caller_identity, payload, response_timeout)
556-
557-
handler = self._rpc_handlers.get(method)
597+
params = RpcInvocationData(
598+
request_id, caller_identity, payload, response_timeout, method=method
599+
)
558600

559-
if not handler:
560-
response_error = RpcError._built_in(RpcError.ErrorCode.UNSUPPORTED_METHOD)
561-
else:
562-
try:
563-
if asyncio.iscoroutinefunction(handler):
564-
try:
565-
response_payload = await asyncio.wait_for(
566-
handler(params), timeout=response_timeout
567-
)
568-
except asyncio.TimeoutError:
569-
raise RpcError._built_in(RpcError.ErrorCode.RESPONSE_TIMEOUT)
570-
except asyncio.CancelledError:
571-
raise RpcError._built_in(RpcError.ErrorCode.RECIPIENT_DISCONNECTED)
572-
else:
573-
response_payload = cast(Optional[str], handler(params))
574-
except RpcError as error:
575-
response_error = error
576-
except Exception:
577-
logger.exception(
578-
f"Uncaught error returned by RPC handler for {method}. "
579-
"Returning APPLICATION_ERROR instead. "
580-
)
581-
response_error = RpcError._built_in(RpcError.ErrorCode.APPLICATION_ERROR)
601+
handle = _chain_incoming(list(self._rpc_interceptors), self._invoke_rpc_handler)
602+
try:
603+
response_payload = await handle(params)
604+
except RpcError as error:
605+
response_error = error
606+
except Exception:
607+
logger.exception(
608+
f"Uncaught error returned by RPC handler for {method}. "
609+
"Returning APPLICATION_ERROR instead. "
610+
)
611+
response_error = RpcError._built_in(RpcError.ErrorCode.APPLICATION_ERROR)
582612

583613
req = proto_ffi.FfiRequest(
584614
rpc_method_invocation_response=RpcMethodInvocationResponseRequest(
@@ -595,6 +625,28 @@ async def _handle_rpc_method_invocation(
595625
err = res.rpc_method_invocation_response.error
596626
logger.error(f"error sending rpc method invocation response: {err}")
597627

628+
async def _invoke_rpc_handler(self, invocation: RpcInvocationData) -> Optional[str]:
629+
"""Run the registered handler for ``invocation`` (the innermost step of the chain).
630+
631+
Raises ``RpcError`` for an unregistered method, a handler timeout, or a cancelled
632+
handler; any other exception from the handler propagates unchanged so interceptors
633+
can observe it before the caller's response is built.
634+
"""
635+
handler = self._rpc_handlers.get(invocation.method)
636+
if not handler:
637+
raise RpcError._built_in(RpcError.ErrorCode.UNSUPPORTED_METHOD)
638+
639+
if asyncio.iscoroutinefunction(handler):
640+
try:
641+
return await asyncio.wait_for(
642+
handler(invocation), timeout=invocation.response_timeout
643+
)
644+
except asyncio.TimeoutError:
645+
raise RpcError._built_in(RpcError.ErrorCode.RESPONSE_TIMEOUT) from None
646+
except asyncio.CancelledError:
647+
raise RpcError._built_in(RpcError.ErrorCode.RECIPIENT_DISCONNECTED) from None
648+
return cast(Optional[str], handler(invocation))
649+
598650
async def set_metadata(self, metadata: str) -> None:
599651
"""
600652
Set the metadata for the local participant.

‎livekit-rtc/livekit/rtc/rpc.py‎

Lines changed: 114 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15-
from typing import Optional, Dict, Union, ClassVar
15+
from __future__ import annotations
16+
17+
from typing import Awaitable, Callable, ClassVar, Dict, Optional, Sequence, Union
1618
from enum import IntEnum
1719
from ._proto import rpc_pb2 as proto_rpc
1820
from dataclasses import dataclass
@@ -27,12 +29,123 @@ class RpcInvocationData:
2729
caller_identity (str): The unique participant identity of the caller.
2830
payload (str): The payload of the request. User-definable format, typically JSON.
2931
response_timeout (float): The maximum time the caller will wait for a response.
32+
method (str): The name of the invoked RPC method.
3033
"""
3134

3235
request_id: str
3336
caller_identity: str
3437
payload: str
3538
response_timeout: float
39+
method: str = ""
40+
41+
42+
@dataclass
43+
class RpcCallInfo:
44+
"""An outgoing RPC call, as passed to :meth:`RpcInterceptor.intercept_outgoing`.
45+
46+
Mirrors the arguments of :meth:`LocalParticipant.perform_rpc`. An interceptor may pass a
47+
modified copy to ``next`` (for example to add a header to a JSON payload).
48+
49+
Attributes:
50+
destination_identity (str): The identity of the participant being called.
51+
method (str): The method name.
52+
payload (str): The request payload.
53+
response_timeout (Optional[float]): Seconds to wait for a response, or ``None`` for the default.
54+
max_round_trip_latency (Optional[float]): See :meth:`LocalParticipant.perform_rpc`.
55+
"""
56+
57+
destination_identity: str
58+
method: str
59+
payload: str
60+
response_timeout: Optional[float] = None
61+
max_round_trip_latency: Optional[float] = None
62+
63+
64+
OutgoingRpcNext = Callable[["RpcCallInfo"], Awaitable[str]]
65+
"""Continuation handed to :meth:`RpcInterceptor.intercept_outgoing`: performs the call."""
66+
IncomingRpcNext = Callable[["RpcInvocationData"], Awaitable[Optional[str]]]
67+
"""Continuation handed to :meth:`RpcInterceptor.intercept_incoming`: runs the handler."""
68+
69+
70+
class RpcInterceptor:
71+
"""Observe or wrap RPC calls made and handled by a :class:`LocalParticipant`.
72+
73+
Register with :meth:`LocalParticipant.add_rpc_interceptor`. Each method receives the call
74+
and a ``next`` continuation and must return (or raise) what ``next`` returns (or raises),
75+
unless it deliberately short-circuits the call. Interceptors run in registration order:
76+
the first one added is the outermost. Both methods default to pass-through, so override
77+
only the direction you care about.
78+
79+
Errors flow through the chain unchanged: a :class:`RpcError` raised by the remote side
80+
(outgoing) or by the handler (incoming) is visible to every interceptor before it
81+
reaches the caller. On the incoming side, any other exception raised by the handler is
82+
also visible; the SDK converts it to ``APPLICATION_ERROR`` only after the chain returns,
83+
and a call for an unregistered method reaches the chain with ``next`` raising
84+
``UNSUPPORTED_METHOD``.
85+
86+
Example:
87+
Time every RPC in both directions::
88+
89+
class TimingInterceptor(rtc.RpcInterceptor):
90+
async def intercept_outgoing(self, call, next):
91+
start = time.perf_counter()
92+
try:
93+
return await next(call)
94+
finally:
95+
log("rpc call", call.method, time.perf_counter() - start)
96+
97+
async def intercept_incoming(self, invocation, next):
98+
start = time.perf_counter()
99+
try:
100+
return await next(invocation)
101+
finally:
102+
log("rpc handled", invocation.method, time.perf_counter() - start)
103+
104+
room.local_participant.add_rpc_interceptor(TimingInterceptor())
105+
"""
106+
107+
async def intercept_outgoing(self, call: RpcCallInfo, next: OutgoingRpcNext) -> str:
108+
"""Wrap an outgoing :meth:`LocalParticipant.perform_rpc`. Return the response payload."""
109+
return await next(call)
110+
111+
async def intercept_incoming(
112+
self, invocation: RpcInvocationData, next: IncomingRpcNext
113+
) -> Optional[str]:
114+
"""Wrap the handling of an incoming invocation. Return the response payload."""
115+
return await next(invocation)
116+
117+
118+
def _chain_outgoing(
119+
interceptors: Sequence[RpcInterceptor], terminal: OutgoingRpcNext
120+
) -> OutgoingRpcNext:
121+
"""Compose ``interceptors`` around ``terminal``; the first interceptor is outermost."""
122+
call_next = terminal
123+
for interceptor in reversed(interceptors):
124+
call_next = _bind_outgoing(interceptor, call_next)
125+
return call_next
126+
127+
128+
def _chain_incoming(
129+
interceptors: Sequence[RpcInterceptor], terminal: IncomingRpcNext
130+
) -> IncomingRpcNext:
131+
call_next = terminal
132+
for interceptor in reversed(interceptors):
133+
call_next = _bind_incoming(interceptor, call_next)
134+
return call_next
135+
136+
137+
def _bind_outgoing(interceptor: RpcInterceptor, call_next: OutgoingRpcNext) -> OutgoingRpcNext:
138+
async def bound(call: RpcCallInfo) -> str:
139+
return await interceptor.intercept_outgoing(call, call_next)
140+
141+
return bound
142+
143+
144+
def _bind_incoming(interceptor: RpcInterceptor, call_next: IncomingRpcNext) -> IncomingRpcNext:
145+
async def bound(invocation: RpcInvocationData) -> Optional[str]:
146+
return await interceptor.intercept_incoming(invocation, call_next)
147+
148+
return bound
36149

37150

38151
class RpcError(Exception):

0 commit comments

Comments
 (0)