Skip to main content

google_cloud_lro/internal/
tracing.rs

1// Copyright 2026 Google LLC
2//
3// Licensed under the Apache License, Version 2.0 (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//     https://www.apache.org/licenses/LICENSE-2.0
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
15use crate::{Poller, PollingResult, Result, sealed};
16use gaxi::observability::errors::error_type;
17use google_cloud_gax::polling_state::PollingState;
18use tracing::{Instrument, Span, info_span};
19
20tokio::task_local! {
21    static LRO_RECORDER: LroRecorder;
22}
23
24/// A recorder that manages LRO spans and propagates active telemetry context.
25///
26/// To prevent concurrent mutation race conditions under multi-threaded tokio executors,
27/// `LroRecorder` is largely immutable. Context updates (like setting the transient `attempt_count`
28/// during a polling cycle) are performed using copy-on-write builders (`with_attempt_count`)
29/// to establish new task-local scopes.
30///
31/// The `destination_id` is an exception: it is a write-once, read-many value shared across
32/// all clones of a given recorder, ensuring that once discovered, the ID propagates to all
33/// future polling spans.
34#[derive(Clone, Debug)]
35#[non_exhaustive]
36pub struct LroRecorder {
37    span: Span,
38    attempt_count: Option<u32>,
39    destination_id: std::sync::Arc<std::sync::OnceLock<String>>,
40}
41
42impl LroRecorder {
43    /// Creates a new `LroRecorder` wrapping the given tracing `Span`.
44    pub fn new(span: Span) -> Self {
45        Self {
46            span,
47            attempt_count: None,
48            destination_id: std::sync::Arc::new(std::sync::OnceLock::new()),
49        }
50    }
51
52    /// Returns the recorder in the current task scope.
53    pub fn current() -> Option<Self> {
54        LRO_RECORDER.try_get().ok()
55    }
56
57    /// Runs a future within the scope of this recorder.
58    pub async fn scope<F, T>(&self, future: F) -> T
59    where
60        F: std::future::Future<Output = T>,
61    {
62        LRO_RECORDER.scope(self.clone(), future).await
63    }
64
65    /// Returns the active LRO tracing `Span` wrapped by this recorder.
66    pub fn span(&self) -> &Span {
67        &self.span
68    }
69
70    /// Returns the current LRO polling attempt count, if active.
71    ///
72    /// This returns `Some(u32)` when queried during an active polling attempt,
73    /// and `None` otherwise (e.g., when executing outside the scope of an active polling cycle).
74    pub fn attempt_count(&self) -> Option<u32> {
75        self.attempt_count
76    }
77}
78
79/// Helper macro to record telemetry for Discovery LROs.
80#[macro_export]
81#[doc(hidden)]
82macro_rules! record_discovery_polling_result {
83    ($span:expr, $op:expr) => {
84        let span = &$span;
85        let op = &$op;
86        let done = $crate::internal::DiscoveryOperation::done(op);
87        span.record("gcp.longrunning.done", done);
88        if done {
89            let error = $crate::internal::DiscoveryOperation::error(op);
90            let code = error.as_ref().map(|e| e.code as i32).unwrap_or(0);
91            span.record("gcp.longrunning.status_code", code);
92            if let Some(status) = error {
93                span.record("otel.status_code", "ERROR");
94                span.record("otel.status_description", &status.message);
95                span.record("rpc.response.status_code", status.code as i32);
96                span.record("error.type", status.code.to_string());
97            }
98        }
99    };
100}
101
102impl LroRecorder {
103    /// Creates a new clone of `LroRecorder` carrying the specified LRO polling attempt count.
104    ///
105    /// Since `LroRecorder` is immutable to guarantee thread-safety, this updates the context
106    /// via copy-on-write, returning a new value to be bound to a new task-local scope.
107    pub fn with_attempt_count(&self, count: u32) -> Self {
108        Self {
109            span: self.span.clone(),
110            attempt_count: Some(count),
111            destination_id: self.destination_id.clone(),
112        }
113    }
114
115    pub fn record_destination_id(&self, name: &str) {
116        self.span.record("gcp.resource.destination.id", name);
117        let _ = self.destination_id.set(name.to_string());
118    }
119
120    pub fn destination_id(&self) -> Option<String> {
121        self.destination_id.get().cloned()
122    }
123
124    pub fn record_error(&self, err: &crate::Error) {
125        self.span.record("otel.status_code", "ERROR");
126        self.span.record("otel.status_description", err.to_string());
127        self.span.record("error.type", error_type(err));
128    }
129
130    pub async fn record_action<F, Fut, T>(&self, f: F) -> T
131    where
132        F: FnOnce(Span) -> Fut,
133        Fut: std::future::Future<Output = T>,
134    {
135        let span = self.span.clone();
136        self.scope(async move { f(span).await }).await
137    }
138}
139
140/// Injects LRO-specific telemetry attributes into the active span.
141#[macro_export]
142#[doc(hidden)]
143macro_rules! record_polling_attributes {
144    ($span:expr) => {
145        if let Some(recorder) = $crate::LroRecorder::current() {
146            if let Some(attempt) = recorder.attempt_count() {
147                let span = &$span;
148                span.record("gcp.longrunning.poll_attempt_count", attempt);
149            }
150            if let Some(dest_id) = recorder.destination_id() {
151                let span = &$span;
152                span.record("gcp.resource.destination.id", dest_id);
153            }
154        }
155    };
156}
157
158/// Decorate a poller with tracing information.
159#[derive(Clone, Debug)]
160pub struct Tracing<P> {
161    inner: P,
162    recorder: LroRecorder,
163    /// Stateful count of poll attempts managed directly on the decorator.
164    poll_attempt_count: u32,
165    started: bool,
166}
167
168impl<P> Tracing<P> {
169    pub(crate) fn new(inner: P, span: Span) -> Self {
170        Self {
171            inner,
172            recorder: LroRecorder::new(span),
173            poll_attempt_count: 0,
174            started: false,
175        }
176    }
177}
178
179impl<P> sealed::Poller for Tracing<P>
180where
181    P: sealed::Poller + Send,
182{
183    async fn backoff(&mut self, state: &PollingState) {
184        let span = info_span!("LRO Sleep");
185        let inner = &mut self.inner;
186        self.recorder
187            .record_action(|_| async move { inner.backoff(state).instrument(span).await })
188            .await
189    }
190}
191
192impl<P, ResponseType, MetadataType> Poller<ResponseType, MetadataType> for Tracing<P>
193where
194    P: Poller<ResponseType, MetadataType>,
195    ResponseType: Send,
196    MetadataType: Send,
197{
198    async fn poll(&mut self) -> Option<PollingResult<ResponseType, MetadataType>> {
199        // Stateful count of poll attempts is managed directly on the decorator instance,
200        // which is called via `&mut self` and is safe from divergent mutations.
201        let attempt = if self.started {
202            self.poll_attempt_count += 1;
203            self.poll_attempt_count
204        } else {
205            self.started = true;
206            0 // Initial triggers record nothing
207        };
208
209        let inner = &mut self.inner;
210        let span = self.recorder.span().clone();
211
212        // We map the consolidated LroRecorder (holding the active LRO span and stateful attempt count)
213        // for the duration of the active poll future.
214        let recorder = self.recorder.with_attempt_count(attempt);
215        recorder
216            .scope(async move { inner.poll().instrument(span).await })
217            .await
218    }
219
220    async fn until_done(self) -> Result<ResponseType> {
221        let this = self;
222        let recorder = this.recorder.clone();
223        let result = recorder
224            .record_action(|wait_span| async move {
225                crate::until_done(this).instrument(wait_span).await
226            })
227            .await;
228        if let Err(ref e) = result {
229            recorder.record_error(e);
230        }
231        result
232    }
233    #[cfg(feature = "unstable-stream")]
234    fn into_stream(
235        self,
236    ) -> impl futures::Stream<Item = PollingResult<ResponseType, MetadataType>> + Unpin {
237        crate::into_stream(self)
238    }
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244    use crate::Error;
245    use gaxi::client_request_signals;
246    use gaxi::options::InstrumentationClientInfo;
247    use google_cloud_test_utils::test_layer::TestLayer;
248    use google_cloud_wkt::{Duration, Timestamp};
249
250    struct FailingPoller;
251    impl sealed::Poller for FailingPoller {
252        async fn backoff(&mut self, _state: &PollingState) {}
253    }
254    impl Poller<Duration, Timestamp> for FailingPoller {
255        async fn poll(&mut self) -> Option<PollingResult<Duration, Timestamp>> {
256            Some(PollingResult::Completed(Err(Error::io(
257                "logical-test-failure",
258            ))))
259        }
260        async fn until_done(self) -> Result<Duration> {
261            Err(Error::io("logical-test-failure"))
262        }
263        #[cfg(feature = "unstable-stream")]
264        fn into_stream(
265            self,
266        ) -> impl futures::Stream<Item = PollingResult<Duration, Timestamp>> + Unpin {
267            crate::into_stream(self)
268        }
269    }
270
271    #[tokio::test]
272    async fn test_tracing_decorator_error_reporting() {
273        let guard = TestLayer::initialize();
274
275        let span = tracing::info_span!(
276            "test_span",
277            "otel.status_code" = tracing::field::Empty,
278            "otel.status_description" = tracing::field::Empty,
279        );
280
281        let poller = Tracing::new(FailingPoller, span);
282
283        let got = poller.until_done().await;
284        assert!(got.is_err());
285
286        {
287            let captured = TestLayer::capture(&guard);
288            let got = captured
289                .iter()
290                .find(|s| s.name == "test_span")
291                .unwrap_or_else(|| panic!("missing `test_span` in captured spans: {captured:?}"));
292            assert_eq!(
293                got.attributes
294                    .get("otel.status_code")
295                    .and_then(|v| v.as_string()),
296                Some("ERROR".to_string())
297            );
298            assert!(
299                got.attributes
300                    .get("otel.status_description")
301                    .and_then(|v| v.as_string())
302                    .unwrap()
303                    .contains("logical-test-failure")
304            );
305        }
306    }
307
308    struct CountingPoller {
309        attempts: Vec<u32>,
310    }
311    impl sealed::Poller for CountingPoller {
312        async fn backoff(&mut self, _state: &PollingState) {}
313    }
314    impl Poller<Duration, Timestamp> for CountingPoller {
315        async fn poll(&mut self) -> Option<PollingResult<Duration, Timestamp>> {
316            // Safe to unwrap because this mock poller is only called under the `Tracing::poll`
317            // decorator, which guarantees that an active `LroRecorder` is in scope with a
318            // populated attempt count.
319            let attempt = LroRecorder::current()
320                .and_then(|r| r.attempt_count())
321                .unwrap();
322            self.attempts.push(attempt);
323            Some(PollingResult::InProgress(None))
324        }
325        async fn until_done(self) -> Result<Duration> {
326            Ok(Duration::clamp(0, 0))
327        }
328        #[cfg(feature = "unstable-stream")]
329        fn into_stream(
330            self,
331        ) -> impl futures::Stream<Item = PollingResult<Duration, Timestamp>> + Unpin {
332            crate::into_stream(self)
333        }
334    }
335
336    #[tokio::test]
337    async fn test_tracing_decorator_attempt_counting() {
338        let span = tracing::info_span!("test_lro_span");
339        let poller = CountingPoller { attempts: vec![] };
340        let mut traced = Tracing::new(poller, span);
341
342        // First poll should record attempt 0
343        let _ = traced.poll().await;
344
345        // Second poll should record attempt 1
346        let _ = traced.poll().await;
347
348        // Third poll should record attempt 2
349        let _ = traced.poll().await;
350
351        assert_eq!(traced.inner.attempts, vec![0, 1, 2]);
352    }
353
354    #[tokio::test]
355    async fn test_lro_recorder_span_nesting() {
356        let _guard = TestLayer::initialize();
357        let span = tracing::info_span!("test_lro_span");
358        let recorder = LroRecorder::new(span.clone());
359
360        // Verify span is active in record_action
361        let span_clone = span.clone();
362        recorder
363            .record_action(|_| async move {
364                let active_recorder = LroRecorder::current().unwrap();
365                assert_eq!(
366                    active_recorder.span.metadata().unwrap().name(),
367                    "test_lro_span"
368                );
369                assert_eq!(active_recorder.span, span_clone);
370            })
371            .await;
372    }
373
374    #[tokio::test]
375    async fn record_polling_attributes_macro() {
376        let guard = TestLayer::initialize();
377
378        let span =
379            client_request_signals!(info: &InstrumentationClientInfo::default(), method: "test");
380
381        let recorder = LroRecorder::new(span.clone()).with_attempt_count(42);
382        recorder.record_destination_id("my-test-lro-id");
383
384        recorder
385            .scope(async move {
386                crate::record_polling_attributes!(&span);
387            })
388            .await;
389
390        drop(recorder);
391
392        let captured = TestLayer::capture(&guard);
393        let got = captured
394            .iter()
395            .find(|s| s.name == "client_request")
396            .unwrap();
397
398        assert_eq!(
399            got.attributes.get("gcp.longrunning.poll_attempt_count"),
400            Some(&google_cloud_test_utils::test_layer::AttributeValue::UInt64(42))
401        );
402        assert_eq!(
403            got.attributes.get("gcp.resource.destination.id"),
404            Some(
405                &google_cloud_test_utils::test_layer::AttributeValue::String(
406                    std::borrow::Cow::Borrowed("my-test-lro-id")
407                )
408            )
409        );
410    }
411
412    #[tokio::test]
413    async fn record_polling_attributes_macro_no_recorder() {
414        let guard = TestLayer::initialize();
415
416        let span =
417            client_request_signals!(info: &InstrumentationClientInfo::default(), method: "test");
418
419        crate::record_polling_attributes!(&span);
420
421        drop(span); // capture it
422
423        let captured = TestLayer::capture(&guard);
424        let got = captured
425            .iter()
426            .find(|s| s.name == "client_request")
427            .unwrap();
428
429        assert!(
430            !got.attributes
431                .contains_key("gcp.longrunning.poll_attempt_count"),
432            "found poll_attempt_count in attributes: {:#?}",
433            got.attributes
434        );
435    }
436
437    #[tokio::test]
438    async fn record_polling_attributes_macro_no_attempt_count() {
439        let guard = TestLayer::initialize();
440
441        let span =
442            client_request_signals!(info: &InstrumentationClientInfo::default(), method: "test");
443
444        let recorder = LroRecorder::new(span.clone());
445
446        recorder
447            .scope(async move {
448                crate::record_polling_attributes!(&span);
449            })
450            .await;
451
452        drop(recorder);
453
454        let captured = TestLayer::capture(&guard);
455        let got = captured
456            .iter()
457            .find(|s| s.name == "client_request")
458            .unwrap();
459
460        assert!(
461            !got.attributes
462                .contains_key("gcp.longrunning.poll_attempt_count"),
463            "found poll_attempt_count in attributes: {:#?}",
464            got.attributes
465        );
466    }
467
468    #[derive(Default)]
469    struct MockDiscoveryOperation {
470        done: bool,
471        error: Option<google_cloud_gax::error::rpc::Status>,
472    }
473
474    impl crate::internal::DiscoveryOperation for MockDiscoveryOperation {
475        fn done(&self) -> bool {
476            self.done
477        }
478
479        fn name(&self) -> Option<&String> {
480            None
481        }
482
483        fn error(&self) -> Option<google_cloud_gax::error::rpc::Status> {
484            self.error.clone()
485        }
486    }
487
488    #[tokio::test]
489    async fn record_discovery_polling_result_success() {
490        let guard = TestLayer::initialize();
491        let span =
492            client_request_signals!(info: &InstrumentationClientInfo::default(), method: "test");
493        let op = MockDiscoveryOperation {
494            done: true,
495            error: None,
496        };
497
498        record_discovery_polling_result!(span, op);
499
500        {
501            let captured = TestLayer::capture(&guard);
502            let got = captured
503                .iter()
504                .find(|s| s.name == "client_request")
505                .unwrap();
506
507            assert_eq!(
508                got.attributes
509                    .get("gcp.longrunning.done")
510                    .and_then(|v| v.as_bool()),
511                Some(true)
512            );
513            assert_eq!(
514                got.attributes
515                    .get("gcp.longrunning.status_code")
516                    .and_then(|v| v.as_i64()),
517                Some(0)
518            );
519            assert_eq!(
520                got.attributes
521                    .get("otel.status_code")
522                    .and_then(|v| v.as_string()),
523                Some("UNSET".to_string())
524            );
525        }
526    }
527
528    #[tokio::test]
529    async fn record_discovery_polling_result_error() {
530        let guard = TestLayer::initialize();
531        let span =
532            client_request_signals!(info: &InstrumentationClientInfo::default(), method: "test");
533        let status = google_cloud_gax::error::rpc::Status::default()
534            .set_code(google_cloud_gax::error::rpc::Code::NotFound)
535            .set_message("not found");
536        let op = MockDiscoveryOperation {
537            done: true,
538            error: Some(status),
539        };
540
541        record_discovery_polling_result!(span, op);
542
543        {
544            let captured = TestLayer::capture(&guard);
545            let got = captured
546                .iter()
547                .find(|s| s.name == "client_request")
548                .unwrap();
549
550            assert_eq!(
551                got.attributes
552                    .get("gcp.longrunning.done")
553                    .and_then(|v| v.as_bool()),
554                Some(true)
555            );
556            assert_eq!(
557                got.attributes
558                    .get("gcp.longrunning.status_code")
559                    .and_then(|v| v.as_i64()),
560                Some(google_cloud_gax::error::rpc::Code::NotFound as i64)
561            );
562            assert_eq!(
563                got.attributes
564                    .get("otel.status_code")
565                    .and_then(|v| v.as_string()),
566                Some("ERROR".to_string())
567            );
568            assert_eq!(
569                got.attributes
570                    .get("otel.status_description")
571                    .and_then(|v| v.as_string()),
572                Some("not found".to_string())
573            );
574            assert_eq!(
575                got.attributes.get("error.type").and_then(|v| v.as_string()),
576                Some("NOT_FOUND".to_string())
577            );
578        }
579    }
580
581    #[tokio::test]
582    async fn record_discovery_polling_result_in_progress() {
583        let guard = TestLayer::initialize();
584        let span =
585            client_request_signals!(info: &InstrumentationClientInfo::default(), method: "test");
586        let op = MockDiscoveryOperation {
587            done: false,
588            error: None,
589        };
590
591        record_discovery_polling_result!(span, op);
592
593        {
594            let captured = TestLayer::capture(&guard);
595            let got = captured
596                .iter()
597                .find(|s| s.name == "client_request")
598                .unwrap();
599
600            assert_eq!(
601                got.attributes
602                    .get("gcp.longrunning.done")
603                    .and_then(|v| v.as_bool()),
604                Some(false)
605            );
606            assert!(
607                !got.attributes.contains_key("gcp.longrunning.status_code"),
608                "found status_code in attributes: {:#?}",
609                got.attributes
610            );
611        }
612    }
613}