Skip to content

Commit ebefc25

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. 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 7caff12 commit ebefc25

27 files changed

Lines changed: 841 additions & 75 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
}
@@ -280,6 +283,34 @@ async fn build_plain_channel(endpoint: &str) -> Result<Channel> {
280283
.wrap_err("failed to connect to OpenShell server")
281284
}
282285

286+
/// Marks the current client span failed unless the call finishes successfully.
287+
struct ClientSpanStatus {
288+
span: tracing::Span,
289+
finished: bool,
290+
}
291+
292+
impl ClientSpanStatus {
293+
fn current() -> Self {
294+
Self {
295+
span: tracing::Span::current(),
296+
finished: false,
297+
}
298+
}
299+
300+
fn finish<T>(mut self, result: Result<T>) -> Result<T> {
301+
self.finished = result.is_ok();
302+
result
303+
}
304+
}
305+
306+
impl Drop for ClientSpanStatus {
307+
fn drop(&mut self) {
308+
if !self.finished {
309+
self.span.record("otel.status_code", "ERROR");
310+
}
311+
}
312+
}
313+
283314
/// Build a Bearer-authenticated channel to the gateway.
284315
///
285316
/// First call per process resolves the sandbox JWT via the three-step
@@ -918,13 +949,19 @@ pub async fn fetch_policy(
918949
/// this snapshot instead of re-fetching metadata after policy construction.
919950
/// The snapshot also carries the external middleware registrations required
920951
/// by the policy.
952+
#[tracing::instrument(
953+
name = "supervisor.gateway.fetch_settings_snapshot",
954+
skip_all,
955+
fields(otel.kind = "client", otel.status_code = tracing::field::Empty)
956+
)]
921957
pub async fn fetch_settings_snapshot(
922958
endpoint: &str,
923959
sandbox_name: &str,
924960
) -> Result<SettingsPollResult> {
961+
let status = ClientSpanStatus::current();
925962
debug!(endpoint = %endpoint, sandbox_name = %sandbox_name, "Connecting to fetch OpenShell settings snapshot");
926963
let mut client = connect(endpoint).await?;
927-
fetch_settings_snapshot_with_client(&mut client, sandbox_name, None).await
964+
status.finish(fetch_settings_snapshot_with_client(&mut client, sandbox_name, None).await)
928965
}
929966

930967
async fn fetch_settings_snapshot_with_client(
@@ -1031,19 +1068,30 @@ pub async fn sync_policy(
10311068
}
10321069

10331070
/// Sync an enriched policy and return the authoritative revision snapshot.
1071+
#[tracing::instrument(
1072+
name = "supervisor.gateway.sync_policy_and_fetch_snapshot",
1073+
skip_all,
1074+
fields(otel.kind = "client", otel.status_code = tracing::field::Empty)
1075+
)]
10341076
pub async fn sync_policy_and_fetch_snapshot(
10351077
endpoint: &str,
10361078
sandbox: &str,
10371079
policy: &ProtoSandboxPolicy,
10381080
workspace: &str,
10391081
) -> Result<SettingsPollResult> {
1082+
let status = ClientSpanStatus::current();
10401083
let mut client = connect(endpoint).await?;
10411084
sync_policy_with_client(&mut client, sandbox, policy, workspace).await?;
1042-
fetch_settings_snapshot_with_client(&mut client, sandbox, Some(workspace)).await
1085+
status.finish(fetch_settings_snapshot_with_client(&mut client, sandbox, Some(workspace)).await)
10431086
}
10441087

