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}