Skip to content

Commit 2b2d4d8

Browse files
authored
feat: configure OpenTelemetry batching per endpoint (#719)
#### Overview Add endpoint-local OpenTelemetry batch processor configuration for observability config version 3. Omitted values continue to inherit the standard `OTEL_BSP_*` environment settings and SDK defaults. - [x] I confirm this contribution is my own work, or I have the right to submit it under this project's license. - [x] I searched existing issues and open pull requests, and this does not duplicate existing work. #### Details - Add optional `max_queue_size`, `max_export_batch_size`, and `scheduled_delay_millis` fields to each configured OpenTelemetry endpoint. - Apply endpoint overrides after the environment-aware SDK batch defaults are constructed, preserving independent endpoint behavior and existing diagnostics, flush, and shutdown semantics. - Reject zero values and explicitly configured export batches larger than their queue with indexed configuration diagnostics. - Add configuration parity and serialization coverage for Rust, Python, Node.js, and Go while leaving manual subscriber APIs and the C FFI unchanged. - Preserve the existing Python `OpenTelemetryEndpointConfig` positional constructor order by appending the new fields after all pre-existing fields, with regression coverage for legacy positional calls. - Document precedence, sizing semantics, queue overflow behavior, scheduled-delay behavior, and the endpoint-local OTLP request timeout. Validation: - `uv run pre-commit run --all-files` - `cargo clippy --workspace --all-targets -- -D warnings` - `just test-go` - `just docs` - Full isolated Python suite: 640 passed, 45 skipped - Focused Python observability suite: 16 passed - Focused Rust and Node.js observability configuration tests The canonical Rust, Python, and Node.js commands were also attempted. Configuration-sensitive cases discovered a local `/Users/wkillian/.nemo-relay/plugins.toml`, producing unrelated inherited-configuration warnings and conflicts. The canonical Python run completed with 629 passing tests and 11 affected tests; the full suite passes when run with isolated configuration discovery. #### Where should the reviewer start? Start with `crates/core/src/observability/plugin_component.rs` for the public endpoint schema and validation, then `crates/core/src/observability/otel.rs` for environment-aware batch processor construction and endpoint override precedence. The Python compatibility follow-up is covered by `python/tests/test_observability_plugin.py`. #### Related Issues: (use one of the action keywords Closes / Fixes / Resolves / Relates to) - Relates to: RELAY-626 ## Summary by CodeRabbit * **New Features** * Added per-endpoint OpenTelemetry settings for queue size, export batch size, and scheduled export delay. * Added support across Rust, Node.js, Go, and Python configuration interfaces. * Endpoint-specific settings take precedence over process-wide defaults. * Added validation for zero values and batch sizes larger than the queue. * **Documentation** * Updated configuration guidance, examples, fallback behavior, and known issues. * **Tests** * Added coverage for serialization, validation, defaults, and runtime propagation. Authors: - Will Killian (https://github.com/willkill07) Approvers: - Zhongxuan (Daniel) Wang (https://github.com/zhongxuanwang-nv) URL: #719
1 parent faf6e2c commit 2b2d4d8

14 files changed

Lines changed: 385 additions & 41 deletions

File tree

crates/core/src/observability/otel.rs

Lines changed: 51 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,8 @@ use opentelemetry_otlp::{
4949
use opentelemetry_sdk::Resource;
5050
use opentelemetry_sdk::error::{OTelSdkError, OTelSdkResult};
5151
use opentelemetry_sdk::trace::{
52-
BatchSpanProcessor, IdGenerator, RandomIdGenerator, SdkTracer, SdkTracerProvider, Span,
53-
SpanData, SpanExporter, SpanProcessor,
52+
BatchConfigBuilder, BatchSpanProcessor, IdGenerator, RandomIdGenerator, SdkTracer,
53+
SdkTracerProvider, Span, SpanData, SpanExporter, SpanProcessor,
5454
};
5555
use uuid::Uuid;
5656

@@ -197,6 +197,9 @@ pub struct OpenTelemetryConfig {
197197
attribute_mappings: Vec<OtlpAttributeMapping>,
198198
timeout: Duration,
199199
transport: OtlpTransport,
200+
max_queue_size: Option<usize>,
201+
max_export_batch_size: Option<usize>,
202+
scheduled_delay: Option<Duration>,
200203
}
201204

202205
impl OpenTelemetryConfig {
@@ -215,6 +218,9 @@ impl OpenTelemetryConfig {
215218
attribute_mappings: Vec::new(),
216219
timeout: Duration::from_secs(3),
217220
transport: OtlpTransport::HttpBinary,
221+
max_queue_size: None,
222+
max_export_batch_size: None,
223+
scheduled_delay: None,
218224
}
219225
}
220226

@@ -292,6 +298,33 @@ impl OpenTelemetryConfig {
292298
self
293299
}
294300

301+
/// Overrides the batch processor queue size for this endpoint.
302+
pub(crate) fn with_max_queue_size(mut self, max_queue_size: usize) -> Self {
303+
self.max_queue_size = Some(max_queue_size);
304+
self
305+
}
306+
307+
/// Overrides the maximum export batch size for this endpoint.
308+
pub(crate) fn with_max_export_batch_size(mut self, max_export_batch_size: usize) -> Self {
309+
self.max_export_batch_size = Some(max_export_batch_size);
310+
self
311+
}
312+
313+
/// Overrides the maximum delay before exporting a non-full batch.
314+
pub(crate) fn with_scheduled_delay(mut self, scheduled_delay: Duration) -> Self {
315+
self.scheduled_delay = Some(scheduled_delay);
316+
self
317+
}
318+
319+
#[cfg(test)]
320+
pub(crate) fn batch_overrides(&self) -> (Option<usize>, Option<usize>, Option<Duration>) {
321+
(
322+
self.max_queue_size,
323+
self.max_export_batch_size,
324+
self.scheduled_delay,
325+
)
326+
}
327+
295328
/// Sets the service namespace resource attribute.
296329
pub fn with_service_namespace(mut self, namespace: impl Into<String>) -> Self {
297330
self.service_namespace = Some(namespace.into());
@@ -787,8 +820,22 @@ fn build_tracer_provider(
787820
.with_max_attributes_per_span(u32::MAX)
788821
.with_max_attributes_per_event(u32::MAX);
789822

790-
let processor =
791-
DiagnosticBatchSpanProcessor::new(exporter, config.endpoint.clone(), diagnostic_field);
823+
let mut batch_config = BatchConfigBuilder::default();
824+
if let Some(max_queue_size) = config.max_queue_size {
825+
batch_config = batch_config.with_max_queue_size(max_queue_size);
826+
}
827+
if let Some(max_export_batch_size) = config.max_export_batch_size {
828+
batch_config = batch_config.with_max_export_batch_size(max_export_batch_size);
829+
}
830+
if let Some(scheduled_delay) = config.scheduled_delay {
831+
batch_config = batch_config.with_scheduled_delay(scheduled_delay);
832+
}
833+
let processor = DiagnosticBatchSpanProcessor::new_with_batch_config(
834+
exporter,
835+
config.endpoint.clone(),
836+
diagnostic_field,
837+
batch_config.build(),
838+
);
792839
Ok(builder.with_span_processor(processor).build())
793840
}
794841

@@ -829,19 +876,6 @@ struct DiagnosticBatchSpanProcessor {
829876
}
830877

831878
impl DiagnosticBatchSpanProcessor {
832-
fn new<E: SpanExporter + 'static>(
833-
exporter: E,
834-
endpoint: String,
835-
diagnostic_field: Option<String>,
836-
) -> Self {
837-
Self::new_with_batch_config(
838-
exporter,
839-
endpoint,
840-
diagnostic_field,
841-
opentelemetry_sdk::trace::BatchConfig::default(),
842-
)
843-
}
844-
845879
fn new_with_batch_config<E: SpanExporter + 'static>(
846880
exporter: E,
847881
endpoint: String,

crates/core/src/observability/plugin_component.rs

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,9 +206,18 @@ pub struct OpenTelemetryEndpointConfig {
206206
/// Instrumentation scope name.
207207
#[serde(default = "default_otel_instrumentation_scope")]
208208
pub instrumentation_scope: String,
209-
/// Export timeout in milliseconds.
209+
/// OTLP request timeout in milliseconds.
210210
#[serde(default = "default_timeout_millis")]
211211
pub timeout_millis: u64,
212+
/// Maximum completed spans buffered before the endpoint drops new spans.
213+
#[serde(default, skip_serializing_if = "Option::is_none")]
214+
pub max_queue_size: Option<usize>,
215+
/// Maximum spans exported in one batch.
216+
#[serde(default, skip_serializing_if = "Option::is_none")]
217+
pub max_export_batch_size: Option<usize>,
218+
/// Maximum delay before exporting a non-full batch, in milliseconds.
219+
#[serde(default, skip_serializing_if = "Option::is_none")]
220+
pub scheduled_delay_millis: Option<u64>,
212221
}
213222

214223
/// Multi-sink ATOF JSONL exporter config.
@@ -531,6 +540,14 @@ impl EditorConfig for OpenTelemetryEndpointConfig {
531540
otel_editor_field("service_version", EditorFieldKind::String, &[], true),
532541
otel_editor_field("instrumentation_scope", EditorFieldKind::String, &[], false),
533542
otel_editor_field("timeout_millis", EditorFieldKind::Integer, &[], false),
543+
otel_editor_field("max_queue_size", EditorFieldKind::Integer, &[], true),
544+
otel_editor_field("max_export_batch_size", EditorFieldKind::Integer, &[], true),
545+
otel_editor_field(
546+
"scheduled_delay_millis",
547+
EditorFieldKind::Integer,
548+
&[],
549+
true,
550+
),
534551
otel_editor_field("headers", EditorFieldKind::StringMap, &[], false),
535552
otel_editor_field("header_env", EditorFieldKind::StringMap, &[], false),
536553
otel_editor_field(
@@ -1976,6 +1993,7 @@ fn build_otel_config(
19761993
}
19771994
};
19781995
validate_otel_header_env(index, &section)?;
1996+
validate_otel_batch_config(index, &section)?;
19791997
let mut config = CoreOpenTelemetryConfig::new(section.otel_type, section.endpoint)
19801998
.with_transport(transport)
19811999
.with_service_name(section.service_name)
@@ -1984,6 +2002,15 @@ fn build_otel_config(
19842002
.with_mark_projection(section.mark_projection)
19852003
.with_mark_exclude_names(section.mark_exclude_names)
19862004
.with_attribute_mappings(section.attribute_mappings);
2005+
if let Some(max_queue_size) = section.max_queue_size {
2006+
config = config.with_max_queue_size(max_queue_size);
2007+
}
2008+
if let Some(max_export_batch_size) = section.max_export_batch_size {
2009+
config = config.with_max_export_batch_size(max_export_batch_size);
2010+
}
2011+
if let Some(scheduled_delay_millis) = section.scheduled_delay_millis {
2012+
config = config.with_scheduled_delay(Duration::from_millis(scheduled_delay_millis));
2013+
}
19872014
if let Some(namespace) = section.service_namespace {
19882015
config = config.with_service_namespace(namespace);
19892016
}
@@ -2000,6 +2027,36 @@ fn build_otel_config(
20002027
Ok(config)
20012028
}
20022029

2030+
fn validate_otel_batch_config(
2031+
index: usize,
2032+
section: &OpenTelemetryEndpointConfig,
2033+
) -> PluginResult<()> {
2034+
for (field, value) in [
2035+
("max_queue_size", section.max_queue_size),
2036+
("max_export_batch_size", section.max_export_batch_size),
2037+
] {
2038+
if value == Some(0) {
2039+
return Err(PluginError::InvalidConfig(format!(
2040+
"OpenTelemetry endpoints[{index}].{field} must be greater than 0"
2041+
)));
2042+
}
2043+
}
2044+
if section.scheduled_delay_millis == Some(0) {
2045+
return Err(PluginError::InvalidConfig(format!(
2046+
"OpenTelemetry endpoints[{index}].scheduled_delay_millis must be greater than 0"
2047+
)));
2048+
}
2049+
if matches!(
2050+
(section.max_export_batch_size, section.max_queue_size),
2051+
(Some(batch), Some(queue)) if batch > queue
2052+
) {
2053+
return Err(PluginError::InvalidConfig(format!(
2054+
"OpenTelemetry endpoints[{index}].max_export_batch_size must be less than or equal to max_queue_size"
2055+
)));
2056+
}
2057+
Ok(())
2058+
}
2059+
20032060
fn validate_otel_header_env(
20042061
index: usize,
20052062
section: &OpenTelemetryEndpointConfig,
@@ -2238,6 +2295,9 @@ fn validate_opentelemetry_endpoint_fields(
22382295
"service_version",
22392296
"instrumentation_scope",
22402297
"timeout_millis",
2298+
"max_queue_size",
2299+
"max_export_batch_size",
2300+
"scheduled_delay_millis",
22412301
];
22422302
const REMOVED: &[&str] = &["semantic_selector", "capture_content"];
22432303
let Some(endpoints) = opentelemetry.get("endpoints").and_then(Json::as_array) else {
@@ -2416,6 +2476,7 @@ fn validate_opentelemetry_section(
24162476
error,
24172477
);
24182478
}
2479+
validate_opentelemetry_batch_config(diagnostics, policy, index, endpoint);
24192480
validate_opentelemetry_headers(diagnostics, policy, index, endpoint);
24202481
}
24212482
for error in opentelemetry_destination_collision_errors(&section.endpoints) {
@@ -2430,6 +2491,50 @@ fn validate_opentelemetry_section(
24302491
validate_opentelemetry_feature_support(diagnostics, policy, section);
24312492
}
24322493

2494+
fn validate_opentelemetry_batch_config(
2495+
diagnostics: &mut Vec<ConfigDiagnostic>,
2496+
policy: &ConfigPolicy,
2497+
index: usize,
2498+
endpoint: &OpenTelemetryEndpointConfig,
2499+
) {
2500+
for (field, is_zero) in [
2501+
("max_queue_size", endpoint.max_queue_size == Some(0)),
2502+
(
2503+
"max_export_batch_size",
2504+
endpoint.max_export_batch_size == Some(0),
2505+
),
2506+
(
2507+
"scheduled_delay_millis",
2508+
endpoint.scheduled_delay_millis == Some(0),
2509+
),
2510+
] {
2511+
if is_zero {
2512+
push_policy_diag(
2513+
diagnostics,
2514+
policy.unsupported_value,
2515+
"observability.unsupported_value",
2516+
Some("opentelemetry".to_string()),
2517+
Some(format!("endpoints[{index}].{field}")),
2518+
format!("OpenTelemetry endpoint {field} must be greater than 0"),
2519+
);
2520+
}
2521+
}
2522+
if matches!(
2523+
(endpoint.max_export_batch_size, endpoint.max_queue_size),
2524+
(Some(batch), Some(queue)) if batch > queue
2525+
) {
2526+
push_policy_diag(
2527+
diagnostics,
2528+
policy.unsupported_value,
2529+
"observability.unsupported_value",
2530+
Some("opentelemetry".to_string()),
2531+
Some(format!("endpoints[{index}].max_export_batch_size")),
2532+
"OpenTelemetry endpoint max_export_batch_size must be less than or equal to max_queue_size"
2533+
.to_string(),
2534+
);
2535+
}
2536+
}
2537+
24332538
struct OpenTelemetryDestinationCollision {
24342539
index: usize,
24352540
message: String,

crates/core/tests/unit/observability/otel_tests.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3427,7 +3427,10 @@ fn provider_builders_cover_success_paths() {
34273427
.with_header("authorization", "Bearer token")
34283428
.with_resource_attribute("deployment.environment", "test")
34293429
.with_service_namespace("agents")
3430-
.with_service_version("1.2.3"),
3430+
.with_service_version("1.2.3")
3431+
.with_max_queue_size(16)
3432+
.with_max_export_batch_size(4)
3433+
.with_scheduled_delay(Duration::from_millis(10)),
34313434
None,
34323435
)
34333436
.unwrap();

0 commit comments

Comments
 (0)