Skip to content

Commit 0890eee

Browse files
committed
fix(observability): bind OTLP receiver on capability confirmation
The supervisor bound the OTLP receiver on 127.0.0.1:4318 before the session handshake, regardless of whether the gateway confirmed the otel_export capability. When the capability was declined the port stayed reserved and the receiver answered 200 OK for spans that were then dropped on the way to a session that never drained them. Shutdown could also hang: each HTTP connection task held a buffer sender and the forwarder only exited once the last sender dropped, so a single keep-alive connection blocked sandbox teardown. Move the relay into openshell-supervisor-process and make the supervisor session own it. The session binds the receiver on the first SessionAccepted that confirms otel_export, keeps it across reconnects, and drains the buffer straight into the session stream. This removes the forwarder task and the intermediate session channel, leaving one bounded buffer between receiver and gateway. The receiver now tracks connections in a JoinSet under hyper-util's GracefulShutdown with a header read timeout, so shutdown disables keep-alive, waits a bounded 2s for in-flight requests, and aborts stragglers. run_process asks the session to stop the receiver and flush buffered telemetry after the entrypoint exits and before the exit is reported, bounded at 3s, so final spans still reach the gateway. The previous lib.rs drain ran after the session task had been aborted and flushed into a channel nobody read. Also drop the never-read RelayConfig.enabled field, factor the netns bind into bind_tcp_in_netns_fd, and update the architecture doc. Signed-off-by: Roland Huß <rhuss@redhat.com>
1 parent 852b4c1 commit 0890eee

19 files changed

Lines changed: 1207 additions & 772 deletions

File tree

‎AGENTS.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ These pipelines connect skills into end-to-end workflows. Individual skill files
6464
| `crates/openshell-supervisor-middleware/` | Middleware runtime | Generic middleware registry, remote service integration, and chain execution |
6565
| `crates/openshell-supervisor-middleware-builtins/` | Built-in middleware | First-party in-process middleware implementations |
6666
| `crates/openshell-supervisor-network/` | Network supervisor | Proxying, L7 enforcement, policy evaluation, and provider credential injection |
67-
| `crates/openshell-supervisor-process/` | Process supervisor | Process lifecycle, namespace, and bypass monitoring |
67+
| `crates/openshell-supervisor-process/` | Process supervisor | Process lifecycle, namespace, bypass monitoring, and the OTLP telemetry relay |
6868
| `crates/openshell-vfio/` | VFIO support | PCI and GPU passthrough preparation and lifecycle |
6969
| `python/openshell/` | Python SDK | Python bindings and CLI packaging |
7070
| `sdk/typescript/` | TypeScript SDK | Native Connect client, curated sandbox API, and generated protobuf types |

‎Cargo.lock‎

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

