{"title":"Connect Load Tests to OpenTelemetry Traces","excerpt":"A failing k6 gate tells you users waited, not where the time went. This wires a k6 v2.3.0 checkout test to OpenTelemetry with three bounded keys (run, journey, phase) carried as tags and W3C baggage, and shows what the tracing library really sends: a new trace per request and a parent span that never arrives. It measures an identifier tag turning 200 requests into 200 series, 10% generator-side sampling keeping traces for 2 of 20 failures, and baggage reaching a stand-in payment provider until it is stripped at egress, then joins one steady-phase failure to its waterfall and logs.","canonicalUrl":"https://automationtester.in/blog/automation-tutorials/connect-load-tests-opentelemetry-traces","category":{"name":"Automation Tutorials","slug":"automation-tutorials"},"tags":["k6","opentelemetry","load-testing","distributed-tracing","observability","performance-testing"],"author":{"name":"Shashank Rawlani","url":"https://shashank.rawlani.com","linkedin":"https://www.linkedin.com/in/shashankrawlani/"},"datePublished":"2026-10-24T13:30:00.000Z","dateModified":"2026-10-24T13:30:00.000Z","coverImage":{"url":"https://ik.imagekit.io/automationtester/automationtester-in/blog/covers/v3/connect-load-tests-opentelemetry-traces.webp","alt":"On the left, four green dots each trailed by the same three short green pills run right into a large dashed rectangle, while scattered dim dots sit outside them near the edge. Inside the rectangle, one line opens into four stacked horizontal bars at increasing offsets, the last one long and ending at a ringed green node. Below are three log rows carrying the same three pills, crossed by a dashed vertical line. On the right, a solid green line leaves the long bar and crosses the dashed edge into a small dim panel, while beside three orange pills a dashed orange line rises from the node and stops at the edge in a crossed-out circle."},"contentHtml":"<div class=\"callout callout-info\"><strong>Quick answer:</strong> Give every request a load test sends three pieces of context whose possible values you can list in advance: the run (<code>test.run</code>, set by CI), the journey and the phase. Put them on k6 metrics as tags and send them to the services in a W3C <code>baggage</code> header, next to the <code>traceparent</code> that Grafana's <code>http-instrumentation-tempo</code> library adds. In the services, copy only those three keys onto spans and logs, and remove <code>baggage</code> before any call leaves your network. Keep per-request identifiers out of tags and metric attributes. Leave k6's own sampling at 1 and drop traces in the Collector with tail sampling, sized for one trace per request.</div>\n\n<p>A checkout load test fails its gate in the steady phase: 2% of payments returned 503. The latency percentile tells you users waited. It does not tell you where the time went, and when you open the tracing backend for that hour you find tens of thousands of checkout traces from the load test, a synthetic monitor and two other teams sharing the environment. Without a way to select the traffic your run sent, the investigation turns into comparing broad time windows and guessing which spans belong to the experiment.</p>\n\n<p>Part 2 of this series turned objectives into <a href=\"/blog/automation-tutorials/k6-thresholds-fail-ci-right-reasons\">k6 thresholds that fail CI for the right reasons</a>, and Part 4 split the workload into <a href=\"/blog/automation-tutorials/k6-scenarios-realistic-workload-models\">one scenario per journey</a>. This part connects a failing threshold to the spans and log lines behind it, for the same bounded set of requests, without creating a metric series per user or leaking test context to a payment provider.</p>\n\n<div class=\"callout callout-warning\"><strong>Checked 2026-10-19:</strong> the newest k6 release is v2.3.0 (21 September 2026); the v1 line received v1.8.2 on 12 October 2026. Tracing for k6 HTTP requests comes from the <code>http-instrumentation-tempo</code> jslib, version 1.0.1, whose source in the grafana/jslib.k6.io repository last changed on 25 April 2025 (commit <code>ad7d1a4</code>). The older <code>k6/experimental/tracing</code> import now stops the test with \"k6/experimental/tracing has been removed\". OpenTelemetry specification v1.61.0, Collector contrib v0.163.0 (where the tail sampling processor is beta), and <code>@opentelemetry/baggage-span-processor</code> 0.5.0 are current. All output below came from k6 v2.3.0 on macOS arm64 driving a Node.js 26.0.0 stub of four services. The stub propagates <code>traceparent</code> and <code>baggage</code> and records a span only when the incoming sampled flag is set, which is what OpenTelemetry's default sampler does. The SDK and Collector configuration shown later was checked against their documentation, not run.</div>\n\n<h2 id=\"otelk6-what-k6-sends\">What the tracing library actually sends</h2>\n\n<p>One call in the init context replaces the request functions of the <code>k6/http</code> module with traced versions. It does not replace <code>http.batch</code>, so requests sent in a batch carry no trace context:</p>\n\n<pre><code class=\"language-javascript\">import tempo from 'https://jslib.k6.io/http-instrumentation-tempo/1.0.1/index.js';\n\ntempo.instrumentHTTP({ propagator: 'w3c' }); // the default; 'jaeger' sends uber-trace-id instead\n</code></pre>\n\n<p>The library's source shows four behaviours worth knowing before you query anything, and the test run confirmed each of them.</p>\n\n<p><strong>Every request is its own trace.</strong> The library generates a new trace ID inside each <code>request()</code> call. The checkout journey below sends two requests per iteration, and the run's 2,000 requests carried 2,000 distinct trace IDs. A journey is not a trace. You find one request by its trace ID and the whole journey by its context attributes, which is why the next section exists.</p>\n\n<p><strong>The IDs are recognisable.</strong> Each trace ID starts with the fixed prefix <code>dc0718</code>, followed by bytes derived from the clock and then random bytes. All 2,000 IDs in the run started that way, which lets you recognise k6-originated traces at a glance.</p>\n\n<p><strong>The parent span never arrives.</strong> The parent-id field in <code>traceparent</code> is 16 random hex characters, and k6 exports no span with that ID. The first service's server span is therefore the top of the recorded trace, pointing at a parent nobody reports. That is expected, not a broken pipeline.</p>\n\n<p><strong>The trace ID goes into metadata, not tags.</strong> The library sets <code>exec.vu.metrics.metadata.trace_id</code> for the duration of the call. The <code>k6/execution</code> reference describes metadata as similar to tags but usable for high-cardinality data, unavailable to thresholds, and \"likely handled differently by each output\". With <code>--out json</code>, each data point carries it:</p>\n\n<pre><code class=\"language-json\">{\"metric\":\"http_req_duration\",\"type\":\"Point\",\"data\":{\"time\":\"2026-10-19T16:51:01.285354+05:30\",\"value\":963.226,\"tags\":{\"error_code\":\"1503\",\"expected_response\":\"false\",\"group\":\"\",\"journey\":\"checkout\",\"method\":\"POST\",\"name\":\"checkout\",\"phase\":\"steady\",\"proto\":\"HTTP/1.1\",\"scenario\":\"steady\",\"status\":\"503\",\"test.run\":\"perf-2026-10-24-a\",\"url\":\"checkout\"},\"metadata\":{\"trace_id\":\"dc071818dfea5ca3c16c9422504d92dd\"}}}\n</code></pre>\n\n<p>The CSV output writes it into a trailing <code>metadata</code> column as <code>trace_id=dc0718…</code>. The end-of-test summary aggregates, so it shows no trace IDs at all; keep a per-request output file whenever you intend to follow a slow request into a trace.</p>\n\n<h2 id=\"otelk6-bounded-context\">Three keys whose values you can list</h2>\n\n<p>The context that selects your traffic needs values you could write down before the test starts:</p>\n\n<ul>\n<li><code>test.run</code>: one value per run, supplied by CI so it matches the job that produced it, for example <code>perf-2026-10-24-a</code>.</li>\n<li><code>journey</code>: one of the scenarios from your workload model, such as <code>checkout</code> or <code>browse</code>.</li>\n<li><code>phase</code>: <code>ramp</code>, <code>steady</code> or <code>recovery</code>, matching how the test is built.</li>\n</ul>\n\n<p>The script below carries all three. Phases are separate scenarios, each with a <code>phase</code> tag, so every metric the scenario emits is labelled without extra code. The run ID comes in through <code>-e</code> and is validated, because a test with a missing or malformed ID produces data nobody can select later:</p>\n\n<pre><code class=\"language-javascript\">// checkout.js: k6 run -e TEST_RUN=perf-2026-10-24-a checkout.js\nimport http from 'k6/http';\nimport exec from 'k6/execution';\nimport { check } from 'k6';\nimport tempo from './tempo.js';\n\nconst BASE = __ENV.BASE_URL || 'http://127.0.0.1:3581';\nconst RUN = __ENV.TEST_RUN;\nif (!/^perf-\\d{4}-\\d{2}-\\d{2}-[a-z0-9]+$/.test(RUN || '')) {\n  throw new Error('set TEST_RUN, e.g. -e TEST_RUN=perf-2026-10-24-a');\n}\n\ntempo.instrumentHTTP({ propagator: 'w3c', sampling: Number(__ENV.SAMPLING || 1) });\n\nexport const options = {\n  tags: { 'test.run': RUN, journey: 'checkout' },\n  scenarios: {\n    ramp: {\n      executor: 'ramping-arrival-rate',\n      exec: 'checkout',\n      startRate: 0,\n      timeUnit: '1s',\n      stages: [{ target: 20, duration: '20s' }],\n      preAllocatedVUs: 20,\n      tags: { phase: 'ramp' },\n    },\n    steady: {\n      executor: 'constant-arrival-rate',\n      exec: 'checkout',\n      rate: 20,\n      timeUnit: '1s',\n      startTime: '20s',\n      duration: '40s',\n      preAllocatedVUs: 30,\n      tags: { phase: 'steady' },\n    },\n  },\n  thresholds: {\n    'http_req_duration{phase:steady,name:checkout}': ['p(95)&lt;300'],\n    'http_req_failed{phase:steady,name:checkout}': ['rate&lt;0.01'],\n  },\n};\n\nexport function checkout() {\n  const t = exec.vu.metrics.tags;\n  const headers = {\n    'Content-Type': 'application/json',\n    baggage: `test.run=${RUN},journey=checkout,phase=${t.phase}`,\n  };\n  http.post(`${BASE}/cart/items`, '{\"sku\":\"SKU-1\"}', { headers, tags: { name: 'cart' } });\n  const res = http.post(`${BASE}/checkout`, '{}', { headers, tags: { name: 'checkout' } });\n  check(res, { 'order placed': (r) =&gt; r.status === 201 });\n}\n</code></pre>\n\n<p>The import points at a local copy of the 1.0.1 file from the jslib repository, so the run did not depend on the network; on the day of the check it was byte-for-byte identical to the file served at the jslib.k6.io URL in the first snippet. Two details are easy to get wrong.</p>\n\n<p>The <code>baggage</code> header is the W3C format for passing key-value context between services: comma-separated <code>key=value</code> members, with keys restricted to the HTTP token characters, which include the dot. The specification only obliges a platform to propagate all members while there are at most 64 of them and the header stays within 8,192 bytes. Three short keys are far below that, which is one more reason to keep the context small.</p>\n\n<p>The phase comes from <code>exec.vu.metrics.tags</code>, which includes the scenario's own tags. Logged from inside a scenario tagged <code>phase: 'steady'</code>, it printed <code>{\"scenario\":\"a\",\"phase\":\"steady\",\"group\":\"\"}</code>. If your phases are stages of a single ramping scenario rather than separate scenarios, the k6 tags documentation shows <code>tagWithCurrentStageIndex</code> from the utils jslib, which adds a numeric <code>stage</code> tag to every sample in the iteration. You then keep a mapping from stage number to phase name next to the script.</p>\n\n<h3 id=\"otelk6-id-as-tag\">An identifier as a tag multiplies the series</h3>\n\n<p>The tempting mistake is to tag requests with whatever identifies them: an account, a cart, an order. Here is the same script fragment both ways, run for ten seconds at 20 iterations per second:</p>\n\n<pre><code class=\"language-javascript\">// cardinality.js: the same identifier as a tag, then as metadata\nimport http from 'k6/http';\nimport exec from 'k6/execution';\n\nexport const options = {\n  scenarios: {\n    c: { executor: 'constant-arrival-rate', rate: 20, timeUnit: '1s', duration: '10s', preAllocatedVUs: 10 },\n  },\n};\n\nexport default function () {\n  const cart = `cart-${exec.scenario.iterationInTest}`;\n  if (__ENV.CART_AS === 'tag') exec.vu.metrics.tags.cart = cart;          // wrong\n  else exec.vu.metrics.metadata.cart = cart;                              // right\n  http.post('http://127.0.0.1:3581/cart/items', '{}', { tags: { name: 'cart' } });\n}\n</code></pre>\n\n<p>Counting distinct tag sets per metric in each JSON output:</p>\n\n<pre><code class=\"language-text\">CART_AS=tag\nhttp_req_duration  200\nhttp_reqs          200\niterations         200\nCART_AS=metadata\nhttp_req_duration  1\nhttp_reqs          1\niterations         1\n</code></pre>\n\n<p>Two hundred requests became two hundred series of one sample each. Locally that is only a bigger file. Sent onward, it is a cost: the k6 OpenTelemetry output (<code>--out opentelemetry</code>) maps all k6 tags to OpenTelemetry attributes, so each tag value becomes its own attribute set at the metrics backend. The same applies to the default <code>url</code> system tag on dynamic paths, which is why the k6 HTTP documentation recommends setting <code>name</code> to group them.</p>\n\n<p>Service-side metrics have a sharper failure. The OpenTelemetry metrics SDK specification sets a default cardinality limit of 2,000 attribute sets per metric. Past it, for a synchronous instrument with cumulative temporality, measurements for attribute sets not seen before are folded into one data point carrying only <code>otel.metric.overflow=true</code>, while sets seen before the overflow keep being exported. If a service records a user or order ID as a metric attribute during a load test, the ramp can fill the limit, and the steady phase you actually wanted to read then lands in a single anonymous point. The identifiers are not lost to the investigation if you keep them where they cost per record instead of per series: k6 metadata, span attributes or log fields.</p>\n\n<h2 id=\"otelk6-service-side\">Turning baggage into span attributes in the services</h2>\n\n<p>The services need nothing new to forward the headers. The SDK configuration specification sets <code>OTEL_PROPAGATORS</code> to <code>tracecontext,baggage</code> by default, so instrumented services extract both headers and inject them into their outgoing calls. What they do not do is record baggage: the OpenTelemetry baggage page says entries are a separate store, not attributes, until you copy them.</p>\n\n<p>For Node.js, the contrib <code>BaggageSpanProcessor</code> copies entries onto every span at start. Its README shows <code>ALLOW_ALL_BAGGAGE_KEYS</code>; pass a predicate instead, so a caller who adds a key cannot add an attribute:</p>\n\n<pre><code class=\"language-javascript\">import { NodeSDK } from '@opentelemetry/sdk-node';\nimport { BaggageSpanProcessor } from '@opentelemetry/baggage-span-processor';\n\nconst TEST_CONTEXT = new Set(['test.run', 'journey', 'phase']);\n\nconst sdk = new NodeSDK({\n  serviceName: 'payment',\n  spanProcessors: [\n    // ...your exporting span processor first\n    new BaggageSpanProcessor((key) =&gt; TEST_CONTEXT.has(key)),\n  ],\n});\nsdk.start();\n</code></pre>\n\n<p>The stub in this article does the equivalent by hand: it parses <code>baggage</code>, keeps the same three keys, and writes them to each span and to each JSON log line along with the trace ID. Logs need that explicit step only where the logging library is not bridged to OpenTelemetry; the context propagation concepts page notes that OpenTelemetry SDKs can inject the trace and span ID into log records automatically.</p>\n\n<p>Two limits apply to what the services do with these values. The baggage page warns that there are no built-in integrity checks, so any caller can send <code>test.run=…</code>; use the keys to label telemetry, never to change behaviour such as skipping a fraud check for \"test\" traffic. And add <code>journey</code> and <code>phase</code> to service metrics only if you need them there, because their value sets are fixed. <code>test.run</code> gains a new value every run, so on a long-lived metrics backend it adds a set of series per run; spans and logs are the cheaper place for it.</p>\n\n<h2 id=\"otelk6-egress\">Stopping test context at your network edge</h2>\n\n<p>Automatic propagation is also how test context reaches people who should not see it. The OpenTelemetry baggage page says automatic instrumentation includes baggage in most of a service's network requests and that downstream services could propagate it outside your network. The W3C Baggage specification tells application owners to make sure baggage is not present in requests that cross trust boundaries. A payment provider receiving <code>test.run</code> and <code>phase</code> learns that you are load testing and when; a header carrying a user ID would hand it personal data.</p>\n\n<p>The stub's payment service calls a stand-in provider on every charge. Its outgoing-call helper marks that call as egress and drops <code>baggage</code> from it, while the internal calls keep it:</p>\n\n<pre><code class=\"language-javascript\">function call(port, path, ctx, bag, { egress = false } = {}) {\n  const headers = { traceparent: `00-${ctx.traceId}-${ctx.spanId}-${ctx.sampled ? '01' : '00'}` };\n  const baggage = Object.entries(bag).map(([k, v]) =&gt; `${k}=${encodeURIComponent(v)}`).join(',');\n  if (baggage &amp;&amp; !(egress &amp;&amp; STRIP_AT_EGRESS)) headers.baggage = baggage;\n  return new Promise((resolve, reject) =&gt; {\n    http.request({ host: '127.0.0.1', port, path, method: 'POST', headers }, (r) =&gt; {\n      r.resume(); r.on('end', () =&gt; resolve(r.statusCode));\n    }).on('error', reject).end();\n  });\n}\n</code></pre>\n\n<p>With stripping turned off, all 1,000 provider calls in a run carried <code>baggage: test.run=perf-2026-10-24-b,journey=checkout,phase=ramp</code> or its steady equivalent. With it on, none of the 1,000 did. In an instrumented service, do the same in whatever owns outbound calls to third parties: a client wrapper, an egress proxy that removes the header, or code that calls the provider under a context with baggage cleared, which the OpenTelemetry JS API exposes as <code>propagation.deleteBaggage(context)</code>.</p>\n\n<p><code>traceparent</code> still reached the provider in both runs. W3C Trace Context forbids putting personal or sensitive data in it and calls the remaining correlation risk theoretical, but it allows a service at a trust boundary to restart the trace with new IDs. The cost of restarting is that the provider's own records can no longer be joined to your trace, so do it only where that join is not needed.</p>\n\n<p>Check the k6 side too. A header such as <code>baggage: user=acct-0042,email=…</code> in the script sends exactly the data both specifications warn about to every service the request touches.</p>\n\n<h2 id=\"otelk6-sampling-at-generator\">Sampling before the response exists</h2>\n\n<p>The library's <code>sampling</code> option sets the sampled flag in <code>traceparent</code> with <code>Math.random() &lt; samplingRate</code>, once per request, before the request is sent. The trace SDK specification makes the default sampler <code>ParentBased(root=AlwaysOn)</code>, and a parent-based sampler applies <code>AlwaysOff</code> when the remote parent is not sampled. So when k6 marks a request unsampled, every default-configured service drops its spans, and a tail sampler further down the pipeline never receives them.</p>\n\n<p>The same checkout test, rerun with <code>-e SAMPLING=0.1</code>:</p>\n\n<pre><code class=\"language-text\">requests=2000 distinct trace ids=2000 all start dc0718: true\nfailed checkouts=20 with a recorded trace=2\ncheckout traces recorded=91 of 1000\n</code></pre>\n\n<p>Twenty checkouts failed; traces exist for two of them. The stub's logs, which are not sampled, still had all 20 failures with their trace IDs, so the evidence existed and the traces did not. Sampling also distorted the span numbers: only 18 payment spans survived from the ramp, and one 929 ms failure among them became the ramp's p95.</p>\n\n<p>The OpenTelemetry sampling page states the limitation directly: with head sampling alone you cannot ensure that all traces with an error are sampled. Leave k6's <code>sampling</code> at its default of 1 and make the decision in the Collector, after the trace is complete. Every policy below uses a type and fields documented in the tail sampling processor's README at v0.163.0:</p>\n\n<pre><code class=\"language-yaml\">processors:\n  tail_sampling:\n    decision_wait: 10s\n    num_traces: 200000\n    policies:\n      - name: keep-errors\n        type: status_code\n        status_code: { status_codes: [ERROR] }\n      - name: keep-client-errors\n        type: numeric_attribute\n        numeric_attribute: { key: http.response.status_code, min_value: 400, max_value: 499 }\n      - name: keep-slow\n        type: latency\n        latency: { threshold_ms: 500 }\n      - name: baseline\n        type: probabilistic\n        probabilistic: { sampling_percentage: 10 }\n</code></pre>\n\n<p>A trace is kept when any policy returns a sample decision. The second policy exists because the HTTP semantic conventions leave span status unset for 4xx responses on server spans, so a 409 from checkout would pass the error policy unnoticed. The latency policy measures from the earliest span start to the latest span end in the trace.</p>\n\n<p>This moves the cost to the Collector, and a load test is when it is highest. The processor holds spans in memory until <code>decision_wait</code> expires; the README requires all spans of a trace to reach the same Collector instance, and stores at most <code>num_traces</code> traces in a circular buffer, 50,000 by default. When a new trace arrives at a full buffer, the oldest is removed before any decision is made. Because the k6 library creates one trace per request, 2,000 requests per second with the default 30-second <code>decision_wait</code> means 60,000 open traces, more than the default holds. Size <code>num_traces</code> to at least your peak request rate multiplied by <code>decision_wait</code>, with headroom for the traces your services start themselves, and watch <code>otelcol_processor_tail_sampling_sampling_trace_dropped_too_early</code> during the run. A non-zero value means the Collector dropped traces before sampling them, which can include the failures you needed.</p>\n\n<h2 id=\"otelk6-one-cohort\">A failing threshold, its trace and its logs</h2>\n\n<p>The first run, at full sampling, printed this for the cohort the thresholds select:</p>\n\n<pre><code class=\"language-text\">http_req_duration{phase:steady,name:checkout}\n✓ 'p(95)&lt;300' p(95)=66ms\n\nhttp_req_failed{phase:steady,name:checkout}\n✗ 'rate&lt;0.01' rate=2.00%\n...\nhttp_req_failed....................: 1.00%  20 out of 2000\n  { phase:steady,name:checkout }...: 2.00%  16 out of 800\n</code></pre>\n\n<p>Across all 2,000 requests, which include cart calls that never fail in this stub, the failure rate was 1.00%. Restricted to steady-phase checkouts it was 2.00%, sixteen failures in 800 requests; a gate on the unfiltered rate would have judged a diluted number. This is the metric slice. To go from it to a trace, join k6's per-request output to the span and log stores by trace ID:</p>\n\n<pre><code class=\"language-javascript\">// join.mjs: node join.mjs run.json spans.jsonl logs.jsonl\n// Finds the slowest steady-phase checkout in k6's output and prints its trace and logs.\nimport { readFileSync } from 'node:fs';\n\nconst [k6File, spanFile, logFile] = process.argv.slice(2);\nconst lines = (f) =&gt; readFileSync(f, 'utf8').trim().split('\\n').map((l) =&gt; JSON.parse(l));\n\nconst checkouts = lines(k6File).filter((l) =&gt; l.type === 'Point' &amp;&amp; l.metric === 'http_req_duration'\n  &amp;&amp; l.data.tags.name === 'checkout' &amp;&amp; l.data.tags.phase === 'steady');\nconst slowest = checkouts.reduce((a, b) =&gt; (b.data.value &gt; a.data.value ? b : a));\nconst traceId = slowest.data.metadata.trace_id;\nconsole.log(`k6: ${slowest.data.value.toFixed(1)} ms  status=${slowest.data.tags.status}  trace_id=${traceId}`);\n\nconst spans = lines(spanFile).filter((s) =&gt; s.traceId === traceId);\nconst byParent = Object.groupBy(spans, (s) =&gt; s.parentId ?? 'none');\nconst ids = new Set(spans.map((s) =&gt; s.spanId));\nconst t0 = Math.min(...spans.map((s) =&gt; s.start));\nconst print = (s, depth) =&gt; {\n  const bar = ' '.repeat(Math.round((s.start - t0) / 20)) + '#'.repeat(Math.max(1, Math.round((s.end - s.start) / 20)));\n  console.log(`${'  '.repeat(depth)}${s.service.padEnd(12 - 2 * depth)} ${String(s.end - s.start).padStart(4)} ms ${s.status}  ${bar}`);\n  for (const c of byParent[s.spanId] ?? []) print(c, depth + 1);\n};\nfor (const root of spans.filter((s) =&gt; !ids.has(s.parentId))) {\n  console.log(`${root.service} parent span ${root.parentId} recorded: ${ids.has(root.parentId)}`);\n  print(root, 0);\n}\nfor (const l of lines(logFile).filter((l) =&gt; l.trace_id === traceId)) {\n  console.log(`log ${l.service} status=${l.status} phase=${l.phase} test.run=${l['test.run']}`);\n}\n</code></pre>\n\n<pre><code class=\"language-text\">k6: 963.2 ms  status=503  trace_id=dc071818dfea5ca3c16c9422504d92dd\ngateway parent span ec75339ddeb8d0b2 recorded: false\ngateway       962 ms 503  ################################################\n  inventory    22 ms 200  #\n    db          6 ms 200   #\n  payment     940 ms 503   ###############################################\n    provider   37 ms 200   ##\nlog db status=200 phase=steady test.run=perf-2026-10-24-a\nlog inventory status=200 phase=steady test.run=perf-2026-10-24-a\nlog provider status=200 phase=undefined test.run=undefined\nlog payment status=503 phase=steady test.run=perf-2026-10-24-a\nlog gateway status=503 phase=steady test.run=perf-2026-10-24-a\n</code></pre>\n\n<p>Read it top down. k6 measured 963.2 ms and the gateway span 962 ms, so the time was spent in the system, not the load generator. Inventory and its database write took 22 ms. Payment took 940 ms, and the provider it called answered in 37 ms, so roughly 900 ms passed inside payment after the provider replied and before it returned 503. That is where the time moved, and it is a different finding from \"the provider is slow\" or \"the database is slow\", both of which a time-window comparison could have suggested. In the stub this is deliberate: every 50th charge stalls for 900 ms and fails. In your system the span tells you which service's code to open.</p>\n\n<p>The log lines carry the run and phase, so a query for <code>test.run=perf-2026-10-24-a</code> and <code>phase=steady</code> returns this cohort and nothing from the synthetic monitor or the other teams. The provider's line has neither, because baggage was stripped before the call; that is the egress rule working. The second line of output is the missing k6 parent from earlier.</p>\n\n<p>An AI assistant can usefully summarise span patterns across hundreds of such traces, for example which service's self-time grew between ramp and steady. The selection that feeds it should not be the assistant's job. The cohort query, the sampling policies and the egress rule are configuration that someone reviews, so the evidence an assistant reads was chosen deterministically, and any cause it proposes should cite trace IDs you can open.</p>\n\n<h2 id=\"otelk6-symptoms\">Four symptoms and what each one points to</h2>\n\n<p><strong>Traces exist for the run, but not for its failures.</strong> Sampling happened before the outcome was known. Check the <code>sampling</code> option in the script, then take the trace IDs your services logged for failed requests and look them up: if the logs have them and the trace store does not, the spans were never recorded.</p>\n\n<p><strong>The first service's spans show a parent that never arrives.</strong> That is the k6 library's random parent ID. Nothing is wrong; filter or group on the service's server span instead.</p>\n\n<p><strong>Metric series counts grow with every run, or an <code>otel.metric.overflow</code> point appears.</strong> An identifier became a tag or attribute. Find the attribute whose distinct values track request or user count, and move it to metadata, spans or logs.</p>\n\n<p><strong>Tail-sampled traces thin out only at peak load.</strong> The Collector buffer is too small for one trace per request. Check <code>sampling_trace_dropped_too_early</code> and raise <code>num_traces</code>, or lower <code>decision_wait</code> if your traces finish quickly.</p>\n\n<p>The <a href=\"/tools/api-response-time\">API Response Time Analyzer</a> helps you look at the latency distribution of a cohort rather than one percentile, and the <a href=\"/tools/http-status\">HTTP Status Code Reference</a> settles whether a failure code belongs in the error policy or the 4xx one.</p>\n\n<h2 id=\"otelk6-apply-now\">Apply this now</h2>\n\n<p>Pick the load test that gates your next release. Add <code>test.run</code> from the CI job, <code>journey</code> and <code>phase</code> as k6 tags, and send the same three in a <code>baggage</code> header with the <code>http-instrumentation-tempo</code> library at its default sampling. Add <code>--out json</code> so every request keeps its trace ID. In each service, copy only those three baggage keys onto spans and logs, and confirm by running once against a stand-in for each external dependency that no <code>baggage</code> header reaches it. Set the Collector's tail sampling to keep errors, 4xx, slow traces and a baseline, with <code>num_traces</code> sized from your peak rate. Then make one controlled failure and keep three things together as the run's evidence: the per-phase threshold line, the waterfall of the slowest failing request in that phase, and the log query for the same <code>test.run</code> and <code>phase</code> that returns its lines.</p>\n\n<h2 id=\"otelk6-faq\">Questions about tracing load tests</h2>\n\n<h3 id=\"otelk6-faq-jaeger-propagator\">Should the script use the Jaeger propagator?</h3>\n<p>Only if your services understand nothing but <code>uber-trace-id</code>. The OpenTelemetry context propagation page names W3C TraceContext as the default propagator, so <code>w3c</code> matches what instrumented services expect.</p>\n\n<h3 id=\"otelk6-faq-k6-metrics-collector\">Can k6 send its own metrics to the same Collector?</h3>\n<p>Yes, with <code>--out opentelemetry</code>, which defaults to gRPC on <code>localhost:4317</code>. Trends become histograms, and each rate becomes one counter with a <code>condition</code> attribute of <code>zero</code> or <code>nonzero</code>. All tags become attributes, so the cardinality rule above applies to your k6 tags as much as to service attributes.</p>\n\n<h3 id=\"otelk6-faq-trace-id-threshold\">Can a threshold reference a trace ID?</h3>\n<p>No. Thresholds select on tags, and the k6 documentation says metadata cannot be used in thresholds. That is the right split: thresholds judge a cohort, and the trace ID is how you inspect one member of it afterwards.</p>\n\n<h3 id=\"otelk6-faq-one-trace-journey\">Can the whole journey be one trace?</h3>\n<p>Not with this library: both <code>instrumentHTTP</code> and the <code>Client</code> class generate a new trace ID inside every request call. Select a journey through its <code>journey</code>, <code>phase</code> and <code>test.run</code> attributes, and use the trace ID for the single request you want to inspect. The <code>Client</code> class is still useful for a different reason: it lets you trace the requests that matter and send the rest through the plain <code>k6/http</code> module.</p>\n\n<h2 id=\"otelk6-references\">Primary references</h2>\n<ul>\n<li><a href=\"https://grafana.com/docs/k6/latest/javascript-api/jslib/http-instrumentation-tempo/\">k6 HTTP instrumentation for Tempo</a>: the jslib that adds trace context to k6 requests, the W3C and Jaeger propagators, and migration from <code>k6/experimental/tracing</code>.</li>\n<li><a href=\"https://grafana.com/docs/k6/latest/javascript-api/jslib/http-instrumentation-tempo/options/\">Tempo instrumentation options</a>: the <code>propagator</code> and <code>sampling</code> options and their defaults.</li>\n<li><a href=\"https://grafana.com/docs/k6/latest/javascript-api/jslib/http-instrumentation-tempo/client/\">The instrumented HTTP Client class</a>: selective instrumentation and the <code>trace_id</code> added to output metadata.</li>\n<li><a href=\"https://github.com/grafana/jslib.k6.io/blob/ad7d1a4a55f23cc4dde35d04f7245cedd80285b2/lib/http-instrumentation-tempo/1.0.1/index.js\">Source of http-instrumentation-tempo 1.0.1 at commit ad7d1a4</a>: one trace per request, the <code>dc0718</code> prefix, the random parent ID, the per-request sampling decision, and <code>http.batch</code> left unpatched.</li>\n<li><a href=\"https://grafana.com/docs/k6/latest/javascript-api/k6-execution/\">Execution context API for k6 scripts</a>: <code>vu.metrics.tags</code>, and metadata as the place for high-cardinality data that thresholds cannot use.</li>\n<li><a href=\"https://grafana.com/docs/k6/latest/using-k6/tags-and-groups/\">Tags and groups in k6</a>: system and scenario tags, and <code>tagWithCurrentStageIndex</code> for stage tags.</li>\n<li><a href=\"https://grafana.com/docs/k6/latest/using-k6/http-requests/\">Making HTTP requests in k6</a>: grouping dynamic URLs under one <code>name</code> tag.</li>\n<li><a href=\"https://grafana.com/docs/k6/latest/results-output/real-time/opentelemetry/\">k6 OpenTelemetry output</a>: tags mapped to attributes, metric type mapping and exporter defaults.</li>\n<li><a href=\"https://github.com/grafana/k6/releases/tag/v2.3.0\">k6 v2.3.0 release notes</a>: the version that produced the output in this article.</li>\n<li><a href=\"https://www.w3.org/TR/trace-context-1/\">W3C Trace Context Level 1 Recommendation</a>: the <code>traceparent</code> fields, the sampled flag, restarting a trace, and the privacy rules for both headers.</li>\n<li><a href=\"https://www.w3.org/TR/baggage/\">W3C Baggage, Candidate Recommendation Snapshot</a>: the header format, the 64-member and 8,192-byte limits, and keeping baggage out of requests that cross trust boundaries.</li>\n<li><a href=\"https://opentelemetry.io/docs/concepts/signals/baggage/\">Baggage as an OpenTelemetry signal</a>: baggage is not attributes until copied, automatic propagation to outgoing requests, and no integrity checks.</li>\n<li><a href=\"https://github.com/open-telemetry/opentelemetry-js/blob/api/v1.9.1/api/src/api/propagation.ts\">OpenTelemetry JS propagation API at api/v1.9.1</a>: <code>propagation.deleteBaggage(context)</code>, which returns a context without baggage.</li>\n<li><a href=\"https://opentelemetry.io/docs/concepts/context-propagation/\">How OpenTelemetry moves context between services</a>: W3C TraceContext as the default propagator and trace IDs injected into logs.</li>\n<li><a href=\"https://opentelemetry.io/docs/concepts/sampling/\">OpenTelemetry sampling concepts</a>: head sampling cannot guarantee that traces with errors are kept, and the operating cost of tail sampling.</li>\n<li><a href=\"https://opentelemetry.io/docs/specs/otel/trace/sdk/#sampling\">Tracing SDK specification, sampling section</a>: the default <code>ParentBased(root=AlwaysOn)</code> sampler and <code>AlwaysOff</code> for unsampled remote parents.</li>\n<li><a href=\"https://opentelemetry.io/docs/specs/otel/configuration/sdk-environment-variables/\">SDK environment variable specification</a>: <code>OTEL_PROPAGATORS</code> defaulting to <code>tracecontext,baggage</code>.</li>\n<li><a href=\"https://opentelemetry.io/docs/specs/otel/metrics/sdk/#cardinality-limits\">Metrics SDK specification, cardinality limits</a>: the default limit of 2,000 and the <code>otel.metric.overflow</code> attribute set.</li>\n<li><a href=\"https://opentelemetry.io/docs/specs/semconv/http/http-spans/\">Semantic conventions for HTTP spans</a>: span status left unset for 4xx on server spans and set to Error for 5xx.</li>\n<li><a href=\"https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/v0.163.0/processor/tailsamplingprocessor/README.md\">Tail sampling processor README at Collector contrib v0.163.0</a>: policy types, <code>decision_wait</code> and <code>num_traces</code> defaults, same-instance routing, and the dropped-too-early metric.</li>\n<li><a href=\"https://github.com/open-telemetry/opentelemetry-js-contrib/blob/baggage-span-processor-v0.5.0/packages/baggage-span-processor/README.md\">BaggageSpanProcessor README at release 0.5.0</a>: copying baggage entries to span attributes with a key predicate.</li>\n</ul>\n"}