10451088
/// Report an exact runtime configuration generation. Pending registration uses
10461089
/// the snapshot's instance fence; retain that snapshot across registration retries.
1090+
#[tracing::instrument(
1091+
name = "supervisor.gateway.report_sandbox_configuration",
1092+
skip_all,
1093+
fields(otel.kind = "client", otel.status_code = tracing::field::Empty)
1094+
)]
10471095
pub async fn report_sandbox_configuration(
10481096
endpoint: &str,
10491097
sandbox_id: &str,
@@ -1052,6 +1100,7 @@ pub async fn report_sandbox_configuration(
10521100
state: crate::proto::ConfigurationAdmissionState,
10531101
error: &str,
10541102
) -> Result<()> {
1103+
let status = ClientSpanStatus::current();
10551104
let mut client = connect(endpoint).await?;
10561105
client
10571106
.report_sandbox_configuration(crate::proto::ReportSandboxConfigurationRequest {
@@ -1073,18 +1122,24 @@ pub async fn report_sandbox_configuration(
10731122
})
10741123
.await
10751124
.map_err(grpc_status_error)?;
1076-
Ok(())
1125+
status.finish(Ok(()))
10771126
}
10781127

10791128
/// Fetch provider environment variables for a sandbox from `OpenShell` server via gRPC.
10801129
///
10811130
/// Returns the credential snapshot and its exact readiness identity. An empty
10821131
/// environment represents a sandbox without provider credentials. Transport
10831132
/// failure returns an error so callers can revoke credentials and retry.
1133+
#[tracing::instrument(
1134+
name = "supervisor.gateway.fetch_provider_environment",
1135+
skip_all,
1136+
fields(otel.kind = "client", otel.status_code = tracing::field::Empty)
1137+
)]
10841138
pub async fn fetch_provider_environment(
10851139
endpoint: &str,
10861140
sandbox_id: &str,
10871141
) -> Result<ProviderEnvironmentResult> {
1142+
let status = ClientSpanStatus::current();
10881143
debug!(endpoint = %endpoint, sandbox_id = %sandbox_id, "Fetching provider environment");
10891144

10901145
let mut client = connect(endpoint).await?;
@@ -1097,7 +1152,7 @@ pub async fn fetch_provider_environment(
10971152
.await
10981153
.map_err(grpc_status_error)?;
10991154

1100-
provider_environment_result(response.into_inner())
1155+
status.finish(provider_environment_result(response.into_inner()))
11011156
}
11021157

11031158
/// Preserve snapshot authority and reject invalid credential expiration times.
@@ -1198,13 +1253,19 @@ mod provider_environment_tests {
11981253
}
11991254
}
12001255

