Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ These pipelines connect skills into end-to-end workflows. Individual skill files
| `crates/openshell-bootstrap/` | Gateway metadata | Gateway registration metadata, auth token storage, mTLS bundle storage |
| `crates/openshell-gateway-interceptors/` | Gateway interceptors | Intercepts and transforms configured gRPC requests at the gateway routing boundary |
| `crates/openshell-ocsf/` | OCSF logging | OCSF v1.7.0 event types, builders, shorthand/JSONL formatters, tracing layers |
| `crates/openshell-otel/` | OpenTelemetry support | Shared OTLP trace provider, resource, and tracing-layer construction |
| `crates/openshell-core/` | Shared core | Common types, configuration, error handling |
| `crates/openshell-sdk/` | Shared client SDK | Async Rust gateway client (gRPC transport, TLS, OIDC refresh, edge tunnel); consumed by CLI, TUI, and `@openshell/sdk` |
| `crates/openshell-providers/` | Provider management | Credential provider backends |
Expand Down
17 changes: 16 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion architecture/gateway.md
Original file line number Diff line number Diff line change
Expand Up @@ -615,7 +615,8 @@ replace the existing logging paths.
export: the table's presence is the on-switch, and `OTEL_EXPORTER_OTLP_ENDPOINT`
is ignored so enablement has a single source. TOML decides whether and where
to export; the SDK's `OTEL_*` variables tune how. Transport is OTLP over gRPC
only.
only. Shared provider, resource, and tracing-layer construction lives in
`openshell-otel`.

Span emission requires no per-handler instrumentation. The `tower_http`
`TraceLayer` in `multiplex.rs` opens a span per inbound request, and that span
Expand Down
27 changes: 27 additions & 0 deletions crates/openshell-otel/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

[package]
name = "openshell-otel"
description = "Shared OpenTelemetry trace export support for OpenShell services"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true

[dependencies]
http = { workspace = true }
opentelemetry = { workspace = true }
opentelemetry_sdk = { workspace = true }
opentelemetry-otlp = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
tracing-opentelemetry = { workspace = true }
tracing-subscriber = { workspace = true }

[dev-dependencies]
tokio = { workspace = true }

[lints]
workspace = true
198 changes: 198 additions & 0 deletions crates/openshell-otel/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

//! Shared OpenTelemetry trace export support for `OpenShell` services.

use opentelemetry::KeyValue;
use opentelemetry::trace::TracerProvider as _;
use opentelemetry_otlp::{SpanExporter, WithExportConfig};
use opentelemetry_sdk::Resource;
use opentelemetry_sdk::trace::SdkTracer;
pub use opentelemetry_sdk::trace::SdkTracerProvider;
use tracing::Subscriber;
use tracing_opentelemetry::OpenTelemetryLayer;
use tracing_subscriber::Layer as _;
use tracing_subscriber::registry::LookupSpan;

const SDK_UNKNOWN_SERVICE_PREFIX: &str = "unknown_service";

/// How a process chooses its OpenTelemetry `service.name`.
#[derive(Debug, Clone, Copy)]
pub enum ServiceName<'a> {
/// Always use this name, overriding `OTEL_SERVICE_NAME`.
Fixed(&'a str),
/// Use `OTEL_SERVICE_NAME` when set, otherwise use this default.
EnvironmentOr(&'a str),
}

/// Inputs for an OTLP/gRPC trace provider.
#[derive(Debug, Clone)]
pub struct OtlpTraceConfig<'a> {
pub endpoint: &'a str,
pub service_name: ServiceName<'a>,
pub service_version: Option<&'a str>,
pub resource_attributes: Vec<KeyValue>,
}

