Skip to content

Commit f82246e

Browse files
committed
feat(supervisor): export OTLP traces from sandbox supervisors
When the gateway exports OTLP traces, compute drivers pass the gateway's endpoint to supervisors as OPENSHELL_OTLP_ENDPOINT, along with TRACEPARENT from the operation that launched them. The Kubernetes, Docker, Podman, and VM drivers all receive the endpoint from the gateway; driver TOML cannot set it. On Podman Machine, the Podman driver points a loopback endpoint at host.containers.internal, since supervisor loopback is the VM's. The supervisor exports spans as openshell-supervisor, tagged with the sandbox ID, and flushes them before exiting. supervisor.startup joins the sandbox's creation trace and covers image policy discovery, policy load, and boundary attach, confirm, agent start, and access start. Supervisor calls to the gateway carry W3C trace context, so the gateway's server spans nest under them. Each egress connection emits supervisor.egress.connect with authorize, resolve, and dial children. These spans use DEBUG level because every outbound connection starts its own trace. OpenShell spans export at INFO, or at the sandbox log level when it is debug or trace; spans from other libraries export at INFO. Signed-off-by: Kris Hicks <khicks@nvidia.com>
1 parent be7af99 commit f82246e

28 files changed

Lines changed: 975 additions & 78 deletions

File tree

‎Cargo.lock‎

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎crates/openshell-core/Cargo.toml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ chrono = { version = "0.4", default-features = false, features = ["clock", "std"
4040
reqwest = { workspace = true, features = ["blocking", "rustls-tls-native-roots-no-provider"], optional = true }
4141
tar = { version = "0.4", optional = true }
4242
tempfile = { version = "3", optional = true }
43+
openshell-otel = { path = "../openshell-otel", optional = true }
4344

4445
[target.'cfg(unix)'.dependencies]
4546
nix = { workspace = true }
@@ -56,6 +57,8 @@ telemetry = ["dep:reqwest", "dep:chrono", "reqwest?/blocking"]
5657
oauth = ["dep:reqwest"]
5758
## Strict Ed25519 JWT issuance and verification for authenticated sandbox sessions.
5859
jwt = ["dep:jsonwebtoken", "dep:zeroize"]
60+
## Propagate the active W3C trace context on supervisor gateway calls.
61+
trace-context = ["dep:openshell-otel"]
5962

6063
[build-dependencies]
6164
tonic-prost-build = { workspace = true }

‎crates/openshell-core/src/grpc_client.rs‎

Lines changed: 67 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,9 @@ impl tonic::service::Interceptor for AuthInterceptor {
195195
.expect("auth interceptor token slot poisoned")
196196
.clone();
197197
req.metadata_mut().insert("authorization", bearer);
198+
#[cfg(feature = "trace-context")]
199+
let req =
200+
tonic::service::Interceptor::call(&mut openshell_otel::TraceContextInterceptor, req)?;
198201
Ok(req)
199202
}
200203
}
@@ -263,6 +266,34 @@ async fn build_plain_channel(endpoint: &str) -> Result<Channel> {
263266
.wrap_err("failed to connect to OpenShell server")
264267
}
265268

269+
/// Marks the current client span failed unless the call finishes successfully.
270+
struct ClientSpanStatus {
271+
span: tracing::Span,
272+
finished: bool,
273+
}
274+
275+
impl ClientSpanStatus {
276+
fn current() -> Self {
277+
Self {
278+
span: tracing::Span::current(),
279+
finished: false,
280+
}
281+
}
282+
283+
fn finish<T>(mut self, result: Result<T>) -> Result<T> {
284+
self.finished = result.is_ok();
285+
result
286+
}
287+
}
288+
289+
impl Drop for ClientSpanStatus {
290+
fn drop(&mut self) {
291+
if !self.finished {
292+
self.span.record("otel.status_code", "ERROR");
293+
}
294+
}
295+
}
296+
266297
/// Build a Bearer-authenticated channel to the gateway.
267298
///
268299
/// First call per process resolves the sandbox JWT via the three-step
@@ -901,13 +932,19 @@ pub async fn fetch_policy(
901932
/// this snapshot instead of re-fetching metadata after policy construction.
902933
/// The snapshot also carries the external middleware registrations required
903934
/// by the policy.
935+
#[tracing::instrument(
936+
name = "supervisor.gateway.fetch_settings_snapshot",
937+
skip_all,
938+
fields(otel.kind = "client", otel.status_code = tracing::field::Empty)
939+
)]
904940
pub async fn fetch_settings_snapshot(
905941
endpoint: &str,
906942
sandbox_name: &str,
907943
) -> Result<SettingsPollResult> {
944+
let status = ClientSpanStatus::current();
908945
debug!(endpoint = %endpoint, sandbox_name = %sandbox_name, "Connecting to fetch OpenShell settings snapshot");
909946
let mut client = connect(endpoint).await?;
910-
fetch_settings_snapshot_with_client(&mut client, sandbox_name, None).await
947+
status.finish(fetch_settings_snapshot_with_client(&mut client, sandbox_name, None).await)
911948
}
912949

