Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .changeset/python-connection-logger.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
"@e2b/python-sdk": patch
---

feat(python-sdk): add a `logger` option for request/debug logging

You can now pass a standard library `logging.Logger` to `Sandbox.create` /
`AsyncSandbox.create` (and the static `Sandbox.connect(sandbox_id, ...)`) to
route that sandbox's request/response logs to your own logger. The logger is
stored on the sandbox and propagates to all of its later operations —
including control-plane calls such as `kill`, `pause`, `set_timeout`, and
`get_info`. Matching the JavaScript SDK, `logger` is a construction-time option
and is **not** a per-request parameter that those methods accept from the
caller. The stdlib `logging.Logger` is used directly as the adapter instead of
a custom interface.

The logger is wired into the API client, the envd client, and the RPC
(ConnectRPC) path. Mirroring the JS SDK: requests log at `INFO`, successful API
and unary RPC responses at `INFO`, streamed RPC messages at `DEBUG`, and failed
API responses (status >= 400) at `ERROR`. When no logger is supplied, the SDK
emits no request/response logging at all (also matching the JS SDK).

Volume content operations continue to accept `logger` per call via
`VolumeApiParams`, matching the JS Volume API.

```python
import logging
from e2b import Sandbox

logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger("my-app.e2b")

sbx = Sandbox.create(logger=logger)
sbx.commands.run("echo hello") # request/response logged via `logger`
```
67 changes: 46 additions & 21 deletions packages/python-sdk/e2b/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,45 @@
SandboxException,
)

logger = logging.getLogger(__name__)

def make_logging_event_hooks(log: Optional[logging.Logger]) -> dict:
"""Build synchronous httpx ``event_hooks`` that log requests and responses
to the given ``logging.Logger``. Requests log at ``INFO``, successful
responses at ``INFO`` and responses with status >= 400 at ``ERROR``.

Returns no hooks when ``log`` is ``None`` so that nothing is logged unless a
logger was explicitly supplied."""
if log is None:
return {}

def on_request(request) -> None:
log.info(f"Request {request.method} {request.url}")

def on_response(response: Response) -> None:
if response.status_code >= 400:
log.error(f"Response {response.status_code}")
else:
log.info(f"Response {response.status_code}")

return {"request": [on_request], "response": [on_response]}


def make_async_logging_event_hooks(log: Optional[logging.Logger]) -> dict:
"""Asynchronous counterpart of :func:`make_logging_event_hooks`."""
if log is None:
return {}

async def on_request(request) -> None:
log.info(f"Request {request.method} {request.url}")

async def on_response(response: Response) -> None:
if response.status_code >= 400:
log.error(f"Response {response.status_code}")
else:
log.info(f"Response {response.status_code}")

return {"request": [on_request], "response": [on_response]}