‎architecture/sandbox.md‎

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -469,17 +469,32 @@ collector without requiring direct egress from the sandbox.
469469
Agent process --> OTLP HTTP (127.0.0.1:4318) --> Supervisor receiver
470470
--> Enrichment (sandbox resource attributes)
471471
--> Bounded buffer (4096 slots, shared traces + OCSF)
472-
--> Forwarder --> Session channel (OtelExportData message)
472+
--> Supervisor session (OtelExportData message on the session stream)
473473
--> Gateway --> Dedicated SpanExporter --> External OTLP collector
474474
```
475475

476+
The supervisor session owns the relay (`openshell-supervisor-process::otlp`).
477+
There is no separate forwarder task: the session drains the buffer directly
478+
into its outbound stream, so the buffer is the only queue between the
479+
receiver and the gateway.
480+
476481
### Receiver Binding
477482

478483
The OTLP HTTP receiver binds to `127.0.0.1:4318` only when the relay is
479484
active (the gateway has `[openshell.gateway.otlp]` configured and confirms
480485
the `otel_export` capability). When OTLP is not configured, no port is
481486
bound and no receiver runs.
482487

488+
The bind is lazy: the session binds the receiver on the first
489+
`SessionAccepted` that confirms the capability and keeps it bound across
490+
gateway reconnects. The agent process starts before the session handshake
491+
completes, so an exporter that flushes in that window sees a refused
492+
connection rather than a silent drop; OTel SDK exporters retry with
493+
backoff, so those spans land once the port is up. If a later reconnect
494+
declines the capability, the receiver stays bound and forwarding pauses
495+
until a session confirms again. A bind failure is logged and disables the
496+
relay for the rest of the sandbox lifetime.
497+
483498
The bind address depends on the supervisor topology. In all current
484499
topologies, the process supervisor runs co-located with the agent workload,
485500
so `127.0.0.1` is reachable from the agent. For Docker/Podman drivers
@@ -515,10 +530,22 @@ relay is active, the supervisor sets `OTEL_EXPORTER_OTLP_ENDPOINT` and
515530

516531
### Non-Interference
517532

518-
The buffer uses `try_send` (non-blocking) on the session channel. When the
519-
buffer reaches capacity, the oldest entries are dropped and a counter records
520-
each drop. A queue depth gauge tracks buffer pressure. This ensures telemetry
521-
cannot block or degrade sandbox control operations.
533+
Both hops are non-blocking. The receiver uses `try_send` into the bounded
534+
buffer: when the buffer is full, the newest item is dropped and a counter
535+
records each drop. The session uses `try_send` into its outbound stream and
536+
drops the message when that stream is backed up. A queue depth gauge tracks
537+
buffer pressure. This ensures telemetry cannot block or degrade sandbox
538+
control operations.
539+
540+
### Shutdown
541+
542+
After the entrypoint exits and before the exit is reported to the gateway,
543+
the supervisor asks the session to stop the receiver and flush the buffer.
544+
The receiver stops accepting, disables keep-alive on open connections (idle
545+
ones close immediately), waits up to 2 seconds for in-flight requests, and
546+
aborts stragglers. The session then pushes whatever is still buffered onto
547+
the session stream. The whole flush is bounded at 3 seconds so an
548+
unreachable gateway cannot delay the exit report.
522549

523550
### OCSF Event Relay
524551

‎crates/openshell-sandbox/src/lib.rs‎

Lines changed: 18 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -850,9 +850,6 @@ pub async fn run_sandbox(
850850
};
851851
tokio::pin!(proxy_exited);
852852

853-
#[cfg_attr(not(target_os = "linux"), allow(unused_mut))]
854-
let mut otel_relay_handle: Option<openshell_supervisor_network::otlp::RelayHandle> = None;
855-
856853
let exit_code = if process_enabled {
857854
let ca_file_paths = networking
858855
.as_ref()
@@ -953,22 +950,21 @@ pub async fn run_sandbox(
953950
None
954951
};
955952

956-
// OTEL relay: bind OTLP receiver for all Linux topologies.
957-
// All current topologies keep the process supervisor co-located with
958-
// the agent, so 127.0.0.1 is reachable from agent processes. Future
959-
// topologies that move the supervisor out of the workload pod would
960-
// need to derive the bind address from the topology (e.g., pod IP
961-
// via downward API) and update OTEL_EXPORTER_OTLP_ENDPOINT to match.
962-
let otel_rx = {
953+
// OTEL relay: describe the OTLP receiver for the supervisor session,
954+
// which binds it only once the gateway confirms the otel_export
955+
// capability. All current topologies keep the process supervisor
956+
// co-located with the agent, so 127.0.0.1 is reachable from agent
957+
// processes. Future topologies that move the supervisor out of the
958+
// workload pod would need to derive the bind address from the
959+
// topology and update OTEL_EXPORTER_OTLP_ENDPOINT to match.
960+
let otel_relay = {
963961
#[cfg(target_os = "linux")]
964962
{
965-
let otlp_addr = openshell_core::sandbox_env::OTLP_RECEIVER_ADDR;
966-
967-
let (otel_session_tx, otel_session_rx) =
968-
tokio::sync::mpsc::channel::<openshell_core::proto::SupervisorMessage>(64);
969-
970-
let relay_config = openshell_supervisor_network::otlp::RelayConfig::default();
971-
let metadata = openshell_supervisor_network::otlp::SandboxMetadata {
963+
let bind_addr: std::net::SocketAddr =
964+
openshell_core::sandbox_env::OTLP_RECEIVER_ADDR
965+
.parse()
966+
.expect("OTLP_RECEIVER_ADDR is a valid socket address");
967+
let metadata = openshell_supervisor_process::otlp::SandboxMetadata {
972968
sandbox_id: sandbox_id.clone().unwrap_or_default(),
973969
workspace_id: workspace_rx.borrow().clone(),
974970
policy: sandbox_name_for_agg.clone().unwrap_or_default(),
@@ -979,37 +975,11 @@ pub async fn run_sandbox(
979975
driver: std::env::var(openshell_core::sandbox_env::SUPERVISOR_TOPOLOGY)
980976
.unwrap_or_else(|_| "container".to_string()),
981977
};
982-
let relay = openshell_supervisor_network::otlp::OtelRelay::new(
983-
relay_config,
978+
Some(openshell_supervisor_process::otlp::RelaySetup {
979+
config: openshell_supervisor_process::otlp::RelayConfig::default(),
984980
metadata,
985-
otel_session_tx,
986-
);
987-
988-
let bind_addr: std::net::SocketAddr = otlp_addr.parse().unwrap();
989-
990-
if let Some(ns) = netns.as_ref() {
991-
match ns.bind_tcp_in_netns(otlp_addr).await {
992-
Ok(listener) => {
993-
let handle = relay.start_with_listener(listener);
994-
tracing::info!(bind = %bind_addr, "OTEL relay started (netns)");
995-
otel_relay_handle = Some(handle);
996-
}
997-
Err(e) => {
998-
tracing::warn!(error = %e, "OTEL relay failed to bind in netns; continuing without relay");
999-
}
1000-
}
1001-
} else {
1002-
match relay.start(bind_addr).await {
1003-
Ok(handle) => {
1004-
tracing::info!(bind = %bind_addr, "OTEL relay started");
1005-
otel_relay_handle = Some(handle);
1006-
}
1007-
Err(e) => {
1008-
tracing::warn!(error = %e, "OTEL relay failed to start; continuing without relay");
1009-
}
1010-
}
1011-
}
1012-
Some(otel_session_rx)
981+
bind_addr,
982+
})
1013983
}
1014984
#[cfg(not(target_os = "linux"))]
1015985
{
@@ -1046,7 +1016,7 @@ pub async fn run_sandbox(
10461016
bypass_denial_tx,
10471017
#[cfg(target_os = "linux")]
10481018
bypass_activity_tx,
1049-
otel_rx,
1019+
otel_relay,
10501020
);
10511021

10521022
if let Some(control_closed) = process_control_closed.as_mut() {
@@ -1206,12 +1176,6 @@ pub async fn run_sandbox(
12061176
}
12071177
};
12081178

1209-
// Drain OTEL relay before tearing down networking so short-lived
1210-
// agents don't lose their final spans.
1211-
if let Some(handle) = otel_relay_handle {
1212-
handle.shutdown().await;
1213-
}
1214-
12151179
// Drop networking explicitly so the proxy + bypass monitor RAII
12161180
// handles tear down before we return.
12171181
drop(networking);

‎crates/openshell-server/src/lib.rs‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ mod grpc;
2525
mod http;
2626
mod middleware;
2727
mod multiplex;
28+
pub(crate) mod otel_relay;
2829
mod otel_tracing;
2930
mod persistence;
3031
pub(crate) mod policy_store;
@@ -37,7 +38,6 @@ mod service_routing;
3738
mod ssh_sessions;
3839
pub mod supervisor_session;
3940
mod telemetry;
40-
pub(crate) mod otel_relay;
4141
#[cfg(any(test, feature = "test-support"))]
4242
pub mod test_support;
4343
mod tls;
@@ -666,8 +666,7 @@ pub(crate) async fn run_server(
666666
state.middleware_registry = middleware_registry;
667667
state.gateway_interceptors = gateway_interceptors;
668668
state.provider_profile_sources = provider_profile_sources;
669-
state.otel_relay_exporter =
670-
otel_relay::try_create_exporter(config_file.as_ref()).await;
669+
state.otel_relay_exporter = otel_relay::try_create_exporter(config_file.as_ref()).await;
671670
state.sandbox_jwt_issuer = sandbox_jwt_issuer.clone();
672671
state.sandbox_jwt_authenticator = sandbox_jwt_authenticator;
673672
if let Some(issuer) = sandbox_jwt_issuer {

‎crates/openshell-server/src/supervisor_session.rs‎

Lines changed: 26 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1059,6 +1059,9 @@ fn confirm_capabilities(advertised: &[String], state: &Arc<ServerState>) -> Vec<
10591059
confirmed
10601060
}
10611061

1062+
/// Upper bound on a single relayed OCSF event; larger events are skipped.
1063+
const MAX_OCSF_EVENT_SIZE: usize = 256 * 1024;
1064+
10621065
/// Handle incoming OTEL export data from the supervisor: forward trace data to
10631066
/// the configured OTLP collector and dispatch OCSF events to the log sink.
10641067
fn handle_otel_export(
@@ -1069,39 +1072,33 @@ fn handle_otel_export(
10691072
) {
10701073
if let Some(openshell_core::proto::otel_export_data::Signal::TraceData(trace_data)) =
10711074
otel_data.signal
1075+
&& !trace_data.is_empty()
1076+
&& let Some(relay_exporter) = state.otel_relay_exporter.as_ref()
10721077
{
1073-
if !trace_data.is_empty() {
1074-
if let Some(relay_exporter) = state.otel_relay_exporter.as_ref() {
1075-
let exporter = relay_exporter.clone();
1076-
let sandbox_id = sandbox_id.to_string();
1077-
tokio::spawn(async move {
1078-
match tokio::time::timeout(
1079-
Duration::from_secs(10),
1080-
exporter.export_raw(trace_data),
1081-
)
1082-
.await
1083-
{
1084-
Ok(Err(e)) => {
1085-
debug!(
1086-
sandbox_id = %sandbox_id,
1087-
error = %e,
1088-
"OTEL relay: failed to export trace data"
1089-
);
1090-
}
1091-
Err(_) => {
1092-
debug!(
1093-
sandbox_id = %sandbox_id,
1094-
"OTEL relay: export timed out"
1095-
);
1096-
}
1097-
Ok(Ok(())) => {}
1098-
}
1099-
});
1078+
let exporter = relay_exporter.clone();
1079+
let sandbox_id = sandbox_id.to_string();
1080+
tokio::spawn(async move {
1081+
match tokio::time::timeout(Duration::from_secs(10), exporter.export_raw(trace_data))
1082+
.await
1083+
{
1084+
Ok(Err(e)) => {
1085+
debug!(
1086+
sandbox_id = %sandbox_id,
1087+
error = %e,
1088+
"OTEL relay: failed to export trace data"
1089+
);
1090+
}
1091+
Err(_) => {
1092+
debug!(
1093+
sandbox_id = %sandbox_id,
1094+
"OTEL relay: export timed out"
1095+
);
1096+
}
1097+
Ok(Ok(())) => {}
11001098
}
1101-
}
1099+
});
11021100
}
11031101

1104-
const MAX_OCSF_EVENT_SIZE: usize = 256 * 1024;
11051102
for ocsf_event in &otel_data.ocsf_events {
11061103
if ocsf_event.len() > MAX_OCSF_EVENT_SIZE {
11071104
debug!(

‎crates/openshell-supervisor-network/Cargo.toml‎

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,6 @@ openshell-otel = { path = "../openshell-otel" }
1717
openshell-policy = { path = "../openshell-policy" }
1818
openshell-supervisor-middleware = { path = "../openshell-supervisor-middleware" }
1919

20-
hyper = { workspace = true }
21-
hyper-util = { version = "0.1", features = ["tokio", "http1"] }
22-
http-body-util = "0.1"
23-
opentelemetry-proto = { workspace = true }
24-
prost = { workspace = true }
2520
apollo-parser = { workspace = true }
2621
aws-sigv4 = { version = "1", features = ["sign-http", "http1"] }
2722
aws-credential-types = { version = "1", features = ["hardcoded-credentials"] }

‎crates/openshell-supervisor-network/src/lib.rs‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111
pub mod identity;
1212
pub mod l7;
1313
pub mod opa;
14-
pub mod otlp;
1514
pub(crate) mod policy_dns;
1615
pub mod policy_local;
1716
pub mod procfs;

0 commit comments

Comments
 (0)