mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-20 16:02:19 +00:00
fad1a549d9
* feat(otel): propagate inbound W3C traceparent to job spans Capture the inbound traceparent header at the run endpoints (WebhookArgs::to_args_from_format) into a reserved _wm_traceparent arg key (gated on OTEL_TRACING_ENABLED), riding the args jsonb like _ENTRYPOINT_OVERRIDE. At pickup, create_span_with_name attaches a span link from the job's worker span to the originating distributed trace, so a job triggered by an instrumented service is connected to the caller's trace while keeping its UUID-derived trace id (trace-by-job-id unaffected). The link/parse logic lives in the EE otel modules; this OSS side only captures the header and calls the (no-op outside EE) hook. Companion EE PR required. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: bump ee-repo-ref to inbound-trace-propagation EE branch Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(agents): don't attribute work to specific customers in repo content Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(otel): relocate job + script spans into the inbound trace Builds on the captured _wm_traceparent: the worker job span is re-parented on the inbound caller context, the script subprocess's TRACEPARENT env is the inbound context (so its spans join the caller's trace), and the context is propagated to flow steps so the whole flow relocates. Carried to the worker via a new LogContext.inbound_traceparent field. Non-inbound jobs are unchanged. Adds a relocation integration test. Companion EE PR required. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: bump ee-repo-ref to inbound-trace-propagation relocate commit Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(otel): harden inbound traceparent capture Address review feedback: - strip any caller-supplied _wm_traceparent from args/extra before stashing the header-captured value, so the reserved key is Windmill-controlled only - valid_w3c_traceparent: reject version ff and require lowercase hex, so we don't forward an inbound header that downstream OTel parsers would reject - clarify that the capture helper does not validate the W3C format (done at use) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: update ee-repo-ref to 2c7964460327fab5e3a27c0f74b8d6f26ab7f79a This commit updates the EE repository reference after PR #604 was merged in windmill-ee-private. Previous ee-repo-ref: 8fc04fb105dc49769205f7174d551a0d134d1bec New ee-repo-ref: 2c7964460327fab5e3a27c0f74b8d6f26ab7f79a Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
630 lines
24 KiB
Rust
630 lines
24 KiB
Rust
//! E2E tests for OpenTelemetry integration.
|
|
//!
|
|
//! Verify that metrics are recorded with correct names/values/attributes and
|
|
//! spans are created with correct trace IDs, attributes, and status codes.
|
|
//!
|
|
//! Run with: cargo test --features enterprise,private,otel --test otel -- --test-threads=1
|
|
|
|
#![cfg(all(feature = "otel", feature = "enterprise"))]
|
|
|
|
use std::sync::{atomic::Ordering, Arc};
|
|
|
|
use opentelemetry::global;
|
|
use opentelemetry::trace::TracerProvider as _;
|
|
use opentelemetry_sdk::{
|
|
metrics::{InMemoryMetricExporter, PeriodicReader, SdkMeterProvider},
|
|
trace::{InMemorySpanExporter, SdkTracerProvider, SimpleSpanProcessor},
|
|
};
|
|
use windmill_common::otel_ee::*;
|
|
use windmill_common::{OTEL_METRICS_ENABLED, OTEL_TRACING_ENABLED};
|
|
|
|
// ── Global test infrastructure ──────────────────────────────────────────
|
|
|
|
struct OtelTestState {
|
|
metric_exporter: InMemoryMetricExporter,
|
|
span_exporter: InMemorySpanExporter,
|
|
meter_provider: SdkMeterProvider,
|
|
}
|
|
|
|
static STATE: tokio::sync::OnceCell<Arc<OtelTestState>> = tokio::sync::OnceCell::const_new();
|
|
|
|
async fn ensure_setup() -> Arc<OtelTestState> {
|
|
STATE
|
|
.get_or_init(|| async {
|
|
// Metrics: InMemoryMetricExporter + PeriodicReader (needs async tokio context)
|
|
let metric_exporter = InMemoryMetricExporter::default();
|
|
let reader = PeriodicReader::builder(metric_exporter.clone()).build();
|
|
let meter_provider = SdkMeterProvider::builder().with_reader(reader).build();
|
|
global::set_meter_provider(meter_provider.clone());
|
|
OTEL_METRICS_ENABLED.store(true, Ordering::SeqCst);
|
|
|
|
// Tracing: InMemorySpanExporter + SimpleSpanProcessor
|
|
let span_exporter = InMemorySpanExporter::default();
|
|
let tracer_provider = SdkTracerProvider::builder()
|
|
.with_span_processor(SimpleSpanProcessor::new(span_exporter.clone()))
|
|
.build();
|
|
let tracer = tracer_provider.tracer("windmill");
|
|
*TRACER.write().unwrap() = Some(tracer);
|
|
OTEL_TRACING_ENABLED.store(true, Ordering::SeqCst);
|
|
|
|
Arc::new(OtelTestState { metric_exporter, span_exporter, meter_provider })
|
|
})
|
|
.await
|
|
.clone()
|
|
}
|
|
|
|
// ── Metric helper: flush + collect ──────────────────────────────────────
|
|
|
|
fn flush_and_get_metrics(
|
|
state: &OtelTestState,
|
|
) -> Vec<opentelemetry_sdk::metrics::data::ResourceMetrics> {
|
|
state.meter_provider.force_flush().expect("flush failed");
|
|
state
|
|
.metric_exporter
|
|
.get_finished_metrics()
|
|
.expect("get_finished_metrics failed")
|
|
}
|
|
|
|
fn find_metric<'a>(
|
|
all: &'a [opentelemetry_sdk::metrics::data::ResourceMetrics],
|
|
name: &str,
|
|
) -> Option<&'a opentelemetry_sdk::metrics::data::Metric> {
|
|
all.iter()
|
|
.flat_map(|rm| rm.scope_metrics())
|
|
.flat_map(|sm| sm.metrics())
|
|
.find(|m| m.name() == name)
|
|
}
|
|
|
|
fn metric_names(all: &[opentelemetry_sdk::metrics::data::ResourceMetrics]) -> Vec<String> {
|
|
all.iter()
|
|
.flat_map(|rm| rm.scope_metrics())
|
|
.flat_map(|sm| sm.metrics())
|
|
.map(|m| m.name().to_string())
|
|
.collect()
|
|
}
|
|
|
|
// ── Counter value helpers ───────────────────────────────────────────────
|
|
|
|
fn sum_u64_value(metric: &opentelemetry_sdk::metrics::data::Metric) -> Option<u64> {
|
|
use opentelemetry_sdk::metrics::data::{AggregatedMetrics, MetricData};
|
|
match metric.data() {
|
|
AggregatedMetrics::U64(MetricData::Sum(sum)) => {
|
|
Some(sum.data_points().map(|dp| dp.value()).sum())
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn gauge_i64_values(
|
|
metric: &opentelemetry_sdk::metrics::data::Metric,
|
|
) -> Vec<(Vec<opentelemetry::KeyValue>, i64)> {
|
|
use opentelemetry_sdk::metrics::data::{AggregatedMetrics, MetricData};
|
|
match metric.data() {
|
|
AggregatedMetrics::I64(MetricData::Gauge(gauge)) => gauge
|
|
.data_points()
|
|
.map(|dp| (dp.attributes().cloned().collect(), dp.value()))
|
|
.collect(),
|
|
_ => panic!("expected I64 Gauge metric"),
|
|
}
|
|
}
|
|
|
|
fn gauge_f64_value(metric: &opentelemetry_sdk::metrics::data::Metric) -> Option<f64> {
|
|
use opentelemetry_sdk::metrics::data::{AggregatedMetrics, MetricData};
|
|
match metric.data() {
|
|
AggregatedMetrics::F64(MetricData::Gauge(gauge)) => {
|
|
gauge.data_points().next().map(|dp| dp.value())
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn histogram_f64_count(metric: &opentelemetry_sdk::metrics::data::Metric) -> Option<u64> {
|
|
use opentelemetry_sdk::metrics::data::{AggregatedMetrics, MetricData};
|
|
match metric.data() {
|
|
AggregatedMetrics::F64(MetricData::Histogram(hist)) => {
|
|
Some(hist.data_points().map(|dp| dp.count()).sum())
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn histogram_f64_sum(metric: &opentelemetry_sdk::metrics::data::Metric) -> Option<f64> {
|
|
use opentelemetry_sdk::metrics::data::{AggregatedMetrics, MetricData};
|
|
match metric.data() {
|
|
AggregatedMetrics::F64(MetricData::Histogram(hist)) => {
|
|
Some(hist.data_points().map(|dp| dp.sum()).sum())
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
// METRICS E2E TEST
|
|
//
|
|
// All metric assertions live in one test function because the PeriodicReader's
|
|
// background task is tied to the tokio runtime that created it. Separate
|
|
// #[tokio::test] functions each get their own runtime, and the reader becomes
|
|
// disconnected after the first test's runtime is dropped.
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn test_all_metrics_e2e() {
|
|
let state = ensure_setup().await;
|
|
|
|
// ── Counters ────────────────────────────────────────────────────
|
|
|
|
otel_incr_queue_push_count();
|
|
otel_incr_queue_push_count();
|
|
otel_incr_queue_push_count();
|
|
otel_incr_queue_delete_count();
|
|
otel_incr_queue_pull_count();
|
|
otel_incr_zombie_restart_count(7);
|
|
otel_incr_zombie_delete_count(3);
|
|
otel_incr_worker_execution_count("bun");
|
|
otel_incr_worker_execution_count("bun");
|
|
otel_incr_worker_execution_failed("go");
|
|
otel_incr_worker_started();
|
|
|
|
// ── Gauges ──────────────────────────────────────────────────────
|
|
|
|
otel_set_queue_count("python3", 42);
|
|
otel_set_queue_running_count("deno", 5);
|
|
otel_set_worker_busy("worker-test-1", 1);
|
|
otel_set_db_pool(5, 10, 20);
|
|
otel_set_health_db_latency(2.5);
|
|
otel_set_worker_uptime("w-uptime", 3600.0);
|
|
otel_set_health_status_phase("healthy");
|
|
otel_set_health_db_unresponsive(true);
|
|
|
|
// ── Histograms ──────────────────────────────────────────────────
|
|
|
|
otel_record_worker_execution_duration("python3", 1.5);
|
|
otel_record_worker_execution_duration("python3", 2.5);
|
|
otel_record_worker_pull_duration("w1", true, 0.05);
|
|
otel_record_worker_pull_duration("w1", false, 0.01);
|
|
|
|
// ── Flush and collect ───────────────────────────────────────────
|
|
|
|
let metrics = flush_and_get_metrics(&state);
|
|
let names = metric_names(&metrics);
|
|
|
|
// ── Verify all 20 metric names are present ──────────────────────
|
|
|
|
let expected = [
|
|
"windmill.queue.push_count",
|
|
"windmill.queue.delete_count",
|
|
"windmill.queue.pull_count",
|
|
"windmill.queue.zombie_restart_count",
|
|
"windmill.queue.zombie_delete_count",
|
|
"windmill.queue.count",
|
|
"windmill.queue.running_count",
|
|
"windmill.worker.execution_count",
|
|
"windmill.worker.execution_duration",
|
|
"windmill.worker.busy",
|
|
"windmill.worker.pull_duration",
|
|
"windmill.worker.execution_failed",
|
|
"windmill.db.pool.active",
|
|
"windmill.db.pool.idle",
|
|
"windmill.db.pool.max",
|
|
"windmill.health.db_latency",
|
|
"windmill.worker.started",
|
|
"windmill.worker.uptime",
|
|
"windmill.health.status",
|
|
"windmill.health.db_unresponsive",
|
|
];
|
|
for name in expected {
|
|
assert!(
|
|
names.iter().any(|n| n == name),
|
|
"metric '{}' not found in {:?}",
|
|
name,
|
|
names
|
|
);
|
|
}
|
|
|
|
// ── Counter values ──────────────────────────────────────────────
|
|
|
|
let m = find_metric(&metrics, "windmill.queue.push_count").unwrap();
|
|
assert!(sum_u64_value(m).unwrap() >= 3, "push_count should be >= 3");
|
|
|
|
let m = find_metric(&metrics, "windmill.queue.delete_count").unwrap();
|
|
assert!(sum_u64_value(m).unwrap() >= 1);
|
|
|
|
let m = find_metric(&metrics, "windmill.queue.pull_count").unwrap();
|
|
assert!(sum_u64_value(m).unwrap() >= 1);
|
|
|
|
let m = find_metric(&metrics, "windmill.queue.zombie_restart_count").unwrap();
|
|
assert!(sum_u64_value(m).unwrap() >= 7);
|
|
|
|
let m = find_metric(&metrics, "windmill.queue.zombie_delete_count").unwrap();
|
|
assert!(sum_u64_value(m).unwrap() >= 3);
|
|
|
|
let m = find_metric(&metrics, "windmill.worker.execution_count").unwrap();
|
|
assert!(sum_u64_value(m).unwrap() >= 2);
|
|
|
|
let m = find_metric(&metrics, "windmill.worker.execution_failed").unwrap();
|
|
assert!(sum_u64_value(m).unwrap() >= 1);
|
|
|
|
let m = find_metric(&metrics, "windmill.worker.started").unwrap();
|
|
assert!(sum_u64_value(m).unwrap() >= 1);
|
|
|
|
// ── Gauge values ────────────────────────────────────────────────
|
|
|
|
let m = find_metric(&metrics, "windmill.queue.count").unwrap();
|
|
let values = gauge_i64_values(m);
|
|
let dp = values
|
|
.iter()
|
|
.find(|(attrs, _)| {
|
|
attrs
|
|
.iter()
|
|
.any(|kv| kv.key.as_str() == "tag" && kv.value.as_str() == "python3")
|
|
})
|
|
.expect("queue.count data point with tag=python3 not found");
|
|
assert_eq!(dp.1, 42);
|
|
|
|
let m = find_metric(&metrics, "windmill.queue.running_count").unwrap();
|
|
let values = gauge_i64_values(m);
|
|
let dp = values
|
|
.iter()
|
|
.find(|(attrs, _)| {
|
|
attrs
|
|
.iter()
|
|
.any(|kv| kv.key.as_str() == "tag" && kv.value.as_str() == "deno")
|
|
})
|
|
.expect("running_count data point with tag=deno not found");
|
|
assert_eq!(dp.1, 5);
|
|
|
|
let m = find_metric(&metrics, "windmill.worker.busy").unwrap();
|
|
let values = gauge_i64_values(m);
|
|
let dp = values
|
|
.iter()
|
|
.find(|(attrs, _)| {
|
|
attrs
|
|
.iter()
|
|
.any(|kv| kv.key.as_str() == "worker" && kv.value.as_str() == "worker-test-1")
|
|
})
|
|
.expect("worker.busy data point with worker=worker-test-1 not found");
|
|
assert_eq!(dp.1, 1);
|
|
|
|
let m = find_metric(&metrics, "windmill.db.pool.active").unwrap();
|
|
assert_eq!(gauge_i64_values(m)[0].1, 5);
|
|
let m = find_metric(&metrics, "windmill.db.pool.idle").unwrap();
|
|
assert_eq!(gauge_i64_values(m)[0].1, 10);
|
|
let m = find_metric(&metrics, "windmill.db.pool.max").unwrap();
|
|
assert_eq!(gauge_i64_values(m)[0].1, 20);
|
|
|
|
let m = find_metric(&metrics, "windmill.health.db_latency").unwrap();
|
|
assert!((gauge_f64_value(m).unwrap() - 2.5).abs() < f64::EPSILON);
|
|
|
|
let m = find_metric(&metrics, "windmill.worker.uptime").unwrap();
|
|
assert!((gauge_f64_value(m).unwrap() - 3600.0).abs() < f64::EPSILON);
|
|
|
|
let m = find_metric(&metrics, "windmill.health.db_unresponsive").unwrap();
|
|
assert_eq!(gauge_i64_values(m)[0].1, 1);
|
|
|
|
// ── Health status phase (all 3 phases) ──────────────────────────
|
|
|
|
let m = find_metric(&metrics, "windmill.health.status").unwrap();
|
|
let values = gauge_i64_values(m);
|
|
let healthy = values
|
|
.iter()
|
|
.find(|(attrs, _)| {
|
|
attrs
|
|
.iter()
|
|
.any(|kv| kv.key.as_str() == "phase" && kv.value.as_str() == "healthy")
|
|
})
|
|
.expect("phase=healthy");
|
|
let degraded = values
|
|
.iter()
|
|
.find(|(attrs, _)| {
|
|
attrs
|
|
.iter()
|
|
.any(|kv| kv.key.as_str() == "phase" && kv.value.as_str() == "degraded")
|
|
})
|
|
.expect("phase=degraded");
|
|
let unhealthy = values
|
|
.iter()
|
|
.find(|(attrs, _)| {
|
|
attrs
|
|
.iter()
|
|
.any(|kv| kv.key.as_str() == "phase" && kv.value.as_str() == "unhealthy")
|
|
})
|
|
.expect("phase=unhealthy");
|
|
assert_eq!(healthy.1, 1);
|
|
assert_eq!(degraded.1, 0);
|
|
assert_eq!(unhealthy.1, 0);
|
|
|
|
// ── Histogram values ────────────────────────────────────────────
|
|
|
|
let m = find_metric(&metrics, "windmill.worker.execution_duration").unwrap();
|
|
assert!(histogram_f64_count(m).unwrap() >= 2);
|
|
assert!(histogram_f64_sum(m).unwrap() >= 4.0);
|
|
|
|
let m = find_metric(&metrics, "windmill.worker.pull_duration").unwrap();
|
|
assert!(histogram_f64_count(m).unwrap() >= 2);
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
// SPAN E2E TESTS
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
|
|
fn make_test_job(id: uuid::Uuid, parent: Option<uuid::Uuid>) -> windmill_queue::MiniPulledJob {
|
|
use windmill_types::jobs::JobKind;
|
|
let mut job = windmill_queue::MiniPulledJob::new_inline(
|
|
"test-workspace".to_string(),
|
|
None,
|
|
"test-user".to_string(),
|
|
"u/test-user".to_string(),
|
|
"test@example.com".to_string(),
|
|
Some("f/test/script".to_string()),
|
|
JobKind::Script,
|
|
None,
|
|
"deno".to_string(),
|
|
None,
|
|
);
|
|
job.id = id;
|
|
job.parent_job = parent;
|
|
job.started_at = Some(chrono::Utc::now());
|
|
job
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn test_root_job_span_created_on_success() {
|
|
let state = ensure_setup().await;
|
|
state.span_exporter.reset();
|
|
|
|
let job_id = uuid::Uuid::new_v4();
|
|
let job = make_test_job(job_id, None);
|
|
windmill_worker::otel_ee::add_root_flow_job_to_otlp(&job, true);
|
|
|
|
let spans = state.span_exporter.get_finished_spans().unwrap();
|
|
let span = spans
|
|
.iter()
|
|
.find(|s| s.name == "full_job")
|
|
.expect("full_job span not found");
|
|
|
|
assert_eq!(span.status, opentelemetry::trace::Status::Ok,);
|
|
|
|
// Verify attributes
|
|
let attrs: Vec<_> = span.attributes.iter().map(|kv| kv.key.as_str()).collect();
|
|
assert!(attrs.contains(&"job_id"), "missing job_id attribute");
|
|
assert!(
|
|
attrs.contains(&"workspace_id"),
|
|
"missing workspace_id attribute"
|
|
);
|
|
assert!(
|
|
attrs.contains(&"script_path"),
|
|
"missing script_path attribute"
|
|
);
|
|
assert!(attrs.contains(&"job_kind"), "missing job_kind attribute");
|
|
assert!(
|
|
attrs.contains(&"created_by"),
|
|
"missing created_by attribute"
|
|
);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn test_root_job_span_error_on_failure() {
|
|
let state = ensure_setup().await;
|
|
state.span_exporter.reset();
|
|
|
|
let job_id = uuid::Uuid::new_v4();
|
|
let job = make_test_job(job_id, None);
|
|
windmill_worker::otel_ee::add_root_flow_job_to_otlp(&job, false);
|
|
|
|
let spans = state.span_exporter.get_finished_spans().unwrap();
|
|
let span = spans
|
|
.iter()
|
|
.find(|s| s.name == "full_job")
|
|
.expect("full_job span not found");
|
|
|
|
match &span.status {
|
|
opentelemetry::trace::Status::Error { description } => {
|
|
assert_eq!(description.as_ref(), "Job failed");
|
|
}
|
|
other => panic!("expected Error status, got {:?}", other),
|
|
}
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn test_root_job_trace_id_matches_uuid() {
|
|
let state = ensure_setup().await;
|
|
state.span_exporter.reset();
|
|
|
|
let job_id = uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
|
|
let job = make_test_job(job_id, None);
|
|
windmill_worker::otel_ee::add_root_flow_job_to_otlp(&job, true);
|
|
|
|
let spans = state.span_exporter.get_finished_spans().unwrap();
|
|
let span = spans
|
|
.iter()
|
|
.find(|s| s.name == "full_job")
|
|
.expect("full_job span not found");
|
|
|
|
let expected_trace_id =
|
|
opentelemetry::trace::TraceId::from_bytes(job_id.as_u128().to_be_bytes());
|
|
assert_eq!(span.span_context.trace_id(), expected_trace_id);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn test_root_job_span_id_matches_uuid() {
|
|
let state = ensure_setup().await;
|
|
state.span_exporter.reset();
|
|
|
|
let job_id = uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
|
|
let job = make_test_job(job_id, None);
|
|
windmill_worker::otel_ee::add_root_flow_job_to_otlp(&job, true);
|
|
|
|
let spans = state.span_exporter.get_finished_spans().unwrap();
|
|
let span = spans
|
|
.iter()
|
|
.find(|s| s.name == "full_job")
|
|
.expect("full_job span not found");
|
|
|
|
let expected_span_id =
|
|
opentelemetry::trace::SpanId::from_bytes(job_id.as_u64_pair().1.to_be_bytes());
|
|
assert_eq!(span.span_context.span_id(), expected_span_id);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn test_child_job_produces_no_span() {
|
|
let state = ensure_setup().await;
|
|
state.span_exporter.reset();
|
|
|
|
let parent_id = uuid::Uuid::new_v4();
|
|
let job_id = uuid::Uuid::new_v4();
|
|
let job = make_test_job(job_id, Some(parent_id));
|
|
windmill_worker::otel_ee::add_root_flow_job_to_otlp(&job, true);
|
|
|
|
let spans = state.span_exporter.get_finished_spans().unwrap();
|
|
let found = spans.iter().any(|s| s.name == "full_job");
|
|
assert!(!found, "child job should not produce a span");
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn test_root_job_span_attributes_values() {
|
|
let state = ensure_setup().await;
|
|
state.span_exporter.reset();
|
|
|
|
let job_id = uuid::Uuid::new_v4();
|
|
let job = make_test_job(job_id, None);
|
|
windmill_worker::otel_ee::add_root_flow_job_to_otlp(&job, true);
|
|
|
|
let spans = state.span_exporter.get_finished_spans().unwrap();
|
|
let span = spans
|
|
.iter()
|
|
.find(|s| s.name == "full_job")
|
|
.expect("full_job span not found");
|
|
|
|
let get_attr = |key: &str| -> String {
|
|
span.attributes
|
|
.iter()
|
|
.find(|kv| kv.key.as_str() == key)
|
|
.map(|kv| kv.value.as_str().to_string())
|
|
.unwrap_or_default()
|
|
};
|
|
|
|
assert_eq!(get_attr("job_id"), job_id.to_string());
|
|
assert_eq!(get_attr("workspace_id"), "test-workspace");
|
|
assert_eq!(get_attr("script_path"), "f/test/script");
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
// INBOUND TRACE CONTEXT (W3C traceparent → span link)
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
|
|
const SAMPLE_TRACEPARENT: &str = "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01";
|
|
|
|
fn sample_trace_id() -> opentelemetry::trace::TraceId {
|
|
opentelemetry::trace::TraceId::from_hex("0af7651916cd43dd8448eb211c80319c").unwrap()
|
|
}
|
|
|
|
fn sample_span_id() -> opentelemetry::trace::SpanId {
|
|
opentelemetry::trace::SpanId::from_hex("b7ad6b7169203331").unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn test_span_cx_from_traceparent_valid() {
|
|
let cx = span_cx_from_traceparent(SAMPLE_TRACEPARENT).expect("valid traceparent");
|
|
assert_eq!(cx.trace_id(), sample_trace_id());
|
|
assert_eq!(cx.span_id(), sample_span_id());
|
|
assert!(cx.is_remote());
|
|
assert!(cx.is_sampled());
|
|
}
|
|
|
|
#[test]
|
|
fn test_span_cx_from_traceparent_unsampled_flag() {
|
|
let cx = span_cx_from_traceparent("00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-00")
|
|
.expect("valid traceparent");
|
|
assert!(!cx.is_sampled());
|
|
}
|
|
|
|
#[test]
|
|
fn test_span_cx_from_traceparent_malformed() {
|
|
for bad in [
|
|
"",
|
|
"garbage",
|
|
"00-tooshort-b7ad6b7169203331-01",
|
|
// missing flags field
|
|
"00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331",
|
|
// trailing extra field
|
|
"00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01-extra",
|
|
// all-zero trace id / span id are invalid per the spec
|
|
"00-00000000000000000000000000000000-b7ad6b7169203331-01",
|
|
"00-0af7651916cd43dd8448eb211c80319c-0000000000000000-01",
|
|
// non-hex
|
|
"00-zzf7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01",
|
|
] {
|
|
assert!(
|
|
span_cx_from_traceparent(bad).is_none(),
|
|
"expected None for {bad:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
fn job_with_traceparent(tp: Option<&str>) -> windmill_queue::MiniPulledJob {
|
|
let mut job = make_test_job(uuid::Uuid::new_v4(), None);
|
|
if let Some(tp) = tp {
|
|
let mut args = std::collections::HashMap::new();
|
|
args.insert(
|
|
windmill_common::jobs::WM_TRACEPARENT.to_string(),
|
|
windmill_common::worker::to_raw_value(&tp),
|
|
);
|
|
job.args = Some(sqlx::types::Json(args));
|
|
}
|
|
job
|
|
}
|
|
|
|
#[test]
|
|
fn test_inbound_span_cx_from_job_present() {
|
|
let job = job_with_traceparent(Some(SAMPLE_TRACEPARENT));
|
|
let cx = windmill_worker::otel_ee::inbound_span_cx_from_job(&job).expect("link expected");
|
|
assert_eq!(cx.trace_id(), sample_trace_id());
|
|
assert_eq!(cx.span_id(), sample_span_id());
|
|
}
|
|
|
|
#[test]
|
|
fn test_inbound_span_cx_from_job_absent_or_malformed() {
|
|
// No reserved key (e.g. a flow step or internally-created job) → no link.
|
|
assert!(
|
|
windmill_worker::otel_ee::inbound_span_cx_from_job(&job_with_traceparent(None)).is_none()
|
|
);
|
|
// Malformed header is ignored rather than producing a bogus link.
|
|
assert!(
|
|
windmill_worker::otel_ee::inbound_span_cx_from_job(&job_with_traceparent(Some("garbage")))
|
|
.is_none()
|
|
);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn test_root_job_span_relocated_to_inbound_trace() {
|
|
let state = ensure_setup().await;
|
|
state.span_exporter.reset();
|
|
|
|
let job = job_with_traceparent(Some(SAMPLE_TRACEPARENT));
|
|
let job_id = job.id;
|
|
windmill_worker::otel_ee::add_root_flow_job_to_otlp(&job, true);
|
|
|
|
let spans = state.span_exporter.get_finished_spans().unwrap();
|
|
let span = spans
|
|
.iter()
|
|
.find(|s| s.name == "full_job")
|
|
.expect("full_job span not found");
|
|
|
|
// Relocated into the inbound trace, keeping the job-UUID-derived span id and
|
|
// parented on the inbound caller span.
|
|
assert_eq!(span.span_context.trace_id(), sample_trace_id());
|
|
let expected_span_id =
|
|
opentelemetry::trace::SpanId::from_bytes(job_id.as_u64_pair().1.to_be_bytes());
|
|
assert_eq!(span.span_context.span_id(), expected_span_id);
|
|
assert_eq!(span.parent_span_id, sample_span_id());
|
|
|
|
// Linked back to the UUID-derived context so trace-by-job-id still resolves.
|
|
assert_eq!(span.links.links.len(), 1);
|
|
let expected_uuid_trace =
|
|
opentelemetry::trace::TraceId::from_bytes(job_id.as_u128().to_be_bytes());
|
|
assert_eq!(
|
|
span.links.links[0].span_context.trace_id(),
|
|
expected_uuid_trace
|
|
);
|
|
}
|