Skip to content

Commit c9471cc

Browse files
feat(metrics): add real-time metrics for a single sandbox
## Background Add an E2B-compatible sandbox metrics endpoint as the first step toward historical metrics support. This PR intentionally implements a real-time single-sandbox snapshot only: start/end are accepted for API compatibility and filter the current point, but no metrics persistence or historical range query is introduced yet. ## Summary Expose GET /sandboxes/:sandboxID/metrics in CubeAPI and proxy the request to CubeMaster's /cube/sandbox/metrics endpoint. CubeAPI validates the optional start/end range, maps CubeMaster errors into public API errors, and converts CubeMaster's snake_case metric payload into the E2B-compatible camelCase response shape. Add CubeMaster HTTP routing and service logic for sandbox metrics. CubeMaster resolves the sandbox's owning Cubelet from local cache, calls Cubelet over gRPC using the inherited request context, filters the returned current point by start/end, and guarantees empty results serialize as data: [] instead of null. Add Cubelet GetSandboxMetrics support. Cubelet builds a fallback snapshot from local CubeBox resource metadata, requests envd's /metrics endpoint for live CPU, memory, cache, and disk usage, and overlays live envd values when available while preserving fallback behavior for old templates or transient envd failures. ## Compatibility The new protobuf messages use stable field numbers for the metrics request, response, and SandboxMetric fields. Generated pb.go/grpc.pb.go files are updated from the proto definitions; business logic lives separately in CubeAPI, CubeMaster, and Cubelet service files. ## Tests - cargo test metrics - go test ./pkg/service/httpservice/cube -run 'TestHandleSandboxMetrics|Metrics|RegisterCubeRoutes|InternalHttp' -count=1 - go test ./pkg/service/sandbox -run 'TestMetric|TestSandboxMetrics|Test.*Metrics.*Data|TestSandboxMetricsResponse' -count=1 - go test ./services/cubebox -run 'Test.*SandboxMetric|TestGetSandboxMetrics|TestFirstContainerResources' -count=1 Signed-off-by: zry <1292625211@qq.com>
1 parent 299e5b3 commit c9471cc

23 files changed

Lines changed: 5420 additions & 1535 deletions

File tree

CubeAPI/src/cubemaster/mod.rs

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
///
1212
/// Implemented on CubeMaster (see pkg/service/sandbox/types):
1313
/// - GET /cube/sandbox/info get single sandbox detail (query: sandbox_id, instance_type)
14+
/// - GET /cube/sandbox/metrics get single sandbox metrics snapshot
1415
/// - POST /cube/sandbox/update update sandbox (action: "pause" | "resume")
1516
/// Implemented on CubeMaster (snapshot APIs):
1617
/// - POST /cube/snapshot create runtime snapshot (synchronous terminal result)
@@ -125,6 +126,36 @@ impl CubeMasterClient {
125126
parse_response(resp).await
126127
}
127128