1256+
#[tracing::instrument(
1257+
name = "supervisor.gateway.exchange_provider_subject_token",
1258+
skip_all,
1259+
fields(otel.kind = "client", otel.status_code = tracing::field::Empty)
1260+
)]
12011261
pub async fn exchange_provider_subject_token(
12021262
endpoint: &str,
12031263
sandbox_id: &str,
12041264
provider: &str,
12051265
credential_key: &str,
12061266
supervisor_jwt_svid: &str,
12071267
) -> Result<ProviderSubjectTokenExchangeResult> {
1268+
let status = ClientSpanStatus::current();
12081269
debug!(
12091270
endpoint = %endpoint,
12101271
sandbox_id = %sandbox_id,
@@ -1233,11 +1294,11 @@ pub async fn exchange_provider_subject_token(
12331294
.map_or(0, |value| {
12341295
i64::try_from(value.as_secs()).unwrap_or(i64::MAX)
12351296
});
1236-
Ok(ProviderSubjectTokenExchangeResult {
1297+
status.finish(Ok(ProviderSubjectTokenExchangeResult {
12371298
access_token: inner.access_token,
12381299
expires_in,
12391300
token_type: inner.token_type,
1240-
})
1301+
}))
12411302
}
12421303

12431304
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
@@ -177,6 +177,11 @@ pub struct DockerComputeConfig {
177177
/// Gateway gRPC endpoint the sandbox connects back to.
178178
pub grpc_endpoint: String,
179179

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

@@ -273,6 +278,7 @@ impl Default for DockerComputeConfig {
273278
image_pull_policy: ImagePullPolicy::default(),
274279
sandbox_label: "default".to_string(),
275280
grpc_endpoint: String::new(),
281+
supervisor_otlp_endpoint: None,
276282
sandbox_runtime_image: None,
277283
supervisor_bin: None,
278284
supervisor_image: None,
@@ -309,6 +315,7 @@ struct DockerDriverRuntimeConfig {
309315
sandbox_binary: Arc<Vec<u8>>,
310316
supervisor_image_id: String,
311317
supervisor_grpc_endpoint: String,
318+
supervisor_otlp_endpoint: Option<String>,
312319
ssh_socket_path: String,
313320
guest_tls: Option<DockerGuestTlsPaths>,
314321
gpu: DockerGpuRuntimeCapabilities,
@@ -938,6 +945,7 @@ impl DockerComputeDriver {
938945
sandbox_binary,
939946
supervisor_image_id,
940947
supervisor_grpc_endpoint,
948+
supervisor_otlp_endpoint: docker_config.supervisor_otlp_endpoint.clone(),
941949
ssh_socket_path: docker_config.ssh_socket_path.clone(),
942950
guest_tls,
943951
gpu,
@@ -5096,6 +5104,7 @@ async fn spawn_docker_control_process(
50965104
openshell_core::telemetry::enabled_env_value()
50975105
),
50985106
];
5107+
environment.extend(supervisor_tracing_environment(config));
50995108
if config.guest_tls.is_some() {
51005109
environment.extend([
51015110
format!(
@@ -5550,6 +5559,17 @@ fn docker_child_environment(sandbox: &DriverSandbox) -> HashMap<String, String>
55505559
environment
55515560
}
55525561

5562+
/// Environment that lets the supervisor export spans and join the current trace.
5563+
fn supervisor_tracing_environment(config: &DockerDriverRuntimeConfig) -> Vec<String> {
5564+
let Some(endpoint) = &config.supervisor_otlp_endpoint else {
5565+
return Vec::new();
5566+
};
5567+
std::iter::once((openshell_core::sandbox_env::OTLP_ENDPOINT, endpoint.clone()))
5568+
.chain(openshell_otel::current_trace_context_environment())
5569+
.map(|(name, value)| format!("{name}={value}"))
5570+
.collect()
5571+
}
5572+
55535573
fn build_boundary_environment(
55545574
sandbox: &DriverSandbox,
55555575
config: &DockerDriverRuntimeConfig,

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ async fn main() -> Result<()> {
6363
docker_config.allow_driver_config = policy.allow_driver_config;
6464
docker_config.resource_admission = policy.resource_admission;
6565
}
66+
docker_config.supervisor_otlp_endpoint = args.otlp_endpoint.clone();
6667
let driver = DockerComputeDriver::new(args.gateway_bind, &args.log_level, &docker_config)
6768
.await
6869
.into_diagnostic()?;

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,7 @@ fn runtime_config() -> DockerDriverRuntimeConfig {
185185
sandbox_binary: Arc::new(b"\x7fELFtest".to_vec()),
186186
supervisor_image_id: "sha256:supervisor-test".to_string(),
187187
supervisor_grpc_endpoint: "https://host.openshell.internal:8443".to_string(),
188+
supervisor_otlp_endpoint: None,
188189
ssh_socket_path: openshell_core::container_paths::SSH_SOCKET_PATH.to_string(),
189190
guest_tls: Some(DockerGuestTlsPaths {
190191
ca: PathBuf::from("/tmp/ca.crt"),
@@ -1366,6 +1367,21 @@ fn docker_child_environment_strips_supervisor_control_keys() {
13661367
assert!(!env.values().any(|value| value == "spoofed"));
13671368
}
13681369

1370+
#[test]
1371+
fn supervisor_tracing_environment_requires_an_endpoint() {
1372+
let mut config = runtime_config();
1373+
assert!(supervisor_tracing_environment(&config).is_empty());
1374+
1375+
config.supervisor_otlp_endpoint = Some("http://127.0.0.1:4317".to_string());
1376+
assert_eq!(
1377+
supervisor_tracing_environment(&config),
1378+
[format!(
1379+
"{}=http://127.0.0.1:4317",
1380+
openshell_core::sandbox_env::OTLP_ENDPOINT
1381+
)]
1382+
);
1383+
}
1384+
13691385
#[test]
13701386
fn boundary_environment_contains_only_driver_owned_values() {
13711387
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
@@ -75,6 +75,12 @@ The supervisor Pod has a direct, non-controller owner reference to the Sandbox
7575
resource. This links its garbage-collection lifecycle to the sandbox without
7676
competing with the Agent Sandbox controller for workload-Pod ownership.
7777

78+
When the gateway exports OTLP traces, the driver sets
79+
`OPENSHELL_OTLP_ENDPOINT` on the supervisor Pod to the gateway's endpoint and
80+
`TRACEPARENT` to the trace context of the operation that created the Pod. The
81+
supervisor exports its spans there and parents its startup span on that
82+
context. The endpoint is not configurable in driver TOML.
83+
7884
The driver creates one namespace-wide `NetworkPolicy` before it releases any
7985
workload Pod. It selects every OpenShell workload, denies all workload egress,
8086
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,
@@ -349,6 +353,7 @@ impl Default for KubernetesComputeConfig {
349353
proxy_connect_by_hostname: None,
350354
proxy_ca_bundle: None,
351355
grpc_endpoint: String::new(),
356+
supervisor_otlp_endpoint: None,
352357
ssh_socket_path: openshell_core::container_paths::SSH_SOCKET_PATH.to_string(),
353358
client_tls_secret_name: String::new(),
354359
host_gateway_ip: String::new(),

0 commit comments

Comments
 (0)