mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-21 06:11:31 +08:00
Consolidate shared constants and helpers into openshell-core to avoid duplicate definitions scattered across crates. Adds OPENSHELL_SANDBOX to the sandbox_env module so all drivers and the sandbox supervisor reference it via a typed constant rather than string literals, matching the existing pattern for SANDBOX_ID, ENDPOINT, and the other env vars.
477 lines
15 KiB
Rust
477 lines
15 KiB
Rust
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
//! Capture openshell-server tracing logs for streaming over gRPC.
|
|
|
|
use std::collections::{HashMap, VecDeque};
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
use openshell_core::proto::{SandboxLogLine, SandboxStreamEvent};
|
|
use openshell_ocsf::OCSF_TARGET;
|
|
use tokio::sync::broadcast;
|
|
use tracing::{Event, Subscriber};
|
|
use tracing_subscriber::layer::Context;
|
|
use tracing_subscriber::prelude::*;
|
|
use tracing_subscriber::{EnvFilter, Layer};
|
|
|
|
/// Bus that publishes server log lines keyed by sandbox id.
|
|
#[derive(Debug, Clone)]
|
|
pub struct TracingLogBus {
|
|
inner: Arc<Mutex<Inner>>,
|
|
pub(crate) platform_event_bus: PlatformEventBus,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct Inner {
|
|
per_id: HashMap<String, broadcast::Sender<SandboxStreamEvent>>,
|
|
tails: HashMap<String, VecDeque<SandboxStreamEvent>>,
|
|
}
|
|
|
|
impl Default for TracingLogBus {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
impl TracingLogBus {
|
|
#[must_use]
|
|
pub fn new() -> Self {
|
|
Self {
|
|
inner: Arc::new(Mutex::new(Inner {
|
|
per_id: HashMap::new(),
|
|
tails: HashMap::new(),
|
|
})),
|
|
platform_event_bus: PlatformEventBus::new(),
|
|
}
|
|
}
|
|
|
|
/// Install a tracing subscriber that logs to stdout and publishes events into this bus.
|
|
pub fn install_subscriber(&self, env_filter: EnvFilter) {
|
|
let layer = SandboxLogLayer {
|
|
bus: self.clone(),
|
|
default_tail: Self::DEFAULT_TAIL,
|
|
};
|
|
|
|
tracing_subscriber::registry()
|
|
.with(env_filter)
|
|
.with(tracing_subscriber::fmt::layer())
|
|
.with(layer)
|
|
.init();
|
|
}
|
|
|
|
fn sender_for(&self, sandbox_id: &str) -> broadcast::Sender<SandboxStreamEvent> {
|
|
let mut inner = self.inner.lock().expect("tracing bus lock poisoned");
|
|
inner
|
|
.per_id
|
|
.entry(sandbox_id.to_string())
|
|
.or_insert_with(|| {
|
|
let (tx, _rx) = broadcast::channel(1024);
|
|
tx
|
|
})
|
|
.clone()
|
|
}
|
|
|
|
pub fn subscribe(&self, sandbox_id: &str) -> broadcast::Receiver<SandboxStreamEvent> {
|
|
self.sender_for(sandbox_id).subscribe()
|
|
}
|
|
|
|
/// Remove all bus entries for the given sandbox id.
|
|
///
|
|
/// This drops the broadcast sender (closing any active receivers with
|
|
/// `RecvError::Closed`) and frees the tail buffer.
|
|
pub fn remove(&self, sandbox_id: &str) {
|
|
let mut inner = self.inner.lock().expect("tracing bus lock poisoned");
|
|
inner.per_id.remove(sandbox_id);
|
|
inner.tails.remove(sandbox_id);
|
|
}
|
|
|
|
pub fn tail(&self, sandbox_id: &str, max: usize) -> Vec<SandboxStreamEvent> {
|
|
let inner = self.inner.lock().expect("tracing bus lock poisoned");
|
|
inner
|
|
.tails
|
|
.get(sandbox_id)
|
|
.map(|d| d.iter().rev().take(max).cloned().collect::<Vec<_>>())
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.rev()
|
|
.collect()
|
|
}
|
|
|
|
/// Publish a log line from an external source (e.g., sandbox push).
|
|
///
|
|
/// Injects the line into the same broadcast channel and tail buffer
|
|
/// used by the tracing layer, so it appears in `WatchSandbox` and
|
|
/// `GetSandboxLogs` transparently.
|
|
pub fn publish_external(&self, log: SandboxLogLine) {
|
|
let evt = SandboxStreamEvent {
|
|
payload: Some(openshell_core::proto::sandbox_stream_event::Payload::Log(
|
|
log.clone(),
|
|
)),
|
|
};
|
|
self.publish(&log.sandbox_id, evt, Self::DEFAULT_TAIL);
|
|
}
|
|
|
|
/// Default tail buffer capacity (lines per sandbox).
|
|
const DEFAULT_TAIL: usize = 2000;
|
|
|
|
fn publish(&self, sandbox_id: &str, event: SandboxStreamEvent, tail_cap: usize) {
|
|
let tx = self.sender_for(sandbox_id);
|
|
let _ = tx.send(event.clone());
|
|
|
|
let mut inner = self.inner.lock().expect("tracing bus lock poisoned");
|
|
let deque = inner.tails.entry(sandbox_id.to_string()).or_default();
|
|
deque.push_back(event);
|
|
while deque.len() > tail_cap {
|
|
deque.pop_front();
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct SandboxLogLayer {
|
|
bus: TracingLogBus,
|
|
default_tail: usize,
|
|
}
|
|
|
|
impl<S> Layer<S> for SandboxLogLayer
|
|
where
|
|
S: Subscriber,
|
|
{
|
|
fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
|
|
let meta = event.metadata();
|
|
let mut visitor = LogVisitor::default();
|
|
event.record(&mut visitor);
|
|
|
|
let Some(sandbox_id) = visitor.sandbox_id else {
|
|
return;
|
|
};
|
|
|
|
let msg = visitor.message.unwrap_or_else(|| meta.name().to_string());
|
|
let level = display_level(meta.target(), &meta.level().to_string());
|
|
|
|
let ts = openshell_core::time::now_ms();
|
|
let log = SandboxLogLine {
|
|
sandbox_id: sandbox_id.clone(),
|
|
timestamp_ms: ts,
|
|
level,
|
|
target: meta.target().to_string(),
|
|
message: msg,
|
|
source: "gateway".to_string(),
|
|
fields: HashMap::new(),
|
|
};
|
|
let evt = SandboxStreamEvent {
|
|
payload: Some(openshell_core::proto::sandbox_stream_event::Payload::Log(
|
|
log,
|
|
)),
|
|
};
|
|
self.bus.publish(&sandbox_id, evt, self.default_tail);
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Default)]
|
|
struct LogVisitor {
|
|
sandbox_id: Option<String>,
|
|
message: Option<String>,
|
|
}
|
|
|
|
impl tracing::field::Visit for LogVisitor {
|
|
fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
|
|
match field.name() {
|
|
"sandbox_id" => self.sandbox_id = Some(value.to_string()),
|
|
"message" => self.message = Some(value.to_string()),
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
|
|
match field.name() {
|
|
"sandbox_id" => self.sandbox_id = Some(format!("{value:?}")),
|
|
"message" => self.message = Some(format!("{value:?}")),
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn display_level(target: &str, level: &str) -> String {
|
|
if target == OCSF_TARGET {
|
|
"OCSF".to_string()
|
|
} else {
|
|
level.to_string()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn make_log_event(sandbox_id: &str, message: &str) -> SandboxLogLine {
|
|
SandboxLogLine {
|
|
sandbox_id: sandbox_id.to_string(),
|
|
timestamp_ms: 1000,
|
|
level: "INFO".to_string(),
|
|
target: "test".to_string(),
|
|
message: message.to_string(),
|
|
source: "gateway".to_string(),
|
|
fields: HashMap::new(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn tracing_log_bus_remove_cleans_up_all_maps() {
|
|
let bus = TracingLogBus::new();
|
|
let sandbox_id = "sb-1";
|
|
|
|
// Create entries via subscribe and publish
|
|
let _rx = bus.subscribe(sandbox_id);
|
|
bus.publish_external(make_log_event(sandbox_id, "hello"));
|
|
|
|
// Verify entries exist
|
|
assert_eq!(bus.tail(sandbox_id, 10).len(), 1);
|
|
|
|
// Remove
|
|
bus.remove(sandbox_id);
|
|
|
|
// Verify entries are gone
|
|
assert!(bus.tail(sandbox_id, 10).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn tracing_log_bus_subscribe_after_remove_creates_fresh_channel() {
|
|
let bus = TracingLogBus::new();
|
|
let sandbox_id = "sb-2";
|
|
|
|
// Create and remove
|
|
bus.publish_external(make_log_event(sandbox_id, "old message"));
|
|
bus.remove(sandbox_id);
|
|
|
|
// Subscribe again — should get a fresh channel with no history
|
|
let mut rx = bus.subscribe(sandbox_id);
|
|
assert!(bus.tail(sandbox_id, 10).is_empty());
|
|
|
|
// New publish should reach the new subscriber
|
|
bus.publish_external(make_log_event(sandbox_id, "new message"));
|
|
let evt = rx.try_recv().expect("should receive new event");
|
|
assert!(evt.payload.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn tracing_log_bus_remove_closes_active_receivers() {
|
|
let bus = TracingLogBus::new();
|
|
let sandbox_id = "sb-3";
|
|
|
|
let mut rx = bus.subscribe(sandbox_id);
|
|
|
|
// Remove drops the sender
|
|
bus.remove(sandbox_id);
|
|
|
|
// Existing receiver should get Closed error
|
|
match rx.try_recv() {
|
|
Err(broadcast::error::TryRecvError::Closed) => {} // expected
|
|
other => panic!("expected Closed, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn tracing_log_bus_remove_nonexistent_is_noop() {
|
|
let bus = TracingLogBus::new();
|
|
// Should not panic
|
|
bus.remove("nonexistent");
|
|
}
|
|
|
|
#[test]
|
|
fn display_level_maps_ocsf_target_to_ocsf() {
|
|
assert_eq!(display_level(OCSF_TARGET, "INFO"), "OCSF");
|
|
assert_eq!(display_level("openshell_server", "WARN"), "WARN");
|
|
}
|
|
|
|
#[test]
|
|
fn platform_event_bus_remove_cleans_up() {
|
|
let bus = PlatformEventBus::new();
|
|
let sandbox_id = "sb-4";
|
|
|
|
let mut rx = bus.subscribe(sandbox_id);
|
|
|
|
// Publish an event
|
|
let evt = SandboxStreamEvent { payload: None };
|
|
bus.publish(sandbox_id, evt);
|
|
assert!(rx.try_recv().is_ok());
|
|
|
|
// Remove
|
|
bus.remove(sandbox_id);
|
|
|
|
// Receiver should be closed
|
|
match rx.try_recv() {
|
|
Err(broadcast::error::TryRecvError::Closed) => {} // expected
|
|
other => panic!("expected Closed, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn platform_event_bus_subscribe_after_remove_creates_fresh_channel() {
|
|
let bus = PlatformEventBus::new();
|
|
let sandbox_id = "sb-5";
|
|
|
|
let _old_rx = bus.subscribe(sandbox_id);
|
|
bus.remove(sandbox_id);
|
|
|
|
// New subscription should work
|
|
let mut new_rx = bus.subscribe(sandbox_id);
|
|
let evt = SandboxStreamEvent { payload: None };
|
|
bus.publish(sandbox_id, evt);
|
|
assert!(new_rx.try_recv().is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn platform_event_bus_remove_nonexistent_is_noop() {
|
|
let bus = PlatformEventBus::new();
|
|
// Should not panic
|
|
bus.remove("nonexistent");
|
|
}
|
|
|
|
#[test]
|
|
fn platform_event_bus_tail_returns_buffered_events() {
|
|
use openshell_core::proto::{PlatformEvent, sandbox_stream_event};
|
|
|
|
let bus = PlatformEventBus::new();
|
|
let sandbox_id = "sb-6";
|
|
|
|
// Publish some events
|
|
for i in 0..5 {
|
|
let evt = SandboxStreamEvent {
|
|
payload: Some(sandbox_stream_event::Payload::Event(PlatformEvent {
|
|
timestamp_ms: i,
|
|
source: "test".to_string(),
|
|
r#type: "Normal".to_string(),
|
|
reason: format!("Event{i}"),
|
|
message: format!("Message {i}"),
|
|
metadata: HashMap::new(),
|
|
})),
|
|
};
|
|
bus.publish(sandbox_id, evt);
|
|
}
|
|
|
|
// Tail should return all events in order
|
|
let events = bus.tail(sandbox_id, 10);
|
|
assert_eq!(events.len(), 5);
|
|
|
|
// Verify order (oldest first)
|
|
for (i, evt) in events.iter().enumerate() {
|
|
if let Some(sandbox_stream_event::Payload::Event(ref e)) = evt.payload {
|
|
assert_eq!(e.reason, format!("Event{i}"));
|
|
} else {
|
|
panic!("expected Event payload");
|
|
}
|
|
}
|
|
|
|
// Tail with smaller max should return most recent events
|
|
let events = bus.tail(sandbox_id, 2);
|
|
assert_eq!(events.len(), 2);
|
|
if let Some(sandbox_stream_event::Payload::Event(ref e)) = events[0].payload {
|
|
assert_eq!(e.reason, "Event3");
|
|
}
|
|
if let Some(sandbox_stream_event::Payload::Event(ref e)) = events[1].payload {
|
|
assert_eq!(e.reason, "Event4");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn platform_event_bus_tail_empty_sandbox() {
|
|
let bus = PlatformEventBus::new();
|
|
let events = bus.tail("nonexistent", 10);
|
|
assert!(events.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn platform_event_bus_remove_clears_tail() {
|
|
let bus = PlatformEventBus::new();
|
|
let sandbox_id = "sb-7";
|
|
|
|
let evt = SandboxStreamEvent { payload: None };
|
|
bus.publish(sandbox_id, evt);
|
|
assert_eq!(bus.tail(sandbox_id, 10).len(), 1);
|
|
|
|
bus.remove(sandbox_id);
|
|
assert!(bus.tail(sandbox_id, 10).is_empty());
|
|
}
|
|
}
|
|
|
|
/// Separate bus for platform event stream events.
|
|
///
|
|
/// This keeps platform events isolated from tracing capture.
|
|
#[derive(Debug, Clone)]
|
|
pub(crate) struct PlatformEventBus {
|
|
inner: Arc<Mutex<PlatformEventBusInner>>,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct PlatformEventBusInner {
|
|
senders: HashMap<String, broadcast::Sender<SandboxStreamEvent>>,
|
|
tails: HashMap<String, VecDeque<SandboxStreamEvent>>,
|
|
}
|
|
|
|
impl PlatformEventBus {
|
|
/// Default tail buffer capacity (events per sandbox).
|
|
/// Platform events are infrequent (typically 5-10 per sandbox lifecycle).
|
|
const DEFAULT_TAIL: usize = 50;
|
|
|
|
fn new() -> Self {
|
|
Self {
|
|
inner: Arc::new(Mutex::new(PlatformEventBusInner {
|
|
senders: HashMap::new(),
|
|
tails: HashMap::new(),
|
|
})),
|
|
}
|
|
}
|
|
|
|
fn sender_for(&self, sandbox_id: &str) -> broadcast::Sender<SandboxStreamEvent> {
|
|
let mut inner = self.inner.lock().expect("platform event bus lock poisoned");
|
|
inner
|
|
.senders
|
|
.entry(sandbox_id.to_string())
|
|
.or_insert_with(|| {
|
|
let (tx, _rx) = broadcast::channel(1024);
|
|
tx
|
|
})
|
|
.clone()
|
|
}
|
|
|
|
pub(crate) fn subscribe(&self, sandbox_id: &str) -> broadcast::Receiver<SandboxStreamEvent> {
|
|
self.sender_for(sandbox_id).subscribe()
|
|
}
|
|
|
|
pub(crate) fn publish(&self, sandbox_id: &str, event: SandboxStreamEvent) {
|
|
let tx = self.sender_for(sandbox_id);
|
|
let _ = tx.send(event.clone());
|
|
|
|
let mut inner = self.inner.lock().expect("platform event bus lock poisoned");
|
|
let deque = inner.tails.entry(sandbox_id.to_string()).or_default();
|
|
deque.push_back(event);
|
|
while deque.len() > Self::DEFAULT_TAIL {
|
|
deque.pop_front();
|
|
}
|
|
}
|
|
|
|
/// Return buffered platform events for replay to late subscribers.
|
|
pub(crate) fn tail(&self, sandbox_id: &str, max: usize) -> Vec<SandboxStreamEvent> {
|
|
let inner = self.inner.lock().expect("platform event bus lock poisoned");
|
|
inner
|
|
.tails
|
|
.get(sandbox_id)
|
|
.map(|d| d.iter().rev().take(max).cloned().collect::<Vec<_>>())
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.rev()
|
|
.collect()
|
|
}
|
|
|
|
/// Remove the bus entry for the given sandbox id.
|
|
///
|
|
/// This drops the broadcast sender, closing any active receivers,
|
|
/// and frees the tail buffer.
|
|
pub(crate) fn remove(&self, sandbox_id: &str) {
|
|
let mut inner = self.inner.lock().expect("platform event bus lock poisoned");
|
|
inner.senders.remove(sandbox_id);
|
|
inner.tails.remove(sandbox_id);
|
|
}
|
|
}
|