/build/source/nativelink-util/src/telemetry.rs
Line | Count | Source |
1 | | // Copyright 2025 Trace Machina, Inc. All rights reserved. |
2 | | // |
3 | | // Licensed under the Functional Source License, Version 1.1, Apache 2.0 Future License (the "License"); |
4 | | // you may not use this file except in compliance with the License. |
5 | | // You may obtain a copy of the License at |
6 | | // |
7 | | // See LICENSE file for details |
8 | | // |
9 | | // Unless required by applicable law or agreed to in writing, software |
10 | | // distributed under the License is distributed on an "AS IS" BASIS, |
11 | | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
12 | | // See the License for the specific language governing permissions and |
13 | | // limitations under the License. |
14 | | |
15 | | use core::default::Default; |
16 | | use std::collections::HashMap; |
17 | | use std::env; |
18 | | use std::sync::{Arc, OnceLock}; |
19 | | |
20 | | use base64::Engine; |
21 | | use base64::prelude::BASE64_STANDARD_NO_PAD; |
22 | | use ginepro::LoadBalancedChannel; |
23 | | use hyper::http::Response; |
24 | | use nativelink_error::{Code, Error, ResultExt, make_err}; |
25 | | use nativelink_proto::build::bazel::remote::execution::v2::RequestMetadata; |
26 | | use opentelemetry::propagation::{Injector, TextMapCompositePropagator, TextMapPropagator}; |
27 | | use opentelemetry::trace::{TraceContextExt, Tracer, TracerProvider}; |
28 | | use opentelemetry::{KeyValue, global}; |
29 | | use opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge; |
30 | | use opentelemetry_http::HeaderExtractor; |
31 | | use opentelemetry_otlp::{ |
32 | | LogExporter, MetricExporter, Protocol, SpanExporter, WithExportConfig, WithTonicConfig, |
33 | | }; |
34 | | use opentelemetry_sdk::Resource; |
35 | | use opentelemetry_sdk::logs::SdkLoggerProvider; |
36 | | use opentelemetry_sdk::metrics::SdkMeterProvider; |
37 | | use opentelemetry_sdk::propagation::{BaggagePropagator, TraceContextPropagator}; |
38 | | use opentelemetry_sdk::trace::SdkTracerProvider; |
39 | | use opentelemetry_semantic_conventions::attribute::ENDUSER_ID; |
40 | | use prost::Message; |
41 | | use tonic::transport::{Certificate, ClientTlsConfig, Identity}; |
42 | | use tracing::debug; |
43 | | use tracing::metadata::LevelFilter; |
44 | | use tracing_opentelemetry::{MetricsLayer, layer}; |
45 | | use tracing_subscriber::filter::Directive; |
46 | | use tracing_subscriber::prelude::__tracing_subscriber_SubscriberExt; |
47 | | use tracing_subscriber::util::SubscriberInitExt; |
48 | | use tracing_subscriber::{EnvFilter, Layer, Registry, fmt, registry}; |
49 | | use uuid::Uuid; |
50 | | |
51 | | use crate::metrics::record_rpc_served; |
52 | | use crate::origin_event::{BAZEL_METADATA_KEY, request_metadata_to_baggage}; |
53 | | |
54 | | /// The OTLP "service.name" field for all nativelink services. |
55 | | const NATIVELINK_SERVICE_NAME: &str = "nativelink"; |
56 | | |
57 | | // An `EnvFilter` to filter out non-nativelink information. |
58 | | // |
59 | | // See: https://github.com/open-telemetry/opentelemetry-rust/issues/2877 |
60 | | // |
61 | | // Note that `EnvFilter` doesn't implement `clone`, so create a new one for |
62 | | // each telemetry kind. |
63 | 0 | fn otlp_filter() -> EnvFilter { |
64 | 0 | fn expect_parse(directive: &str) -> Directive { |
65 | 0 | directive |
66 | 0 | .parse() |
67 | 0 | .unwrap_or_else(|_| panic!("Static directive '{directive}' failed to parse")) |
68 | 0 | } |
69 | | |
70 | 0 | EnvFilter::builder() |
71 | 0 | .with_default_directive(LevelFilter::INFO.into()) |
72 | 0 | .from_env_lossy() |
73 | 0 | .add_directive(expect_parse("hyper=off")) |
74 | 0 | .add_directive(expect_parse("tonic=off")) |
75 | 0 | .add_directive(expect_parse("h2=off")) |
76 | 0 | .add_directive(expect_parse("reqwest=off")) |
77 | 0 | .add_directive(expect_parse("tower=off")) |
78 | 0 | } |
79 | | |
80 | | // Create a tracing layer intended for stdout printing. |
81 | | // |
82 | | // The output of this layer is configurable via the `NL_LOG` environment |
83 | | // variable. |
84 | 0 | fn tracing_stdout_layer() -> impl Layer<Registry> { |
85 | 0 | let nl_log_fmt = env::var("NL_LOG").unwrap_or_else(|_| "pretty".to_string()); |
86 | | |
87 | 0 | let stdout_filter = otlp_filter(); |
88 | | |
89 | 0 | match nl_log_fmt.as_str() { |
90 | 0 | "compact" => fmt::layer() |
91 | 0 | .compact() |
92 | 0 | .with_timer(fmt::time::time()) |
93 | 0 | .with_filter(stdout_filter) |
94 | 0 | .boxed(), |
95 | 0 | "json" => fmt::layer() |
96 | 0 | .json() |
97 | 0 | .with_timer(fmt::time::time()) |
98 | 0 | .with_filter(stdout_filter) |
99 | 0 | .boxed(), |
100 | 0 | _ => fmt::layer() |
101 | 0 | .pretty() |
102 | 0 | .with_timer(fmt::time::time()) |
103 | 0 | .with_filter(stdout_filter) |
104 | 0 | .boxed(), |
105 | | } |
106 | 0 | } |
107 | | |
108 | | /// Initialize tracing with OpenTelemetry support. |
109 | | /// |
110 | | /// # Errors |
111 | | /// |
112 | | /// Returns `Err` if logging was already initialized or if the exporters can't |
113 | | /// be initialized. |
114 | 0 | pub async fn init_tracing() -> Result<(), Error> { |
115 | | static INITIALIZED: OnceLock<()> = OnceLock::new(); |
116 | | |
117 | | if INITIALIZED.get().is_some() { |
118 | | return Err(make_err!(Code::Internal, "Logging already initialized")); |
119 | | } |
120 | | |
121 | | // We currently use a UUIDv4 for "service.instance.id" as per: |
122 | | // https://opentelemetry.io/docs/specs/semconv/attributes-registry/service/ |
123 | | // This might change as we get a better understanding of its use cases in the |
124 | | // context of broader observability infrastructure. |
125 | | let resource = Resource::builder() |
126 | | .with_service_name(NATIVELINK_SERVICE_NAME) |
127 | | .with_attribute(KeyValue::new( |
128 | | "service.instance.id", |
129 | | Uuid::new_v4().to_string(), |
130 | | )) |
131 | | .build(); |
132 | | |
133 | | init_propagator(); |
134 | | |
135 | | let channels = load_balanced_channels().await?; |
136 | | |
137 | | // Logs |
138 | | let log_exporter_builder = |
139 | | with_transport(LogExporter::builder().with_tonic(), channels.logs, "LOGS")?; |
140 | | let otlp_log_layer = OpenTelemetryTracingBridge::new( |
141 | | &SdkLoggerProvider::builder() |
142 | | .with_resource(resource.clone()) |
143 | | .with_batch_exporter( |
144 | | log_exporter_builder |
145 | | .with_protocol(Protocol::Grpc) |
146 | | .build() |
147 | | .map_err(|e| make_err!(Code::Internal, "{e}")) |
148 | | .err_tip(|| "While creating OpenTelemetry OTLP Log exporter")?, |
149 | | ) |
150 | | .build(), |
151 | | ) |
152 | | .with_filter(otlp_filter()); |
153 | | |
154 | | // Traces |
155 | | let span_exporter_builder = with_transport( |
156 | | SpanExporter::builder().with_tonic(), |
157 | | channels.traces, |
158 | | "TRACES", |
159 | | )?; |
160 | | let otlp_trace_layer = layer() |
161 | | .with_tracer( |
162 | | SdkTracerProvider::builder() |
163 | | .with_resource(resource.clone()) |
164 | | .with_batch_exporter( |
165 | | span_exporter_builder |
166 | | .with_protocol(Protocol::Grpc) |
167 | | .build() |
168 | | .map_err(|e| make_err!(Code::Internal, "{e}")) |
169 | | .err_tip(|| "While creating OpenTelemetry OTLP Span exporter")?, |
170 | | ) |
171 | | .build() |
172 | | .tracer(NATIVELINK_SERVICE_NAME), |
173 | | ) |
174 | | .with_filter(otlp_filter()); |
175 | | |
176 | | // Metrics |
177 | | let metric_exporter_builder = with_transport( |
178 | | MetricExporter::builder().with_tonic(), |
179 | | channels.metrics, |
180 | | "METRICS", |
181 | | )?; |
182 | | let meter_provider = SdkMeterProvider::builder() |
183 | | .with_resource(resource) |
184 | | .with_periodic_exporter( |
185 | | metric_exporter_builder |
186 | | .with_protocol(Protocol::Grpc) |
187 | | .build() |
188 | | .map_err(|e| make_err!(Code::Internal, "{e}")) |
189 | | .err_tip(|| "While creating OpenTelemetry OTLP Metric exporter")?, |
190 | | ) |
191 | | .build(); |
192 | | |
193 | | global::set_meter_provider(meter_provider.clone()); |
194 | | |
195 | | let otlp_metrics_layer = MetricsLayer::new(meter_provider).with_filter(otlp_filter()); |
196 | | |
197 | | registry() |
198 | | .with(tracing_stdout_layer()) |
199 | | .with(otlp_log_layer) |
200 | | .with(otlp_trace_layer) |
201 | | .with(otlp_metrics_layer) |
202 | | .init(); |
203 | | |
204 | | INITIALIZED.set(()).unwrap_or(()); |
205 | | |
206 | | Ok(()) |
207 | | } |
208 | | |
209 | | /// Environment variable pointing OTLP exporters at a load-balanced endpoint. |
210 | | pub const NL_OTEL_ENDPOINT: &str = "NL_OTEL_ENDPOINT"; |
211 | | |
212 | | /// The OTLP signals, as the OpenTelemetry specification spells them in the |
213 | | /// per-signal variables (`OTEL_EXPORTER_OTLP_TRACES_CERTIFICATE`). |
214 | | const OTLP_SIGNALS: [&str; 3] = ["LOGS", "TRACES", "METRICS"]; |
215 | | |
216 | | /// The load-balanced channel each exporter sends on when |
217 | | /// [`NL_OTEL_ENDPOINT`] is set, and `None` for each when it is not. |
218 | | /// |
219 | | /// The common case is one channel shared by all three. A channel carries |
220 | | /// its TLS configuration, and an exporter given a channel ignores its own |
221 | | /// `with_tls_config`, so a signal with a certificate variable of its own |
222 | | /// gets a channel of its own, built with that signal's material. |
223 | | #[derive(Debug, Default)] |
224 | | pub struct SignalChannels { |
225 | | pub logs: Option<LoadBalancedChannel>, |
226 | | pub traces: Option<LoadBalancedChannel>, |
227 | | pub metrics: Option<LoadBalancedChannel>, |
228 | | } |
229 | | |
230 | | /// Whether any per-signal certificate variable is set and non-empty, which |
231 | | /// is when the signals need channels of their own. |
232 | 4 | fn any_per_signal_tls_variable_set() -> bool { |
233 | 10 | OTLP_SIGNALS.iter()4 .any4 (|signal| { |
234 | 10 | [OTLP_CERTIFICATE, OTLP_CLIENT_CERTIFICATE, OTLP_CLIENT_KEY] |
235 | 10 | .iter() |
236 | 26 | .any10 (|suffix| { |
237 | 26 | env::var(format!("OTEL_EXPORTER_OTLP_{signal}_{suffix}")) |
238 | 26 | .is_ok_and(|value| !value3 .is_empty3 ()) |
239 | 26 | }) |
240 | 10 | }) |
241 | 4 | } |
242 | | |
243 | | /// Builds the channels the exporters send on when [`NL_OTEL_ENDPOINT`] is |
244 | | /// set: one shared channel with the general certificate variables, or, when |
245 | | /// any per-signal certificate variable is set, one per signal with that |
246 | | /// signal's resolution of the variables. Without the endpoint variable |
247 | | /// every field is `None` and each exporter builds its own connection. |
248 | | /// |
249 | | /// # Errors |
250 | | /// |
251 | | /// As [`load_balanced_channel_for`]. |
252 | 2 | pub async fn load_balanced_channels() -> Result<SignalChannels, Error> { |
253 | 2 | if env::var(NL_OTEL_ENDPOINT).is_err() { |
254 | 1 | return Ok(SignalChannels::default()); |
255 | 1 | } |
256 | 1 | if !any_per_signal_tls_variable_set() { |
257 | 0 | let shared = load_balanced_channel_for(None).await?; |
258 | 0 | return Ok(SignalChannels { |
259 | 0 | logs: shared.clone(), |
260 | 0 | traces: shared.clone(), |
261 | 0 | metrics: shared, |
262 | 0 | }); |
263 | 1 | } |
264 | | Ok(SignalChannels { |
265 | 1 | logs: load_balanced_channel_for(Some("LOGS")).await?, |
266 | 0 | traces: load_balanced_channel_for(Some("TRACES")).await?, |
267 | 0 | metrics: load_balanced_channel_for(Some("METRICS")).await?, |
268 | | }) |
269 | 2 | } |
270 | | |
271 | | /// Creates a load-balanced gRPC channel for the endpoint configured via |
272 | | /// [`NL_OTEL_ENDPOINT`] with the general certificate variables, or returns |
273 | | /// `None` if the variable isn't set. This is [`load_balanced_channel_for`] |
274 | | /// with no signal, kept for the integration tests that verify exporters |
275 | | /// behave the same with and without the load-balanced channel; |
276 | | /// [`init_tracing`] goes through [`load_balanced_channels`]. |
277 | | /// |
278 | | /// # Errors |
279 | | /// |
280 | | /// As [`load_balanced_channel_for`]. |
281 | 2 | pub async fn maybe_load_balanced_channel() -> Result<Option<LoadBalancedChannel>, Error>1 {
|
282 | 2 | load_balanced_channel_for(None).await |
283 | 2 | } |
284 | | |
285 | | /// Creates a load-balanced gRPC channel for the endpoint configured via |
286 | | /// [`NL_OTEL_ENDPOINT`], or returns `None` if the variable isn't set. An |
287 | | /// `https://` endpoint gets TLS, with the material the OpenTelemetry |
288 | | /// certificate variables name for `signal` (see [`otlp_tls_config`]) or the |
289 | | /// system trust store without them; a plaintext endpoint gets no TLS |
290 | | /// configuration at all. |
291 | | /// |
292 | | /// # Errors |
293 | | /// |
294 | | /// Returns `Err` if the endpoint is not a URL with a host and an explicit |
295 | | /// port, if its hostname cannot be resolved, or if the certificate variables |
296 | | /// name files that cannot be read. |
297 | 5 | pub async fn load_balanced_channel_for( |
298 | 5 | signal: Option<&str>, |
299 | 5 | ) -> Result<Option<LoadBalancedChannel>, Error> { |
300 | 5 | let Ok(endpoint3 ) = env::var(NL_OTEL_ENDPOINT) else { |
301 | 2 | return Ok(None); |
302 | | }; |
303 | 3 | let url = Url::parse(endpoint.as_str()) |
304 | 3 | .map_err(|e| {0 |
305 | 0 | make_err!( |
306 | 0 | Code::InvalidArgument, |
307 | | "Unable to parse endpoint {endpoint}: {e:?}" |
308 | | ) |
309 | 0 | }) |
310 | 3 | .err_tip(|| format!0 ("In {NL_OTEL_ENDPOINT}"))?0 ; |
311 | | |
312 | 3 | let host = url |
313 | 3 | .host() |
314 | 3 | .err_tip(|| format!0 ("Unable to get host from endpoint {endpoint}"))?0 ; |
315 | 3 | let port = url |
316 | 3 | .port() |
317 | 3 | .err_tip(|| format!0 ("Unable to get port from endpoint {endpoint}"))?0 ; |
318 | | |
319 | 3 | let mut builder = LoadBalancedChannel::builder((host.to_string(), port)); |
320 | 3 | if url.scheme() == "https" { |
321 | 3 | let tls_config1 = otlp_tls_config(signal)?2 .unwrap_or_else1 (system_roots_tls_config); |
322 | 1 | builder = builder.with_tls(tls_config); |
323 | 0 | } |
324 | 1 | builder |
325 | 1 | .channel() |
326 | 1 | .await |
327 | 1 | .map_err(|e| make_err!(Code::Internal, "Invalid hostname '{endpoint}': {e}")) |
328 | 1 | .map(Some) |
329 | 5 | } |
330 | | |
331 | | /// The OpenTelemetry specification's variables for the OTLP exporters' TLS |
332 | | /// material, as the suffix after `OTEL_EXPORTER_OTLP_`: the CA that signs |
333 | | /// the collector's certificate, and the certificate and key the exporter |
334 | | /// presents when the collector asks for one. `opentelemetry-otlp` 0.32 does |
335 | | /// not read these itself, so they are read here. |
336 | | const OTLP_CERTIFICATE: &str = "CERTIFICATE"; |
337 | | const OTLP_CLIENT_CERTIFICATE: &str = "CLIENT_CERTIFICATE"; |
338 | | const OTLP_CLIENT_KEY: &str = "CLIENT_KEY"; |
339 | | |
340 | | /// The default OTLP endpoint of the gRPC exporters, as `opentelemetry-otlp` |
341 | | /// resolves it when no endpoint variable is set. |
342 | | const OTLP_GRPC_ENDPOINT_DEFAULT: &str = "http://localhost:4317"; |
343 | | |
344 | | /// Looks an OTLP variable up the way the specification orders them: the |
345 | | /// per-signal form (`OTEL_EXPORTER_OTLP_TRACES_CERTIFICATE`) wins over the |
346 | | /// general one (`OTEL_EXPORTER_OTLP_CERTIFICATE`), and an empty value counts |
347 | | /// as unset. Returns the name that was found with its value, for messages. |
348 | 70 | fn otlp_env_var(signal: Option<&str>, suffix: &str) -> Option<(String, String)> { |
349 | 70 | signal |
350 | 70 | .map(|signal| format!61 ("OTEL_EXPORTER_OTLP_{signal}_{suffix}")) |
351 | 70 | .into_iter() |
352 | 70 | .chain(core::iter::once(format!("OTEL_EXPORTER_OTLP_{suffix}"))) |
353 | 124 | .find_map70 (|name| { |
354 | 124 | env::var(&name) |
355 | 124 | .ok() |
356 | 124 | .filter(|value| !value23 .is_empty23 ()) |
357 | 124 | .map(|value| (name22 , value22 )) |
358 | 124 | }) |
359 | 70 | } |
360 | | |
361 | | /// Whether the exporter for `signal` will talk to an `https://` endpoint, |
362 | | /// resolved the way `opentelemetry-otlp` resolves it. The scheme is |
363 | | /// compared case-insensitively, as the SDK accepts `HTTPS://` too; a value |
364 | | /// that does not parse as a URL counts as plaintext and is left for the |
365 | | /// exporter to reject. (The `NL_OTEL_ENDPOINT` path parses its value with |
366 | | /// `Url` as well, so it judges the scheme the same way.) |
367 | 10 | fn otlp_endpoint_is_https(signal: &str) -> bool { |
368 | 10 | let endpoint = otlp_env_var(Some(signal), "ENDPOINT").map_or_else( |
369 | 4 | || OTLP_GRPC_ENDPOINT_DEFAULT.to_string(), |
370 | | |(_, value)| value, |
371 | | ); |
372 | 10 | Url::parse(&endpoint).is_ok_and(|url| url.scheme() == "https") |
373 | 10 | } |
374 | | |
375 | | /// A TLS configuration that trusts the system's certificate store: what an |
376 | | /// `https://` collector gets when no certificate variable names a CA. |
377 | | /// tonic's bare `ClientTlsConfig::new()` trusts nothing at all. |
378 | 7 | fn system_roots_tls_config() -> ClientTlsConfig { |
379 | 7 | ClientTlsConfig::new().with_enabled_roots() |
380 | 7 | } |
381 | | |
382 | 14 | fn read_pem((name, path): &(String, String)) -> Result<Vec<u8>, Error> { |
383 | 14 | std::fs::read(path).map_err(|e| {6 |
384 | 6 | make_err!( |
385 | 6 | Code::InvalidArgument, |
386 | | "Could not read {path}, which {name} names: {e}" |
387 | | ) |
388 | 6 | }) |
389 | 14 | } |
390 | | |
391 | | /// The TLS configuration the OpenTelemetry certificate variables describe |
392 | | /// for `signal` (`LOGS`, `TRACES` or `METRICS`; `None` reads only the |
393 | | /// general variables), or `None` when none of them is set. |
394 | | /// |
395 | | /// `OTEL_EXPORTER_OTLP_CERTIFICATE` names the CA to verify the collector |
396 | | /// with, and then only that CA is trusted; without it the system trust store |
397 | | /// is. `OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE` and |
398 | | /// `OTEL_EXPORTER_OTLP_CLIENT_KEY` name the identity to present for mTLS |
399 | | /// and have to be set together. Each has a per-signal form that wins over |
400 | | /// the general one. The files are read here, once, at startup. |
401 | | /// |
402 | | /// # Errors |
403 | | /// |
404 | | /// Returns `Err` if a named file cannot be read, or if only one of the |
405 | | /// client certificate and key is set. |
406 | 20 | pub fn otlp_tls_config(signal: Option<&str>) -> Result<Option<ClientTlsConfig>, Error> { |
407 | 20 | let ca = otlp_env_var(signal, OTLP_CERTIFICATE); |
408 | 20 | let client_certificate = otlp_env_var(signal, OTLP_CLIENT_CERTIFICATE); |
409 | 20 | let client_key = otlp_env_var(signal, OTLP_CLIENT_KEY); |
410 | 20 | if ca.is_none() && client_certificate10 .is_none10 () && client_key7 .is_none7 () { |
411 | 6 | return Ok(None); |
412 | 14 | } |
413 | 14 | let mut config8 = match &ca { |
414 | 10 | Some(ca) => ClientTlsConfig::new().ca_certificate4 (Certificate::from_pem4 (read_pem(ca)?6 )), |
415 | 4 | None => system_roots_tls_config(), |
416 | | }; |
417 | 8 | match (&client_certificate, &client_key) { |
418 | 2 | (Some(certificate), Some(key)) => { |
419 | 2 | config = config.identity(Identity::from_pem(read_pem(certificate)?0 , read_pem(key)?0 )); |
420 | | } |
421 | 4 | (None, None) => {} |
422 | 1 | (Some((name, _)), None) | (None, Some((name, _))) => { |
423 | 2 | return Err(make_err!( |
424 | 2 | Code::InvalidArgument, |
425 | 2 | "{name} is set without its pair: a client certificate and its key have to be \ |
426 | 2 | set together, as OTEL_EXPORTER_OTLP_{OTLP_CLIENT_CERTIFICATE} and \ |
427 | 2 | OTEL_EXPORTER_OTLP_{OTLP_CLIENT_KEY} or their per-signal forms" |
428 | 2 | )); |
429 | | } |
430 | | } |
431 | 6 | Ok(Some(config)) |
432 | 20 | } |
433 | | |
434 | | /// The TLS configuration for the exporter of `signal` when it builds its |
435 | | /// own channel: nothing for a plaintext endpoint, whatever the certificate |
436 | | /// variables say, and for an `https://` endpoint the variables' material or |
437 | | /// the system trust store without them. A plaintext signal must get no |
438 | | /// configuration at all: the exporter would still hand it to tonic, which |
439 | | /// loads the trust roots as it builds the connector and fails on a host |
440 | | /// without a certificate bundle, so a deployment that sends one signal |
441 | | /// over TLS and another in plaintext would die building the plaintext one. |
442 | 10 | fn exporter_tls_config(signal: &str) -> Result<Option<ClientTlsConfig>, Error> { |
443 | 10 | if !otlp_endpoint_is_https(signal) { |
444 | 5 | return Ok(None); |
445 | 5 | } |
446 | | Ok(Some( |
447 | 5 | otlp_tls_config(Some(signal))?1 .unwrap_or_else4 (system_roots_tls_config), |
448 | | )) |
449 | 10 | } |
450 | | |
451 | | /// Gives the exporter builder for `signal` its transport: the channel |
452 | | /// [`NL_OTEL_ENDPOINT`] supplied, which carries its own TLS configuration, |
453 | | /// or else the TLS configuration the signal's own endpoint calls for. With |
454 | | /// a channel the SDK's endpoint is ignored, so it is not examined either: |
455 | | /// an `OTEL_EXPORTER_OTLP_ENDPOINT` left pointing at an `https://` |
456 | | /// collector, with a certificate file that is no longer there, must not |
457 | | /// stop a process that is not going to connect to it. |
458 | 1 | fn with_transport<B: WithTonicConfig>( |
459 | 1 | builder: B, |
460 | 1 | channel: Option<LoadBalancedChannel>, |
461 | 1 | signal: &str, |
462 | 1 | ) -> Result<B, Error> { |
463 | 1 | if let Some(channel0 ) = channel { |
464 | 0 | return Ok(builder.with_channel(channel.into())); |
465 | 1 | } |
466 | 1 | Ok(match exporter_tls_config(signal)? { |
467 | 0 | Some(tls_config) => builder.with_tls_config(tls_config), |
468 | 0 | None => builder, |
469 | | }) |
470 | 1 | } |
471 | | /// This is the header that bazel sends when using the `--remote_header` flag. |
472 | | /// TODO(palfrey): Bazel supports other headers, and we should optimize their usage. |
473 | | const BAZEL_REQUESTMETADATA_HEADER: &str = "build.bazel.remote.execution.v2.requestmetadata-bin"; |
474 | | |
475 | | use opentelemetry::baggage::BaggageExt; |
476 | | use opentelemetry::context::FutureExt; |
477 | | use url::Url; |
478 | | |
479 | | /// ASCII headers from an inbound client request, stored in the task context |
480 | | /// so that outgoing upstream calls can forward them (e.g. JWT auth tokens). |
481 | | #[derive(Clone, Debug, Default)] |
482 | | pub struct ClientHeaders(pub Arc<HashMap<String, String>>); |
483 | | |
484 | | #[derive(Debug, Clone)] |
485 | | pub struct OtlpMiddleware<S> { |
486 | | inner: S, |
487 | | identity_required: bool, |
488 | | } |
489 | | |
490 | | impl<S> OtlpMiddleware<S> { |
491 | 5 | const fn new(inner: S, identity_required: bool) -> Self { |
492 | 5 | Self { |
493 | 5 | inner, |
494 | 5 | identity_required, |
495 | 5 | } |
496 | 5 | } |
497 | | } |
498 | | |
499 | | impl<S, ReqBody, ResBody> tower::Service<hyper::http::Request<ReqBody>> for OtlpMiddleware<S> |
500 | | where |
501 | | S: tower::Service<hyper::http::Request<ReqBody>, Response = Response<ResBody>> |
502 | | + Clone |
503 | | + Send |
504 | | + 'static, |
505 | | S::Future: Send + 'static, |
506 | | ReqBody: core::fmt::Debug + Send + 'static, |
507 | | ResBody: From<String> + Send + 'static + Default, |
508 | | { |
509 | | type Response = S::Response; |
510 | | type Error = S::Error; |
511 | | type Future = futures::future::BoxFuture<'static, Result<Self::Response, Self::Error>>; |
512 | | |
513 | 6 | fn poll_ready( |
514 | 6 | &mut self, |
515 | 6 | cx: &mut core::task::Context<'_>, |
516 | 6 | ) -> core::task::Poll<Result<(), Self::Error>> { |
517 | 6 | self.inner.poll_ready(cx) |
518 | 6 | } |
519 | | |
520 | 6 | fn call(&mut self, req: hyper::http::Request<ReqBody>) -> Self::Future { |
521 | | // We must take the current `inner` and not the clone. |
522 | | // See: <https://docs.rs/tower/latest/tower/trait.Service.html#be-careful-when-cloning-inner-services> |
523 | 6 | let clone = self.inner.clone(); |
524 | 6 | let mut inner = core::mem::replace(&mut self.inner, clone); |
525 | | |
526 | | // Capture all ASCII-valued request headers before req is consumed, so |
527 | | // they can be forwarded to upstream services (e.g. JWT auth tokens). |
528 | 6 | let client_headers = ClientHeaders(Arc::new( |
529 | 6 | req.headers() |
530 | 6 | .iter() |
531 | 17 | .filter_map6 (|(name, value)| { |
532 | 17 | value |
533 | 17 | .to_str() |
534 | 17 | .ok() |
535 | 17 | .map(|v| (name.as_str().to_lowercase(), v.to_string())) |
536 | 17 | }) |
537 | 6 | .collect(), |
538 | | )); |
539 | | |
540 | 6 | let parent_cx = global::get_text_map_propagator(|propagator| { |
541 | 6 | propagator.extract(&HeaderExtractor(req.headers())) |
542 | 6 | }); |
543 | | |
544 | 6 | let identity = parent_cx |
545 | 6 | .baggage() |
546 | 6 | .get(ENDUSER_ID) |
547 | 6 | .map(|value| value.as_str()4 .to_string4 ()) |
548 | 6 | .unwrap_or_default(); |
549 | | |
550 | 6 | if identity.is_empty() { |
551 | 2 | if self.identity_required { |
552 | 0 | return Box::pin(async move { |
553 | 0 | Ok(tonic::Status::failed_precondition( |
554 | 0 | r" |
555 | 0 |
|
556 | 0 | NativeLink instance configured to require this OpenTelemetry Baggage header: |
557 | 0 |
|
558 | 0 | `Baggage: enduser.id=YOUR_IDENTITY` |
559 | 0 |
|
560 | 0 | ", |
561 | 0 | ) |
562 | 0 | .into_http()) |
563 | 0 | }); |
564 | 2 | } |
565 | | } else { |
566 | 4 | debug!("Baggage enduser.id: {identity}"); |
567 | | } |
568 | | |
569 | 6 | let tracer = global::tracer("origin_middleware"); |
570 | 6 | let span = tracer |
571 | 6 | .span_builder("origin_request") |
572 | 6 | .with_kind(opentelemetry::trace::SpanKind::Server) |
573 | 6 | .start_with_context(&tracer, &parent_cx); |
574 | | |
575 | 6 | let mut cx = parent_cx.with_span(span); |
576 | | |
577 | 6 | if let Some(bazel_header0 ) = req.headers().get(BAZEL_REQUESTMETADATA_HEADER) |
578 | 0 | && let Ok(decoded) = BASE64_STANDARD_NO_PAD.decode(bazel_header.as_bytes()) |
579 | 0 | && let Ok(metadata) = RequestMetadata::decode(decoded.as_slice()) |
580 | | { |
581 | 0 | let metadata_str = format!("{metadata:?}"); |
582 | 0 | debug!("Baggage Bazel request metadata: {metadata_str}"); |
583 | 0 | cx = cx.with_baggage(vec![ |
584 | 0 | KeyValue::new(BAZEL_METADATA_KEY, request_metadata_to_baggage(&metadata)), |
585 | 0 | KeyValue::new(ENDUSER_ID, identity), |
586 | | ]); |
587 | 6 | } |
588 | | |
589 | 6 | let cx = cx.with_value(client_headers); |
590 | | // Rate, errors and latency for every gRPC service, taken here because |
591 | | // this layer already wraps all of them. |
592 | 6 | let route = req.uri().path().to_string(); |
593 | 6 | let started = std::time::Instant::now(); |
594 | 6 | Box::pin(async move { |
595 | 6 | let result = inner.call(req).with_context(cx).await; |
596 | 6 | if let Ok(response) = &result { |
597 | 6 | record_rpc_served( |
598 | 6 | &route, |
599 | 6 | grpc_status_of(response.headers()), |
600 | 6 | started.elapsed().as_secs_f64(), |
601 | 6 | ); |
602 | 6 | }0 |
603 | 6 | result |
604 | 6 | }) |
605 | 6 | } |
606 | | } |
607 | | |
608 | | /// Reads the gRPC status from a response. |
609 | | /// |
610 | | /// Tonic puts `grpc-status` in the headers when it fails before streaming and |
611 | | /// in the trailers otherwise. Trailers are not available here without |
612 | | /// consuming the body, so a call that got far enough to stream is reported as |
613 | | /// OK. Transport-level failures never reach this point at all. |
614 | 6 | fn grpc_status_of(headers: &hyper::http::HeaderMap) -> i32 { |
615 | 6 | headers |
616 | 6 | .get("grpc-status") |
617 | 6 | .and_then(|value| value0 .to_str0 ().ok0 ()) |
618 | 6 | .and_then(|value| value0 .parse0 ::<i32>().ok0 ()) |
619 | 6 | .unwrap_or(0) |
620 | 6 | } |
621 | | |
622 | | #[derive(Debug, Clone, Copy)] |
623 | | pub struct OtlpLayer { |
624 | | identity_required: bool, |
625 | | } |
626 | | |
627 | | impl OtlpLayer { |
628 | 0 | pub const fn new(identity_required: bool) -> Self { |
629 | 0 | Self { identity_required } |
630 | 0 | } |
631 | | } |
632 | | |
633 | | impl<S> tower::Layer<S> for OtlpLayer { |
634 | | type Service = OtlpMiddleware<S>; |
635 | | |
636 | 5 | fn layer(&self, service: S) -> Self::Service { |
637 | 5 | OtlpMiddleware::new(service, self.identity_required) |
638 | 5 | } |
639 | | } |
640 | | |
641 | | /// Installs the propagator the servers extract with and the forwarders inject |
642 | | /// with: W3C baggage and trace context. |
643 | 1 | pub fn init_propagator() { |
644 | 1 | global::set_text_map_propagator(TextMapCompositePropagator::new(vec![ |
645 | 1 | Box::new(BaggagePropagator::new()), |
646 | 1 | Box::new(TraceContextPropagator::new()), |
647 | | ])); |
648 | 1 | } |
649 | | |
650 | | /// Copies the nominated headers of the inbound client request, as the |
651 | | /// [`OtlpMiddleware`] stored them in the context, onto an outgoing request. |
652 | | /// This is how a client's credentials reach the upstream behind a forwarding |
653 | | /// store or scheduler; names match case-insensitively. |
654 | 57 | pub fn forward_client_headers<T>(request: &mut tonic::Request<T>, names: &[String]) { |
655 | 57 | if names.is_empty() { |
656 | 52 | return; |
657 | 5 | } |
658 | 5 | let Some(client_headers) = opentelemetry::Context::current() |
659 | 5 | .get::<ClientHeaders>() |
660 | 5 | .cloned() |
661 | | else { |
662 | 0 | return; |
663 | | }; |
664 | 5 | for name in names { |
665 | 5 | let Some(value) = client_headers.0.get(&name.to_lowercase()) else { |
666 | 0 | continue; |
667 | | }; |
668 | | // Tonic rejects a `-bin` name as an ASCII key, so a binary header such |
669 | | // as Bazel's request metadata needs the binary API. |
670 | 5 | if name.to_lowercase().ends_with("-bin") { |
671 | 1 | if let (Ok(k), Ok(bytes)) = ( |
672 | 1 | tonic::metadata::MetadataKey::<tonic::metadata::Binary>::from_bytes( |
673 | 1 | name.as_bytes(), |
674 | | ), |
675 | 1 | BASE64_STANDARD_NO_PAD.decode(value.trim_end_matches('=')), |
676 | 1 | ) { |
677 | 1 | request |
678 | 1 | .metadata_mut() |
679 | 1 | .insert_bin(k, tonic::metadata::MetadataValue::from_bytes(&bytes)); |
680 | 1 | }0 |
681 | 4 | } else if let (Ok(k), Ok(v)) = ( |
682 | 4 | tonic::metadata::MetadataKey::from_bytes(name.as_bytes()), |
683 | 4 | tonic::metadata::MetadataValue::try_from(value.as_str()), |
684 | 4 | ) { |
685 | 4 | request.metadata_mut().insert(k, v); |
686 | 4 | }0 |
687 | | } |
688 | 57 | } |
689 | | |
690 | | /// Writes a context's trace and baggage into an outgoing gRPC request, so a |
691 | | /// service that forwards a call (a grpc store, a grpc scheduler) hands the |
692 | | /// upstream the identity, the client's build metadata and the trace it was |
693 | | /// given instead of a bare request. |
694 | 57 | pub fn inject_context_with<T>( |
695 | 57 | propagator: &dyn TextMapPropagator, |
696 | 57 | context: &opentelemetry::Context, |
697 | 57 | request: &mut tonic::Request<T>, |
698 | 57 | ) { |
699 | | struct MetadataInjector<'a>(&'a mut tonic::metadata::MetadataMap); |
700 | | |
701 | | impl Injector for MetadataInjector<'_> { |
702 | 4 | fn set(&mut self, key: &str, value: String) { |
703 | 4 | if let (Ok(k), Ok(v)) = ( |
704 | 4 | tonic::metadata::MetadataKey::from_bytes(key.as_bytes()), |
705 | 4 | tonic::metadata::MetadataValue::try_from(&value), |
706 | 4 | ) { |
707 | 4 | self.0.insert(k, v); |
708 | 4 | }0 |
709 | 4 | } |
710 | | } |
711 | | |
712 | 57 | propagator.inject_context(context, &mut MetadataInjector(request.metadata_mut())); |
713 | 57 | } |
714 | | |
715 | | /// [`inject_context_with`] for the current context and the global propagator. |
716 | 56 | pub fn inject_context<T>(request: &mut tonic::Request<T>) { |
717 | 56 | global::get_text_map_propagator(|propagator| { |
718 | 56 | inject_context_with(propagator, &opentelemetry::Context::current(), request); |
719 | 56 | }); |
720 | 56 | } |
721 | | |
722 | | #[cfg(test)] |
723 | | mod tests { |
724 | | use nativelink_macro::nativelink_test; |
725 | | use serial_test::serial; |
726 | | |
727 | | use super::*; |
728 | | |
729 | | // ginepro's default resolver (hickory-dns) reads /etc/resolv.conf, which |
730 | | // doesn't exist in sandboxed environments (e.g. Nix builds). |
731 | | #[cfg(unix)] |
732 | 4 | fn dns_configured() -> bool { |
733 | 4 | std::path::Path::new("/etc/resolv.conf").exists() |
734 | 4 | } |
735 | | #[cfg(not(unix))] |
736 | | const fn dns_configured() -> bool { |
737 | | true |
738 | | } |
739 | | |
740 | | // Env vars are process-global, so concurrent writes would be a data race |
741 | | // on Unix. `#[serial(env)]` serializes all env-mutating tests. |
742 | | #[serial(env)] |
743 | | #[nativelink_test("crate")] |
744 | | async fn channel_absent_when_env_not_set() { |
745 | | // SAFETY: `#[serial(env)]` serializes all env-var writes across tests. |
746 | | unsafe { env::remove_var(NL_OTEL_ENDPOINT) }; |
747 | | assert!( |
748 | | maybe_load_balanced_channel() |
749 | | .await |
750 | | .expect("an unset endpoint is not an error") |
751 | | .is_none(), |
752 | | "Expected None when {NL_OTEL_ENDPOINT} is unset" |
753 | | ); |
754 | | } |
755 | | |
756 | | #[serial(env)] |
757 | | #[nativelink_test("crate")] |
758 | | async fn channel_present_when_valid_endpoint_set() { |
759 | | if !dns_configured() { |
760 | | eprintln!( |
761 | | "Skipping channel_present_when_valid_endpoint_set: no DNS configuration \ |
762 | | available (e.g. sandboxed Nix build)" |
763 | | ); |
764 | | return; |
765 | | } |
766 | | // SAFETY: `#[serial(env)]` serializes all env-var writes across tests. |
767 | | unsafe { env::set_var(NL_OTEL_ENDPOINT, "http://localhost:4317") }; |
768 | | let result = maybe_load_balanced_channel().await; |
769 | | // SAFETY: `#[serial(env)]` serializes all env-var writes across tests. |
770 | | unsafe { env::remove_var(NL_OTEL_ENDPOINT) }; |
771 | | assert!( |
772 | | result.expect("a valid endpoint builds").is_some(), |
773 | | "Expected Some(channel) when {NL_OTEL_ENDPOINT} points to a valid URL" |
774 | | ); |
775 | | } |
776 | | |
777 | | /// Clears every OTLP variable a TLS test may set, before and after it. |
778 | 27 | fn clear_otlp_tls_env() { |
779 | 108 | for signal in ["", 27 "LOGS_"27 , "TRACES_", "METRICS_"] { |
780 | 432 | for suffix in [ |
781 | 108 | "ENDPOINT", |
782 | 108 | "CERTIFICATE", |
783 | 108 | "CLIENT_CERTIFICATE", |
784 | 108 | "CLIENT_KEY", |
785 | 432 | ] { |
786 | 432 | // SAFETY: `#[serial(env)]` serializes all env-var writes across tests. |
787 | 432 | unsafe { env::remove_var(format!("OTEL_EXPORTER_OTLP_{signal}{suffix}")) }; |
788 | 432 | } |
789 | | } |
790 | 27 | } |
791 | | |
792 | | /// Writes dummy PEM bytes to a file in `dir`; tonic only stores them |
793 | | /// until a connection is made, so the content need not parse. |
794 | 8 | fn pem_file(dir: &tempfile::TempDir, name: &str) -> String { |
795 | 8 | let path = dir.path().join(name); |
796 | 8 | std::fs::write(&path, format!("-----BEGIN {name}-----\n")).unwrap(); |
797 | 8 | path.to_str().unwrap().to_string() |
798 | 8 | } |
799 | | |
800 | | // The exporter used to refuse an https endpoint outright ("uses HTTPS |
801 | | // but no TLS feature is enabled"); this fails without `tls-ring` on |
802 | | // `opentelemetry-otlp`. |
803 | | #[serial(env)] |
804 | | #[nativelink_test("crate")] |
805 | | async fn https_endpoint_builds_an_exporter() { |
806 | | clear_otlp_tls_env(); |
807 | | // SAFETY: `#[serial(env)]` serializes all env-var writes across tests. |
808 | | unsafe { env::set_var("OTEL_EXPORTER_OTLP_ENDPOINT", "https://localhost:4317") }; |
809 | | let mut builder = SpanExporter::builder().with_tonic(); |
810 | | if let Some(tls_config) = exporter_tls_config("TRACES").unwrap() { |
811 | | builder = builder.with_tls_config(tls_config); |
812 | | } |
813 | | let result = builder.with_protocol(Protocol::Grpc).build(); |
814 | | clear_otlp_tls_env(); |
815 | | assert!( |
816 | | result.is_ok(), |
817 | | "An https endpoint must build an exporter: {:?}", |
818 | | result.err() |
819 | | ); |
820 | | } |
821 | | |
822 | | #[serial(env)] |
823 | | #[nativelink_test("crate")] |
824 | | async fn tls_config_absent_without_variables() { |
825 | | clear_otlp_tls_env(); |
826 | | assert!(otlp_tls_config(Some("TRACES")).unwrap().is_none()); |
827 | | assert!(otlp_tls_config(None).unwrap().is_none()); |
828 | | // A plaintext endpoint gets no TLS configuration at all, so a host |
829 | | // without a certificate store still exports. |
830 | | assert!(exporter_tls_config("TRACES").unwrap().is_none()); |
831 | | // An empty value is unset, as the specification says. |
832 | | // SAFETY: `#[serial(env)]` serializes all env-var writes across tests. |
833 | | unsafe { env::set_var("OTEL_EXPORTER_OTLP_CERTIFICATE", "") }; |
834 | | let result = otlp_tls_config(Some("TRACES")); |
835 | | clear_otlp_tls_env(); |
836 | | assert!(result.unwrap().is_none()); |
837 | | } |
838 | | |
839 | | #[serial(env)] |
840 | | #[nativelink_test("crate")] |
841 | | async fn tls_config_present_with_ca_certificate() { |
842 | | clear_otlp_tls_env(); |
843 | | let dir = tempfile::tempdir().unwrap(); |
844 | | let ca = pem_file(&dir, "ca"); |
845 | | // SAFETY: `#[serial(env)]` serializes all env-var writes across tests. |
846 | | unsafe { env::set_var("OTEL_EXPORTER_OTLP_CERTIFICATE", &ca) }; |
847 | | let general = otlp_tls_config(None); |
848 | | let per_signal = otlp_tls_config(Some("METRICS")); |
849 | | // The endpoint is the plaintext default, so the exporter gets no |
850 | | // TLS configuration however the variables are set. |
851 | | let exporter = exporter_tls_config("METRICS"); |
852 | | clear_otlp_tls_env(); |
853 | | assert!(general.unwrap().is_some()); |
854 | | assert!(per_signal.unwrap().is_some()); |
855 | | assert!(exporter.unwrap().is_none()); |
856 | | } |
857 | | |
858 | | // A deployment that sends metrics over TLS with an identity and logs in |
859 | | // plaintext: the plaintext signal must get no TLS configuration, or the |
860 | | // exporter loads the trust roots for it and a host without a |
861 | | // certificate bundle dies building the log exporter. |
862 | | #[serial(env)] |
863 | | #[nativelink_test("crate")] |
864 | | async fn plaintext_signal_gets_no_tls_config_whatever_the_variables() { |
865 | | clear_otlp_tls_env(); |
866 | | let dir = tempfile::tempdir().unwrap(); |
867 | | let certificate = pem_file(&dir, "client"); |
868 | | let key = pem_file(&dir, "key"); |
869 | | // SAFETY: `#[serial(env)]` serializes all env-var writes across tests. |
870 | | unsafe { env::set_var("OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE", &certificate) }; |
871 | | // SAFETY: `#[serial(env)]` serializes all env-var writes across tests. |
872 | | unsafe { env::set_var("OTEL_EXPORTER_OTLP_CLIENT_KEY", &key) }; |
873 | | // SAFETY: `#[serial(env)]` serializes all env-var writes across tests. |
874 | | unsafe { |
875 | | env::set_var( |
876 | | "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", |
877 | | "https://collector:4317", |
878 | | ); |
879 | | }; |
880 | | let logs = exporter_tls_config("LOGS"); |
881 | | let metrics = exporter_tls_config("METRICS"); |
882 | | clear_otlp_tls_env(); |
883 | | assert!( |
884 | | logs.unwrap().is_none(), |
885 | | "the plaintext default endpoint gets no TLS configuration" |
886 | | ); |
887 | | assert!( |
888 | | metrics.unwrap().is_some(), |
889 | | "the https endpoint gets the variables' configuration" |
890 | | ); |
891 | | } |
892 | | |
893 | | // The SDK accepts an uppercase scheme; an `HTTPS://` endpoint has to |
894 | | // get its trust roots too, or the process starts with an empty store. |
895 | | #[serial(env)] |
896 | | #[nativelink_test("crate")] |
897 | | async fn uppercase_https_scheme_gets_the_default_roots() { |
898 | | clear_otlp_tls_env(); |
899 | | // SAFETY: `#[serial(env)]` serializes all env-var writes across tests. |
900 | | unsafe { |
901 | | env::set_var( |
902 | | "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", |
903 | | "HTTPS://collector:4317", |
904 | | ); |
905 | | }; |
906 | | let traces = exporter_tls_config("TRACES"); |
907 | | // A value that is not a URL is not https; the exporter rejects it. |
908 | | // SAFETY: `#[serial(env)]` serializes all env-var writes across tests. |
909 | | unsafe { env::set_var("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", "collector:4317:https") }; |
910 | | let unparsable = exporter_tls_config("TRACES"); |
911 | | clear_otlp_tls_env(); |
912 | | assert!(traces.unwrap().is_some(), "HTTPS:// is an https endpoint"); |
913 | | assert!(unparsable.unwrap().is_none()); |
914 | | } |
915 | | |
916 | | #[serial(env)] |
917 | | #[nativelink_test("crate")] |
918 | | async fn tls_config_with_client_identity() { |
919 | | clear_otlp_tls_env(); |
920 | | let dir = tempfile::tempdir().unwrap(); |
921 | | let certificate = pem_file(&dir, "client"); |
922 | | let key = pem_file(&dir, "key"); |
923 | | // SAFETY: `#[serial(env)]` serializes all env-var writes across tests. |
924 | | unsafe { env::set_var("OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE", &certificate) }; |
925 | | // SAFETY: `#[serial(env)]` serializes all env-var writes across tests. |
926 | | unsafe { env::set_var("OTEL_EXPORTER_OTLP_CLIENT_KEY", &key) }; |
927 | | let result = otlp_tls_config(Some("LOGS")); |
928 | | clear_otlp_tls_env(); |
929 | | assert!(result.unwrap().is_some()); |
930 | | } |
931 | | |
932 | | #[serial(env)] |
933 | | #[nativelink_test("crate")] |
934 | | async fn per_signal_variable_wins_over_the_general_one() { |
935 | | clear_otlp_tls_env(); |
936 | | let dir = tempfile::tempdir().unwrap(); |
937 | | let ca = pem_file(&dir, "ca"); |
938 | | let missing = dir.path().join("missing").to_str().unwrap().to_string(); |
939 | | // SAFETY: `#[serial(env)]` serializes all env-var writes across tests. |
940 | | unsafe { env::set_var("OTEL_EXPORTER_OTLP_CERTIFICATE", &missing) }; |
941 | | // SAFETY: `#[serial(env)]` serializes all env-var writes across tests. |
942 | | unsafe { env::set_var("OTEL_EXPORTER_OTLP_TRACES_CERTIFICATE", &ca) }; |
943 | | let traces = otlp_tls_config(Some("TRACES")); |
944 | | let metrics = otlp_tls_config(Some("METRICS")); |
945 | | let general = otlp_tls_config(None); |
946 | | clear_otlp_tls_env(); |
947 | | assert!(traces.unwrap().is_some(), "TRACES names a readable file"); |
948 | | let metrics_err = metrics.expect_err("METRICS falls back to the general, missing file"); |
949 | | assert_eq!(metrics_err.code, Code::InvalidArgument); |
950 | | assert!( |
951 | | metrics_err |
952 | | .to_string() |
953 | | .contains("OTEL_EXPORTER_OTLP_CERTIFICATE"), |
954 | | "{metrics_err}" |
955 | | ); |
956 | | assert_eq!( |
957 | | general.expect_err("the general file is missing").code, |
958 | | Code::InvalidArgument |
959 | | ); |
960 | | } |
961 | | |
962 | | #[serial(env)] |
963 | | #[nativelink_test("crate")] |
964 | | async fn client_certificate_without_key_is_rejected() { |
965 | | clear_otlp_tls_env(); |
966 | | let dir = tempfile::tempdir().unwrap(); |
967 | | let certificate = pem_file(&dir, "client"); |
968 | | // SAFETY: `#[serial(env)]` serializes all env-var writes across tests. |
969 | | unsafe { env::set_var("OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE", &certificate) }; |
970 | | let without_key = otlp_tls_config(Some("LOGS")); |
971 | | clear_otlp_tls_env(); |
972 | | // SAFETY: `#[serial(env)]` serializes all env-var writes across tests. |
973 | | unsafe { env::set_var("OTEL_EXPORTER_OTLP_LOGS_CLIENT_KEY", &certificate) }; |
974 | | let without_certificate = otlp_tls_config(Some("LOGS")); |
975 | | clear_otlp_tls_env(); |
976 | | for result in [without_key, without_certificate] { |
977 | | let err = result.expect_err("half an identity is an error"); |
978 | | assert_eq!(err.code, Code::InvalidArgument); |
979 | | assert!( |
980 | | err.to_string().contains("OTEL_EXPORTER_OTLP_CLIENT_KEY"), |
981 | | "{err}" |
982 | | ); |
983 | | } |
984 | | } |
985 | | |
986 | | #[serial(env)] |
987 | | #[nativelink_test("crate")] |
988 | | async fn missing_certificate_file_is_rejected() { |
989 | | clear_otlp_tls_env(); |
990 | | let dir = tempfile::tempdir().unwrap(); |
991 | | let missing = dir.path().join("missing.pem").to_str().unwrap().to_string(); |
992 | | // SAFETY: `#[serial(env)]` serializes all env-var writes across tests. |
993 | | unsafe { env::set_var("OTEL_EXPORTER_OTLP_CERTIFICATE", &missing) }; |
994 | | let result = otlp_tls_config(Some("METRICS")); |
995 | | clear_otlp_tls_env(); |
996 | | let err = result.expect_err("a missing file is an error"); |
997 | | assert_eq!(err.code, Code::InvalidArgument); |
998 | | assert!(err.to_string().contains("missing.pem"), "{err}"); |
999 | | assert!( |
1000 | | err.to_string().contains("OTEL_EXPORTER_OTLP_CERTIFICATE"), |
1001 | | "{err}" |
1002 | | ); |
1003 | | } |
1004 | | |
1005 | | // A per-signal certificate variable has to reach the channel the |
1006 | | // exporter is handed: an exporter given a channel ignores its own TLS |
1007 | | // configuration, so a shared channel built from the general variables |
1008 | | // alone failed the handshake with "unknown CA" for a signal whose CA |
1009 | | // is set per signal. |
1010 | | #[serial(env)] |
1011 | | #[nativelink_test("crate")] |
1012 | | async fn per_signal_certificate_reaches_the_load_balanced_channel() { |
1013 | | clear_otlp_tls_env(); |
1014 | | let dir = tempfile::tempdir().unwrap(); |
1015 | | let ca = pem_file(&dir, "ca"); |
1016 | | let missing = dir.path().join("missing").to_str().unwrap().to_string(); |
1017 | | // SAFETY: `#[serial(env)]` serializes all env-var writes across tests. |
1018 | | unsafe { env::set_var(NL_OTEL_ENDPOINT, "https://localhost:4317") }; |
1019 | | // SAFETY: `#[serial(env)]` serializes all env-var writes across tests. |
1020 | | unsafe { env::set_var("OTEL_EXPORTER_OTLP_CERTIFICATE", &missing) }; |
1021 | | // SAFETY: `#[serial(env)]` serializes all env-var writes across tests. |
1022 | | unsafe { env::set_var("OTEL_EXPORTER_OTLP_TRACES_CERTIFICATE", &ca) }; |
1023 | | assert!(any_per_signal_tls_variable_set()); |
1024 | | let traces = load_balanced_channel_for(Some("TRACES")).await; |
1025 | | let metrics = load_balanced_channel_for(Some("METRICS")).await; |
1026 | | let all = load_balanced_channels().await; |
1027 | | // SAFETY: `#[serial(env)]` serializes all env-var writes across tests. |
1028 | | unsafe { env::remove_var(NL_OTEL_ENDPOINT) }; |
1029 | | clear_otlp_tls_env(); |
1030 | | |
1031 | | // METRICS falls back to the general variable, whose file is |
1032 | | // missing; that error precedes any name resolution, so it holds |
1033 | | // with or without DNS, and so does the whole-set build that |
1034 | | // includes METRICS. |
1035 | | for result in [metrics, all.map(|channels| channels.metrics)] { |
1036 | | let err = result.expect_err("the general CA file is missing"); |
1037 | | assert_eq!(err.code, Code::InvalidArgument); |
1038 | | assert!( |
1039 | | err.to_string().contains("OTEL_EXPORTER_OTLP_CERTIFICATE"), |
1040 | | "{err}" |
1041 | | ); |
1042 | | } |
1043 | | // TRACES reads its own file; building the channel then resolves |
1044 | | // the host, which needs DNS. |
1045 | | if dns_configured() { |
1046 | | assert!( |
1047 | | traces.expect("TRACES names a readable CA").is_some(), |
1048 | | "Expected a channel for TRACES when {NL_OTEL_ENDPOINT} is set" |
1049 | | ); |
1050 | | } else { |
1051 | | eprintln!( |
1052 | | "Skipping the TRACES channel assertion: no DNS configuration \ |
1053 | | available (e.g. sandboxed Nix build)" |
1054 | | ); |
1055 | | } |
1056 | | } |
1057 | | |
1058 | | // Without a per-signal variable the three signals share one channel, |
1059 | | // and without the endpoint variable they get none. |
1060 | | #[serial(env)] |
1061 | | #[nativelink_test("crate")] |
1062 | | async fn signals_share_one_channel_without_per_signal_variables() { |
1063 | | clear_otlp_tls_env(); |
1064 | | // SAFETY: `#[serial(env)]` serializes all env-var writes across tests. |
1065 | | unsafe { env::remove_var(NL_OTEL_ENDPOINT) }; |
1066 | | assert!(!any_per_signal_tls_variable_set()); |
1067 | | let channels = load_balanced_channels() |
1068 | | .await |
1069 | | .expect("an unset endpoint is not an error"); |
1070 | | assert!(channels.logs.is_none() && channels.traces.is_none() && channels.metrics.is_none()); |
1071 | | |
1072 | | // An empty per-signal value is unset, so it does not split the |
1073 | | // channel either. |
1074 | | // SAFETY: `#[serial(env)]` serializes all env-var writes across tests. |
1075 | | unsafe { env::set_var("OTEL_EXPORTER_OTLP_METRICS_CLIENT_KEY", "") }; |
1076 | | let split = any_per_signal_tls_variable_set(); |
1077 | | clear_otlp_tls_env(); |
1078 | | assert!(!split); |
1079 | | |
1080 | | if !dns_configured() { |
1081 | | eprintln!( |
1082 | | "Skipping the shared channel assertion: no DNS configuration \ |
1083 | | available (e.g. sandboxed Nix build)" |
1084 | | ); |
1085 | | return; |
1086 | | } |
1087 | | // SAFETY: `#[serial(env)]` serializes all env-var writes across tests. |
1088 | | unsafe { env::set_var(NL_OTEL_ENDPOINT, "http://localhost:4317") }; |
1089 | | let channels = load_balanced_channels().await; |
1090 | | // SAFETY: `#[serial(env)]` serializes all env-var writes across tests. |
1091 | | unsafe { env::remove_var(NL_OTEL_ENDPOINT) }; |
1092 | | let channels = channels.expect("a valid endpoint builds"); |
1093 | | assert!(channels.logs.is_some() && channels.traces.is_some() && channels.metrics.is_some()); |
1094 | | } |
1095 | | |
1096 | | #[serial(env)] |
1097 | | #[nativelink_test("crate")] |
1098 | | async fn https_endpoint_without_variables_trusts_the_system_store() { |
1099 | | clear_otlp_tls_env(); |
1100 | | // SAFETY: `#[serial(env)]` serializes all env-var writes across tests. |
1101 | | unsafe { env::set_var("OTEL_EXPORTER_OTLP_LOGS_ENDPOINT", "https://collector:4317") }; |
1102 | | let logs = exporter_tls_config("LOGS"); |
1103 | | let traces = exporter_tls_config("TRACES"); |
1104 | | clear_otlp_tls_env(); |
1105 | | assert!( |
1106 | | logs.unwrap().is_some(), |
1107 | | "an https endpoint gets a TLS config" |
1108 | | ); |
1109 | | assert!( |
1110 | | traces.unwrap().is_none(), |
1111 | | "the default endpoint is plaintext" |
1112 | | ); |
1113 | | } |
1114 | | |
1115 | | // An exporter handed the NL_OTEL_ENDPOINT channel sends on that channel, |
1116 | | // so the SDK endpoint it would otherwise use, and that endpoint's |
1117 | | // certificate variables, are not examined: a stale https endpoint with |
1118 | | // a missing CA file used to stop startup although nothing was going to |
1119 | | // connect to it. |
1120 | | #[serial(env)] |
1121 | | #[nativelink_test("crate")] |
1122 | | async fn supplied_channel_skips_the_sdk_endpoints_tls_configuration() { |
1123 | | clear_otlp_tls_env(); |
1124 | | let dir = tempfile::tempdir().unwrap(); |
1125 | | let missing = dir.path().join("missing.pem").to_str().unwrap().to_string(); |
1126 | | // SAFETY: `#[serial(env)]` serializes all env-var writes across tests. |
1127 | | unsafe { env::set_var("OTEL_EXPORTER_OTLP_ENDPOINT", "https://unused.example:4317") }; |
1128 | | // SAFETY: `#[serial(env)]` serializes all env-var writes across tests. |
1129 | | unsafe { env::set_var("OTEL_EXPORTER_OTLP_CERTIFICATE", &missing) }; |
1130 | | // Without a channel the exporter would connect to that endpoint, so |
1131 | | // its missing CA file is an error. |
1132 | | let own_connection = with_transport(SpanExporter::builder().with_tonic(), None, "TRACES"); |
1133 | | let channel = if dns_configured() { |
1134 | | LoadBalancedChannel::builder(("localhost", 4317)) |
1135 | | .channel() |
1136 | | .await |
1137 | | .ok() |
1138 | | } else { |
1139 | | eprintln!( |
1140 | | "Skipping the supplied-channel assertion: no DNS configuration \ |
1141 | | available (e.g. sandboxed Nix build)" |
1142 | | ); |
1143 | | None |
1144 | | }; |
1145 | 0 | let supplied = channel.map(|channel| { |
1146 | 0 | with_transport( |
1147 | 0 | SpanExporter::builder().with_tonic(), |
1148 | 0 | Some(channel), |
1149 | 0 | "TRACES", |
1150 | | ) |
1151 | 0 | }); |
1152 | | clear_otlp_tls_env(); |
1153 | | assert_eq!( |
1154 | | own_connection |
1155 | | .expect_err("the missing CA file is an error for an exporter that would use it") |
1156 | | .code, |
1157 | | Code::InvalidArgument |
1158 | | ); |
1159 | | if let Some(supplied) = supplied { |
1160 | | assert!( |
1161 | | supplied.is_ok(), |
1162 | | "a supplied channel must not read the ignored endpoint's variables: {:?}", |
1163 | | supplied.err() |
1164 | | ); |
1165 | | } |
1166 | | } |
1167 | | |
1168 | | #[test] |
1169 | 1 | fn inject_context_writes_baggage_into_request_metadata() { |
1170 | | use opentelemetry::baggage::BaggageExt; |
1171 | | |
1172 | 1 | let propagator = BaggagePropagator::new(); |
1173 | 1 | let context = |
1174 | 1 | opentelemetry::Context::new().with_baggage(vec![KeyValue::new(ENDUSER_ID, "alice")]); |
1175 | 1 | let mut request = tonic::Request::new(()); |
1176 | 1 | inject_context_with(&propagator, &context, &mut request); |
1177 | 1 | let baggage = request |
1178 | 1 | .metadata() |
1179 | 1 | .get("baggage") |
1180 | 1 | .expect("the baggage header is written") |
1181 | 1 | .to_str() |
1182 | 1 | .unwrap(); |
1183 | 1 | assert!(baggage.contains("enduser.id=alice"), "{baggage}"); |
1184 | 1 | } |
1185 | | |
1186 | | #[test] |
1187 | 1 | fn forward_client_headers_forwards_binary_header() { |
1188 | 1 | let name = BAZEL_REQUESTMETADATA_HEADER; |
1189 | 1 | let metadata = RequestMetadata { |
1190 | 1 | tool_invocation_id: "inv-1".to_string(), |
1191 | 1 | ..Default::default() |
1192 | 1 | }; |
1193 | 1 | let encoded = BASE64_STANDARD_NO_PAD.encode(metadata.encode_to_vec()); |
1194 | 1 | let headers = ClientHeaders(Arc::new(HashMap::from([(name.to_string(), encoded)]))); |
1195 | 1 | let _guard = opentelemetry::Context::new().with_value(headers).attach(); |
1196 | | |
1197 | 1 | let mut request = tonic::Request::new(()); |
1198 | 1 | forward_client_headers(&mut request, &[name.to_string()]); |
1199 | | |
1200 | 1 | let value = request |
1201 | 1 | .metadata() |
1202 | 1 | .get_bin(name) |
1203 | 1 | .expect("the binary header is forwarded") |
1204 | 1 | .to_bytes() |
1205 | 1 | .unwrap(); |
1206 | 1 | let forwarded = RequestMetadata::decode(value.as_ref()).unwrap(); |
1207 | 1 | assert_eq!(forwarded.tool_invocation_id, "inv-1"); |
1208 | 1 | } |
1209 | | } |