limits = Limits(
max_keepalive_connections=int(os.getenv("E2B_MAX_KEEPALIVE_CONNECTIONS", "20")),
Expand Down Expand Up @@ -139,6 +177,8 @@ def __init__(
auth_header_name = "X-API-KEY" if require_api_key else "Authorization"
prefix = "" if require_api_key else "Bearer"

self._logger = config.logger

headers = {
**default_headers,
**(config.headers or {}),
Expand All @@ -153,10 +193,7 @@ def __init__(
kwargs.pop("prefix", None)

httpx_args = {
"event_hooks": {
"request": [self._log_request],
"response": [self._log_response],
},
"event_hooks": self._logging_event_hooks(),
"transport": transport,
}
if transport is None:
Expand All @@ -173,23 +210,11 @@ def __init__(
**kwargs,
)

def _log_request(self, request):
logger.info(f"Request {request.method} {request.url}")

def _log_response(self, response: Response):
if response.status_code >= 400:
logger.error(f"Response {response.status_code}")
else:
logger.info(f"Response {response.status_code}")
def _logging_event_hooks(self) -> dict:
return make_logging_event_hooks(self._logger)


# We need to override the logging hooks for the async usage
class AsyncApiClient(ApiClient):
async def _log_request(self, request):
logger.info(f"Request {request.method} {request.url}")

async def _log_response(self, response: Response):
if response.status_code >= 400:
logger.error(f"Response {response.status_code}")
else:
logger.info(f"Response {response.status_code}")
def _logging_event_hooks(self) -> dict:
return make_async_logging_event_hooks(self._logger)
13 changes: 0 additions & 13 deletions packages/python-sdk/e2b/api/client_async/__init__.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
import asyncio
import logging
from typing import Dict, Tuple

import httpx

from e2b.api import AsyncApiClient, limits
from e2b.connection_config import ConnectionConfig

logger = logging.getLogger(__name__)


def get_api_client(config: ConnectionConfig, **kwargs) -> AsyncApiClient:
return AsyncApiClient(
Expand All @@ -21,16 +18,6 @@ def get_api_client(config: ConnectionConfig, **kwargs) -> AsyncApiClient:
class AsyncTransportWithLogger(httpx.AsyncHTTPTransport):
_instances: Dict[Tuple[int, bool], "AsyncTransportWithLogger"] = {}

async def handle_async_request(self, request):
url = f"{request.url.scheme}://{request.url.host}{request.url.path}"
logger.info(f"Request: {request.method} {url}")
response = await super().handle_async_request(request)

# data = connect.GzipCompressor.decompress(response.read()).decode()
logger.info(f"Response: {response.status_code} {url}")

return response

@property
def pool(self):
return self._pool
Expand Down
13 changes: 0 additions & 13 deletions packages/python-sdk/e2b/api/client_sync/__init__.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
from typing import Dict

import httpx
import logging
import threading

from e2b.api import ApiClient, limits
from e2b.connection_config import ConnectionConfig

logger = logging.getLogger(__name__)


def get_api_client(config: ConnectionConfig, **kwargs) -> ApiClient:
return ApiClient(
Expand All @@ -21,16 +18,6 @@ def get_api_client(config: ConnectionConfig, **kwargs) -> ApiClient:
class TransportWithLogger(httpx.HTTPTransport):
_thread_local = threading.local()

def handle_request(self, request):
url = f"{request.url.scheme}://{request.url.host}{request.url.path}"
logger.info(f"Request: {request.method} {url}")
response = super().handle_request(request)

# data = connect.GzipCompressor.decompress(response.read()).decode()
logger.info(f"Response: {response.status_code} {url}")

return response

@property
def pool(self):
return self._pool
Expand Down
24 changes: 23 additions & 1 deletion packages/python-sdk/e2b/connection_config.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import logging
import os

from typing import Optional, Dict, TypedDict
Expand Down Expand Up @@ -49,6 +50,18 @@ class ApiParams(TypedDict, total=False):
"""URL to connect to sandbox, defaults to `E2B_SANDBOX_URL` environment variable."""


class ApiParamsWithLogger(ApiParams, total=False):
""":class:`ApiParams` plus the construction-time ``logger``.

Internal type returned by :meth:`ConnectionConfig.get_api_params` so that the
logger a sandbox was created/connected with keeps propagating to the
throwaway ``ConnectionConfig`` that instance control-plane methods rebuild.
Unlike :class:`ApiParams`, ``logger`` is not a public per-request option.
"""

logger: Optional[logging.Logger]


class ConnectionConfig:
"""
Configuration for the connection to the API.
Expand Down Expand Up @@ -93,7 +106,9 @@ def __init__(
api_headers: Optional[Dict[str, str]] = None,
extra_sandbox_headers: Optional[Dict[str, str]] = None,
proxy: Optional[ProxyTypes] = None,
logger: Optional[logging.Logger] = None,
):
self.logger = logger
Comment thread
mishushakov marked this conversation as resolved.
self.domain = domain or ConnectionConfig._domain()
self.debug = debug or ConnectionConfig._debug()
self.api_key = api_key or ConnectionConfig._api_key()
Expand Down Expand Up @@ -202,15 +217,22 @@ def get_api_params(
if api_headers is not None:
req_headers.update(api_headers)

# `logger` is a construction-time option rather than a per-request
# ApiParams field, but it must propagate to the throwaway
# ConnectionConfig that instance control-plane methods (kill, pause,
# set_timeout, get_info, connect, ...) rebuild from these params, so
# those requests keep logging with the logger the sandbox was created
# or connected with.
return dict(
ApiParams(
ApiParamsWithLogger(
api_key=api_key if api_key is not None else self.api_key,
api_url=api_url if api_url is not None else self.api_url,
domain=domain if domain is not None else self.domain,
debug=debug if debug is not None else self.debug,
request_timeout=self.get_request_timeout(request_timeout),
headers=req_headers,
proxy=proxy if proxy is not None else self.proxy,
logger=self.logger,
)
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ def __init__(
async_pool=pool,
json=True,
headers=connection_config.sandbox_headers,
logger=connection_config.logger,
)

async def list(
Expand Down
1 change: 1 addition & 0 deletions packages/python-sdk/e2b/sandbox_async/commands/pty.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ def __init__(
async_pool=pool,
json=True,
headers=connection_config.sandbox_headers,
logger=connection_config.logger,
)

async def kill(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ def __init__(
async_pool=pool,
json=True,
headers=connection_config.sandbox_headers,
logger=connection_config.logger,
)

@overload
Expand Down
13 changes: 13 additions & 0 deletions packages/python-sdk/e2b/sandbox_async/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from packaging.version import Version
from typing_extensions import Self, Unpack

from e2b.api import make_async_logging_event_hooks
from e2b.api.client.types import Unset
from e2b.api.client_async import get_envd_transport as get_transport
from e2b.connection_config import ApiParams, ConnectionConfig
Expand Down Expand Up @@ -109,6 +110,7 @@ def __init__(
),
transport=self._transport,
headers=self.connection_config.sandbox_headers,
event_hooks=make_async_logging_event_hooks(self.connection_config.logger),
)
self._filesystem = Filesystem(
self.envd_api_url,
Expand Down Expand Up @@ -180,6 +182,7 @@ async def create(
network: Optional[SandboxNetworkOpts] = None,
lifecycle: Optional[SandboxLifecycle] = None,
volume_mounts: Optional[SandboxAsyncVolumeMount] = None,
logger: Optional[logging.Logger] = None,
**opts: Unpack[ApiParams],
) -> Self:
"""
Expand All @@ -197,6 +200,7 @@ async def create(
:param network: Sandbox network configuration. ``allow_out``/``deny_out`` may also be a callable receiving a :class:`SandboxNetworkSelectorContext` (``ctx.all_traffic``, ``ctx.rules``) and returning a list of strings. Per-host transform rules are nested under ``network.rules``.
:param lifecycle: Sandbox lifecycle configuration — ``on_timeout``: ``"kill"`` (default) or ``"pause"``; ``auto_resume``: ``False`` (default) or ``True`` (only when ``on_timeout="pause"``). Example: ``{"on_timeout": "pause", "auto_resume": True}``
:param volume_mounts: Dictionary mapping mount paths to AsyncVolume instances or volume names
:param logger: Logger used for request and response logging for this sandbox. Accepts any standard library `logging.Logger`. When omitted, no request/response logging is emitted.

:return: A Sandbox instance for the new sandbox

Expand Down Expand Up @@ -228,6 +232,7 @@ async def create(
network=network,
lifecycle=lifecycle,
volume_mounts=transformed_mounts,
logger=logger,
**opts,
)

Expand Down Expand Up @@ -277,6 +282,7 @@ async def connect(
async def connect(
sandbox_id: str,
timeout: Optional[int] = None,
logger: Optional[logging.Logger] = None,
**opts: Unpack[ApiParams],
) -> "AsyncSandbox":
"""
Expand All @@ -288,6 +294,7 @@ async def connect(
:param sandbox_id: Sandbox ID
:param timeout: Timeout for the sandbox in **seconds**
For running sandboxes, the timeout will update only if the new timeout is longer than the existing one.
:param logger: Logger used for request and response logging for this sandbox. Accepts any standard library `logging.Logger`. When omitted, no request/response logging is emitted.
:return: A running sandbox instance

@example
Expand Down Expand Up @@ -836,11 +843,13 @@ async def _cls_connect_sandbox(
cls,
sandbox_id: str,
timeout: Optional[int] = None,
logger: Optional[logging.Logger] = None,
**opts: Unpack[ApiParams],
) -> Self:
sandbox = await SandboxApi._cls_connect(
sandbox_id=sandbox_id,
timeout=timeout,
logger=logger,
**opts,
)

Expand All @@ -854,6 +863,7 @@ async def _cls_connect_sandbox(

connection_config = ConnectionConfig(
extra_sandbox_headers=sandbox_headers,
logger=logger,
**opts,
)

Expand All @@ -879,6 +889,7 @@ async def _create(
network: Optional[SandboxNetworkOpts] = None,
lifecycle: Optional[SandboxLifecycle] = None,
volume_mounts: Optional[list] = None,
logger: Optional[logging.Logger] = None,
**opts: Unpack[ApiParams],
) -> Self:
extra_sandbox_headers = {}
Expand All @@ -902,6 +913,7 @@ async def _create(
network=network,
lifecycle=lifecycle,
volume_mounts=volume_mounts,
logger=logger,
**opts,
)

Expand All @@ -921,6 +933,7 @@ async def _create(

connection_config = ConnectionConfig(
extra_sandbox_headers=extra_sandbox_headers,
logger=logger,
**opts,
)

Expand Down
Loading
Loading