913950
async fn fetch_settings_snapshot_with_client(
@@ -1014,19 +1051,30 @@ pub async fn sync_policy(
10141051
}
10151052

10161053
/// Sync an enriched policy and return the authoritative revision snapshot.
1054+
#[tracing::instrument(
1055+
name = "supervisor.gateway.sync_policy_and_fetch_snapshot",
1056+
skip_all,
1057+
fields(otel.kind = "client", otel.status_code = tracing::field::Empty)
1058+
)]
10171059
pub async fn sync_policy_and_fetch_snapshot(
10181060
endpoint: &str,
10191061
sandbox: &str,
10201062
policy: &ProtoSandboxPolicy,
10211063
workspace: &str,
10221064
) -> Result<SettingsPollResult> {
1065+
let status = ClientSpanStatus::current();
10231066
let mut client = connect(endpoint).await?;
10241067
sync_policy_with_client(&mut client, sandbox, policy, workspace).await?;
1025-
fetch_settings_snapshot_with_client(&mut client, sandbox, Some(workspace)).await
1068+
status.finish(fetch_settings_snapshot_with_client(&mut client, sandbox, Some(workspace)).await)
10261069
}
10271070

10281071
/// Report an exact runtime configuration generation. Pending registration uses
10291072
/// the snapshot's instance fence; retain that snapshot across registration retries.
1073+
#[tracing::instrument(
1074+
name = "supervisor.gateway.report_sandbox_configuration",
1075+
skip_all,
1076+
fields(otel.kind = "client", otel.status_code = tracing::field::Empty)
1077+
)]
10301078
pub async fn report_sandbox_configuration(
10311079
endpoint: &str,
10321080
sandbox_id: &str,
@@ -1035,6 +1083,7 @@ pub async fn report_sandbox_configuration(
10351083
state: crate::proto::ConfigurationAdmissionState,
10361084
error: &str,
10371085
) -> Result<()> {
1086+
let status = ClientSpanStatus::current();
10381087
let mut client = connect(endpoint).await?;
10391088
client
10401089
.report_sandbox_configuration(crate::proto::ReportSandboxConfigurationRequest {
@@ -1056,18 +1105,24 @@ pub async fn report_sandbox_configuration(
10561105
})
10571106
.await
10581107
.map_err(grpc_status_error)?;
1059-
Ok(())
1108+
status.finish(Ok(()))
10601109
}
10611110

10621111
/// Fetch provider environment variables for a sandbox from `OpenShell` server via gRPC.
10631112
///
10641113
/// Returns the credential snapshot and its exact readiness identity. An empty
10651114
/// environment represents a sandbox without provider credentials. Transport
10661115
/// failure returns an error so callers can revoke credentials and retry.
1116+
#[tracing::instrument(
1117+
name = "supervisor.gateway.fetch_provider_environment",
1118+
skip_all,
1119+
fields(otel.kind = "client", otel.status_code = tracing::field::Empty)
1120+
)]
10671121
pub async fn fetch_provider_environment(
10681122
endpoint: &str,
10691123
sandbox_id: &str,
10701124
) -> Result<ProviderEnvironmentResult> {
1125+
let status = ClientSpanStatus::current();
10711126
debug!(endpoint = %endpoint, sandbox_id = %sandbox_id, "Fetching provider environment");
10721127

10731128
let mut client = connect(endpoint).await?;
@@ -1080,7 +1135,7 @@ pub async fn fetch_provider_environment(
10801135
.await
10811136
.map_err(grpc_status_error)?;
10821137

1083-
provider_environment_result(response.into_inner())
1138+
status.finish(provider_environment_result(response.into_inner()))
10841139
}
10851140

10861141
/// Preserve snapshot authority and reject invalid credential expiration times.
@@ -1181,13 +1236,19 @@ mod provider_environment_tests {
11811236
}
11821237
}
11831238

