diff --git a/crates/base/test_cases/trace-context-callee/index.ts b/crates/base/test_cases/trace-context-callee/index.ts new file mode 100644 index 000000000..90b6009f4 --- /dev/null +++ b/crates/base/test_cases/trace-context-callee/index.ts @@ -0,0 +1,8 @@ +Deno.serve((req: Request) => + Response.json({ + traceparent: req.headers.get("traceparent"), + tracestate: req.headers.get("tracestate"), + baggage: req.headers.get("baggage"), + custom: req.headers.get("x-app-traceparent"), + }) +); diff --git a/crates/base/test_cases/trace-context-caller/index.ts b/crates/base/test_cases/trace-context-caller/index.ts new file mode 100644 index 000000000..6f76decc9 --- /dev/null +++ b/crates/base/test_cases/trace-context-caller/index.ts @@ -0,0 +1,53 @@ +import { createClient } from "npm:@supabase/supabase-js@2.112.3"; + +const traceparent = "00-b1e669305668dc82c96cc31ce2e6cd99-1a5b74eca03f769f-01"; + +Deno.serve(async (req: Request) => { + const origin = new URL(req.url).origin; + const client = createClient(origin, "test-key", { + auth: { + persistSession: false, + autoRefreshToken: false, + detectSessionInUrl: false, + }, + }); + + const results = await Promise.all( + ["fetch", "request", "invoke"].flatMap((method) => + ["present", "empty", "absent"].map(async (state) => { + const headers: Record = { + // Exercise case-insensitive detection and an unrelated header. + TraceParent: traceparent, + "x-app-traceparent": traceparent, + baggage: "caller=value", + }; + if (state !== "absent") { + headers.TraceState = state === "empty" ? "" : "caller=value"; + } + + let data; + if (method === "invoke") { + const result = await client.functions.invoke("callee", { + headers, + body: { hello: "world" }, + }); + if (result.error) throw result.error; + data = result.data; + } else { + const url = `${origin}/callee`; + const response = method === "request" + ? await fetch(new Request(url, { headers })) + : await fetch(url, { headers }); + if (!response.ok) throw new Error(await response.text()); + data = await response.json(); + } + return { method, state, ...data }; + }) + ), + ); + + // Automatic propagation must still work when no context is supplied. + const automatic = await fetch(`${origin}/callee`); + if (!automatic.ok) throw new Error(await automatic.text()); + return Response.json({ results, automatic: await automatic.json() }); +}); diff --git a/crates/base/test_cases/trace-context-main/index.ts b/crates/base/test_cases/trace-context-main/index.ts new file mode 100644 index 000000000..18262a65f --- /dev/null +++ b/crates/base/test_cases/trace-context-main/index.ts @@ -0,0 +1,20 @@ +Deno.serve(async (req: Request) => { + const name = new URL(req.url).pathname.split("/").pop(); + const isCaller = name === "caller" || name === "caller-untraced"; + if (!isCaller && name !== "callee") { + return new Response("Not found", { status: 404 }); + } + + const worker = await EdgeRuntime.userWorkers.create({ + servicePath: `./test_cases/trace-context-${isCaller ? "caller" : "callee"}`, + memoryLimitMb: 150, + workerTimeoutMs: 60_000, + cpuTimeSoftLimitMs: 60_000, + cpuTimeHardLimitMs: 60_000, + otelConfig: { + tracing_enabled: name !== "caller-untraced", + propagators: ["TraceContext", "Baggage"], + }, + }); + return await worker.fetch(req); +}); diff --git a/crates/base/tests/integration_tests.rs b/crates/base/tests/integration_tests.rs index 0cc559a6c..c41bac686 100644 --- a/crates/base/tests/integration_tests.rs +++ b/crates/base/tests/integration_tests.rs @@ -4271,6 +4271,87 @@ async fn test_brotli_async() { ); } +async fn assert_fetch_trace_context(tracing_enabled: bool) { + init_otel(); + let caller = if tracing_enabled { + "caller" + } else { + "caller-untraced" + }; + + integration_test_with_server_flag!( + ServerFlags::default(), + "./test_cases/trace-context-main", + NON_SECURE_PORT, + caller, + None, + Some( + reqwest::Client::new() + .get(format!("http://localhost:{NON_SECURE_PORT}/{caller}")) + .header( + "traceparent", + "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01", + ) + .header("tracestate", "upstream=value") + .header("baggage", "tenant=test") + ), + None::, + (|resp| async { + let resp = resp.unwrap(); + let status = resp.status(); + let body = resp.text().await.unwrap(); + assert_eq!(status.as_u16(), 200, "{body}"); + let data: serde_json::Value = serde_json::from_str(&body).unwrap(); + let results = data["results"].as_array().unwrap(); + assert_eq!(results.len(), 9); + for result in results { + assert_eq!( + result["traceparent"], + "00-b1e669305668dc82c96cc31ce2e6cd99-1a5b74eca03f769f-01", + "{result}", + ); + assert_eq!(result["custom"], result["traceparent"]); + match result["state"].as_str().unwrap() { + "present" => assert_eq!(result["tracestate"], "caller=value"), + "empty" => assert_eq!(result["tracestate"], ""), + "absent" => assert!(result["tracestate"].is_null()), + state => panic!("unexpected state: {state}"), + } + assert_eq!(result["baggage"], "caller=value"); + } + let automatic = &data["automatic"]; + if tracing_enabled { + let traceparent = automatic["traceparent"].as_str().unwrap(); + let parts: Vec<_> = traceparent.split('-').collect(); + assert_eq!(parts.len(), 4); + assert_eq!(parts[0], "00"); + assert_eq!(parts[1], "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + assert_eq!(parts[2].len(), 16); + assert!(parts[2].bytes().all(|byte| byte.is_ascii_hexdigit())); + assert_ne!(parts[2], "0000000000000000"); + assert_eq!(parts[3], "01"); + } else { + assert!(automatic["traceparent"].is_null()); + assert!(automatic["tracestate"].is_null()); + assert!(automatic["baggage"].is_null()); + } + }), + TerminationToken::new() + ); +} + +#[tokio::test] +#[serial] +async fn test_fetch_preserves_explicit_trace_context() { + assert_fetch_trace_context(true).await; +} + +#[tokio::test] +#[serial] +async fn test_fetch_preserves_explicit_trace_context_without_tracing() { + assert_fetch_trace_context(false).await; +} + async fn assert_rate_limit_error( resp: Result, ) { diff --git a/vendor/deno_fetch/26_fetch.js b/vendor/deno_fetch/26_fetch.js index 516e2cd26..72a2e4cff 100644 --- a/vendor/deno_fetch/26_fetch.js +++ b/vendor/deno_fetch/26_fetch.js @@ -376,6 +376,15 @@ function fetch(input, init = { __proto__: null }) { if (span) { const context = ContextManager.active(); for (const propagator of new SafeArrayIterator(PROPAGATORS)) { + // Explicit trace context belongs to the caller. Appending another + // traceparent makes it invalid, and injecting the active tracestate + // would associate state from a different trace with that context. + if ( + requestObject.headers.has("traceparent") && + ArrayPrototypeIncludes(propagator.fields(), "traceparent") + ) { + continue; + } propagator.inject(context, requestObject.headers, { set(carrier, key, value) { carrier.append(key, value);