Distributed Tracing & Context Propagation
Link traces across microservices using W3C Trace Context so your entire AI pipeline appears as a single trace.
Distributed Tracing & Context Propagation
When your AI pipeline spans multiple services — for example, an API gateway that calls a retrieval service which calls an LLM service — each service creates its own trace by default. Distributed tracing links those traces into a single end-to-end view so you can see the full request lifecycle in the BrowserStack AI Evals dashboard.
Distributed tracing APIs are available for TypeScript and Python. The Java SDK uses automatic ThreadLocal-based context management and does not require explicit carrier propagation.
How It Works
The SDK uses the W3C Trace Context standard to propagate trace identity across service boundaries:
- The upstream service serializes its current trace context into a carrier — a dictionary containing
traceparentandbaggageheaders. - The carrier is passed to the downstream service via HTTP headers, message queue metadata, or any transport.
- The downstream service injects the carrier, linking its local spans to the upstream trace.
- At export time, the SDK rewrites span trace IDs so all spans appear under the same trace in the dashboard.
┌──────────────────┐ carrier ┌──────────────────┐
│ Service A │ ─────────────────> │ Service B │
│ │ (traceparent + │ │
│ get carrier ──> │ baggage) │ <── inject │
│ LLM call │ │ LLM call │
└──────────────────┘ └──────────────────┘
└──────────── Same trace in dashboard ──────────────┘The carrier contains two W3C headers:
| Header | Format | Purpose |
|---|---|---|
traceparent | 00-<traceId>-<spanId>-<flags> | Identifies the trace and parent span |
baggage | key=value,key=value | Propagates session ID, user ID, and custom attributes |
Quick Start
Step 1 — Get the carrier (upstream service):
import { Observe } from '@browserstack/ai-sdk';
// Inside a traced context (e.g., withTrace or an auto-instrumented request)
const carrier = Observe.getBaggage(); // JSON string: {"traceparent":"...","baggage":"..."}Step 2 — Pass the carrier to the downstream service:
Send it as an HTTP header, message queue field, or any serializable transport:
await fetch('http://service-b:3001/process', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Trace-Context': carrier,
},
body: JSON.stringify({ text: 'Hello' }),
});Step 3 — Inject the carrier (downstream service):
import { Observe } from '@browserstack/ai-sdk';
const carrier = req.headers['x-trace-context'] as string;
Observe.setBaggage(carrier);
// All subsequent LLM calls are now linked to the upstream traceStep 1 — Get the carrier (upstream service):
from browserstack_ai_sdk import Observe
# Inside a traced context (e.g., with_trace or an auto-instrumented request)
carrier = Observe.get_carrier() # dict: {"traceparent": "...", "baggage": "..."}Step 2 — Pass the carrier to the downstream service:
Send it as an HTTP header, message queue field, or any serializable transport:
import json, httpx
httpx.post(
"http://service-b:8001/process",
json={"text": "Hello"},
headers={"X-Trace-Context": json.dumps(carrier)},
)Step 3 — Inject the carrier (downstream service):
from browserstack_ai_sdk import Observe
carrier = json.loads(request.headers.get("X-Trace-Context", "{}"))
Observe.inject_remote_carrier(carrier)
# All subsequent LLM calls are now linked to the upstream traceWhich Approach to Use
There are two ways to inject a carrier on the downstream service:
| Approach | When to use |
|---|---|
setBaggage / inject_remote_carrier | Your web framework is already instrumented (via Observe.init() or HTTP Instrumentation), so an active span already exists for the incoming request. Just inject the carrier onto it. |
withTrace / with_trace with carrier | There is no active span — e.g., a message queue consumer, cron job, or standalone script. withTrace creates the span for you and injects the carrier into it. |
setBaggage / inject_remote_carrier requires an active span to exist — it will no-op with a warning if called outside a traced context. If you are unsure whether a span is active, use withTrace / with_trace which always works.
Examples
Service-to-Service HTTP Calls
Two services communicating over HTTP. Since both are web servers with Observe.init(), HTTP instrumentation creates an active span for each request automatically — so the downstream service can use the simpler setBaggage / inject_remote_carrier approach.
Upstream service (Service A — Express, port 3000):
import { Observe } from '@browserstack/ai-sdk';
await Observe.init({
publicKey: process.env.AISDK_PUBLIC_KEY,
secretKey: process.env.AISDK_SECRET_KEY,
});
// Import express AFTER init so http module is already patched
const express = (await import('express')).default;
import OpenAI from 'openai';
const openai = new OpenAI();
const app = express();
app.use(express.json());
app.post('/analyze', async (req, res) => {
const analysis = await openai.chat.completions.create({
model: 'gpt-4o',
messages: [{ role: 'user', content: 'What is 2+2?' }],
});
const carrier = Observe.getBaggage();
console.log('Carrier:', carrier);
const downstream = await fetch('http://localhost:3001/enrich', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Trace-Context': carrier },
body: JSON.stringify({ text: analysis.choices[0].message.content }),
});
res.json(await downstream.json());
});
app.listen(3000, () => console.log('Service A running on :3000'));Downstream service (Service B — Express, port 3001):
import express from 'express';
import { Observe } from '@browserstack/ai-sdk';
// Observe.init() patches LLM providers and creates an active span
// for each incoming request — so we can inject the carrier directly.
await Observe.init({
publicKey: process.env.AISDK_PUBLIC_KEY,
secretKey: process.env.AISDK_SECRET_KEY,
});
import OpenAI from 'openai';
const app = express();
app.use(express.json());
const openai = new OpenAI();
app.post('/enrich', async (req, res) => {
const carrier = req.headers['x-trace-context'];
Observe.setBaggage(carrier);
const enriched = await openai.chat.completions.create({
model: 'gpt-4o',
messages: [{ role: 'user', content: `Enrich: ${req.body.text}` }],
});
res.json({ enriched: enriched.choices[0].message.content });
});
app.listen(3001, () => console.log('Service B running on :3001'));Upstream service (Service A — Flask, port 8000):
import os, json
from browserstack_ai_sdk import Observe
Observe.init(
public_key=os.environ["AISDK_PUBLIC_KEY"],
secret_key=os.environ["AISDK_SECRET_KEY"],
)
# Import AFTER Observe.init() so LLM providers are patched
from flask import Flask, request, jsonify
import openai, httpx
app = Flask(__name__)
openai_client = openai.OpenAI()
@app.route("/analyze", methods=["POST"])
def analyze():
body = request.get_json()
text = body["text"]
session_id = body.get("session_id", "")
Observe.set_attribute(Observe.TraceAttribute.SESSION_ID, session_id)
# LLM call
analysis = openai_client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": text}],
)
# Get carrier and call downstream
carrier = Observe.get_carrier()
import sys
print(f"[FLASK SERVICE 1] carrier: {carrier}", file=sys.stderr, flush=True)
response = httpx.post(
"http://localhost:8001/enrich",
json={"text": analysis.choices[0].message.content},
headers={"X-Trace-Context": json.dumps(carrier)},
timeout=60.0,
)
return jsonify(response.json())
if __name__ == "__main__":
app.run(port=8000, debug=False)Downstream service (Service B — Flask, port 8001):
import os, json
from browserstack_ai_sdk import Observe
# Observe.init() patches LLM providers and creates an active span
# for each incoming request — so we can inject the carrier directly.
Observe.init(
public_key=os.environ["AISDK_PUBLIC_KEY"],
secret_key=os.environ["AISDK_SECRET_KEY"],
)
from flask import Flask, request, jsonify
import openai
app = Flask(__name__)
openai_client = openai.OpenAI()
@app.route("/enrich", methods=["POST"])
def enrich():
body = request.get_json()
text = body["text"]
carrier = json.loads(request.headers.get("X-Trace-Context", "{}"))
import sys
print(f"[FLASK SERVICE 2] received carrier: {carrier}", file=sys.stderr, flush=True)
Observe.inject_remote_carrier(carrier)
result = openai_client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": f"Enrich: {text}"}],
)
return jsonify({"enriched": result.choices[0].message.content})
if __name__ == "__main__":
app.run(port=8001, debug=False)The upstream service can also use setBaggage / inject_remote_carrier directly (without withTrace) if HTTP instrumentation is active. The examples above show this pattern for both sides.
Alternative: Using withTrace for HTTP Calls
If you want explicit control over trace boundaries, use withTrace / with_trace to create a named span and inject the carrier in one step:
import express from 'express';
import { Observe } from '@browserstack/ai-sdk';
await Observe.init({
publicKey: process.env.AISDK_PUBLIC_KEY,
secretKey: process.env.AISDK_SECRET_KEY,
});
import OpenAI from 'openai';
const app = express();
app.use(express.json());
const openai = new OpenAI();
app.post('/enrich', async (req, res) => {
const carrier = req.headers['x-trace-context'];
const result = await Observe.withTrace('enrich-request', carrier, async () => {
const enriched = await openai.chat.completions.create({
model: 'gpt-4o',
messages: [{ role: 'user', content: `Enrich: ${req.body.text}` }],
});
return { enriched: enriched.choices[0].message.content };
});
res.json(result);
});
app.listen(3001, () => console.log('Service B running on :3001'));import os, json
from browserstack_ai_sdk import Observe
Observe.init(
public_key=os.environ["AISDK_PUBLIC_KEY"],
secret_key=os.environ["AISDK_SECRET_KEY"],
)
from flask import Flask, request, jsonify
import openai
app = Flask(__name__)
openai_client = openai.OpenAI()
@app.route("/enrich", methods=["POST"])
def enrich():
body = request.get_json()
carrier = json.loads(request.headers.get("X-Trace-Context", "{}"))
def do_enrich():
result = openai_client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": f"Enrich: {body['text']}"}],
)
return {"enriched": result.choices[0].message.content}
return jsonify(Observe.with_trace("enrich-request", do_enrich, carrier=carrier))
if __name__ == "__main__":
app.run(port=8001, debug=False)Standalone Trace with withTrace (No Carrier)
withTrace / with_trace can also be used without a carrier to create a named trace scope for scripts, cron jobs, or any code that runs outside an HTTP request context.
import { Observe } from '@browserstack/ai-sdk';
await Observe.init({
publicKey: process.env.AISDK_PUBLIC_KEY,
secretKey: process.env.AISDK_SECRET_KEY,
});
await new Promise(resolve => setTimeout(resolve, 3000));
import OpenAI from 'openai';
const openai = new OpenAI();
await Observe.withTrace('process-documents', null, async () => {
Observe.setAttribute(Observe.TraceAttribute.SESSION_ID, 'session-123');
const response = await openai.chat.completions.create({
model: 'gpt-4o',
messages: [{ role: 'user', content: 'Summarize this document.' }],
});
console.log(response.choices[0].message.content);
});
await new Promise(resolve => setTimeout(resolve, 5000));import os
from browserstack_ai_sdk import Observe
Observe.init(
public_key=os.environ["AISDK_PUBLIC_KEY"],
secret_key=os.environ["AISDK_SECRET_KEY"],
)
import openai
openai_client = openai.OpenAI()
def process_documents():
Observe.set_attribute(Observe.TraceAttribute.SESSION_ID, "session-123")
response = openai_client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": "Summarize this document."}],
)
return response.choices[0].message.content
result = Observe.with_trace("process-documents", process_documents)Message Queue / Async Workers
The carrier is a serializable dictionary (Python) or JSON string (TypeScript) — embed it in any message payload:
Producer:
await Observe.withTrace('submit-job', null, async () => {
Observe.setAttribute(Observe.TraceAttribute.SESSION_ID, 'session-abc');
const carrier = Observe.getBaggage();
await messageQueue.publish('enrichment-jobs', {
payload: { documentId: 'doc-123' },
traceContext: carrier,
});
});Consumer:
messageQueue.subscribe('enrichment-jobs', async (message) => {
const { payload, traceContext } = message;
await Observe.withTrace('process-enrichment', traceContext, async () => {
const result = await openai.chat.completions.create({
model: 'gpt-4o',
messages: [{ role: 'user', content: `Process document ${payload.documentId}` }],
});
return result;
});
});Producer:
def submit_job():
Observe.set_attribute(Observe.TraceAttribute.SESSION_ID, "session-abc")
carrier = Observe.get_carrier()
message_queue.publish("enrichment-jobs", {
"payload": {"document_id": "doc-123"},
"trace_context": carrier,
})
Observe.with_trace("submit-job", submit_job)Consumer:
def on_message(message):
payload = message["payload"]
carrier = message["trace_context"]
def process_enrichment():
result = openai_client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": f"Process document {payload['document_id']}"}],
)
return result
Observe.with_trace("process-enrichment", process_enrichment, carrier=carrier)
message_queue.subscribe("enrichment-jobs", on_message)Multi-Hop Propagation
Carriers chain automatically through 3+ services. When a downstream service calls getBaggage() / get_carrier() after receiving an upstream carrier, the SDK forwards the original root trace ID. All hops appear under the same trace — no extra configuration needed.
Idempotent traces with custom IDs
Carrier propagation links work across services that already participate in the same trace. For scenarios where a logical request may run more than once — retried API calls, replayed messages, distributed workers picking up the same job — you can additionally use custom trace IDs so every run resolves to the same deterministic trace.
The pattern: hand the SDK your business or request identifier as id (or pre-compute with generateTraceId / generate_trace_id). The first run creates the trace; later runs with the same custom ID land under the same trace instead of creating duplicates.
See Custom trace IDs for the full API and resolution rules.
API Reference
| Method | Returns | Description |
|---|---|---|
Observe.getBaggage() | string | Serializes current trace context as a JSON carrier string containing traceparent and baggage. Returns empty string if no active span. |
Observe.setBaggage(carrier) | void | Parses a carrier (JSON string or W3C baggage string) and links the current span to the upstream trace. |
Observe.withTrace(name, carrier, fn) | T | Promise<T> | Creates a named trace scope. If carrier is provided (non-null), the scope is linked to the upstream trace. Pass null for standalone traces. |
| Method | Returns | Description |
|---|---|---|
Observe.get_carrier() | dict | Serializes current trace context into a carrier dict with traceparent and baggage keys. Returns empty dict if no active trace. |
Observe.inject_remote_carrier(carrier) | None | Accepts a carrier (dict, JSON string, or W3C baggage string) and links the current span to the upstream trace. |
Observe.with_trace(name, fn, carrier=None) | Result of fn() | Creates a named trace scope. If carrier is provided, injection happens automatically before fn runs. |
Observe.get_trace_id() | Optional[str] | Returns the current trace ID as a 32-character hex string, or None. |
Session & User ID Propagation
Attributes set via Observe.setAttribute before calling getBaggage() / get_carrier() are automatically included in the baggage and propagated to downstream services.
await Observe.withTrace('api-request', null, async () => {
Observe.setAttribute(Observe.TraceAttribute.SESSION_ID, 'session-abc');
Observe.setAttribute(Observe.TraceAttribute.USER_ID, 'user-42');
// Session and user ID are included in the carrier automatically
const carrier = Observe.getBaggage();
// Pass carrier to downstream service...
});def handle_request():
Observe.set_attribute(Observe.TraceAttribute.SESSION_ID, "session-abc")
Observe.set_attribute(Observe.TraceAttribute.USER_ID, "user-42")
# Session and user ID are included in the carrier automatically
carrier = Observe.get_carrier()
# Pass carrier to downstream service...
Observe.with_trace("api-request", handle_request)The downstream service does not need to set session or user ID again — these values are extracted from the baggage automatically and attached to downstream spans.