129+
/// GET /cube/sandbox/metrics — fetch one sandbox's current metrics snapshot.
130+
/// Query: sandbox_id, instance_type, optional start/end Unix seconds.
131+
pub async fn get_sandbox_metrics(
132+
&self,
133+
sandbox_id: &str,
134+
instance_type: &str,
135+
start: Option<i64>,
136+
end: Option<i64>,
137+
) -> Result<GetSandboxMetricsResponse, CubeMasterError> {
138+
let url = format!("{}/cube/sandbox/metrics", self.base_url);
139+
let mut query = vec![
140+
("sandbox_id", sandbox_id.to_string()),
141+
("instance_type", instance_type.to_string()),
142+
];
143+
if let Some(start) = start {
144+
query.push(("start", start.to_string()));
145+
}
146+
if let Some(end) = end {
147+
query.push(("end", end.to_string()));
148+
}
149+
let resp = self
150+
.inner
151+
.get(&url)
152+
.query(&query)
153+
.send()
154+
.await
155+
.map_err(CubeMasterError::Http)?;
156+
parse_response(resp).await
157+
}
158+
128159
/// POST /cube/sandbox/update — pause or resume a sandbox (action: "pause" | "resume").
129160
pub async fn update_sandbox(
130161
&self,
@@ -1060,6 +1091,37 @@ pub struct GetSandboxContainerItem {
10601091
pub pause_at: i64,
10611092
}
10621093

1094+
#[derive(Debug, Deserialize)]
1095+
#[allow(dead_code)]
1096+
pub struct GetSandboxMetricsResponse {
1097+
#[serde(rename = "RequestID", alias = "requestID", default)]
1098+
pub request_id: String,
1099+
#[serde(default)]
1100+
pub data: Vec<SandboxMetricItem>,
1101+
pub ret: RetCode,
1102+
}
1103+
1104+
#[derive(Debug, Deserialize, Clone)]
1105+
#[allow(dead_code)]
1106+
pub struct SandboxMetricItem {
1107+
#[serde(default)]
1108+
pub timestamp_unix_nano: i64,
1109+
#[serde(default)]
1110+
pub cpu_count: i32,
1111+
#[serde(default)]
1112+
pub cpu_used_pct: f64,
1113+
#[serde(default)]
1114+
pub mem_used: i64,
1115+
#[serde(default)]
1116+
pub mem_total: i64,
1117+
#[serde(default)]
1118+
pub mem_cache: i64,
1119+
#[serde(default)]
1120+
pub disk_used: i64,
1121+
#[serde(default)]
1122+
pub disk_total: i64,
1123+
}
1124+
10631125
/// Normalized sandbox detail used by handlers (built from GetSandboxDataItem).
10641126
#[derive(Debug, Clone)]
10651127
#[allow(dead_code)]

CubeAPI/src/handlers/sandboxes.rs

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ use crate::{
1616
models::{
1717
ApiError, ConnectSandbox, ListSandboxesQuery, ListSandboxesV2Query, NewSandbox,
1818
RefreshRequest, ResumedSandbox, Sandbox, SandboxDetail, SandboxLogsQuery,
19-
SandboxLogsV2Query, SandboxLogsV2Response, SetTimeoutRequest,
19+
SandboxLogsV2Query, SandboxLogsV2Response, SandboxMetricsQuery, SetTimeoutRequest,
2020
},
2121
state::AppState,
2222
};
@@ -154,6 +154,55 @@ pub async fn get_sandbox(
154154
Ok(Json(detail))
155155
}
156156

157+
// ─── GET /sandboxes/:sandboxID/metrics ─────────────────────────────────────
158+
159+
/// Handles the public E2B-compatible metrics route and delegates range
160+
/// validation plus CubeMaster error translation to SandboxService.
161+
#[utoipa::path(
162+
get,
163+
path = "/sandboxes/{sandboxID}/metrics",
164+
params(
165+
("sandboxID" = String, Path, description = "Sandbox identifier"),
166+
SandboxMetricsQuery
167+
),
168+
responses(
169+
(status = 200, description = "Sandbox metrics", body = [crate::models::SandboxMetric]),
170+
(status = 400, description = "Invalid time range", body = ApiError),
171+
(status = 404, description = "Sandbox not found", body = ApiError),
172+
(status = 500, description = "Unexpected backend error", body = ApiError)
173+
)
174+
)]
175+
pub async fn get_sandbox_metrics(
176+
State(state): State<AppState>,
177+
Path(sandbox_id): Path<String>,
178+
Query(params): Query<SandboxMetricsQuery>,
179+
) -> AppResult<impl IntoResponse> {
180+
state
181+
.logger
182+
.log(
183+
LogEvent::new(LogLevel::Debug, "api.request")
184+
.field("handler", "get_sandbox_metrics")
185+
.field("sandbox_id", &sandbox_id),
186+
)
187+
.await;
188+
189+
let metrics = state
190+
.services
191+
.sandboxes
192+
.get_metrics(&sandbox_id, params.start, params.end)
193+
.await?;
194+
state
195+
.logger
196+
.log(
197+
LogEvent::new(LogLevel::Info, "api.response")
198+
.field("handler", "get_sandbox_metrics")
199+
.field("sandbox_id", &sandbox_id)
200+
.field_value("count", metrics.len()),
201+
)
202+
.await;
203+
Ok(Json(metrics))
204+
}
205+
157206
// ─── POST /sandboxes ──────────────────────────────────────────────────────────
158207

