mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
**PR Summary**
This PR adds the OpenTelemetry `host.name` resource attribute to Codex
OTEL exports so every OTEL log (and trace, via the shared resource)
carries the machine hostname.
**What changed**
- Added `host.name` to the shared OTEL `Resource` in
`/Users/michael.mcgrew/code/codex/codex-rs/otel/src/otel_provider.rs`
- This applies to both:
- OTEL logs (`SdkLoggerProvider`)
- OTEL traces (`SdkTracerProvider`)
- Hostname is now resolved via `gethostname::gethostname()`
(best-effort)
- Value is trimmed
- Empty values are omitted (non-fatal)
- Added focused unit tests for:
- including `host.name` when present
- omitting `host.name` when missing/empty
**Why**
- `host.name` is host/process metadata and belongs on the OTEL
`resource`, not per-event attributes.
- Attaching it in the shared resource is the smallest change that
guarantees coverage across all exported OTEL logs/traces.
**Scope / Non-goals**
- No public API changes
- No changes to metrics behavior (this PR only updates log/trace
resource metadata)
**Dependency updates**
- Added `gethostname` as a workspace dependency and `codex-otel`
dependency
- `Cargo.lock` updated accordingly
- `MODULE.bazel.lock` unchanged after refresh/check
**Validation**
- `just fmt`
- `cargo test -p codex-otel`
- `just bazel-lock-update`
- `just bazel-lock-check`
469 lines
15 KiB
Rust
469 lines
15 KiB
Rust
use crate::config::OtelExporter;
|
|
use crate::config::OtelHttpProtocol;
|
|
use crate::config::OtelSettings;
|
|
use crate::metrics::MetricsClient;
|
|
use crate::metrics::MetricsConfig;
|
|
use gethostname::gethostname;
|
|
use opentelemetry::Context;
|
|
use opentelemetry::KeyValue;
|
|
use opentelemetry::context::ContextGuard;
|
|
use opentelemetry::global;
|
|
use opentelemetry::propagation::TextMapPropagator;
|
|
use opentelemetry::trace::TraceContextExt;
|
|
use opentelemetry::trace::TracerProvider as _;
|
|
use opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge;
|
|
use opentelemetry_otlp::LogExporter;
|
|
use opentelemetry_otlp::OTEL_EXPORTER_OTLP_LOGS_TIMEOUT;
|
|
use opentelemetry_otlp::OTEL_EXPORTER_OTLP_TRACES_TIMEOUT;
|
|
use opentelemetry_otlp::Protocol;
|
|
use opentelemetry_otlp::SpanExporter;
|
|
use opentelemetry_otlp::WithExportConfig;
|
|
use opentelemetry_otlp::WithHttpConfig;
|
|
use opentelemetry_otlp::WithTonicConfig;
|
|
use opentelemetry_otlp::tonic_types::metadata::MetadataMap;
|
|
use opentelemetry_otlp::tonic_types::transport::ClientTlsConfig;
|
|
use opentelemetry_sdk::Resource;
|
|
use opentelemetry_sdk::logs::SdkLoggerProvider;
|
|
use opentelemetry_sdk::propagation::TraceContextPropagator;
|
|
use opentelemetry_sdk::trace::BatchSpanProcessor;
|
|
use opentelemetry_sdk::trace::SdkTracerProvider;
|
|
use opentelemetry_sdk::trace::Tracer;
|
|
use opentelemetry_semantic_conventions as semconv;
|
|
use std::cell::RefCell;
|
|
use std::collections::HashMap;
|
|
use std::env;
|
|
use std::error::Error;
|
|
use std::sync::OnceLock;
|
|
use tracing::debug;
|
|
use tracing::level_filters::LevelFilter;
|
|
use tracing::warn;
|
|
use tracing_subscriber::Layer;
|
|
use tracing_subscriber::registry::LookupSpan;
|
|
|
|
const ENV_ATTRIBUTE: &str = "env";
|
|
const HOST_NAME_ATTRIBUTE: &str = "host.name";
|
|
const TRACEPARENT_ENV_VAR: &str = "TRACEPARENT";
|
|
const TRACESTATE_ENV_VAR: &str = "TRACESTATE";
|
|
static TRACEPARENT_CONTEXT: OnceLock<Option<Context>> = OnceLock::new();
|
|
|
|
thread_local! {
|
|
static TRACEPARENT_GUARD: RefCell<Option<ContextGuard>> = const { RefCell::new(None) };
|
|
}
|
|
pub struct OtelProvider {
|
|
pub logger: Option<SdkLoggerProvider>,
|
|
pub tracer_provider: Option<SdkTracerProvider>,
|
|
pub tracer: Option<Tracer>,
|
|
pub metrics: Option<MetricsClient>,
|
|
}
|
|
|
|
impl OtelProvider {
|
|
pub fn shutdown(&self) {
|
|
if let Some(logger) = &self.logger {
|
|
let _ = logger.shutdown();
|
|
}
|
|
if let Some(tracer_provider) = &self.tracer_provider {
|
|
let _ = tracer_provider.shutdown();
|
|
}
|
|
if let Some(metrics) = &self.metrics {
|
|
let _ = metrics.shutdown();
|
|
}
|
|
}
|
|
|
|
pub fn from(settings: &OtelSettings) -> Result<Option<Self>, Box<dyn Error>> {
|
|
let log_enabled = !matches!(settings.exporter, OtelExporter::None);
|
|
let trace_enabled = !matches!(settings.trace_exporter, OtelExporter::None);
|
|
|
|
let metric_exporter = crate::config::resolve_exporter(&settings.metrics_exporter);
|
|
let metrics = if matches!(metric_exporter, OtelExporter::None) {
|
|
None
|
|
} else {
|
|
let mut config = MetricsConfig::otlp(
|
|
settings.environment.clone(),
|
|
settings.service_name.clone(),
|
|
settings.service_version.clone(),
|
|
metric_exporter,
|
|
);
|
|
if settings.runtime_metrics {
|
|
config = config.with_runtime_reader();
|
|
}
|
|
Some(MetricsClient::new(config)?)
|
|
};
|
|
|
|
if let Some(metrics) = metrics.as_ref() {
|
|
crate::metrics::install_global(metrics.clone());
|
|
}
|
|
|
|
if !log_enabled && !trace_enabled && metrics.is_none() {
|
|
debug!("No OTEL exporter enabled in settings.");
|
|
return Ok(None);
|
|
}
|
|
|
|
let resource = make_resource(settings);
|
|
let logger = log_enabled
|
|
.then(|| build_logger(&resource, &settings.exporter))
|
|
.transpose()?;
|
|
|
|
let tracer_provider = trace_enabled
|
|
.then(|| build_tracer_provider(&resource, &settings.trace_exporter))
|
|
.transpose()?;
|
|
|
|
let tracer = tracer_provider
|
|
.as_ref()
|
|
.map(|provider| provider.tracer(settings.service_name.clone()));
|
|
|
|
if let Some(provider) = tracer_provider.clone() {
|
|
global::set_tracer_provider(provider);
|
|
global::set_text_map_propagator(TraceContextPropagator::new());
|
|
}
|
|
if tracer.is_some() {
|
|
attach_traceparent_context();
|
|
}
|
|
|
|
Ok(Some(Self {
|
|
logger,
|
|
tracer_provider,
|
|
tracer,
|
|
metrics,
|
|
}))
|
|
}
|
|
|
|
pub fn logger_layer<S>(&self) -> Option<impl Layer<S> + Send + Sync>
|
|
where
|
|
S: tracing::Subscriber + for<'span> LookupSpan<'span> + Send + Sync,
|
|
{
|
|
self.logger.as_ref().map(|logger| {
|
|
OpenTelemetryTracingBridge::new(logger).with_filter(
|
|
tracing_subscriber::filter::filter_fn(OtelProvider::codex_export_filter),
|
|
)
|
|
})
|
|
}
|
|
|
|
pub fn tracing_layer<S>(&self) -> Option<impl Layer<S> + Send + Sync>
|
|
where
|
|
S: tracing::Subscriber + for<'span> LookupSpan<'span> + Send + Sync,
|
|
{
|
|
self.tracer.as_ref().map(|tracer| {
|
|
tracing_opentelemetry::layer()
|
|
.with_tracer(tracer.clone())
|
|
.with_filter(LevelFilter::TRACE)
|
|
})
|
|
}
|
|
|
|
pub fn codex_export_filter(meta: &tracing::Metadata<'_>) -> bool {
|
|
meta.target().starts_with("codex_otel")
|
|
}
|
|
|
|
pub fn metrics(&self) -> Option<&MetricsClient> {
|
|
self.metrics.as_ref()
|
|
}
|
|
}
|
|
|
|
impl Drop for OtelProvider {
|
|
fn drop(&mut self) {
|
|
if let Some(logger) = &self.logger {
|
|
let _ = logger.shutdown();
|
|
}
|
|
if let Some(tracer_provider) = &self.tracer_provider {
|
|
let _ = tracer_provider.shutdown();
|
|
}
|
|
if let Some(metrics) = &self.metrics {
|
|
let _ = metrics.shutdown();
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn traceparent_context_from_env() -> Option<Context> {
|
|
TRACEPARENT_CONTEXT
|
|
.get_or_init(load_traceparent_context)
|
|
.clone()
|
|
}
|
|
|
|
fn attach_traceparent_context() {
|
|
TRACEPARENT_GUARD.with(|guard| {
|
|
let mut guard = guard.borrow_mut();
|
|
if guard.is_some() {
|
|
return;
|
|
}
|
|
if let Some(context) = traceparent_context_from_env() {
|
|
*guard = Some(context.attach());
|
|
}
|
|
});
|
|
}
|
|
|
|
fn load_traceparent_context() -> Option<Context> {
|
|
let traceparent = env::var(TRACEPARENT_ENV_VAR).ok()?;
|
|
let tracestate = env::var(TRACESTATE_ENV_VAR).ok();
|
|
|
|
match extract_traceparent_context(traceparent, tracestate) {
|
|
Some(context) => {
|
|
debug!("TRACEPARENT detected; continuing trace from parent context");
|
|
Some(context)
|
|
}
|
|
None => {
|
|
warn!("TRACEPARENT is set but invalid; ignoring trace context");
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
fn extract_traceparent_context(traceparent: String, tracestate: Option<String>) -> Option<Context> {
|
|
let mut headers = HashMap::new();
|
|
headers.insert("traceparent".to_string(), traceparent);
|
|
if let Some(tracestate) = tracestate {
|
|
headers.insert("tracestate".to_string(), tracestate);
|
|
}
|
|
|
|
let context = TraceContextPropagator::new().extract(&headers);
|
|
let span = context.span();
|
|
let span_context = span.span_context();
|
|
if !span_context.is_valid() {
|
|
return None;
|
|
}
|
|
Some(context)
|
|
}
|
|
|
|
fn make_resource(settings: &OtelSettings) -> Resource {
|
|
Resource::builder()
|
|
.with_service_name(settings.service_name.clone())
|
|
.with_attributes(resource_attributes(
|
|
settings,
|
|
detected_host_name().as_deref(),
|
|
))
|
|
.build()
|
|
}
|
|
|
|
fn resource_attributes(settings: &OtelSettings, host_name: Option<&str>) -> Vec<KeyValue> {
|
|
let mut attributes = vec![
|
|
KeyValue::new(
|
|
semconv::attribute::SERVICE_VERSION,
|
|
settings.service_version.clone(),
|
|
),
|
|
KeyValue::new(ENV_ATTRIBUTE, settings.environment.clone()),
|
|
];
|
|
if let Some(host_name) = host_name.and_then(normalize_host_name) {
|
|
attributes.push(KeyValue::new(HOST_NAME_ATTRIBUTE, host_name));
|
|
}
|
|
attributes
|
|
}
|
|
|
|
fn detected_host_name() -> Option<String> {
|
|
let host_name = gethostname();
|
|
normalize_host_name(host_name.to_string_lossy().as_ref())
|
|
}
|
|
|
|
fn normalize_host_name(host_name: &str) -> Option<String> {
|
|
let host_name = host_name.trim();
|
|
(!host_name.is_empty()).then(|| host_name.to_owned())
|
|
}
|
|
|
|
fn build_logger(
|
|
resource: &Resource,
|
|
exporter: &OtelExporter,
|
|
) -> Result<SdkLoggerProvider, Box<dyn Error>> {
|
|
let mut builder = SdkLoggerProvider::builder().with_resource(resource.clone());
|
|
|
|
match crate::config::resolve_exporter(exporter) {
|
|
OtelExporter::None => return Ok(builder.build()),
|
|
OtelExporter::Statsig => unreachable!("statsig exporter should be resolved"),
|
|
OtelExporter::OtlpGrpc {
|
|
endpoint,
|
|
headers,
|
|
tls,
|
|
} => {
|
|
debug!("Using OTLP Grpc exporter: {endpoint}");
|
|
|
|
let header_map = crate::otlp::build_header_map(&headers);
|
|
|
|
let base_tls_config = ClientTlsConfig::new()
|
|
.with_enabled_roots()
|
|
.assume_http2(true);
|
|
|
|
let tls_config = match tls.as_ref() {
|
|
Some(tls) => crate::otlp::build_grpc_tls_config(&endpoint, base_tls_config, tls)?,
|
|
None => base_tls_config,
|
|
};
|
|
|
|
let exporter = LogExporter::builder()
|
|
.with_tonic()
|
|
.with_endpoint(endpoint)
|
|
.with_metadata(MetadataMap::from_headers(header_map))
|
|
.with_tls_config(tls_config)
|
|
.build()?;
|
|
|
|
builder = builder.with_batch_exporter(exporter);
|
|
}
|
|
OtelExporter::OtlpHttp {
|
|
endpoint,
|
|
headers,
|
|
protocol,
|
|
tls,
|
|
} => {
|
|
debug!("Using OTLP Http exporter: {endpoint}");
|
|
|
|
let protocol = match protocol {
|
|
OtelHttpProtocol::Binary => Protocol::HttpBinary,
|
|
OtelHttpProtocol::Json => Protocol::HttpJson,
|
|
};
|
|
|
|
let mut exporter_builder = LogExporter::builder()
|
|
.with_http()
|
|
.with_endpoint(endpoint)
|
|
.with_protocol(protocol)
|
|
.with_headers(headers);
|
|
|
|
if let Some(tls) = tls.as_ref() {
|
|
let client = crate::otlp::build_http_client(tls, OTEL_EXPORTER_OTLP_LOGS_TIMEOUT)?;
|
|
exporter_builder = exporter_builder.with_http_client(client);
|
|
}
|
|
|
|
let exporter = exporter_builder.build()?;
|
|
|
|
builder = builder.with_batch_exporter(exporter);
|
|
}
|
|
}
|
|
|
|
Ok(builder.build())
|
|
}
|
|
|
|
fn build_tracer_provider(
|
|
resource: &Resource,
|
|
exporter: &OtelExporter,
|
|
) -> Result<SdkTracerProvider, Box<dyn Error>> {
|
|
let span_exporter = match crate::config::resolve_exporter(exporter) {
|
|
OtelExporter::None => return Ok(SdkTracerProvider::builder().build()),
|
|
OtelExporter::Statsig => unreachable!("statsig exporter should be resolved"),
|
|
OtelExporter::OtlpGrpc {
|
|
endpoint,
|
|
headers,
|
|
tls,
|
|
} => {
|
|
debug!("Using OTLP Grpc exporter for traces: {endpoint}");
|
|
|
|
let header_map = crate::otlp::build_header_map(&headers);
|
|
|
|
let base_tls_config = ClientTlsConfig::new()
|
|
.with_enabled_roots()
|
|
.assume_http2(true);
|
|
|
|
let tls_config = match tls.as_ref() {
|
|
Some(tls) => crate::otlp::build_grpc_tls_config(&endpoint, base_tls_config, tls)?,
|
|
None => base_tls_config,
|
|
};
|
|
|
|
SpanExporter::builder()
|
|
.with_tonic()
|
|
.with_endpoint(endpoint)
|
|
.with_metadata(MetadataMap::from_headers(header_map))
|
|
.with_tls_config(tls_config)
|
|
.build()?
|
|
}
|
|
OtelExporter::OtlpHttp {
|
|
endpoint,
|
|
headers,
|
|
protocol,
|
|
tls,
|
|
} => {
|
|
debug!("Using OTLP Http exporter for traces: {endpoint}");
|
|
|
|
let protocol = match protocol {
|
|
OtelHttpProtocol::Binary => Protocol::HttpBinary,
|
|
OtelHttpProtocol::Json => Protocol::HttpJson,
|
|
};
|
|
|
|
let mut exporter_builder = SpanExporter::builder()
|
|
.with_http()
|
|
.with_endpoint(endpoint)
|
|
.with_protocol(protocol)
|
|
.with_headers(headers);
|
|
|
|
if let Some(tls) = tls.as_ref() {
|
|
let client =
|
|
crate::otlp::build_http_client(tls, OTEL_EXPORTER_OTLP_TRACES_TIMEOUT)?;
|
|
exporter_builder = exporter_builder.with_http_client(client);
|
|
}
|
|
|
|
exporter_builder.build()?
|
|
}
|
|
};
|
|
|
|
let processor = BatchSpanProcessor::builder(span_exporter).build();
|
|
|
|
Ok(SdkTracerProvider::builder()
|
|
.with_resource(resource.clone())
|
|
.with_span_processor(processor)
|
|
.build())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use opentelemetry::trace::SpanId;
|
|
use opentelemetry::trace::TraceContextExt;
|
|
use opentelemetry::trace::TraceId;
|
|
use pretty_assertions::assert_eq;
|
|
use std::path::PathBuf;
|
|
|
|
#[test]
|
|
fn parses_valid_traceparent() {
|
|
let trace_id = "00000000000000000000000000000001";
|
|
let span_id = "0000000000000002";
|
|
let context = extract_traceparent_context(format!("00-{trace_id}-{span_id}-01"), None)
|
|
.expect("trace context");
|
|
let span = context.span();
|
|
let span_context = span.span_context();
|
|
assert_eq!(
|
|
span_context.trace_id(),
|
|
TraceId::from_hex(trace_id).unwrap()
|
|
);
|
|
assert_eq!(span_context.span_id(), SpanId::from_hex(span_id).unwrap());
|
|
assert!(span_context.is_remote());
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_traceparent_returns_none() {
|
|
assert!(extract_traceparent_context("not-a-traceparent".to_string(), None).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn resource_attributes_include_host_name_when_present() {
|
|
let attrs = resource_attributes(&test_otel_settings(), Some("opentelemetry-test"));
|
|
|
|
let host_name = attrs
|
|
.iter()
|
|
.find(|kv| kv.key.as_str() == HOST_NAME_ATTRIBUTE)
|
|
.map(|kv| kv.value.as_str().to_string());
|
|
|
|
assert_eq!(host_name, Some("opentelemetry-test".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn resource_attributes_omit_host_name_when_missing_or_empty() {
|
|
let missing = resource_attributes(&test_otel_settings(), None);
|
|
let empty = resource_attributes(&test_otel_settings(), Some(" "));
|
|
|
|
assert!(
|
|
!missing
|
|
.iter()
|
|
.any(|kv| kv.key.as_str() == HOST_NAME_ATTRIBUTE)
|
|
);
|
|
assert!(
|
|
!empty
|
|
.iter()
|
|
.any(|kv| kv.key.as_str() == HOST_NAME_ATTRIBUTE)
|
|
);
|
|
}
|
|
|
|
fn test_otel_settings() -> OtelSettings {
|
|
OtelSettings {
|
|
environment: "test".to_string(),
|
|
service_name: "codex-test".to_string(),
|
|
service_version: "0.0.0".to_string(),
|
|
codex_home: PathBuf::from("."),
|
|
exporter: OtelExporter::None,
|
|
trace_exporter: OtelExporter::None,
|
|
metrics_exporter: OtelExporter::None,
|
|
runtime_metrics: false,
|
|
}
|
|
}
|
|
}
|