Files
openobserve/src/telemetry.rs
T
Huaijin 17ef03b8d6 refactor: cut release build time in the web and telemetry paths (#13528)
Two changes that cut build time, both measured on the same machine (M2
Max,
12 cores) with `cargo build --features mimalloc --profile release`.

## 1. The `web` crate no longer depends on `config`

`src/web` is a leaf crate so the RustEmbed expansion over `web/dist`
only
re-runs when the assets change — that expansion is 149.8s, of which
144.1s is
macro expansion (the crate already sets `#[br = false]` / `#[gzip =
false]`, so
this is not compression).

It also depended on `config`, for exactly one value:
`cfg.common.base_uri`. That
put a 50MB embed downstream of a 724-crate subgraph, so **every `config`
edit
paid the 148.5s embed again** — which is precisely what the crate's own
doc
comment says it exists to avoid.

`ui_routes()` now takes the `<base href>` value from the caller and
`base_href_middleware` carries it as middleware state.
`create_app_router()`
already computed that exact string (`"{base_uri}/web/"`) for its own
redirect,
so it is now resolved once and passed in rather than recomputed per
request
from a second `get_config()` call. `base_uri` is env-derived, normalised
once at
config init and never touched by `refresh_config`, so hoisting it out of
the
request path cannot drift.

The web crate's dependency tree goes **724 → 92 crates**, and `cargo
tree -i
config` no longer lists it.

## 2. Logging and tracing setup moved out of `main.rs`

`setup_logs`, `enable_tracing` and their span-processor / exporter types
compose
very large generic trees. Everything `main.rs` monomorphizes is emitted
into the
binary's own codegen units — the last unit on the critical path,
overlapping
with nothing.

`-Ztime-passes` on the binary unit (91.1s total):

```
LLVM_thinlto        39.8s
LLVM_passes         37.5s
codegen_to_LLVM_IR   5.3s
link_binary          5.3s   (run_linker 3.9s)
type_check_crate     0.4s
```

The `openobserve` library does not depend on `openobserve-api-http`, so
its unit
compiles in parallel with the API crates — in the cold build it finished
23.9s
before the binary needed it. Moving the setup into `src/telemetry.rs`
(563
lines) puts that work in the slack instead of at the tail.

Two tests moved with the code and were dropped rather than relocated:
`test_setup_logs` and `test_enable_tracing_error_handling` both install
a global
tracing subscriber, and `setup_logs()` calls `.init()` unguarded, so
they can
only ever pass depending on ordering. They used to race just with each
other
inside the bin test binary; keeping them would have moved that race into
the lib
test binary next to the `cli` and `migration` tests. Neither asserted
anything —
the second wraps the call in `catch_unwind` and discards the result.

`main.rs` 1304 → 712 lines. **Binary unit 88.8s → 80.7s**; the library
unit grows
30.9s → 62.3s and is fully absorbed by the existing slack.

## 3. Unrelated: a dead-code warning in `openobserve-core`

Not a build-time change, just a warning fix noticed while checking the
above.
`COMPARE_ROLLUP_LOOKBACK_MICROS` was gated on `any(feature =
"enterprise",
test)`, but its only two readers (`fetch_arm_aggregate`,
`fetch_arm_failures`)
are `enterprise`-only and the unit tests below it exercise only the pure
aggregation helpers. So in an OSS build `cargo check -p openobserve-core
--all-targets` warned `constant COMPARE_ROLLUP_LOOKBACK_MICROS is never
used`.
Gate narrowed to `enterprise`, and the doc comment — which claimed the
opposite
— corrected. Happy to split this out if you'd rather it not ride along.

## 4. Review follow-ups

- `enable_tracing` became a public library API in §2 without a doc
comment,
unlike `setup_logs` beside it. Documented: which span processors attach
under
which env vars, why the returned provider must be held rather than
dropped,
and that it and `setup_logs` are mutually exclusive since both call
`.init()`.
- Added the `<base href>` test described under Verification below.

Not taken: the suggestion to re-add `test_setup_logs` /
`test_enable_tracing_error_handling` to `src/telemetry.rs`. Both install
a
global subscriber against `.init()`, so they cannot be made
order-independent,
and neither asserted anything — see §2.
`test_resource_key_value_creation` was
deleted for the same reason; it asserted that
`opentelemetry::KeyValue::new`
stores the key it was given.

## Measured and rejected

Recording these so nobody repeats them.

**Moving route registration into the crates that own the handlers.** 317
of the
371 routes were registered in one 622-line function in
`openobserve-api-http`.
Splitting them out to api-management (234), api-search (46),
api-pipelines (23)
and api-ingest (9) worked as designed — api-http went 145.0s → 63.0s —
but the
cold build only improved 654.4s → 637.7s. Splitting across a crate
boundary
**copies** work rather than relocating it: without LTO each crate
instantiates
the axum/tower machinery for itself, so the four receiving crates grew
222.1s
combined and total CPU rose 190.8s. It also just relocated the
bottleneck —
api-management became the new tail at 135.1s, with api-search finishing
only
6.9s behind it, so the tail is a plateau rather than a peak. Not carried
in this
branch.

| Tried | Result |
|---|---|
| sccache | 97.4% hit rate, **0** wall-clock gain; first fill +54% |
| Remove aws-lc-sys from the critical path | **0** — its `dur=72.0s` in
`--timings` is contention, it needs 15.6s alone |
| `codegen-units = 16` on the glue crates | **31.4s slower**, CPU
+297.8s — `lto` defaults to `false`, so thin-local LTO is on and its
cost scales with CGU count |
| Trim DataFusion features | `datafusion-core` depends on
`datafusion-functions` without `default-features = false`, so the
expression features cannot be turned off from the facade at all; what
remains is worth 13.8s of CPU (0.25%) |

A caveat on reading `cargo --timings`: a unit's `duration` is **not**
its
intrinsic cost — it is inflated by CPU contention when the machine is
saturated.
The aws-lc entry above is the clearest example.

## Verification

At this revision:

- `cargo check --all-targets` clean on OSS — 0 errors, 0 warnings — and
each
  commit checks clean standalone, so `git bisect` works
- `cargo test -p web` passes

Carried over from the revision these changes were first written against
(the
edits since are doc comments, the `ui_routes` parameter rename, two
deleted
tests, a `Cargo.lock` revert, and the `cfg` narrowing in §3 — which is a
no-op
when `enterprise` is on):

- `cargo check` clean on the enterprise feature combination
- Cold release build succeeds
- Smoke-tested against a running binary: `/healthz` 200, UI and API
routes
  resolve, unknown paths 404

The `<base href>` rewrite — the one runtime behaviour §1 touches — is
now
pinned by `base_href_is_rewritten_to_the_mount_point`, and checked end
to end
against the real embedded `index.html`: `ui_routes("/abc/web/")` serves
`<base href="/abc/web/" />`, matching what the old per-request
`format!("{base_uri}/web/")` produced for `ZO_BASE_URI=/abc`. The
committed test
drives a synthetic HTML response instead, so it still asserts something
in
builds where `web/dist` is absent. Verified by mutation: breaking the
rewrite
fails it.
2026-07-29 06:29:41 +00:00

580 lines
24 KiB
Rust

// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! Logging and OpenTelemetry tracing setup.
//!
//! This lives in the library rather than in `main.rs` on purpose. Composing the
//! `tracing_subscriber` layer stack and the OTLP exporter / span-processor chain
//! instantiates very large generic types, and everything `main.rs` monomorphizes
//! is emitted into the binary's own codegen units -- the last unit on the
//! build's critical path, which no other unit can overlap with. The library unit
//! compiles in parallel with the API crates instead, so the same work costs less
//! wall clock.
use std::{collections::HashMap, str::FromStr, time::Duration};
use config::{META_ORG_ID, cluster::LOCAL_NODE, get_config};
#[cfg(feature = "enterprise")]
use o2_enterprise::enterprise::ai;
use opentelemetry::{KeyValue, global, trace::TracerProvider};
use opentelemetry_otlp::{WithExportConfig, WithHttpConfig, WithTonicConfig};
use opentelemetry_sdk::{Resource, propagation::TraceContextPropagator};
use tonic::{
codec::CompressionEncoding,
metadata::{MetadataKey, MetadataMap, MetadataValue},
};
use tracing_opentelemetry::OpenTelemetryLayer;
use tracing_subscriber::{
EnvFilter, Registry, filter::LevelFilter as TracingLevelFilter, fmt::Layer, prelude::*,
};
/// Setup the tracing related components
pub fn setup_logs() -> tracing_appender::non_blocking::WorkerGuard {
use tracing_subscriber::fmt::writer::BoxMakeWriter;
let cfg = get_config();
let (writer, guard) = if cfg.log.file_dir.is_empty() {
let (non_blocking, _guard) = tracing_appender::non_blocking(std::io::stdout());
(BoxMakeWriter::new(non_blocking), _guard)
} else {
let file_name_prefix = if cfg.log.file_name_prefix.is_empty() {
format!("o2.{}.log", cfg.common.instance_name.as_str())
} else {
cfg.log.file_name_prefix.to_string()
};
let file_appender = tracing_appender::rolling::daily(&cfg.log.file_dir, file_name_prefix);
let (non_blocking, _guard) = tracing_appender::non_blocking(file_appender);
(BoxMakeWriter::new(non_blocking), _guard)
};
let layer = if cfg.log.json_format {
Layer::default()
.with_writer(writer)
.with_timer(config::meta::logger::CustomTimeFormat)
.with_ansi(false)
.json()
.with_current_span(false)
.with_span_list(false)
.boxed()
} else {
Layer::default()
.with_writer(writer)
.with_ansi(false)
.with_target(true)
.event_format(config::meta::logger::O2Formatter::default())
.boxed()
};
tracing_subscriber::registry()
.with(
EnvFilter::builder()
.with_default_directive(TracingLevelFilter::INFO.into())
.from_env_lossy(),
)
.with(layer)
.init();
guard
}
/// Custom span processor that filters spans based on their name prefix
#[cfg(feature = "enterprise")]
#[derive(Debug)]
struct FilteringSpanProcessor<P> {
inner: P,
prefix_filter: Option<String>,
}
#[cfg(feature = "enterprise")]
impl<P> FilteringSpanProcessor<P> {
fn new(inner: P, prefix_filter: Option<String>) -> Self {
Self {
inner,
prefix_filter,
}
}
}
#[cfg(feature = "enterprise")]
impl<P: opentelemetry_sdk::trace::SpanProcessor> opentelemetry_sdk::trace::SpanProcessor
for FilteringSpanProcessor<P>
{
fn on_start(&self, span: &mut opentelemetry_sdk::trace::Span, cx: &opentelemetry::Context) {
// Always call inner on_start - we can only filter on_end when we have the full span data
self.inner.on_start(span, cx);
}
fn on_end(&self, span: opentelemetry_sdk::trace::SpanData) {
// If no filter is set, pass through all spans
if let Some(prefix) = &self.prefix_filter {
let span_name = span.name.as_ref();
// Only process spans that match the prefix
if !span_name.starts_with(prefix) {
return;
}
}
self.inner.on_end(span);
}
fn force_flush(&self) -> Result<(), opentelemetry_sdk::error::OTelSdkError> {
self.inner.force_flush()
}
fn shutdown(&self) -> Result<(), opentelemetry_sdk::error::OTelSdkError> {
self.inner.shutdown()
}
fn shutdown_with_timeout(
&self,
timeout: std::time::Duration,
) -> Result<(), opentelemetry_sdk::error::OTelSdkError> {
self.inner.shutdown_with_timeout(timeout)
}
}
/// Custom SpanExporter that ingests traces directly into the _meta org.
/// This is used by the search inspector feature to ensure search profiling
/// traces are always available in the _meta org, regardless of where the user's
/// OTLP endpoint points.
///
/// Handles both single-node and multi-node deployments:
/// - Ingester nodes: calls `handle_otlp_request` directly
/// - Non-ingester nodes (e.g. queriers): forwards via gRPC to an ingester
#[derive(Debug)]
struct MetaOrgTraceExporter {
resource: opentelemetry_proto::transform::common::tonic::ResourceAttributesWithSchema,
}
impl MetaOrgTraceExporter {
fn new(
resource: opentelemetry_proto::transform::common::tonic::ResourceAttributesWithSchema,
) -> Self {
Self { resource }
}
}
impl opentelemetry_sdk::trace::SpanExporter for MetaOrgTraceExporter {
fn export(
&self,
batch: Vec<opentelemetry_sdk::trace::SpanData>,
) -> impl std::future::Future<Output = opentelemetry_sdk::error::OTelSdkResult> + Send {
// Convert SpanData to ExportTraceServiceRequest synchronously
// so we don't hold a borrow on &self across await points
let resource_spans =
opentelemetry_proto::transform::trace::tonic::group_spans_by_resource_and_scope(
batch,
&self.resource,
);
let request = opentelemetry_proto::tonic::collector::trace::v1::ExportTraceServiceRequest {
resource_spans,
};
async move {
if LOCAL_NODE.is_ingester() {
// Ingest directly on ingester nodes
match openobserve_core::traces::handle_otlp_request(
META_ORG_ID,
request,
config::meta::otlp::OtlpRequestType::HttpJson,
None,
ingestion_common::IngestUser::SystemJob(
ingestion_common::SystemJobType::SelfReporting,
),
)
.await
{
Ok(resp) if resp.status().is_success() => {
log::debug!("[SEARCH-INSPECTOR] Successfully ingested traces to _meta org");
Ok(())
}
Ok(resp) => {
log::error!(
"[SEARCH-INSPECTOR] Error ingesting traces to _meta org: status={}",
resp.status()
);
Err(opentelemetry_sdk::error::OTelSdkError::InternalFailure(
format!("Failed to ingest traces: status={}", resp.status()),
))
}
Err(e) => {
log::error!("[SEARCH-INSPECTOR] Error ingesting traces to _meta org: {e}");
Err(opentelemetry_sdk::error::OTelSdkError::InternalFailure(
format!("Failed to ingest traces: {e}"),
))
}
}
} else {
// Forward to an ingester node via gRPC
let cfg = get_config();
let token = match config::meta::cluster::get_internal_grpc_token()
.parse::<tonic::metadata::MetadataValue<_>>()
{
Ok(token) => token,
Err(e) => {
log::error!("[SEARCH-INSPECTOR] Failed to parse internal gRPC token: {e}");
return Err(opentelemetry_sdk::error::OTelSdkError::InternalFailure(
format!("Failed to parse gRPC token: {e}"),
));
}
};
let (_addr, channel) = match openobserve_node::grpc::get_ingester_channel().await {
Ok(v) => v,
Err(e) => {
log::error!("[SEARCH-INSPECTOR] Failed to get ingester channel: {e}");
return Err(opentelemetry_sdk::error::OTelSdkError::InternalFailure(
format!("No ingester available: {e}"),
));
}
};
let client = opentelemetry_proto::tonic::collector::trace::v1::trace_service_client::TraceServiceClient::with_interceptor(
channel,
move |mut req: tonic::Request<()>| {
req.metadata_mut().insert("authorization", token.clone());
Ok(req)
},
);
let org_header_key: MetadataKey<_> = cfg.grpc.org_header_key.parse().unwrap();
let mut grpc_request = tonic::Request::new(request);
grpc_request.metadata_mut().insert(
org_header_key,
META_ORG_ID
.parse::<tonic::metadata::MetadataValue<_>>()
.unwrap(),
);
match client
.send_compressed(CompressionEncoding::Gzip)
.accept_compressed(CompressionEncoding::Gzip)
.max_decoding_message_size(cfg.grpc.max_message_size * 1024 * 1024)
.max_encoding_message_size(cfg.grpc.max_message_size * 1024 * 1024)
.export(grpc_request)
.await
{
Ok(_) => {
log::debug!(
"[SEARCH-INSPECTOR] Successfully forwarded traces to ingester for _meta org"
);
Ok(())
}
Err(e) => {
log::error!("[SEARCH-INSPECTOR] Error forwarding traces to ingester: {e}");
Err(opentelemetry_sdk::error::OTelSdkError::InternalFailure(
format!("Failed to forward traces to ingester: {e}"),
))
}
}
}
}
}
}
/// Build the OpenTelemetry tracer provider and install it as the global tracing
/// subscriber.
///
/// Span processors are attached conditionally, and a deployment can end up with
/// several at once:
/// - the main OTLP exporter, when `ZO_TRACING_ENABLED` or `ZO_TRACING_SEARCH_ENABLED` is set — over
/// gRPC if `ZO_OTEL_OTLP_GRPC_URL` is set, otherwise over HTTP to `ZO_OTEL_OTLP_URL`;
/// - an `ai.*`-filtered exporter (enterprise), when AI tracing is on but general tracing is not,
/// plus a second one to the eval platform if `O2_AI_EVAL_OTLP_ENDPOINT` is set;
/// - `MetaOrgTraceExporter`, when `ZO_SEARCH_INSPECTOR_ENABLED` is set, which ingests into the
/// `_meta` org rather than the configured OTLP endpoint.
///
/// Returns the provider so the caller can hold it for the process lifetime and
/// shut it down — dropping it early stops spans from being exported. This and
/// [`setup_logs`] both call `.init()`, so exactly one of them may run per
/// process.
pub fn enable_tracing() -> Result<opentelemetry_sdk::trace::SdkTracerProvider, anyhow::Error> {
let cfg = get_config();
opentelemetry::global::set_text_map_propagator(TraceContextPropagator::new());
let mut tracer_builder = opentelemetry_sdk::trace::SdkTracerProvider::builder();
// Add main OpenObserve OTLP exporter (if general tracing is enabled)
if cfg.common.tracing_enabled || cfg.common.tracing_search_enabled {
tracer_builder = if cfg.common.otel_otlp_grpc_url.is_empty() {
tracer_builder.with_span_processor(
opentelemetry_sdk::trace::span_processor_with_async_runtime::BatchSpanProcessor::builder(
{
let mut headers = HashMap::new();
headers.insert(
cfg.common.tracing_header_key.clone(),
cfg.common.tracing_header_value.clone(),
);
opentelemetry_otlp::SpanExporter::builder()
.with_http()
.with_http_client(
reqwest::Client::builder()
.danger_accept_invalid_certs(true)
.timeout(Duration::from_secs(10)) // Overall request timeout
.connect_timeout(Duration::from_secs(5)) // Connection establishment timeout
.pool_idle_timeout(Duration::from_secs(60)) // How long to keep idle connections
.pool_max_idle_per_host(10) // How many idle connections to keep per host
.build()?,
)
.with_endpoint(&cfg.common.otel_otlp_url)
.with_headers(headers)
.build()?
},
opentelemetry_sdk::runtime::Tokio,
)
.build(),
)
} else {
tracer_builder.with_span_processor(
opentelemetry_sdk::trace::span_processor_with_async_runtime::BatchSpanProcessor::builder(
{
let mut metadata = MetadataMap::new();
metadata.insert(
MetadataKey::from_str(&cfg.common.tracing_header_key).unwrap(),
MetadataValue::from_str(&cfg.common.tracing_header_value).unwrap(),
);
metadata.insert(
MetadataKey::from_str(&cfg.grpc.org_header_key).unwrap(),
MetadataValue::from_str(&cfg.common.tracing_grpc_header_org).unwrap(),
);
metadata.insert(
MetadataKey::from_str(&cfg.grpc.stream_header_key).unwrap(),
MetadataValue::from_str(&cfg.common.tracing_grpc_header_stream_name)
.unwrap(),
);
opentelemetry_otlp::SpanExporter::builder()
.with_tonic()
.with_endpoint(&cfg.common.otel_otlp_grpc_url)
.with_metadata(metadata)
.with_protocol(opentelemetry_otlp::Protocol::Grpc)
.build()?
},
opentelemetry_sdk::runtime::Tokio,
)
.build(),
)
};
log::info!("Main OpenObserve OTLP exporter configured");
}
// Handle AI tracing (enterprise feature)
#[cfg(feature = "enterprise")]
{
use o2_enterprise::enterprise::common::config::get_config as get_o2_config;
let o2_cfg = get_o2_config();
// If AI tracing is enabled but general tracing is NOT enabled,
// we need to add OpenObserve OTLP exporter for AI traces
if o2_cfg.ai.tracing_enabled
&& !cfg.common.tracing_enabled
&& !cfg.common.tracing_search_enabled
{
log::info!("AI tracing enabled independently - configuring OpenObserve OTLP exporter");
// Add OpenObserve exporter for AI traces
if !cfg.common.otel_otlp_url.is_empty() {
let mut headers = HashMap::new();
headers.insert(
cfg.common.tracing_header_key.clone(),
cfg.common.tracing_header_value.clone(),
);
let oo_exporter = opentelemetry_otlp::SpanExporter::builder()
.with_http()
.with_http_client(
reqwest::Client::builder()
.danger_accept_invalid_certs(true)
.timeout(Duration::from_secs(10))
.connect_timeout(Duration::from_secs(5))
.pool_idle_timeout(Duration::from_secs(60))
.pool_max_idle_per_host(10)
.build()?,
)
.with_endpoint(&cfg.common.otel_otlp_url)
.with_headers(headers)
.build()?;
let oo_processor = opentelemetry_sdk::trace::span_processor_with_async_runtime::BatchSpanProcessor::builder(
oo_exporter,
opentelemetry_sdk::runtime::Tokio,
)
.build();
// Wrap with filtering processor to only send AI traces
let filtered_processor = FilteringSpanProcessor::new(
oo_processor,
Some("ai.".to_string()), // Only send spans starting with "ai."
);
tracer_builder = tracer_builder.with_span_processor(filtered_processor);
log::info!(
"AI traces (ai.* spans only) will be sent to OpenObserve: {}",
cfg.common.otel_otlp_url
);
} else if !cfg.common.otel_otlp_grpc_url.is_empty() {
let mut metadata = MetadataMap::new();
metadata.insert(
MetadataKey::from_str(&cfg.common.tracing_header_key).unwrap(),
MetadataValue::from_str(&cfg.common.tracing_header_value).unwrap(),
);
let oo_exporter = opentelemetry_otlp::SpanExporter::builder()
.with_tonic()
.with_endpoint(&cfg.common.otel_otlp_grpc_url)
.with_metadata(metadata)
.with_protocol(opentelemetry_otlp::Protocol::Grpc)
.build()?;
let oo_processor = opentelemetry_sdk::trace::span_processor_with_async_runtime::BatchSpanProcessor::builder(
oo_exporter,
opentelemetry_sdk::runtime::Tokio,
)
.build();
// Wrap with filtering processor to only send AI traces
let filtered_processor = FilteringSpanProcessor::new(
oo_processor,
Some("ai.".to_string()), // Only send spans starting with "ai."
);
tracer_builder = tracer_builder.with_span_processor(filtered_processor);
log::info!(
"AI traces (ai.* spans only) will be sent to OpenObserve (gRPC): {}",
cfg.common.otel_otlp_grpc_url
);
} else {
log::warn!(
"AI tracing enabled but ZO_OTEL_OTLP_URL not configured - AI traces will not be exported"
);
}
}
// Additionally, if O2_AI_EVAL_OTLP_ENDPOINT is set, send AI traces to evaluation platform
// too
let eval_endpoint = std::env::var("O2_AI_EVAL_OTLP_ENDPOINT")
.ok()
.or_else(|| o2_cfg.ai.eval_otlp_endpoint.clone())
.filter(|s| !s.is_empty());
if let Some(endpoint) = eval_endpoint {
log::info!(
"Configuring additional AI evaluation OTLP exporter to: {}",
endpoint
);
let eval_exporter = opentelemetry_otlp::SpanExporter::builder()
.with_tonic()
.with_endpoint(endpoint)
.with_timeout(std::time::Duration::from_secs(10))
.build()?;
let eval_processor = opentelemetry_sdk::trace::span_processor_with_async_runtime::BatchSpanProcessor::builder(
eval_exporter,
opentelemetry_sdk::runtime::Tokio,
)
.build();
// Wrap with filtering processor to only send AI traces
let filtered_eval_processor =
FilteringSpanProcessor::new(eval_processor, Some("ai.".to_string()));
tracer_builder = tracer_builder.with_span_processor(filtered_eval_processor);
log::info!(
"AI evaluation OTLP exporter configured - AI traces (ai.* spans only) will be sent to evaluation platform"
);
}
}
// Build resource attributes (base + extra envs)
let mut resource_attrs = vec![
KeyValue::new("service.name", cfg.common.node_role.to_string()),
KeyValue::new("service.instance", cfg.common.instance_name.to_string()),
KeyValue::new("service.version", config::VERSION),
];
for env_name in cfg
.common
.tracing_extra_envs
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
{
if let Ok(val) = std::env::var(env_name)
&& !val.is_empty()
{
resource_attrs.push(KeyValue::new(env_name.to_lowercase(), val));
}
}
// If search inspector is enabled, add a span processor that ingests traces
// directly into the _meta org. This ensures search profiling data is always
// available in _meta regardless of where the user's OTLP endpoint points.
if cfg.common.search_inspector_enabled {
let resource = Resource::builder()
.with_attributes(resource_attrs.clone())
.build();
let resource_attrs =
opentelemetry_proto::transform::common::tonic::ResourceAttributesWithSchema::from(
&resource,
);
let meta_exporter = MetaOrgTraceExporter::new(resource_attrs);
let meta_processor =
opentelemetry_sdk::trace::span_processor_with_async_runtime::BatchSpanProcessor::builder(
meta_exporter,
opentelemetry_sdk::runtime::Tokio,
)
.build();
tracer_builder = tracer_builder.with_span_processor(meta_processor);
log::info!(
"Search inspector OTLP exporter configured - traces will be ingested to _meta org"
);
}
// Add UUID v7 ID generator and resource attributes
tracer_builder = tracer_builder.with_id_generator({
#[cfg(feature = "enterprise")]
{
ai::agent::tracing::UuidV7IdGenerator
}
#[cfg(not(feature = "enterprise"))]
{
opentelemetry_sdk::trace::RandomIdGenerator::default()
}
});
let tracer =
tracer_builder.with_resource(Resource::builder().with_attributes(resource_attrs).build());
// build
let tracer = tracer.build();
let layer = if cfg.log.json_format {
tracing_subscriber::fmt::layer()
.with_ansi(false)
.json()
.boxed()
} else {
tracing_subscriber::fmt::layer().with_ansi(false).boxed()
};
global::set_tracer_provider(tracer.clone());
Registry::default()
.with(tracing_subscriber::EnvFilter::new(&cfg.log.level))
.with(layer)
.with(OpenTelemetryLayer::new(
tracer.tracer("tracing-otel-subscriber"),
))
.init();
// Return the tracer provider
Ok(tracer)
}