By Performate
Correlation IDs and Distributed Tracing in Load Tests: Debuggable k6 Runs
Propagate trace context headers from k6, align with backend spans, and debug latency tails faster.
Your soak test shows checkout p99 at 2.4 seconds for an hour—but when on-call opens the APM dashboard, every span looks like anonymous traffic from 10.0.0.0. Without trace context, load-test failures become archaeology: grep logs, guess VU numbers, and hope someone saved the right timestamp.
Random latency spikes become actionable when every k6 iteration emits traceparent and optional baggage headers compatible with OpenTelemetry (W3C Trace Context). In this guide you will learn why correlation IDs change incident response under load, how to generate valid trace headers in k6, and which observability checks prove your generator and backend agree on the same trace.
Why load tests need trace context—not just tags
k6 tags split metrics by route and scenario; APM splits spans by trace ID. Without bridging the two, you optimize blind:
- Anonymous traffic: backends bucket load generators into one service account—spans lack the iteration context you need.
- Tail latency hides causality: p99 spikes may be DB lock waits, mesh hops, or cold caches—tags alone cannot distinguish them (bottleneck analysis).
- Soak tests run long: memory leaks and queue buildup surface after tens of minutes; you need durable trace IDs per request family (soak playbook).
- Multi-hop flows: checkout may touch auth, inventory, and payment—one correlation ID must stitch the graph.
Think of trace headers as barcodes on each synthetic request: scanners downstream should read the same ID your load tool printed.
When metrics say slow but logs say nothing
Teams often add X-Request-Id manually while the service mesh expects W3C traceparent. Gateways strip unknown headers; APM shows partial traces. Align on the standard your platform exports—Jaeger, Tempo, Datadog, and New Relic all ingest W3C trace context when configured (observability tags).
Practical k6 implementation: W3C traceparent per iteration
Generate a fresh trace ID per request (or per VU iteration for multi-step flows), format traceparent correctly, and pass optional baggage for load-test metadata like vu and iter. Backend middleware should continue the trace—not start a new root span silently.
Example script (illustrative—not a production-ready test). The snippet below uses fictional URLs, tokens, and SLO numbers. Adapt base URL, auth, payloads, and thresholds to your environment.
What this example demonstrates:
- Valid W3C traceparent: version
00, 32-hex trace ID, 16-hex span ID, sampled flag01. - Baggage for load metadata:
vu,iter, andscenariopropagate when your collector supports W3C baggage. - Legacy correlation header: optional
X-Correlation-Idfor services not yet on OpenTelemetry. - Tagged metrics + trace IDs: k6 route tags for summaries; trace IDs for APM flame graphs during the same run.
import http from 'k6/http';
import { check, sleep } from 'k6';
import { randomBytes } from 'k6/crypto';
const BASE = __ENV.API_BASE || 'https://staging.example.com';
const TOKEN = __ENV.TOKEN || 'staging-token-replace-me';
function hex(bytes) {
return Array.from(new Uint8Array(bytes))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
function traceparent() {
const traceId = hex(randomBytes(16)); // 32 hex chars
const spanId = hex(randomBytes(8)); // 16 hex chars
return `00-${traceId}-${spanId}-01`;
}
function correlationId() {
return `k6-${__VU}-${__ITER}-${hex(randomBytes(4))}`;
}
function traceHeaders() {
const tp = traceparent();
const cid = correlationId();
return {
traceparent: tp,
tracestate: 'k6=load',
baggage: `vu=${__VU},iter=${__ITER},scenario=checkout`,
'X-Correlation-Id': cid,
};
}
export const options = {
scenarios: {
traced_checkout: {
executor: 'constant-arrival-rate',
rate: Number(__ENV.CHECKOUT_RPS || 8),
timeUnit: '1s',
duration: '10m',
preAllocatedVUs: 10,
maxVUs: 40,
tags: { route: 'checkout', trace: 'w3c' },
exec: 'checkoutTraced',
},
},
thresholds: {
'http_req_duration{route:checkout}': ['p(95)<900', 'p(99)<1400'],
http_req_failed: ['rate<0.01'],
},
};
export function checkoutTraced() {
const body = JSON.stringify({ sku: `SKU-${__VU}`, qty: 1 });
const headers = {
'Content-Type': 'application/json',
Authorization: `Bearer ${TOKEN}`,
...traceHeaders(),
};
const res = http.post(`${BASE}/checkout`, body, {
headers,
tags: { route: 'checkout', trace: 'w3c' },
});
check(res, { 'checkout 2xx': (r) => r.status >= 200 && r.status < 300 });
sleep(0.5);
}
Patterns that work
- One trace per user journey when flows call auth then checkout—reuse traceparent across steps in the same iteration.
- Filter APM by baggage
vu=to jump from k6 summary outliers to exact flame graphs. - Scrub PII from baggage before exporting traces to shared tenants (GDPR test data).
- Pair with env-specific sampling so staging collectors retain 100% of load-test traces during investigations (environment variables).
Anti-patterns to avoid
- Reusing one static
X-Request-Idfor all VUs—collapses thousands of requests into one logical trace. - Sending malformed
traceparent(wrong length hex)—APM drops context silently. - Logging full trace IDs in public CI artifacts when traces contain customer payloads.
- Ignoring mesh sidecars that overwrite trace headers unless you inject at the correct hop.
Pro tip (example command): the line below is an example of how to export k6 summary JSON alongside a run you will correlate manually in APM—not a mandatory flag for every soak.
k6 run traced-checkout.js --summary-export=run-summary.json --tag git_sha=$GIT_SHA
What this command demonstrates: exported summaries include scenario tags and git SHA—paste a slow window's timestamps into APM search with matching git_sha baggage or deployment markers.
Decision framework: request ID vs full W3C trace
| Situation | Recommended action |
|---|---|
| Single monolith with log correlation only | X-Correlation-Id + structured logs; migrate toward W3C when APM arrives |
| OpenTelemetry / mesh already deployed | traceparent + baggage on every k6 request; verify collector retention |
| Multi-step checkout/auth flows | One trace ID per iteration across all HTTP calls in the default function |
| Long soak hunting leaks | Unique trace per request; filter p99 outliers in APM by time window + route tag |
| Regulated data in payloads | Minimize baggage; scrub IDs from exported logs (GDPR testing data) |
Use W3C traceparent if your backend or service mesh already exports OpenTelemetry—this is the default for modern API platforms.
Use legacy correlation headers if older services log X-Request-Id only—but plan migration before dual headers confuse on-call.
Use baggage metadata if your collector supports it and you need to map k6 __VU/__ITER to spans without guessing timestamps.
Observability, documentation, and next steps
Trace propagation only helps when collectors, retention, and runbooks agree. Before you scale traffic:
- Verify staging APM retains traces for the full soak duration—not just the first fifteen minutes.
- Document which headers gateways pass through; strip lists have blocked
traceparentbefore. - Add a runbook step: "filter traces by
route:checkouttime window, then open top p99 trace IDs." - Confirm load-generator IPs are allowlisted without forcing all spans into a single anonymous service node.
- Archive k6 summary JSON, git SHA, and scenario tags per run so postmortems link metrics to traces.
How Performate simplifies trace-aligned load tests
Hand-rolling trace headers in every exported script drifts when someone copies an old template without baggage. Below is a concrete workflow example for traced checkout under soak.
Example: tagged runs that map to APM
- Import checkout requests from Postman or OpenAPI—the same flow you soak for memory leaks. Problem solved: one request definition; no duplicate HTTP blocks for "traced" vs "untraced" variants.
- Add custom headers in the scenario panel: paste
traceparentplaceholder notes and statictracestate=k6=loadwhere your team documents standards—or export and add the helper once. Problem solved: reviewers see header intent before export, not buried in git history. - Apply tags
route:checkoutandtrace:w3con the scenario so k6 summaries align with APM filters. Problem solved: compare metric tails and trace tails using the same vocabulary. - Run a 10-minute soak and export the integrated report plus k6 script for CI. Problem solved: share one link with on-call that includes scenario tags and threshold failures.
- Paste slow window timestamps into APM and search by correlation ID or baggage
vu=from the worst iteration window. Problem solved: minutes to root cause instead of log archaeology. - Re-export after header changes so CI and desktop runs stay aligned—especially when platform upgrades W3C baggage support.
That workflow maps to the cta: tagged runs where correlation IDs map cleanly to APM traces.
Closing takeaway
Load tests without trace context optimize aggregates, not incidents. Propagate W3C traceparent, add safe baggage for k6 metadata, and verify your mesh forwards headers before the next soak.
Run a traced checkout soak this week—filter APM for the worst p99 minute and confirm you can open a flame graph for a single synthetic checkout without guessing VU numbers.
Ready to optimize your API performance?
Use Performate to share tagged runs where correlation IDs map cleanly to APM traces.