-
Notifications
You must be signed in to change notification settings - Fork 1.2k
feat(server): measure lifecycle operations server-side #1412
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 2 commits
f54e55d
e0f9370
208834f
9ad80dc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,109 @@ | ||
| # Copyright 2026 Alibaba Group Holding Ltd. | ||
| # | ||
| # Licensed under the Apache License, Version 2.0 (the "License"); | ||
| # you may not use this file except in compliance with the License. | ||
| # You may obtain a copy of the License at | ||
| # | ||
| # http://www.apache.org/licenses/LICENSE-2.0 | ||
| # | ||
| # Unless required by applicable law or agreed to in writing, software | ||
| # distributed under the License is distributed on an "AS IS" BASIS, | ||
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| # See the License for the specific language governing permissions and | ||
| # limitations under the License. | ||
|
|
||
| """Server-side instrumentation for lifecycle API handlers.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import functools | ||
| import inspect | ||
| import logging | ||
| import time | ||
| from typing import Any, Callable, Optional | ||
|
|
||
| from fastapi import HTTPException | ||
|
|
||
| from opensandbox_server.integrations.otel.metrics import record_sandbox_operation | ||
| from opensandbox_server.services.constants import SandboxErrorCodes | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
| OUTCOME_SUCCESS = "success" | ||
| OUTCOME_ERROR = "error" | ||
|
|
||
|
|
||
| def _error_code_from(exc: BaseException) -> str: | ||
| """Pull the server error code out of a raised exception. | ||
|
|
||
| Handlers raise ``HTTPException`` with ``detail={"code": ..., "message": ...}``. When the | ||
| detail is a plain string, or the exception is not an ``HTTPException`` at all, fall back | ||
| to the generic code so a failure is still counted rather than dropped. | ||
| """ | ||
| if isinstance(exc, HTTPException): | ||
| detail = exc.detail | ||
| if isinstance(detail, dict): | ||
| code = detail.get("code") | ||
| if code is not None: | ||
| return str(code) | ||
| return f"HTTP_{exc.status_code}" | ||
| return SandboxErrorCodes.UNKNOWN_ERROR | ||
|
|
||
|
|
||
| def instrumented_operation(operation: str) -> Callable[[Callable], Callable]: | ||
| """Record duration and outcome of a lifecycle handler. | ||
|
|
||
| Measures the server's own work at the API boundary, which is the one place where every | ||
| runtime converges and where the error code has already been decided. Note that | ||
| ``POST /sandboxes`` returns 202 and provisions asynchronously, so for ``create`` this is | ||
| time-to-scheduled, not time-to-ready. | ||
|
|
||
| ``functools.wraps`` is load-bearing: FastAPI builds the request model from the handler | ||
| signature, and ``inspect.signature`` follows ``__wrapped__``, so the route keeps its | ||
| parameters. Never swallows or alters an exception, and never lets a metrics failure | ||
| affect the response. | ||
| """ | ||
|
|
||
| def decorator(func: Callable) -> Callable: | ||
| def _record(started: float, outcome: str, error_code: Optional[str]) -> None: | ||
| # record_sandbox_operation already swallows instrument errors, but this sits on | ||
| # every mutating route: a bug in telemetry must not be able to fail a request. | ||
| try: | ||
| record_sandbox_operation( | ||
| operation=operation, | ||
| duration_ms=(time.perf_counter() - started) * 1000.0, | ||
| outcome=outcome, | ||
| error_code=error_code, | ||
| ) | ||
| except Exception: | ||
| logger.exception("Failed to record %s operation metric", operation) | ||
|
|
||
| if inspect.iscoroutinefunction(func): | ||
|
|
||
| @functools.wraps(func) | ||
| async def async_wrapper(*args: Any, **kwargs: Any) -> Any: | ||
| started = time.perf_counter() | ||
| try: | ||
| result = await func(*args, **kwargs) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When FastAPI rejects a lifecycle request during parsing or Pydantic validation, this wrapper is never invoked, so neither Useful? React with 👍 / 👎. |
||
| except BaseException as exc: | ||
| _record(started, OUTCOME_ERROR, _error_code_from(exc)) | ||
| raise | ||
| _record(started, OUTCOME_SUCCESS, None) | ||
| return result | ||
|
|
||
| return async_wrapper | ||
|
|
||
| @functools.wraps(func) | ||
| def wrapper(*args: Any, **kwargs: Any) -> Any: | ||
| started = time.perf_counter() | ||
| try: | ||
| result = func(*args, **kwargs) | ||
| except BaseException as exc: | ||
| _record(started, OUTCOME_ERROR, _error_code_from(exc)) | ||
| raise | ||
| _record(started, OUTCOME_SUCCESS, None) | ||
| return result | ||
|
|
||
| return wrapper | ||
|
|
||
| return decorator | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For current Docker and Kubernetes create paths, this note under-describes what the new metric measures:
DockerSandboxService.create_sandboxawaits the provisioning thread's result from_provision_sandbox, andKubernetesSandboxService.create_sandboxexplicitly awaits_wait_for_sandbox_readybefore returning, sooperation.duration{operation="create"}includes readiness/provisioning time rather than only scheduling time. Leaving this text here (and the matching note indocs/guides/sdk-telemetry.md) will steer operators away from the only server-side cold-start signal this change adds.AGENTS.md reference: server/AGENTS.md:L40-L42
Useful? React with 👍 / 👎.