/// Failure to construct an OTLP trace provider.
#[derive(Debug, thiserror::Error)]
pub enum SetupError {
#[error("OTLP endpoint is empty")]
EmptyEndpoint,

#[error("invalid OTLP endpoint {endpoint:?}: {source}")]
InvalidEndpoint {
endpoint: String,
source: http::uri::InvalidUri,
},

#[error("failed to build the OTLP span exporter: {0}")]
Exporter(#[from] opentelemetry_otlp::ExporterBuildError),
}

fn resource_attributes(config: &OtlpTraceConfig<'_>) -> Vec<KeyValue> {
let mut attributes = config.resource_attributes.clone();
if let Some(version) = config
.service_version
.map(str::trim)
.filter(|version| !version.is_empty())
{
attributes.push(KeyValue::new("service.version", version.to_string()));
}
attributes
}

/// Build the OpenTelemetry resource for a trace provider configuration.
#[must_use]
pub fn resource_for(config: &OtlpTraceConfig<'_>) -> Resource {
let attributes = resource_attributes(config);
match config.service_name {
ServiceName::Fixed(name) => Resource::builder()
.with_service_name(name.trim().to_string())
.with_attributes(attributes)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is an ultra-nit:

[P2] Fixed service names can be overwritten by generic attributes. In crates/openshell-otel/src/lib.rs (line 70), with_service_name() runs before with_attributes(). OpenTelemetry resource merging gives later attributes priority, so a caller-supplied service.name silently defeats ServiceName::Fixed. The EnvironmentOr path has the same ambiguity because caller attributes participate in detecting the selected name. Apply generic attributes first, then enforce the selected service-name policy, and add collision tests.

I guess the only concern here would be if an agent accidentally decided to overwrite service.name, that would be possible because with_attributes comes after with_service_name. meh ¯_(ツ)_/¯

.build(),
ServiceName::EnvironmentOr(default) => {
let detected = Resource::builder()
.with_attributes(attributes.clone())
.build();
if detected
.get(&opentelemetry::Key::from_static_str("service.name"))
.is_some_and(|value| !value.to_string().starts_with(SDK_UNKNOWN_SERVICE_PREFIX))
{
detected
} else {
Resource::builder()
.with_service_name(default.trim().to_string())
.with_attributes(attributes)
.build()
}
}
}
}

/// Build an OTLP/gRPC trace provider.
pub fn build_provider(config: &OtlpTraceConfig<'_>) -> Result<SdkTracerProvider, SetupError> {
let endpoint = config.endpoint.trim();
if endpoint.is_empty() {
return Err(SetupError::EmptyEndpoint);
}
endpoint
.parse::<http::Uri>()
.map_err(|source| SetupError::InvalidEndpoint {
endpoint: endpoint.to_string(),
source,
})?;

let exporter = SpanExporter::builder()
.with_tonic()
.with_endpoint(endpoint)
.build()?;

Ok(SdkTracerProvider::builder()
.with_batch_exporter(exporter)
.with_resource(resource_for(config))
.build())
}

/// Build the provider for an optional OTLP configuration.
///
/// Telemetry setup failures disable export and remain available for the caller
/// to report after its tracing subscriber is installed.
#[must_use]
pub fn provider_for(
config: Option<OtlpTraceConfig<'_>>,
) -> (Option<SdkTracerProvider>, Option<SetupError>) {
match config.as_ref().map(build_provider) {
None => (None, None),
Some(Ok(provider)) => (Some(provider), None),
Some(Err(error)) => (None, Some(error)),
}
}

/// Filtered OpenTelemetry layer returned by [`layer`].
pub type OtlpLayer<S> = tracing_subscriber::filter::Filtered<
OpenTelemetryLayer<S, SdkTracer>,
tracing_subscriber::filter::FilterFn,
S,
>;

/// Build a tracing layer that exports spans and excludes exporter callsites.
pub fn layer<S>(provider: &SdkTracerProvider, instrumentation_scope: &'static str) -> OtlpLayer<S>
where
S: Subscriber + for<'span> LookupSpan<'span>,
{
tracing_opentelemetry::layer()
.with_tracer(provider.tracer(instrumentation_scope))
.with_filter(tracing_subscriber::filter::filter_fn(|metadata| {
metadata.is_span() && !metadata.target().starts_with("opentelemetry")
}))
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn resource_uses_fixed_service_identity_and_custom_attributes() {
let resource = resource_for(&OtlpTraceConfig {
endpoint: "http://127.0.0.1:4317",
service_name: ServiceName::Fixed("openshell-driver-vm"),
service_version: Some("1.2.3"),
resource_attributes: vec![KeyValue::new("openshell.gateway.name", "vm-dev")],
});

assert_eq!(
resource
.get(&opentelemetry::Key::from_static_str("service.name"))
.map(|value| value.to_string()),
Some("openshell-driver-vm".to_string())
);
assert_eq!(
resource
.get(&opentelemetry::Key::from_static_str("service.version"))
.map(|value| value.to_string()),
Some("1.2.3".to_string())
);
assert_eq!(
resource
.get(&opentelemetry::Key::from_static_str(
"openshell.gateway.name",
))
.map(|value| value.to_string()),
Some("vm-dev".to_string())
);
}

#[tokio::test]
async fn malformed_endpoint_disables_export_with_a_reportable_error() {
let (provider, error) = provider_for(Some(OtlpTraceConfig {
endpoint: "definitely not a url",
service_name: ServiceName::Fixed("test-service"),
service_version: None,
resource_attributes: Vec::new(),
}));

assert!(provider.is_none());
assert!(matches!(error, Some(SetupError::InvalidEndpoint { .. })));
}
}
2 changes: 1 addition & 1 deletion crates/openshell-server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ openshell-driver-kubernetes = { path = "../openshell-driver-kubernetes" }
openshell-driver-podman = { path = "../openshell-driver-podman" }
openshell-gateway-interceptors = { path = "../openshell-gateway-interceptors" }
openshell-ocsf = { path = "../openshell-ocsf" }
openshell-otel = { path = "../openshell-otel" }
openshell-policy = { path = "../openshell-policy" }
openshell-prover = { path = "../openshell-prover" }
openshell-providers = { path = "../openshell-providers" }
Expand Down Expand Up @@ -72,7 +73,6 @@ tracing-subscriber = { workspace = true }
# OpenTelemetry (OTLP trace export, opt-in via [openshell.gateway.otlp])
opentelemetry = { workspace = true }
opentelemetry_sdk = { workspace = true }
opentelemetry-otlp = { workspace = true }
tracing-opentelemetry = { workspace = true }

# Metrics
Expand Down
Loading
Loading