1239+
#[tracing::instrument(
1240+
name = "supervisor.gateway.exchange_provider_subject_token",
1241+
skip_all,
1242+
fields(otel.kind = "client", otel.status_code = tracing::field::Empty)
1243+
)]
11841244
pub async fn exchange_provider_subject_token(
11851245
endpoint: &str,
11861246
sandbox_id: &str,
11871247
provider: &str,
11881248
credential_key: &str,
11891249
supervisor_jwt_svid: &str,
11901250
) -> Result<ProviderSubjectTokenExchangeResult> {
1251+
let status = ClientSpanStatus::current();
11911252
debug!(
11921253
endpoint = %endpoint,
11931254
sandbox_id = %sandbox_id,
@@ -1216,11 +1277,11 @@ pub async fn exchange_provider_subject_token(
12161277
.map_or(0, |value| {
12171278
i64::try_from(value.as_secs()).unwrap_or(i64::MAX)
12181279
});
1219-
Ok(ProviderSubjectTokenExchangeResult {
1280+
status.finish(Ok(ProviderSubjectTokenExchangeResult {
12201281
access_token: inner.access_token,
12211282
expires_in,
12221283
token_type: inner.token_type,
1223-
})
1284+
}))
12241285
}
12251286

12261287
fn provider_subject_token_exchange_status(status: Status) -> miette::Report {

‎crates/openshell-core/src/sandbox_env.rs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ pub const SSH_SOCKET_PATH: &str = "OPENSHELL_SSH_SOCKET_PATH";
2626
/// Log level for the sandbox supervisor (e.g. `"debug"`, `"info"`, `"warn"`).
2727
pub const LOG_LEVEL: &str = "OPENSHELL_LOG_LEVEL";
2828

29+
/// OTLP/gRPC collector endpoint for supervisor trace export.
30+
pub const OTLP_ENDPOINT: &str = "OPENSHELL_OTLP_ENDPOINT";
31+
2932
/// Versioned specification for the exact canonical main process.
3033
///
3134
/// Most drivers use JSON directly. Transports that cannot preserve spaces in

‎crates/openshell-driver-docker/src/lib.rs‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,11 @@ pub struct DockerComputeConfig {
178178
/// Gateway gRPC endpoint the sandbox connects back to.
179179
pub grpc_endpoint: String,
180180

181+
/// OTLP/gRPC collector endpoint passed to supervisors. The gateway
182+
/// supplies its own export endpoint; driver TOML cannot set it.
183+
#[serde(skip)]
184+
pub supervisor_otlp_endpoint: Option<String>,
185+
181186
/// Image containing the trusted `openshell-sandbox` binary.
182187
pub sandbox_runtime_image: Option<String>,
183188

@@ -276,6 +281,7 @@ impl Default for DockerComputeConfig {
276281
image_pull_policy: ImagePullPolicy::default(),
277282
sandbox_label: "default".to_string(),
278283
grpc_endpoint: String::new(),
284+
supervisor_otlp_endpoint: None,
279285
sandbox_runtime_image: None,
280286
supervisor_bin: None,
281287
supervisor_image: None,
@@ -310,6 +316,7 @@ struct DockerDriverRuntimeConfig {
310316
sandbox_binary: Arc<Vec<u8>>,
311317
supervisor_image_id: String,
312318
supervisor_grpc_endpoint: String,
319+
supervisor_otlp_endpoint: Option<String>,
313320
ssh_socket_path: String,
314321
guest_tls: Option<DockerGuestTlsPaths>,
315322
gpu: DockerGpuRuntimeCapabilities,
@@ -939,6 +946,7 @@ impl DockerComputeDriver {
939946
sandbox_binary,
940947
supervisor_image_id,
941948
supervisor_grpc_endpoint,
949+
supervisor_otlp_endpoint: docker_config.supervisor_otlp_endpoint.clone(),
942950
ssh_socket_path: docker_config.ssh_socket_path.clone(),
943951
guest_tls,
944952
gpu,
@@ -5093,6 +5101,7 @@ async fn spawn_docker_control_process(
50935101
openshell_core::telemetry::enabled_env_value()
50945102
),
50955103
];
5104+
environment.extend(supervisor_tracing_environment(config));
50965105
if config.guest_tls.is_some() {
50975106
environment.push(format!(
50985107
"{}={SUPERVISOR_STATE_MOUNT_PATH}/tls/ca.pem",
@@ -5534,6 +5543,17 @@ fn docker_child_environment(sandbox: &DriverSandbox) -> HashMap<String, String>
55345543
environment
55355544
}
55365545

5546+
/// Environment that lets the supervisor export spans and join the current trace.
5547+
fn supervisor_tracing_environment(config: &DockerDriverRuntimeConfig) -> Vec<String> {
5548+
let Some(endpoint) = &config.supervisor_otlp_endpoint else {
5549+
return Vec::new();
5550+
};
5551+
std::iter::once((openshell_core::sandbox_env::OTLP_ENDPOINT, endpoint.clone()))
5552+
.chain(openshell_otel::current_trace_context_environment())
5553+
.map(|(name, value)| format!("{name}={value}"))
5554+
.collect()
5555+
}
5556+
55375557
fn build_boundary_environment(
55385558
sandbox: &DriverSandbox,
55395559
config: &DockerDriverRuntimeConfig,

‎crates/openshell-driver-docker/src/main.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ async fn main() -> Result<()> {
7777
if let Some(image) = args.supervisor_image {
7878
docker_config.supervisor_image = Some(image);
7979
}
80+
docker_config.supervisor_otlp_endpoint = args.otlp_endpoint.clone();
8081
let driver = DockerComputeDriver::new(args.gateway_bind, &args.log_level, &docker_config)
8182
.await
8283
.into_diagnostic()?;

‎crates/openshell-driver-docker/src/tests.rs‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,7 @@ fn runtime_config() -> DockerDriverRuntimeConfig {
210210
sandbox_binary: Arc::new(b"\x7fELFtest".to_vec()),
211211
supervisor_image_id: "sha256:supervisor-test".to_string(),
212212
supervisor_grpc_endpoint: "https://host.openshell.internal:8443".to_string(),
213+
supervisor_otlp_endpoint: None,
213214
ssh_socket_path: openshell_core::container_paths::SSH_SOCKET_PATH.to_string(),
214215
guest_tls: Some(DockerGuestTlsPaths {
215216
ca: PathBuf::from("/tmp/ca.crt"),
@@ -1389,6 +1390,21 @@ fn docker_child_environment_strips_supervisor_control_keys() {
13891390
assert!(!env.values().any(|value| value == "spoofed"));
13901391
}
13911392

1393+
#[test]
1394+
fn supervisor_tracing_environment_requires_an_endpoint() {
1395+
let mut config = runtime_config();
1396+
assert!(supervisor_tracing_environment(&config).is_empty());
1397+
1398+
config.supervisor_otlp_endpoint = Some("http://127.0.0.1:4317".to_string());
1399+
assert_eq!(
1400+
supervisor_tracing_environment(&config),
1401+
[format!(
1402+
"{}=http://127.0.0.1:4317",
1403+
openshell_core::sandbox_env::OTLP_ENDPOINT
1404+
)]
1405+
);
1406+
}
1407+
13921408
#[test]
13931409
fn boundary_environment_contains_only_driver_owned_values() {
13941410
let env = build_boundary_environment(&test_sandbox(), &runtime_config());

‎crates/openshell-driver-kubernetes/README.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,12 @@ The supervisor Pod has a direct, non-controller owner reference to the Sandbox
7979
resource. This links its garbage-collection lifecycle to the sandbox without
8080
competing with the Agent Sandbox controller for workload-Pod ownership.
8181

82+
When the gateway exports OTLP traces, the driver sets
83+
`OPENSHELL_OTLP_ENDPOINT` on the supervisor Pod to the gateway's endpoint and
84+
`TRACEPARENT` to the trace context of the operation that created the Pod. The
85+
supervisor exports its spans there and parents its startup span on that
86+
context. The endpoint is not configurable in driver TOML.
87+
8288
The driver creates one namespace-wide `NetworkPolicy` before it releases any
8389
workload Pod. It selects every OpenShell workload, denies all workload egress,
8490
and permits OpenShell supervisor Pods to reach the sandbox TLS port. The

‎crates/openshell-driver-kubernetes/src/config.rs‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,10 @@ pub struct KubernetesComputeConfig {
244244
/// contain at least one usable trust anchor.
245245
pub proxy_ca_bundle: Option<String>,
246246
pub grpc_endpoint: String,
247+
/// OTLP/gRPC collector endpoint passed to supervisor pods. The gateway
248+
/// supplies its own export endpoint; driver TOML cannot set it.
249+
#[serde(skip)]
250+
pub supervisor_otlp_endpoint: Option<String>,
247251
pub ssh_socket_path: String,
248252
pub client_tls_secret_name: String,
249253
pub host_gateway_ip: String,
@@ -352,6 +356,7 @@ impl Default for KubernetesComputeConfig {
352356
proxy_connect_by_hostname: None,
353357
proxy_ca_bundle: None,
354358
grpc_endpoint: String::new(),
359+
supervisor_otlp_endpoint: None,
355360
ssh_socket_path: openshell_core::container_paths::SSH_SOCKET_PATH.to_string(),
356361
client_tls_secret_name: String::new(),
357362
host_gateway_ip: String::new(),

0 commit comments

Comments
 (0)