Skip to main content

rustango/
tenant_log.rs

1//! Ambient tenant identity for log lines — the field that says *whose*
2//! request this was.
3//!
4//! Tenant identity lives in the request: `Tenant` is an axum extractor,
5//! not a task-local ([`crate::extractors::tenant`]). By the time anything
6//! logs, the resolved [`Org`](crate::tenancy::Org) has been moved into a
7//! handler and nothing downstream can see it — which is why an access-log
8//! line could name the method, path, status and IP, but never the tenant.
9//!
10//! This module is the missing slot. [`scope`] opens one per request,
11//! the resolver calls [`record`] when it identifies the tenant, and
12//! outer middleware reads it back with [`current`] after the handler
13//! returns.
14//!
15//! ```ignore
16//! // Middleware, outside the handler. Read back INSIDE the scope:
17//! // once the `scope` future resolves the slot is gone, and a read
18//! // after it returns `None` for every request.
19//! let (response, who) = tenant_log::scope(async {
20//!     let response = next.run(req).await;
21//!     (response, tenant_log::current())   // Some(acme)
22//! })
23//! .await;
24//! ```
25//!
26//! [`record`] also writes `tenant` / `org_id` onto the current `tracing`
27//! span, so with [`crate::tracing_layer::TracingLayer`] installed every
28//! event emitted during the request carries the tenant in its span
29//! context — the ORM's included, with no plumbing of their own.
30//!
31//! ## Scope
32//!
33//! Per-task, and `tokio::spawn` does not inherit it: work that leaves the
34//! request leaves the scope. Giving background jobs a tenant is a
35//! different mechanism (issues #1229 / #1223) — this covers the request
36//! path only.
37
38use std::future::Future;
39use std::sync::{Arc, OnceLock};
40
41/// The resolved tenant, as logs should name it.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct TenantLabel {
44    /// `Org.slug` — operator-chosen, and often a customer name. See
45    /// [`crate::access_log::TenantField`] for logging the id instead.
46    pub slug: String,
47    /// `Org.id`. `None` for an unsaved org, which no request path
48    /// produces — the field is Optional because `Auto<i64>` is.
49    pub id: Option<i64>,
50}
51
52tokio::task_local! {
53    /// Per-request slot for the resolved tenant.
54    ///
55    /// `Arc<OnceLock<_>>` rather than a plain task-local value because
56    /// the write happens *below* the read: the resolver runs inside the
57    /// handler, the middleware that logs sits outside it. A task-local
58    /// holding a plain value could not be filled in from in there.
59    /// Set-once matches the semantic — one request, one tenant.
60    static TENANT: Arc<OnceLock<TenantLabel>>;
61}
62
63/// Run `fut` with a fresh tenant slot in scope.
64///
65/// Nesting is safe: an inner call reuses the outer slot rather than
66/// shadowing it, so two layers that both open a scope still agree on
67/// one tenant per request.
68pub async fn scope<F, T>(fut: F) -> T
69where
70    F: Future<Output = T>,
71{
72    if TENANT.try_with(|_| ()).is_ok() {
73        return fut.await;
74    }
75    TENANT.scope(Arc::new(OnceLock::new()), fut).await
76}
77
78/// Publish the request's tenant. Called by the tenant resolver the
79/// moment an `Org` is identified.
80///
81/// Records `tenant` / `org_id` on the current span, and fills the
82/// [`scope`] slot when one is open. Both halves are no-ops outside
83/// their respective contexts, so calling this from a CLI verb or a
84/// test costs nothing and breaks nothing. First call wins.
85pub fn record(slug: &str, id: Option<i64>) {
86    // The slot is the dedupe, so it has to be consulted *first*.
87    //
88    // These two halves used to run in the other order, with the
89    // `span.record` calls above an unguarded `OnceLock::set`. "First
90    // call wins" was then true of the slot and false of the span: every
91    // resolver in `ChainResolver` that published the same tenant
92    // stamped the field again. Measured on a running app, `/healthz`
93    // rendered `tenant=` three times — and `/healthz` is usually the
94    // highest-volume endpoint in a deployment. Cosmetic in text mode; a
95    // duplicate key in JSON, which is the format that ships to a
96    // collector.
97    //
98    // `Ok(false)` means the slot was already filled — a later resolver,
99    // so nothing to do. `Err` means no scope is open (a CLI verb, a
100    // test), and there is no slot to dedupe against; record
101    // unconditionally, as before.
102    let first = TENANT
103        .try_with(|slot| {
104            slot.set(TenantLabel {
105                slug: slug.to_owned(),
106                id,
107            })
108            .is_ok()
109        })
110        .unwrap_or(true);
111    if !first {
112        return;
113    }
114
115    let span = tracing::Span::current();
116    span.record("tenant", slug);
117    if let Some(id) = id {
118        span.record("org_id", id);
119    }
120}
121
122/// The tenant resolved for the current request, if any.
123///
124/// `None` means no tenant was identified — an apex-domain or operator
125/// console request, a single-tenant deployment, or any code running
126/// outside a [`scope`]. It never means "there was one and we lost it".
127#[must_use]
128pub fn current() -> Option<TenantLabel> {
129    TENANT.try_with(|slot| slot.get().cloned()).ok().flatten()
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    /// One `tenant=` per line, however many resolvers run.
137    ///
138    /// `record`'s doc says "First call wins". That holds for the slot —
139    /// `OnceLock::set` fails silently on repeat — but the two
140    /// `span.record` calls sat above that check, unguarded, and fired
141    /// on every `ChainResolver::resolve`. Paths that resolve more than
142    /// once stamped the field repeatedly: measured on a booted CMS,
143    /// `/healthz` rendered `tenant=` three times, `/` three, `/login`
144    /// twice. Cosmetic in text mode; a duplicate key in JSON, which is
145    /// what ships to a collector.
146    #[cfg(feature = "runtime")]
147    #[tokio::test]
148    async fn a_field_is_stamped_once_however_many_resolvers_run() {
149        use std::io::Write;
150        use std::sync::{Arc as StdArc, Mutex};
151        use tracing_subscriber::fmt::MakeWriter;
152
153        #[derive(Clone, Default)]
154        struct Buf(StdArc<Mutex<Vec<u8>>>);
155        impl Write for Buf {
156            fn write(&mut self, b: &[u8]) -> std::io::Result<usize> {
157                self.0.lock().unwrap().extend_from_slice(b);
158                Ok(b.len())
159            }
160            fn flush(&mut self) -> std::io::Result<()> {
161                Ok(())
162            }
163        }
164        impl<'a> MakeWriter<'a> for Buf {
165            type Writer = Buf;
166            fn make_writer(&'a self) -> Self::Writer {
167                self.clone()
168            }
169        }
170
171        let buf = Buf::default();
172        let subscriber = tracing_subscriber::fmt()
173            .with_writer(buf.clone())
174            .with_ansi(false)
175            .finish();
176        let _g = tracing::subscriber::set_default(subscriber);
177
178        scope(async {
179            let span = tracing::info_span!(
180                "http.request",
181                tenant = tracing::field::Empty,
182                org_id = tracing::field::Empty,
183            );
184            let _e = span.enter();
185            // Three resolvers in the chain all publish the same tenant.
186            record("acme", Some(7));
187            record("acme", Some(7));
188            record("acme", Some(7));
189            tracing::info!("handled");
190        })
191        .await;
192
193        let out = String::from_utf8(buf.0.lock().unwrap().clone()).unwrap();
194        assert!(out.contains("handled"), "nothing rendered:\n{out}");
195        assert_eq!(
196            out.matches("tenant=").count(),
197            1,
198            "tenant was stamped onto the span once per resolver instead of once \
199             per request:\n{out}"
200        );
201        assert_eq!(
202            out.matches("org_id=").count(),
203            1,
204            "org_id was stamped more than once:\n{out}"
205        );
206    }
207
208    #[tokio::test]
209    async fn records_and_reads_back_inside_a_scope() {
210        let seen = scope(async {
211            record("acme", Some(7));
212            current()
213        })
214        .await;
215        assert_eq!(
216            seen,
217            Some(TenantLabel {
218                slug: "acme".to_owned(),
219                id: Some(7),
220            })
221        );
222    }
223
224    #[tokio::test]
225    async fn current_is_none_without_a_resolution() {
226        let seen = scope(async { current() }).await;
227        assert_eq!(seen, None, "an apex request resolves no tenant");
228    }
229
230    #[tokio::test]
231    async fn record_outside_a_scope_is_a_noop() {
232        record("acme", Some(7));
233        assert_eq!(current(), None);
234    }
235
236    #[tokio::test]
237    async fn first_resolution_wins() {
238        let seen = scope(async {
239            record("acme", Some(1));
240            record("globex", Some(2));
241            current()
242        })
243        .await;
244        assert_eq!(seen.unwrap().slug, "acme");
245    }
246
247    /// The slot does not outlive its scope. Pins the shape that read
248    /// `tenant=-` on every line until the read moved inside — the
249    /// failure is silent, so it needs a test of its own.
250    #[tokio::test]
251    async fn a_read_after_the_scope_closes_sees_nothing() {
252        scope(async { record("acme", Some(7)) }).await;
253        assert_eq!(current(), None);
254    }
255
256    /// Two layers both opening a scope must not shadow each other —
257    /// otherwise the resolver fills the inner slot and the outer
258    /// middleware reads an empty one.
259    #[tokio::test]
260    async fn a_nested_scope_reuses_the_outer_slot() {
261        let seen = scope(async {
262            scope(async { record("acme", Some(7)) }).await;
263            current()
264        })
265        .await;
266        assert_eq!(seen.map(|t| t.slug), Some("acme".to_owned()));
267    }
268
269    /// A spawned task is a different task: it neither sees the parent's
270    /// tenant nor leaks one back. Pins the boundary #1223 has to cross.
271    #[tokio::test]
272    async fn a_spawned_task_does_not_inherit_the_scope() {
273        let seen = scope(async {
274            record("acme", Some(7));
275            tokio::spawn(async { current() }).await.unwrap()
276        })
277        .await;
278        assert_eq!(seen, None);
279    }
280}