159208
pub async fn create_sandbox(

CubeAPI/src/models/mod.rs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,39 @@ pub struct SandboxDetail {
316316
pub volume_mounts: Option<Vec<SandboxVolumeMount>>,
317317
}
318318

319+
/// Query parameters for GET /sandboxes/{sandboxID}/metrics.
320+
#[derive(Debug, Deserialize, IntoParams)]
321+
#[into_params(parameter_in = Query)]
322+
pub struct SandboxMetricsQuery {
323+
/// Unix timestamp for the start of the interval, in seconds.
324+
pub start: Option<i64>,
325+
/// Unix timestamp for the end of the interval, in seconds.
326+
pub end: Option<i64>,
327+
}
328+
329+
/// One E2B-compatible sandbox metric entry.
330+
#[derive(Debug, Serialize, Deserialize, ToSchema)]
331+
pub struct SandboxMetric {
332+
/// Deprecated by E2B but still returned for compatibility.
333+
pub timestamp: DateTime<Utc>,
334+
#[serde(rename = "timestampUnix")]
335+
pub timestamp_unix: i64,
336+
#[serde(rename = "cpuCount")]
337+
pub cpu_count: i32,
338+
#[serde(rename = "cpuUsedPct")]
339+
pub cpu_used_pct: f64,
340+
#[serde(rename = "memUsed")]
341+
pub mem_used: i64,
342+
#[serde(rename = "memTotal")]
343+
pub mem_total: i64,
344+
#[serde(rename = "memCache")]
345+
pub mem_cache: i64,
346+
#[serde(rename = "diskUsed")]
347+
pub disk_used: i64,
348+
#[serde(rename = "diskTotal")]
349+
pub disk_total: i64,
350+
}
351+
319352
// ─── Sandbox — pause/resume/connect/snapshot ──────────────────────────────
320353

321354
/// Request body for POST /sandboxes/{id}/resume (deprecated).

CubeAPI/src/routes.rs

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,10 @@ fn build_sandbox_routes(state: &AppState, auth_configured: bool) -> Router<AppSt
120120
.route("/v2/sandboxes", get(sandboxes::list_sandboxes_v2))
121121
.route("/sandboxes/:sandboxID", get(sandboxes::get_sandbox))
122122
.route("/sandboxes/:sandboxID", delete(sandboxes::kill_sandbox))
123+
.route(
124+
"/sandboxes/:sandboxID/metrics",
125+
get(sandboxes::get_sandbox_metrics),
126+
)
123127
.route(
124128
"/sandboxes/:sandboxID/logs",
125129
get(sandboxes::get_sandbox_logs),
@@ -364,7 +368,7 @@ fn apply_http_layers(router: Router<AppState>, timeout: Duration) -> Router<AppS
364368

365369
#[cfg(test)]
366370
mod tests {
367-
use super::build_router;
371+
use super::{build_router, build_sandbox_routes};
368372
use crate::{
369373
config::ServerConfig,
370374
logging::{arc, noop::NoopLogger},
@@ -498,6 +502,40 @@ mod tests {
498502
);
499503
}
500504

505+
#[tokio::test]
506+
async fn exposes_sandbox_metrics_routes_on_root_and_cubeapi_prefix() {
507+
let server = test_server().await;
508+
509+
server
510+
.get("/sandboxes/sb-1/metrics")
511+
.add_query_param("start", "2")
512+
.add_query_param("end", "1")
513+
.await
514+
.assert_status(StatusCode::BAD_REQUEST);
515+
server
516+
.get("/cubeapi/v1/sandboxes/sb-1/metrics")
517+
.add_query_param("start", "2")
518+
.add_query_param("end", "1")
519+
.await
520+
.assert_status(StatusCode::BAD_REQUEST);
521+
}
522+
523+
#[tokio::test]
524+
async fn sandbox_metrics_route_matches_inside_sandbox_router() {
525+
let mut config = ServerConfig::default();
526+
config.cubemaster_url = "http://127.0.0.1:9".to_string();
527+
let state = AppState::new(config, arc(NoopLogger)).await;
528+
let app = build_sandbox_routes(&state, false).with_state(state);
529+
let server = TestServer::new(app).expect("sandbox router should build");
530+
531+
server
532+
.get("/sandboxes/sb-1/metrics")
533+
.add_query_param("start", "2")
534+
.add_query_param("end", "1")
535+
.await
536+
.assert_status(StatusCode::BAD_REQUEST);
537+
}
538+
501539
#[tokio::test]
502540
async fn auth_login_route_is_rate_limited_without_auth_middleware() {
503541
let mut config = ServerConfig::default();

0 commit comments

Comments
 (0)