From 89e022e251cd1aa74960ad9ac8bdf5208888216d Mon Sep 17 00:00:00 2001 From: Kris Hicks Date: Thu, 6 Aug 2026 09:40:35 -0700 Subject: [PATCH 1/2] feat(gateway): add installation name configuration Add a first-class operator-assigned gateway name with TOML, CLI, environment, and Helm configuration surfaces. Local gateways default to openshell, while Helm defaults to the chart fullname; operators sharing a collector across namespaces or clusters can set a globally distinct name. Signed-off-by: Kris Hicks --- architecture/gateway.md | 9 ++++++ crates/openshell-core/src/config.rs | 23 +++++++++++++ crates/openshell-server/src/cli.rs | 32 ++++++++++++++++++- crates/openshell-server/src/config_file.rs | 5 +++ deploy/helm/openshell/README.md | 1 + .../openshell/templates/gateway-config.yaml | 1 + .../openshell/tests/gateway_config_test.yaml | 16 ++++++++++ deploy/helm/openshell/values.yaml | 4 +++ docs/reference/gateway-config.mdx | 3 ++ 9 files changed, 93 insertions(+), 1 deletion(-) diff --git a/architecture/gateway.md b/architecture/gateway.md index db8f2508e..32bb24db4 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -639,6 +639,15 @@ Driver implementation settings live in the TOML driver tables. See `docs/reference/gateway-config.mdx` for worked per-driver examples and RFC 0003 for the full schema. +Each installation has an operator-assigned gateway name. Configure it with +`[openshell.gateway].name`, `--name`, or `OPENSHELL_GATEWAY_NAME`. +The built-in default is `openshell`; the Helm chart defaults it to the chart +fullname so every replica in one installation reports the same identity. +Operators must set a globally distinct name when one telemetry collector serves +installations in multiple Kubernetes namespaces or clusters. +The name identifies the gateway installation independently of client-side +aliases, network names, and the sandbox JWT issuer. + `database_url` is env-only and rejected when present in the file (`OPENSHELL_DB_URL` / `--db-url`). diff --git a/crates/openshell-core/src/config.rs b/crates/openshell-core/src/config.rs index fcbdeb73b..76ea4c55c 100644 --- a/crates/openshell-core/src/config.rs +++ b/crates/openshell-core/src/config.rs @@ -28,6 +28,9 @@ pub const DEFAULT_SSH_PORT: u16 = 2222; /// Default gateway server port. pub const DEFAULT_SERVER_PORT: u16 = 17670; +/// Default operator-facing name for a gateway installation. +pub const DEFAULT_GATEWAY_NAME: &str = "openshell"; + /// Default container stop timeout in seconds (SIGTERM → SIGKILL). pub const DEFAULT_STOP_TIMEOUT_SECS: u32 = 10; @@ -412,6 +415,9 @@ fn docker_socket_responds(path: &Path) -> bool { /// `Deserialize` impls for that purpose). #[derive(Debug, Clone)] pub struct Config { + /// Operator-assigned name for this gateway installation. + pub name: String, + /// Address to bind the server to. pub bind_address: SocketAddr, @@ -819,6 +825,7 @@ impl Config { /// Create a new config with optional TLS. pub fn new(tls: Option) -> Self { Self { + name: DEFAULT_GATEWAY_NAME.to_string(), bind_address: default_bind_address(), health_bind_address: None, metrics_bind_address: None, @@ -846,6 +853,13 @@ impl Config { } } + /// Create a new configuration with the gateway installation name. + #[must_use] + pub fn with_name(mut self, name: impl Into) -> Self { + self.name = name.into(); + self + } + /// Create a new configuration with the given bind address. #[must_use] pub const fn with_bind_address(mut self, addr: SocketAddr) -> Self { @@ -1219,6 +1233,15 @@ mod tests { assert_eq!(cfg.ttl_secs, 0); } + #[test] + fn name_defaults_and_can_be_overridden() { + assert_eq!(Config::new(None).name, "openshell"); + assert_eq!( + Config::new(None).with_name("production-us-west").name, + "production-us-west" + ); + } + #[test] fn gateway_interceptor_failure_policy_rejects_ignore() { let err = diff --git a/crates/openshell-server/src/cli.rs b/crates/openshell-server/src/cli.rs index 2e86c3a1b..39e80fe01 100644 --- a/crates/openshell-server/src/cli.rs +++ b/crates/openshell-server/src/cli.rs @@ -7,7 +7,7 @@ use clap::parser::ValueSource; use clap::{ArgAction, ArgMatches, Command, CommandFactory, FromArgMatches, Parser}; use miette::{IntoDiagnostic, Result}; use openshell_core::ComputeDriverKind; -use openshell_core::config::DEFAULT_SERVER_PORT; +use openshell_core::config::{DEFAULT_GATEWAY_NAME, DEFAULT_SERVER_PORT}; use std::net::{IpAddr, SocketAddr}; use std::path::PathBuf; use tracing::{error, info, warn}; @@ -52,6 +52,14 @@ struct RunArgs { #[arg(long, env = "OPENSHELL_GATEWAY_CONFIG")] config: Option, + /// Operator-assigned name for this gateway installation. + #[arg( + long = "name", + default_value = DEFAULT_GATEWAY_NAME, + env = "OPENSHELL_GATEWAY_NAME" + )] + name: String, + /// IP address to bind the server, health, and metrics listeners to. #[arg(long, default_value = "127.0.0.1", env = "OPENSHELL_BIND_ADDRESS")] bind_address: IpAddr, @@ -323,7 +331,13 @@ fn prepare_server_config(args: &mut RunArgs, matches: &ArgMatches) -> Result, + // ── Listeners ──────────────────────────────────────────────────────── #[serde(default)] pub bind_address: Option, diff --git a/deploy/helm/openshell/README.md b/deploy/helm/openshell/README.md index 93dab354b..07c490bed 100644 --- a/deploy/helm/openshell/README.md +++ b/deploy/helm/openshell/README.md @@ -240,6 +240,7 @@ add `ci/values-spire.yaml` to the OpenShell release values files. | server.grpcRateLimit.windowSeconds | int | `0` | gRPC rate-limit window length in seconds. Must be positive (alongside requests) to enable rate limiting; 0 (default) disables it. | | server.hostGatewayIP | string | `""` | Host gateway IP for sandbox pod hostAliases. When set, sandbox pods get hostAliases entries mapping host.docker.internal and host.openshell.internal to this IP, allowing them to reach services running on the Docker host. Auto-detected by the cluster entrypoint script. | | server.logLevel | string | `"info"` | Gateway log level. | +| server.name | string | `""` | Operator-facing gateway name. Defaults to the chart fullname so all replicas in one installation share an identity. Set explicitly when one telemetry collector receives spans from multiple namespaces or clusters. | | server.oidc.adminRole | string | `""` | Role name for admin access. Leave empty (with userRole also empty) for authentication-only mode. Both must be set or both empty. | | server.oidc.audience | string | `"openshell-cli"` | Expected audience claim for the API resource server. This should match the server's --oidc-audience, NOT the CLI client ID. | | server.oidc.caConfigMapName | string | `""` | Name of a ConfigMap containing a CA certificate bundle (key: ca.crt) for verifying the OIDC issuer's TLS certificate. Required when the issuer uses a non-public CA (e.g. OpenShift ingress, private PKI). | diff --git a/deploy/helm/openshell/templates/gateway-config.yaml b/deploy/helm/openshell/templates/gateway-config.yaml index 9d24dbd91..cb105f958 100644 --- a/deploy/helm/openshell/templates/gateway-config.yaml +++ b/deploy/helm/openshell/templates/gateway-config.yaml @@ -34,6 +34,7 @@ data: version = 1 [openshell.gateway] + name = {{ .Values.server.name | default (include "openshell.fullname" .) | quote }} bind_address = "0.0.0.0:{{ .Values.service.port }}" {{- if .Values.service.healthPort }} health_bind_address = "0.0.0.0:{{ .Values.service.healthPort }}" diff --git a/deploy/helm/openshell/tests/gateway_config_test.yaml b/deploy/helm/openshell/tests/gateway_config_test.yaml index afacd01eb..7d60bca11 100644 --- a/deploy/helm/openshell/tests/gateway_config_test.yaml +++ b/deploy/helm/openshell/tests/gateway_config_test.yaml @@ -11,6 +11,22 @@ release: namespace: my-namespace tests: + - it: identifies the gateway by chart fullname by default + template: templates/gateway-config.yaml + asserts: + - matchRegex: + path: data["gateway.toml"] + pattern: '(?m)^name\s*=\s*"openshell"$' + + - it: renders an explicit gateway name + template: templates/gateway-config.yaml + set: + server.name: production-us-west + asserts: + - matchRegex: + path: data["gateway.toml"] + pattern: '(?m)^name\s*=\s*"production-us-west"$' + # Regression for Drew's P2: a ConfigMap-only mutation in `helm upgrade` # must roll the StatefulSet, otherwise pods keep running with stale config. - it: annotates the StatefulSet pod template with a ConfigMap checksum diff --git a/deploy/helm/openshell/values.yaml b/deploy/helm/openshell/values.yaml index 33337c768..bbab455b1 100644 --- a/deploy/helm/openshell/values.yaml +++ b/deploy/helm/openshell/values.yaml @@ -179,6 +179,10 @@ affinity: {} # Server configuration server: + # -- Operator-facing gateway name. Defaults to the chart fullname so all + # replicas in one installation share an identity. Set explicitly when one + # telemetry collector receives spans from multiple namespaces or clusters. + name: "" # -- Gateway log level. logLevel: info # -- Enable anonymous OpenShell telemetry from the gateway and the sandbox diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index 85e965a35..36ed77b1a 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -18,6 +18,8 @@ Gateway CLI flag > gateway OPENSHELL_* env var > TOML file > built-in defa `database_url` is env-only. The loader rejects it when it appears in the file. When `OPENSHELL_DB_URL` is unset, the gateway stores its SQLite database under `$XDG_STATE_HOME/openshell/gateway/openshell.db`. +`name` assigns an operator-facing identity to the gateway installation. Set it with `[openshell.gateway].name`, `--name`, or `OPENSHELL_GATEWAY_NAME`. It defaults to `openshell`; the Helm chart defaults it to the chart fullname so all replicas in one installation share a name. Chart fullnames are only unique within their Kubernetes namespace, so set `server.name` explicitly when one collector receives telemetry from multiple namespaces or clusters. This identity is independent of client-side gateway aliases, TLS names, and `gateway_jwt.gateway_id`. + ## Package-Managed Locations Package-managed gateways do not require a TOML file. Create one at the package's optional config location when you need to override built-in defaults. Set `OPENSHELL_GATEWAY_CONFIG` in the launch environment to use a different file. @@ -69,6 +71,7 @@ A complete gateway configuration covering every section. Trim to the fields you version = 1 [openshell.gateway] +name = "production-us-west" bind_address = "0.0.0.0:8080" health_bind_address = "0.0.0.0:8081" metrics_bind_address = "0.0.0.0:9090" From fcc06786e8abc3da151685c845f868f51433c9d1 Mon Sep 17 00:00:00 2001 From: Kris Hicks Date: Thu, 30 Jul 2026 14:19:51 -0700 Subject: [PATCH 2/2] feat(gateway): identify gateways in exported traces Attach the configured gateway installation name and compute driver to the gateway OpenTelemetry resource so operators can filter traces from multiple installations that share a collector. Forward the gateway name and OTLP endpoint to managed external drivers so their distinct service resources carry the same installation identity. Keep service.name stable per process type, omit blank resource values, and leave per-span operation names and request attributes unchanged. Refs #2507 Signed-off-by: Kris Hicks --- architecture/gateway.md | 8 +- crates/openshell-driver-vm/src/main.rs | 13 +- .../openshell-driver-vm/src/otel_tracing.rs | 62 ++++++---- crates/openshell-server/src/cli.rs | 10 +- crates/openshell-server/src/compute/vm.rs | 18 ++- crates/openshell-server/src/lib.rs | 46 +++++-- crates/openshell-server/src/otel_tracing.rs | 115 +++++++++++++++--- crates/openshell-server/src/tracing_setup.rs | 5 +- docs/reference/gateway-config.mdx | 4 +- 9 files changed, 218 insertions(+), 63 deletions(-) diff --git a/architecture/gateway.md b/architecture/gateway.md index 32bb24db4..f3da5c5e6 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -696,10 +696,12 @@ between a trace and its log lines. Store and compute-driver spans become children of the request span. Reconciliation, provider refresh, and driver-watch loops create their own operation spans because they have no inbound request to provide a parent. gRPC status is recorded when response -trailers arrive. +trailers arrive. Gateway spans carry resource attributes for the gateway +identity and configured compute driver. -The gateway forwards OTLP configuration and W3C trace context to managed -external drivers. Each driver exports under its own service name. +The gateway forwards OTLP configuration, its configured gateway name, and W3C +trace context to managed external drivers. Each driver exports under its own +service name and carries the gateway name as a resource attribute. Two invariants shape the failure behavior. Telemetry is diagnostic, so no OTLP failure stops the gateway from serving: a malformed endpoint is logged at diff --git a/crates/openshell-driver-vm/src/main.rs b/crates/openshell-driver-vm/src/main.rs index 949d4ce05..95ebf0f8b 100644 --- a/crates/openshell-driver-vm/src/main.rs +++ b/crates/openshell-driver-vm/src/main.rs @@ -91,6 +91,9 @@ struct Args { #[arg(long, env = "OPENSHELL_OTLP_ENDPOINT")] otlp_endpoint: Option, + #[arg(long, env = "OPENSHELL_GATEWAY_NAME")] + gateway_name: Option, + #[arg(long, env = "OPENSHELL_GRPC_ENDPOINT")] openshell_endpoint: Option, @@ -186,8 +189,10 @@ async fn main() -> Result<()> { return Ok(()); } - let (tracer_provider, setup_error) = - openshell_driver_vm::otel_tracing::provider_for(args.otlp_endpoint.as_deref()); + let (tracer_provider, setup_error) = openshell_driver_vm::otel_tracing::provider_for( + args.otlp_endpoint.as_deref(), + args.gateway_name.as_deref(), + ); tracing_subscriber::registry() .with(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(&args.log_level))) .with(tracing_subscriber::fmt::layer()) @@ -691,11 +696,13 @@ mod tests { } #[test] - fn accepts_gateway_otlp_endpoint() { + fn accepts_gateway_otlp_configuration() { let args = Args::try_parse_from([ "openshell-driver-vm", "--otlp-endpoint", "http://127.0.0.1:4317", + "--gateway-name", + "production-us-west", ]); assert!( args.is_ok(), diff --git a/crates/openshell-driver-vm/src/otel_tracing.rs b/crates/openshell-driver-vm/src/otel_tracing.rs index adfeb896c..9da6e17ed 100644 --- a/crates/openshell-driver-vm/src/otel_tracing.rs +++ b/crates/openshell-driver-vm/src/otel_tracing.rs @@ -81,14 +81,28 @@ fn compute_driver_rpc_operation(path: &str) -> (&'static str, &'static str) { } } -/// Build a tracer provider for the configured OTLP/gRPC endpoint. +/// Build a tracer provider for the configured OTLP/gRPC endpoint and gateway. #[must_use] -pub fn provider_for(endpoint: Option<&str>) -> (Option, Option) { - openshell_otel::provider_for(endpoint.map(|endpoint| OtlpTraceConfig { - endpoint, - service_name: ServiceName::Fixed(SERVICE_NAME), - service_version: Some(openshell_core::VERSION), - resource_attributes: Vec::new(), +pub fn provider_for( + endpoint: Option<&str>, + gateway_name: Option<&str>, +) -> (Option, Option) { + openshell_otel::provider_for(endpoint.map(|endpoint| { + OtlpTraceConfig { + endpoint, + service_name: ServiceName::Fixed(SERVICE_NAME), + service_version: Some(openshell_core::VERSION), + resource_attributes: gateway_name + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(|name| { + vec![opentelemetry::KeyValue::new( + "openshell.gateway.name", + name.to_string(), + )] + }) + .unwrap_or_default(), + } })) } @@ -115,6 +129,7 @@ mod tests { struct Received { spans: Vec, service_names: Vec, + gateway_names: Vec, } #[derive(Clone)] @@ -133,18 +148,19 @@ mod tests { let mut received = self.received.lock().unwrap(); for resource_span in request.into_inner().resource_spans { if let Some(resource) = resource_span.resource { - received.service_names.extend( - resource - .attributes - .into_iter() - .filter(|attribute| attribute.key == "service.name") - .filter_map(|attribute| attribute.value) - .filter_map(|value| value.value) - .filter_map(|value| match value { - opentelemetry_proto::tonic::common::v1::any_value::Value::StringValue(value) => Some(value), - _ => None, - }), - ); + for attribute in resource.attributes { + let Some(value) = attribute.value.and_then(|value| value.value) else { + continue; + }; + let opentelemetry_proto::tonic::common::v1::any_value::Value::StringValue(value) = value else { + continue; + }; + match attribute.key.as_str() { + "service.name" => received.service_names.push(value), + "openshell.gateway.name" => received.gateway_names.push(value), + _ => {} + } + } } for scope_span in resource_span.scope_spans { received.spans.extend(scope_span.spans); @@ -204,7 +220,7 @@ mod tests { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn vm_driver_spans_reach_otlp_collector_with_distinct_service_name() { + async fn vm_driver_spans_reach_otlp_collector_with_resource_identity() { let received = Arc::new(Mutex::new(Received::default())); let exported = Arc::new(tokio::sync::Notify::new()); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); @@ -226,7 +242,10 @@ mod tests { .await }); - let (provider, error) = super::provider_for(Some(&format!("http://{address}"))); + let (provider, error) = super::provider_for( + Some(&format!("http://{address}")), + Some("production-us-west"), + ); assert!(error.is_none(), "valid OTLP endpoint should configure"); let provider = provider.expect("provider"); let subscriber = tracing_subscriber::registry().with(super::layer(&provider)); @@ -265,5 +284,6 @@ mod tests { "VM spans should use a distinct service name, got {:?}", received.service_names ); + assert_eq!(received.gateway_names, ["production-us-west"]); } } diff --git a/crates/openshell-server/src/cli.rs b/crates/openshell-server/src/cli.rs index 39e80fe01..841b7fb3e 100644 --- a/crates/openshell-server/src/cli.rs +++ b/crates/openshell-server/src/cli.rs @@ -17,7 +17,9 @@ use crate::certgen; use crate::compute::driver_config::GuestTlsPaths; use crate::config_file::{self, ConfigFile, GatewayFileSection}; use crate::defaults::{self, LocalTlsPaths}; -use crate::{ServerStartupConfig, run_server, tracing_bus::TracingLogBus}; +use crate::{ + ServerStartupConfig, configured_compute_driver_name, run_server, tracing_bus::TracingLogBus, +}; /// `OpenShell` gateway process - gRPC and HTTP server with protocol multiplexing. /// @@ -490,11 +492,17 @@ async fn run_from_args(mut args: RunArgs, matches: ArgMatches) -> Result<()> { .config_file .as_ref() .and_then(|f| f.openshell.gateway.otlp.as_ref()); + let compute_driver_name = configured_compute_driver_name(&prepared)?; + let gateway_resource = crate::otel_tracing::GatewayResourceAttributes::new( + Some(prepared.config.name.as_str()), + Some(compute_driver_name.as_str()), + ); let (tracing_handle, setup_error) = crate::tracing_setup::install( EnvFilter::try_from_default_env() .unwrap_or_else(|_| EnvFilter::new(&prepared.config.log_level)), &tracing_log_bus, otlp_config, + gateway_resource, ); let has_client_ca = prepared diff --git a/crates/openshell-server/src/compute/vm.rs b/crates/openshell-server/src/compute/vm.rs index 80b445d20..6a66fc8aa 100644 --- a/crates/openshell-server/src/compute/vm.rs +++ b/crates/openshell-server/src/compute/vm.rs @@ -478,7 +478,7 @@ pub async fn spawn( .arg("--expected-peer-pid") .arg(std::process::id().to_string()); command.arg("--log-level").arg(&config.log_level); - append_otlp_args(&mut command, otlp_config); + append_otlp_args(&mut command, otlp_config, &config.name); command .arg("--openshell-endpoint") .arg(&vm_config.grpc_endpoint); @@ -521,9 +521,10 @@ pub async fn spawn( } #[cfg(unix)] -fn append_otlp_args(command: &mut Command, otlp_config: Option<&OtlpConfig>) { +fn append_otlp_args(command: &mut Command, otlp_config: Option<&OtlpConfig>, gateway_name: &str) { if let Some(config) = otlp_config { command.arg("--otlp-endpoint").arg(&config.endpoint); + command.arg("--gateway-name").arg(gateway_name); } } @@ -621,7 +622,7 @@ mod tests { use tempfile::tempdir; #[test] - fn vm_driver_command_includes_gateway_otlp_endpoint() { + fn vm_driver_command_includes_gateway_otlp_configuration() { let mut command = tokio::process::Command::new("openshell-driver-vm"); append_otlp_args( &mut command, @@ -629,6 +630,7 @@ mod tests { endpoint: "http://collector.internal:4317".to_string(), service_name: Some("custom-gateway".to_string()), }), + "production-us-west", ); let args = command @@ -636,7 +638,15 @@ mod tests { .get_args() .map(|arg| arg.to_string_lossy().into_owned()) .collect::>(); - assert_eq!(args, ["--otlp-endpoint", "http://collector.internal:4317"]); + assert_eq!( + args, + [ + "--otlp-endpoint", + "http://collector.internal:4317", + "--gateway-name", + "production-us-west" + ] + ); } #[tokio::test] diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index a6031a9bc..591f48100 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -583,13 +583,8 @@ pub(crate) async fn run_server( let sandbox_index = SandboxIndex::new(); let sandbox_watch_bus = SandboxWatchBus::new(); let supervisor_sessions = Arc::new(supervisor_session::SupervisorSessionRegistry::new()); - let driver_startup = compute::driver_config::DriverStartupContext { - file: config_file.as_ref(), - guest_tls: guest_tls.as_ref(), - gateway_port: config.bind_address.port(), - gateway_tls_enabled: config.tls.is_some(), - endpoint_overrides: &config.compute_driver_endpoints, - }; + let driver_startup = + compute_driver_startup_context(&config, config_file.as_ref(), guest_tls.as_ref()); let (compute, operator_allowlist) = build_compute_runtime( &config, driver_startup, @@ -1205,6 +1200,30 @@ async fn build_compute_runtime( Ok((runtime, operator_allowlist)) } +pub(crate) fn configured_compute_driver_name(startup: &ServerStartupConfig) -> Result { + let driver_startup = compute_driver_startup_context( + &startup.config, + startup.config_file.as_ref(), + startup.guest_tls.as_ref(), + ); + configured_compute_driver(&startup.config, driver_startup) + .map(|driver| driver.name().to_string()) +} + +fn compute_driver_startup_context<'a>( + config: &'a Config, + config_file: Option<&'a config_file::ConfigFile>, + guest_tls: Option<&'a compute::driver_config::GuestTlsPaths>, +) -> compute::driver_config::DriverStartupContext<'a> { + compute::driver_config::DriverStartupContext { + file: config_file, + guest_tls, + gateway_port: config.bind_address.port(), + gateway_tls_enabled: config.tls.is_some(), + endpoint_overrides: &config.compute_driver_endpoints, + } +} + #[derive(Debug, Clone)] enum ConfiguredComputeDriver { Builtin(ComputeDriverKind), @@ -1347,7 +1366,7 @@ mod tests { BoundGatewayListener, ConfiguredComputeDriver, ConnectionProtocol, ExtensionKind, GatewayListenerScope, MultiplexService, ServerState, TlsAcceptor, allow_plaintext_service_http, bind_gateway_listeners, classify_initial_bytes, - configured_compute_driver, is_benign_tls_handshake_failure, + configured_compute_driver, configured_compute_driver_name, is_benign_tls_handshake_failure, kubernetes_sandbox_jwt_expiry_disabled, mint_gateway_extension_credential, serve_gateway_listener, }; @@ -1860,6 +1879,17 @@ mod tests { )); } + #[test] + fn configured_compute_driver_name_uses_effective_driver_resolution() { + let startup = crate::ServerStartupConfig { + config: Config::new(None).with_compute_drivers([ComputeDriverKind::Podman]), + config_file: None, + guest_tls: None, + }; + + assert_eq!(configured_compute_driver_name(&startup).unwrap(), "podman"); + } + #[test] fn configured_compute_driver_resolves_named_remote() { let config = Config::new(None).with_compute_drivers(["kyma"]); diff --git a/crates/openshell-server/src/otel_tracing.rs b/crates/openshell-server/src/otel_tracing.rs index cbe23f423..d09de7203 100644 --- a/crates/openshell-server/src/otel_tracing.rs +++ b/crates/openshell-server/src/otel_tracing.rs @@ -37,7 +37,26 @@ const DEFAULT_SERVICE_NAME: &str = "openshell-gateway"; /// Instrumentation scope recorded on spans this gateway emits. const INSTRUMENTATION_SCOPE: &str = "openshell-gateway"; -fn trace_config(cfg: &OtlpConfig) -> OtlpTraceConfig<'_> { +/// Gateway identity recorded on every exported span. +#[derive(Debug, Clone, Copy, Default)] +pub struct GatewayResourceAttributes<'a> { + name: Option<&'a str>, + compute_driver: Option<&'a str>, +} + +impl<'a> GatewayResourceAttributes<'a> { + pub fn new(name: Option<&'a str>, compute_driver: Option<&'a str>) -> Self { + Self { + name, + compute_driver, + } + } +} + +fn trace_config<'cfg>( + cfg: &'cfg OtlpConfig, + gateway: GatewayResourceAttributes<'_>, +) -> OtlpTraceConfig<'cfg> { let service_name = cfg .service_name .as_deref() @@ -48,17 +67,35 @@ fn trace_config(cfg: &OtlpConfig) -> OtlpTraceConfig<'_> { ServiceName::Fixed, ); + let mut resource_attributes = Vec::new(); + if let Some(name) = gateway.name.map(str::trim).filter(|s| !s.is_empty()) { + resource_attributes.push(opentelemetry::KeyValue::new( + "openshell.gateway.name", + name.to_string(), + )); + } + if let Some(compute_driver) = gateway + .compute_driver + .map(str::trim) + .filter(|s| !s.is_empty()) + { + resource_attributes.push(opentelemetry::KeyValue::new( + "openshell.gateway.compute_driver", + compute_driver.to_string(), + )); + } + OtlpTraceConfig { endpoint: &cfg.endpoint, service_name, service_version: Some(openshell_core::VERSION), - resource_attributes: Vec::new(), + resource_attributes, } } #[cfg(test)] -fn build_resource(cfg: &OtlpConfig) -> Resource { - openshell_otel::resource_for(&trace_config(cfg)) +fn build_resource(cfg: &OtlpConfig, gateway: GatewayResourceAttributes<'_>) -> Resource { + openshell_otel::resource_for(&trace_config(cfg, gateway)) } /// Build a tracer provider exporting over OTLP/gRPC to the configured endpoint. @@ -70,8 +107,11 @@ fn build_resource(cfg: &OtlpConfig) -> Resource { /// The sampler and span limits are left at the SDK's defaults, which are /// themselves resolved from `OTEL_*` env vars (see the module docs). #[cfg(test)] -fn build_provider(cfg: &OtlpConfig) -> Result { - openshell_otel::build_provider(&trace_config(cfg)) +fn build_provider( + cfg: &OtlpConfig, + gateway: GatewayResourceAttributes<'_>, +) -> Result { + openshell_otel::build_provider(&trace_config(cfg, gateway)) } /// Resolve the tracer provider for a gateway config file's optional @@ -82,8 +122,11 @@ fn build_provider(cfg: &OtlpConfig) -> Result { /// /// The error is returned rather than logged because the provider is built /// before the subscriber it attaches to, so logging here would go nowhere. -pub fn provider_for(cfg: Option<&OtlpConfig>) -> (Option, Option) { - openshell_otel::provider_for(cfg.map(trace_config)) +pub fn provider_for( + cfg: Option<&OtlpConfig>, + gateway: GatewayResourceAttributes<'_>, +) -> (Option, Option) { + openshell_otel::provider_for(cfg.map(|cfg| trace_config(cfg, gateway))) } /// Build the `tracing` layer that forwards spans to `provider`. @@ -253,6 +296,10 @@ mod tests { } } + fn build_test_resource(cfg: &OtlpConfig) -> Resource { + build_resource(cfg, GatewayResourceAttributes::default()) + } + #[test] fn resource_defaults_the_service_name() { let _lock = crate::TEST_ENV_LOCK @@ -261,7 +308,7 @@ mod tests { let _env = EnvVarGuard::remove("OTEL_SERVICE_NAME"); assert_eq!( - service_name_of(&build_resource(&config())), + service_name_of(&build_test_resource(&config())), Some(DEFAULT_SERVICE_NAME.to_string()) ); } @@ -270,7 +317,7 @@ mod tests { fn resource_honors_configured_service_name_and_carries_version() { let mut cfg = config(); cfg.service_name = Some("gateway-staging".into()); - let resource = build_resource(&cfg); + let resource = build_test_resource(&cfg); assert_eq!( resource @@ -286,6 +333,31 @@ mod tests { ); } + #[test] + fn resource_carries_gateway_name_and_compute_driver() { + let resource = build_resource( + &config(), + GatewayResourceAttributes::new(Some("vm-dev"), Some("vm")), + ); + + assert_eq!( + resource + .get(&opentelemetry::Key::from_static_str( + "openshell.gateway.name", + )) + .map(|v| v.to_string()), + Some("vm-dev".to_string()) + ); + assert_eq!( + resource + .get(&opentelemetry::Key::from_static_str( + "openshell.gateway.compute_driver", + )) + .map(|v| v.to_string()), + Some("vm".to_string()) + ); + } + struct EnvVarGuard { key: &'static str, original: Option, @@ -340,7 +412,7 @@ mod tests { cfg.service_name = Some("from-config".into()); assert_eq!( - service_name_of(&build_resource(&cfg)), + service_name_of(&build_test_resource(&cfg)), Some("from-config".to_string()) ); } @@ -355,7 +427,7 @@ mod tests { let _env = EnvVarGuard::set("OTEL_SERVICE_NAME", "from-env"); assert_eq!( - service_name_of(&build_resource(&config())), + service_name_of(&build_test_resource(&config())), Some("from-env".to_string()) ); } @@ -370,7 +442,7 @@ mod tests { let mut cfg = config(); cfg.service_name = Some(" ".into()); assert_eq!( - service_name_of(&build_resource(&cfg)), + service_name_of(&build_test_resource(&cfg)), Some(DEFAULT_SERVICE_NAME.to_string()) ); } @@ -379,7 +451,8 @@ mod tests { fn provider_rejects_a_malformed_endpoint() { let mut cfg = config(); cfg.endpoint = "definitely not a url".into(); - let err = build_provider(&cfg).expect_err("malformed endpoint"); + let err = build_provider(&cfg, GatewayResourceAttributes::default()) + .expect_err("malformed endpoint"); assert!( err.to_string().contains("definitely not a url"), "error names the offending endpoint: {err}" @@ -390,7 +463,10 @@ mod tests { fn provider_rejects_an_empty_endpoint() { let mut cfg = config(); cfg.endpoint = " ".into(); - assert!(build_provider(&cfg).is_err(), "empty endpoint is rejected"); + assert!( + build_provider(&cfg, GatewayResourceAttributes::default()).is_err(), + "empty endpoint is rejected" + ); } #[tokio::test] @@ -398,7 +474,8 @@ mod tests { // The OTLP batch exporter connects lazily, so a valid endpoint must // build even when nothing is listening — the gateway must not fail to // start because its collector is down. - let provider = build_provider(&config()).expect("provider builds"); + let provider = build_provider(&config(), GatewayResourceAttributes::default()) + .expect("provider builds"); provider.shutdown().ok(); } @@ -407,7 +484,7 @@ mod tests { /// error for the caller to log — see the misconfigured-endpoint test. #[tokio::test] async fn absent_otlp_table_disables_export() { - let (provider, err) = provider_for(None); + let (provider, err) = provider_for(None, GatewayResourceAttributes::default()); assert!(provider.is_none(), "export is off"); assert!( err.is_none(), @@ -417,7 +494,7 @@ mod tests { #[tokio::test] async fn present_otlp_table_enables_export() { - let (provider, err) = provider_for(Some(&config())); + let (provider, err) = provider_for(Some(&config()), GatewayResourceAttributes::default()); assert!(err.is_none()); provider.expect("provider is present").shutdown().ok(); } @@ -430,7 +507,7 @@ mod tests { let mut cfg = config(); cfg.endpoint = "definitely not a url".into(); - let (provider, err) = provider_for(Some(&cfg)); + let (provider, err) = provider_for(Some(&cfg), GatewayResourceAttributes::default()); assert!( provider.is_none(), "a bad endpoint degrades to no export rather than failing startup" diff --git a/crates/openshell-server/src/tracing_setup.rs b/crates/openshell-server/src/tracing_setup.rs index 321edefaf..e440bbd3b 100644 --- a/crates/openshell-server/src/tracing_setup.rs +++ b/crates/openshell-server/src/tracing_setup.rs @@ -12,7 +12,7 @@ use tracing_subscriber::EnvFilter; use tracing_subscriber::prelude::*; use crate::config_file::OtlpConfig; -use crate::otel_tracing::SetupError; +use crate::otel_tracing::{GatewayResourceAttributes, SetupError}; use crate::tracing_bus::TracingLogBus; pub struct TracingHandle { @@ -33,8 +33,9 @@ pub fn install( env_filter: EnvFilter, tracing_log_bus: &TracingLogBus, otlp_config: Option<&OtlpConfig>, + gateway: GatewayResourceAttributes<'_>, ) -> (TracingHandle, Option) { - let (tracer_provider, setup_error) = crate::otel_tracing::provider_for(otlp_config); + let (tracer_provider, setup_error) = crate::otel_tracing::provider_for(otlp_config, gateway); tracing_subscriber::registry() .with(env_filter) diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index 36ed77b1a..95697f6b9 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -231,11 +231,11 @@ The transport is **OTLP over gRPC only**. HTTP/protobuf and HTTP/JSON are not su The OpenTelemetry SDK logs export failures after startup. Spans in a failed batch are dropped rather than retried. -`service_name` sets the gateway's `service.name` resource attribute and defaults to `openshell-gateway`. The gateway also reports `service.version`. +`service_name` sets the gateway's `service.name` resource attribute and defaults to `openshell-gateway`. The gateway also reports `service.version`, `openshell.gateway.name` from the gateway's configured `name`, and `openshell.gateway.compute_driver`. Only OpenTelemetry traces are exported. Inbound gRPC and HTTP requests produce server spans named for the RPC or HTTP method. Store and compute-driver operations appear as child spans. Internal reconciliation, credential-refresh, and driver-watch loops create operation roots for their store work because no inbound request supplies a parent. The gateway continues valid W3C `traceparent` context and starts a new trace when none is supplied. Request spans carry `method`, `path`, and the `request_id` that also appears in gateway logs. Health endpoint spans use DEBUG level and are not exported by the default INFO filter. -The gateway forwards the OTLP configuration to managed external drivers. Each driver exports under its own service name. +The gateway forwards the OTLP configuration and configured gateway name to managed external drivers. Each driver exports under its own service name while carrying the same `openshell.gateway.name` resource attribute. Operator-run external drivers own their own telemetry configuration. ### Tuning