mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-12 08:05:44 +00:00
* fix: enrich OTEL log records with per-request LogContext Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: add otlp_smoke example for manual OTEL log bridge verification Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: update ee-repo-ref to 5d6b713b74fc46735807f5c32883002e8d976fbc This commit updates the EE repository reference after PR #529 was merged in windmill-ee-private. Previous ee-repo-ref: 45959d063bc941c567488d330b5819601cdd2d3d New ee-repo-ref: 5d6b713b74fc46735807f5c32883002e8d976fbc Automated by sync-ee-ref workflow. * refactor: store LogContext in ArcSwap instead of Mutex Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: pin ee-repo-ref to ArcSwap branch commit * chore: update ee-repo-ref to be2f3d4d11bb7110200524d7157caab3aac53996 This commit updates the EE repository reference after PR #530 was merged in windmill-ee-private. Previous ee-repo-ref: 45b4d7963a9ebcd583d1a87abe7d07d3d521584a New ee-repo-ref: be2f3d4d11bb7110200524d7157caab3aac53996 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
245 lines
8.6 KiB
Rust
245 lines
8.6 KiB
Rust
/*
|
|
* Author: Ruben Fiszel
|
|
* Copyright: Windmill Labs, Inc 2022
|
|
* This file and its contents are licensed under the AGPLv3 License.
|
|
* Please see the included NOTICE for copyright information and
|
|
* LICENSE-AGPL for a copy of the license.
|
|
*/
|
|
|
|
//! Per-task enrichment context for OTEL log export.
|
|
//!
|
|
//! This is the MDC-equivalent for windmill: a tokio task-local that carries
|
|
//! request/job identifiers from the entry point (axum middleware in
|
|
//! windmill-api, job execution wrapper in windmill-worker) to log emission
|
|
//! sites. The EE OTEL log bridge reads this at event time and attaches the
|
|
//! fields to exported LogRecords, so Sentry / OTLP backends can filter logs
|
|
//! by workspace_id, email, script_path, etc., without joining against
|
|
//! traces.
|
|
//!
|
|
//! The context is stored as `ArcSwap<LogContext>` inside the task-local,
|
|
//! which means reads are effectively free (atomic load of an Arc) and
|
|
//! writes are immutable swaps. There is no lock — so no reentrant-deadlock
|
|
//! risk, no mutex poisoning, and no stale-read race. The tradeoff is that
|
|
//! `update_log_context` takes an `FnOnce(&LogContext) -> LogContext`
|
|
//! closure that must return a new context value instead of mutating the
|
|
//! existing one in place.
|
|
|
|
use arc_swap::ArcSwap;
|
|
use std::sync::Arc;
|
|
|
|
/// Fields promoted from request/job context onto exported log records.
|
|
#[derive(Clone, Debug, Default)]
|
|
pub struct LogContext {
|
|
// HTTP request span (windmill-api/src/tracing_init.rs)
|
|
pub method: Option<String>,
|
|
pub uri: Option<String>,
|
|
pub trace_id: Option<String>,
|
|
|
|
// Auth (windmill-api-auth/src/auth.rs)
|
|
pub email: Option<String>,
|
|
pub username: Option<String>,
|
|
|
|
// Workspace
|
|
pub workspace_id: Option<String>,
|
|
|
|
// Worker / job span (windmill-worker/src/worker.rs)
|
|
pub worker: Option<String>,
|
|
pub hostname: Option<String>,
|
|
pub tag: Option<String>,
|
|
pub job_id: Option<String>,
|
|
pub parent_job: Option<String>,
|
|
pub root_job: Option<String>,
|
|
pub script_path: Option<String>,
|
|
pub script_hash: Option<String>,
|
|
pub job_kind: Option<String>,
|
|
pub language: Option<String>,
|
|
pub flow_step_id: Option<String>,
|
|
pub trigger_kind: Option<String>,
|
|
pub trigger: Option<String>,
|
|
pub created_by: Option<String>,
|
|
}
|
|
|
|
tokio::task_local! {
|
|
pub static LOG_CONTEXT: ArcSwap<LogContext>;
|
|
}
|
|
|
|
/// Run a future inside a freshly-seeded LogContext scope.
|
|
///
|
|
/// Use at request/job entry points. Downstream code can read the context
|
|
/// via [`current_log_context`] and mutate it via [`update_log_context`].
|
|
pub async fn with_log_context<F>(ctx: LogContext, fut: F) -> F::Output
|
|
where
|
|
F: std::future::Future,
|
|
{
|
|
LOG_CONTEXT.scope(ArcSwap::from_pointee(ctx), fut).await
|
|
}
|
|
|
|
/// Snapshot the current LogContext. Returns `None` outside any scope.
|
|
///
|
|
/// The returned `Arc<LogContext>` is a zero-cost view of the context *at
|
|
/// the moment of the call*. Subsequent `update_log_context` calls on the
|
|
/// same task will atomically swap in a new Arc — this snapshot is
|
|
/// unaffected.
|
|
pub fn current_log_context() -> Option<Arc<LogContext>> {
|
|
LOG_CONTEXT.try_with(|c| c.load_full()).ok()
|
|
}
|
|
|
|
/// Replace the current LogContext with a new value derived from the
|
|
/// previous one. No-op outside a scope.
|
|
///
|
|
/// The closure receives the current context by reference and must return
|
|
/// a new owned context. Callers typically use functional record update
|
|
/// syntax:
|
|
///
|
|
/// ```ignore
|
|
/// update_log_context(|c| LogContext {
|
|
/// email: Some("alice@acme.co".into()),
|
|
/// ..(**c).clone()
|
|
/// });
|
|
/// ```
|
|
///
|
|
/// There is no lock held while the closure runs, so reentrant calls
|
|
/// (`update_log_context` inside the closure) are safe — they'll operate on
|
|
/// the intermediate state and may lose the intermediate write if the
|
|
/// enclosing call races, but they cannot deadlock.
|
|
pub fn update_log_context<F>(f: F)
|
|
where
|
|
F: FnOnce(&LogContext) -> LogContext,
|
|
{
|
|
let _ = LOG_CONTEXT.try_with(|c| {
|
|
let current = c.load_full();
|
|
let next = Arc::new(f(¤t));
|
|
c.store(next);
|
|
});
|
|
}
|
|
|
|
/// Spawn a future with a snapshot of the current LogContext forwarded to
|
|
/// the new task. Use in place of `tokio::spawn` when you want the spawned
|
|
/// task's logs to inherit the calling task's context.
|
|
///
|
|
/// The snapshot is a shared `Arc` captured at spawn time — mutations in
|
|
/// either task after that point are independent because each task's
|
|
/// `ArcSwap` is its own task-local slot.
|
|
pub fn spawn_with_log_context<F>(fut: F) -> tokio::task::JoinHandle<F::Output>
|
|
where
|
|
F: std::future::Future + Send + 'static,
|
|
F::Output: Send + 'static,
|
|
{
|
|
let snapshot = current_log_context();
|
|
tokio::spawn(async move {
|
|
match snapshot {
|
|
Some(arc) => LOG_CONTEXT.scope(ArcSwap::new(arc), fut).await,
|
|
None => fut.await,
|
|
}
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[tokio::test]
|
|
async fn reads_and_mutates_current_context() {
|
|
let ctx = LogContext {
|
|
workspace_id: Some("acme".into()),
|
|
email: Some("alice@acme.co".into()),
|
|
..Default::default()
|
|
};
|
|
with_log_context(ctx, async {
|
|
let snap = current_log_context().expect("in scope");
|
|
assert_eq!(snap.workspace_id.as_deref(), Some("acme"));
|
|
assert_eq!(snap.email.as_deref(), Some("alice@acme.co"));
|
|
|
|
update_log_context(|c| LogContext {
|
|
script_path: Some("f/ingest/run".into()),
|
|
..c.clone()
|
|
});
|
|
|
|
let snap = current_log_context().expect("in scope");
|
|
assert_eq!(snap.script_path.as_deref(), Some("f/ingest/run"));
|
|
// Old fields are preserved by functional update:
|
|
assert_eq!(snap.workspace_id.as_deref(), Some("acme"));
|
|
})
|
|
.await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn outside_scope_returns_none() {
|
|
assert!(current_log_context().is_none());
|
|
// update_log_context is a no-op outside a scope, doesn't panic
|
|
update_log_context(|c| LogContext { workspace_id: Some("ignored".into()), ..c.clone() });
|
|
assert!(current_log_context().is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn spawn_inherits_snapshot() {
|
|
let ctx = LogContext { workspace_id: Some("acme".into()), ..Default::default() };
|
|
with_log_context(ctx, async {
|
|
let handle = spawn_with_log_context(async {
|
|
current_log_context()
|
|
.expect("inherited in spawned task")
|
|
.workspace_id
|
|
.clone()
|
|
});
|
|
let ws = handle.await.unwrap();
|
|
assert_eq!(ws.as_deref(), Some("acme"));
|
|
})
|
|
.await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn bare_spawn_has_no_context() {
|
|
let ctx = LogContext { workspace_id: Some("acme".into()), ..Default::default() };
|
|
with_log_context(ctx, async {
|
|
let handle = tokio::spawn(async { current_log_context() });
|
|
assert!(
|
|
handle.await.unwrap().is_none(),
|
|
"bare tokio::spawn drops context"
|
|
);
|
|
})
|
|
.await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn spawn_snapshot_is_independent_of_parent_mutations() {
|
|
let ctx = LogContext { workspace_id: Some("parent".into()), ..Default::default() };
|
|
with_log_context(ctx, async {
|
|
let handle = spawn_with_log_context(async {
|
|
tokio::task::yield_now().await;
|
|
current_log_context().unwrap().workspace_id.clone()
|
|
});
|
|
update_log_context(|c| LogContext {
|
|
workspace_id: Some("mutated".into()),
|
|
..c.clone()
|
|
});
|
|
let child_ws = handle.await.unwrap();
|
|
assert_eq!(child_ws.as_deref(), Some("parent"));
|
|
})
|
|
.await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_preserves_other_fields() {
|
|
let ctx = LogContext {
|
|
method: Some("POST".into()),
|
|
uri: Some("/api/w/acme/jobs".into()),
|
|
workspace_id: Some("acme".into()),
|
|
..Default::default()
|
|
};
|
|
with_log_context(ctx, async {
|
|
update_log_context(|c| LogContext {
|
|
email: Some("alice@acme.co".into()),
|
|
username: Some("alice".into()),
|
|
..c.clone()
|
|
});
|
|
let snap = current_log_context().unwrap();
|
|
assert_eq!(snap.method.as_deref(), Some("POST"));
|
|
assert_eq!(snap.uri.as_deref(), Some("/api/w/acme/jobs"));
|
|
assert_eq!(snap.workspace_id.as_deref(), Some("acme"));
|
|
assert_eq!(snap.email.as_deref(), Some("alice@acme.co"));
|
|
assert_eq!(snap.username.as_deref(), Some("alice"));
|
|
})
|
|
.await;
|
|
}
|
|
}
|