Skip to content

Commit 07fd238

Browse files
authored
feat(server): export gateway OCSF events as OTLP log records (#4500)
The gateway only persisted its OCSF events to the JSONL file, so collectors had to tail that file. Adding `[openshell.gateway.otlp.ocsf]` now also exports each gateway OCSF event as an OTLP log record, using the same resource identity as gateway traces. `schema_version` optionally downgrades the exported JSON to OCSF 1.1 or 1.3. OTLP endpoints are now configured per signal, so OCSF events can go to a collector that feeds a SIEM while spans go elsewhere. The existing `[openshell.gateway.otlp]` `endpoint` is the default for every signal and on its own still enables trace export. `[otlp.traces]` overrides the trace endpoint, and `[otlp.ocsf]` takes its own `endpoint` or falls back to the default. Each record carries the OCSF JSON as its body, the event time as its timestamp, a severity mapped from `severity_id`, and `ocsf.*` and `openshell.sandbox.id` attributes for filtering without parsing the body. Records flow through a queue bounded by count and bytes to the SDK OTLP log exporter in batches bounded the same way. Failed exports retry with backoff; records lost to a full queue, an oversized body, or exhausted retries are counted in metrics and reported to the collector in an `openshell.ocsf.gap` record on the next successful export. The exporter lives in `openshell-otel` so other processes can share it. Helm deployments set the `traces` and `ocsf` tables through `gatewayConfig`, and the local development gateway tasks enable OCSF export. Closes #4499 Refs #2892 Signed-off-by: Kris Hicks <khicks@nvidia.com>
1 parent 36ee06f commit 07fd238

28 files changed

Lines changed: 1753 additions & 189 deletions

File tree

‎Cargo.lock‎

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

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

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ use openshell_core::telemetry::TelemetryComputeDriver;
4141
))]
4242
use openshell_server::ComputeDriverRegistration;
4343
use openshell_server::ComputeDriverRegistry;
44+
use openshell_server::config_file::OtlpConfig;
4445

4546
#[cfg(all(
4647
not(target_os = "windows"),
@@ -265,7 +266,10 @@ impl openshell_server::ComputeDriverFactory for KubernetesFactory {
265266
context: openshell_server::ComputeDriverBuildContext<'_>,
266267
) -> openshell_core::Result<openshell_server::ComputeDriverInstance> {
267268
let mut config = kubernetes_config(context.config_context())?;
268-
config.supervisor_otlp_endpoint = context.otlp_config().map(|otlp| otlp.endpoint.clone());
269+
config.supervisor_otlp_endpoint = context
270+
.otlp_config()
271+
.and_then(OtlpConfig::traces_endpoint)
272+
.map(str::to_string);
269273
let driver = openshell_driver_kubernetes::KubernetesComputeDriver::new(
270274
config,
271275
context.shutdown_receiver(),
@@ -326,7 +330,10 @@ impl openshell_server::ComputeDriverFactory for DockerFactory {
326330
context: openshell_server::ComputeDriverBuildContext<'_>,
327331
) -> openshell_core::Result<openshell_server::ComputeDriverInstance> {
328332
let mut config = docker_config(context.config_context())?;
329-
config.supervisor_otlp_endpoint = context.otlp_config().map(|otlp| otlp.endpoint.clone());
333+
config.supervisor_otlp_endpoint = context
334+
.otlp_config()
335+
.and_then(OtlpConfig::traces_endpoint)
336+
.map(str::to_string);
330337
require_guest_tls_for_local_driver(&context, "docker")?;
331338
apply_guest_tls(&mut config.guest_tls_ca, context.guest_tls_ca());
332339
let driver = openshell_driver_docker::DockerComputeDriver::new(
@@ -388,7 +395,10 @@ impl openshell_server::ComputeDriverFactory for PodmanFactory {
388395
context: openshell_server::ComputeDriverBuildContext<'_>,
389396
) -> openshell_core::Result<openshell_server::ComputeDriverInstance> {
390397
let mut config = podman_config(context.config_context())?;
391-
config.supervisor_otlp_endpoint = context.otlp_config().map(|otlp| otlp.endpoint.clone());
398+
config.supervisor_otlp_endpoint = context
399+
.otlp_config()
400+
.and_then(OtlpConfig::traces_endpoint)
401+
.map(str::to_string);
392402
require_guest_tls_for_local_driver(&context, "podman")?;
393403
apply_guest_tls(&mut config.guest_tls_ca, context.guest_tls_ca());
394404
let driver = openshell_driver_podman::PodmanComputeDriver::new(config)

‎crates/openshell-gateway/src/vm.rs‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -660,8 +660,8 @@ fn append_vm_proxy_and_spiffe_args(command: &mut Command, config: &VmComputeConf
660660
}
661661

662662
fn append_otlp_args(command: &mut Command, otlp_config: Option<&OtlpConfig>, gateway_name: &str) {
663-
if let Some(config) = otlp_config {
664-
command.arg("--otlp-endpoint").arg(&config.endpoint);
663+
if let Some(endpoint) = otlp_config.and_then(OtlpConfig::traces_endpoint) {
664+
command.arg("--otlp-endpoint").arg(endpoint);
665665
command.arg("--gateway-name").arg(gateway_name);
666666
}
667667
}
@@ -760,7 +760,7 @@ mod tests {
760760
validate_vm_sandbox_identity,
761761
};
762762
use openshell_core::UpstreamProxyConfig;
763-
use openshell_server::config_file::OtlpConfig;
763+
use openshell_server::config_file::{OtlpConfig, OtlpTracesConfig};
764764
use std::os::unix::fs::PermissionsExt;
765765
use std::os::unix::net::UnixListener as StdUnixListener;
766766
use std::path::PathBuf;
@@ -808,8 +808,11 @@ mod tests {
808808
append_otlp_args(
809809
&mut command,
810810
Some(&OtlpConfig {
811-
endpoint: "http://collector.internal:4317".to_string(),
812811
service_name: Some("custom-gateway".to_string()),
812+
traces: Some(OtlpTracesConfig {
813+
endpoint: "http://collector.internal:4317".to_string(),
814+
}),
815+
ocsf: None,
813816
}),
814817
"production-us-west",
815818
);

‎crates/openshell-ocsf/src/format/downgrade.rs‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,41 @@
66
//! Transforms serialized OCSF JSON events to conform to older schema versions
77
//! by stripping fields and profiles that don't exist in the target version.
88
9+
use serde::{Deserialize, Serialize};
910
use serde_json::Value;
1011

12+
/// An older OCSF schema version that events can be downgraded to.
13+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14+
pub enum OcsfSchemaVersion {
15+
#[serde(rename = "1.1")]
16+
V1_1,
17+
#[serde(rename = "1.3")]
18+
V1_3,
19+
}
20+
21+
impl OcsfSchemaVersion {
22+
pub const fn as_str(self) -> &'static str {
23+
match self {
24+
Self::V1_1 => "1.1",
25+
Self::V1_3 => "1.3",
26+
}
27+
}
28+
}
29+
30+
impl std::str::FromStr for OcsfSchemaVersion {
31+
type Err = String;
32+
33+
fn from_str(version: &str) -> Result<Self, Self::Err> {
34+
match version {
35+
"1.1" => Ok(Self::V1_1),
36+
"1.3" => Ok(Self::V1_3),
37+
other => Err(format!(
38+
"unsupported OCSF schema version {other:?}; expected 1.1 or 1.3"
39+
)),
40+
}
41+
}
42+
}
43+
1144
/// Fields to strip when downgrading to v1.3.0 or earlier.
1245
const STRIP_FOR_V1_3: &[&str] = &["ai_model", "container", "observation_point_id"];
1346

@@ -96,6 +129,15 @@ fn parse_version(v: &str) -> (u32, u32, u32) {
96129
mod tests {
97130
use super::*;
98131

132+
#[test]
133+
fn schema_versions_parse_from_their_names() {
134+
for version in [OcsfSchemaVersion::V1_1, OcsfSchemaVersion::V1_3] {
135+
assert_eq!(version.as_str().parse(), Ok(version));
136+
}
137+
assert!("1.6".parse::<OcsfSchemaVersion>().is_err());
138+
assert!("".parse::<OcsfSchemaVersion>().is_err());
139+
}
140+
99141
fn test_event() -> Value {
100142
serde_json::json!({
101143
"class_uid": 4002,

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ pub use objects::{
5959
// --- Sanitizers ---
6060
pub use sanitize::sanitize_reason_for_audit;
6161

62+
pub use format::downgrade::OcsfSchemaVersion;
63+
6264
// --- Builders ---
6365
pub use builders::{
6466
ApiActivityBuilder, AppLifecycleBuilder, BaseEventBuilder, ConfigStateChangeBuilder,

‎crates/openshell-otel-test-support/Cargo.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ repository.workspace = true
1212

1313
[dependencies]
1414
openshell-otel = { path = "../openshell-otel" }
15-
opentelemetry-proto = { version = "0.32", default-features = false, features = ["gen-tonic", "trace"] }
15+
opentelemetry-proto = { version = "0.32", default-features = false, features = ["gen-tonic", "logs", "trace"] }
1616
tokio = { workspace = true }
1717
tokio-stream = { workspace = true }
1818
tonic = { workspace = true, features = ["gzip", "tls-aws-lc"] }

‎crates/openshell-otel-test-support/src/lib.rs‎

Lines changed: 64 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,16 @@
66
use std::sync::{Arc, Mutex};
77
use std::time::Duration;
88

9+
use opentelemetry_proto::tonic::collector::logs::v1::{
10+
ExportLogsServiceRequest, ExportLogsServiceResponse,
11+
logs_service_server::{LogsService, LogsServiceServer},
12+
};
913
use opentelemetry_proto::tonic::collector::trace::v1::{
1014
ExportTraceServiceRequest, ExportTraceServiceResponse,
1115
trace_service_server::{TraceService, TraceServiceServer},
1216
};
17+
use opentelemetry_proto::tonic::common::v1::any_value::Value;
18+
use opentelemetry_proto::tonic::resource::v1::Resource;
1319
use opentelemetry_proto::tonic::trace::v1::Span;
1420

1521
/// Serialize tracing tests and keep their callsites enabled process-wide.
@@ -68,12 +74,34 @@ pub async fn assert_compute_driver_tracing(
6874
#[derive(Clone, Debug, Default)]
6975
pub struct ReceivedTraces {
7076
pub spans: Vec<Span>,
77+
/// String bodies of exported log records.
78+
pub log_bodies: Vec<String>,
7179
pub service_names: Vec<String>,
7280
pub gateway_names: Vec<String>,
7381
pub compute_drivers: Vec<String>,
7482
pub encodings: Vec<String>,
7583
}
7684

85+
impl ReceivedTraces {
86+
fn record_resource(&mut self, resource: Option<Resource>) {
87+
for attribute in resource
88+
.into_iter()
89+
.flat_map(|resource| resource.attributes)
90+
{
91+
let Some(Value::StringValue(value)) = attribute.value.and_then(|value| value.value)
92+
else {
93+
continue;
94+
};
95+
match attribute.key.as_str() {
96+
"service.name" => self.service_names.push(value),
97+
"openshell.gateway.name" => self.gateway_names.push(value),
98+
"openshell.gateway.compute_driver" => self.compute_drivers.push(value),
99+
_ => {}
100+
}
101+
}
102+
}
103+
}
104+
77105
#[derive(Clone)]
78106
struct Collector {
79107
received: Arc<Mutex<ReceivedTraces>>,
@@ -99,27 +127,7 @@ impl TraceService for Collector {
99127
received.encodings.push(encoding.to_string());
100128
}
101129
for resource_span in request.into_inner().resource_spans {
102-
if let Some(resource) = resource_span.resource {
103-
for attribute in resource.attributes {
104-
let Some(value) = attribute.value.and_then(|value| value.value) else {
105-
continue;
106-
};
107-
let opentelemetry_proto::tonic::common::v1::any_value::Value::StringValue(
108-
value,
109-
) = value
110-
else {
111-
continue;
112-
};
113-
match attribute.key.as_str() {
114-
"service.name" => received.service_names.push(value),
115-
"openshell.gateway.name" => received.gateway_names.push(value),
116-
"openshell.gateway.compute_driver" => {
117-
received.compute_drivers.push(value);
118-
}
119-
_ => {}
120-
}
121-
}
122-
}
130+
received.record_resource(resource_span.resource);
123131
for scope_span in resource_span.scope_spans {
124132
received.spans.extend(scope_span.spans);
125133
}
@@ -130,7 +138,36 @@ impl TraceService for Collector {
130138
}
131139
}
132140

133-
/// Loopback OTLP/gRPC server that captures exported spans and resources.
141+
#[tonic::async_trait]
142+
impl LogsService for Collector {
143+
async fn export(
144+
&self,
145+
request: tonic::Request<ExportLogsServiceRequest>,
146+
) -> Result<tonic::Response<ExportLogsServiceResponse>, tonic::Status> {
147+
{
148+
let mut received = self
149+
.received
150+
.lock()
151+
.unwrap_or_else(std::sync::PoisonError::into_inner);
152+
for resource_logs in request.into_inner().resource_logs {
153+
received.record_resource(resource_logs.resource);
154+
for scope_logs in resource_logs.scope_logs {
155+
for record in scope_logs.log_records {
156+
if let Some(Value::StringValue(body)) =
157+
record.body.and_then(|body| body.value)
158+
{
159+
received.log_bodies.push(body);
160+
}
161+
}
162+
}
163+
}
164+
}
165+
self.exported.notify_one();
166+
Ok(tonic::Response::new(ExportLogsServiceResponse::default()))
167+
}
168+
}
169+
170+
/// Loopback OTLP/gRPC server that captures exported spans, logs, and resources.
134171
pub struct OtlpTestServer {
135172
endpoint: String,
136173
received: Arc<Mutex<ReceivedTraces>>,
@@ -171,12 +208,15 @@ impl OtlpTestServer {
171208
.tls_config(tonic::transport::ServerTlsConfig::new().identity(identity))
172209
.expect("OTLP test collector should accept its TLS identity");
173210
}
174-
let service = TraceServiceServer::new(collector)
211+
let trace_service = TraceServiceServer::new(collector.clone())
212+
.accept_compressed(tonic::codec::CompressionEncoding::Gzip);
213+
let logs_service = LogsServiceServer::new(collector)
175214
.accept_compressed(tonic::codec::CompressionEncoding::Gzip);
176215
let (shutdown, shutdown_rx) = tokio::sync::oneshot::channel();
177216
let task = tokio::spawn(async move {
178217
server
179-
.add_service(service)
218+
.add_service(trace_service)
219+
.add_service(logs_service)
180220
.serve_with_incoming_shutdown(
181221
tokio_stream::wrappers::TcpListenerStream::new(listener),
182222
async {

‎crates/openshell-otel-test-support/tests/common/mod.rs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
//! Shared TLS export scenario for the one-test-per-binary TLS tests.
55
6-
use openshell_otel::{OtlpTraceConfig, ServiceName, provider_for};
6+
use openshell_otel::{OtlpExportConfig, ServiceName, provider_for};
77
use openshell_otel_test_support::{OtlpTestServer, ReceivedTraces};
88
use rcgen::{
99
BasicConstraints, Certificate, CertificateParams, ExtendedKeyUsagePurpose, IsCa, KeyPair,
@@ -55,7 +55,7 @@ pub async fn export_span_over_tls(server_trusted: bool) -> (bool, ReceivedTraces
5555
server_key.serialize_pem(),
5656
))
5757
.await;
58-
let (provider, error) = provider_for(Some(OtlpTraceConfig {
58+
let (provider, error) = provider_for(Some(OtlpExportConfig {
5959
endpoint: collector.endpoint(),
6060
service_name: ServiceName::Fixed("tls-test"),
6161
service_version: None,

‎crates/openshell-otel/Cargo.toml‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
[package]
55
name = "openshell-otel"
6-
description = "Shared OpenTelemetry trace export support for OpenShell services"
6+
description = "Shared OpenTelemetry trace and log export support for OpenShell services"
77
version.workspace = true
88
edition.workspace = true
99
rust-version.workspace = true
@@ -13,10 +13,14 @@ repository.workspace = true
1313
[dependencies]
1414
futures = { workspace = true }
1515
http = { workspace = true }
16+
metrics = { workspace = true }
17+
openshell-ocsf = { path = "../openshell-ocsf" }
1618
opentelemetry = { workspace = true }
17-
opentelemetry_sdk = { workspace = true }
18-
opentelemetry-otlp = { workspace = true }
19+
opentelemetry_sdk = { workspace = true, features = ["logs"] }
20+
opentelemetry-otlp = { workspace = true, features = ["logs"] }
21+
serde_json = { workspace = true }
1922
thiserror = { workspace = true }
23+
tokio = { workspace = true }
2024
tracing = { workspace = true }
2125
tracing-opentelemetry = { workspace = true }
2226
tracing-subscriber = { workspace = true }
@@ -25,7 +29,7 @@ tower-http = { workspace = true }
2529

2630
[dev-dependencies]
2731
opentelemetry_sdk = { workspace = true, features = ["testing"] }
28-
tokio = { workspace = true }
32+
tokio = { workspace = true, features = ["test-util"] }
2933

3034
[lints]
3135
workspace = true

‎crates/openshell-otel/src/driver.rs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ use tracing_subscriber::EnvFilter;
1212
use tracing_subscriber::prelude::*;
1313
use tracing_subscriber::registry::LookupSpan;
1414

15-
use crate::{OtlpTraceConfig, SdkTracerProvider, ServiceName, SetupError};
15+
use crate::{OtlpExportConfig, SdkTracerProvider, ServiceName, SetupError};
1616

1717
/// Target used by every in-process compute-driver RPC boundary.
1818
pub const IN_PROCESS_COMPUTE_DRIVER_TARGET: &str = "openshell_otel::in_process_compute_driver";
@@ -77,7 +77,7 @@ impl ComputeDriverTracing {
7777
gateway_name: Option<&str>,
7878
compute_driver: Option<&str>,
7979
) -> (Option<SdkTracerProvider>, Option<SetupError>) {
80-
crate::provider_for(endpoint.map(|endpoint| OtlpTraceConfig {
80+
crate::provider_for(endpoint.map(|endpoint| OtlpExportConfig {
8181
endpoint,
8282
service_name: ServiceName::Fixed(self.service_name),
8383
service_version: Some(service_version),

0 commit comments

Comments
 (0)