OpenTelemetry with Playwright: E2E Testing with Full Observability
Playwright tests verify what the user sees. OpenTelemetry traces verify what the backend did to produce it. Together, they give you the most complete picture of correctness available: from browser click to database write.
The Architecture
Playwright test
→ browser action (click, form fill)
→ HTTP request to your API
→ [service A → service B → DB]
spans emitted to Jaeger
→ Playwright asserts on UI outcome
→ Test also asserts on trace contentOne test, two dimensions of correctness.
Setup
You need:
- Playwright installed (
npm install -D @playwright/test) - Your app instrumented with OpenTelemetry
- Jaeger running locally or in CI
# Start Jaeger
docker run -d --name jaeger \
-e COLLECTOR_OTLP_ENABLED=<span class="hljs-literal">true \
-p 16686:16686 -p 4318:4318 \
jaegertracing/all-in-one:latest
<span class="hljs-comment"># Install Playwright
npm install -D @playwright/test
npx playwright install chromiumCreating a Jaeger Query Helper
// tests/helpers/jaeger.js
const JAEGER = 'http://localhost:16686/api';
async function findTrace(service, operation, afterMs) {
const params = new URLSearchParams({
service,
operation,
start: (afterMs * 1000).toString(),
limit: '5',
});
// Retry up to 5 times (traces export asynchronously)
for (let i = 0; i < 5; i++) {
const res = await fetch(`${JAEGER}/traces?${params}`);
const json = await res.json();
if (json.data && json.data.length > 0) return json.data[0];
await new Promise(r => setTimeout(r, 500));
}
throw new Error(`No trace found for ${service} ${operation} after ${afterMs}`);
}
function analyzeTrace(trace) {
const processMap = {};
Object.entries(trace.processes).forEach(([k, v]) => {
processMap[k] = v.serviceName;
});
return {
spans: trace.spans.map(s => ({
operation: s.operationName,
service: processMap[s.processID],
durationMs: s.duration / 1000,
hasError: s.tags.some(t => t.key === 'error' && t.value),
tags: Object.fromEntries(s.tags.map(t => [t.key, t.value])),
})),
get services() { return [...new Set(this.spans.map(s => s.service))]; },
get hasErrors() { return this.spans.some(s => s.hasError); },
};
}
module.exports = { findTrace, analyzeTrace };Writing a Playwright + OpenTelemetry Test
// tests/checkout.spec.js
const { test, expect } = require('@playwright/test');
const { findTrace, analyzeTrace } = require('./helpers/jaeger');
test('checkout flow — UI success and backend trace correctness', async ({ page }) => {
const startMs = Date.now();
// Step 1: Navigate and add item to cart
await page.goto('http://localhost:3000');
await page.click('[data-testid="product-A"]');
await page.click('[data-testid="add-to-cart"]');
await expect(page.locator('[data-testid="cart-count"]')).toHaveText('1');
// Step 2: Go to checkout
await page.click('[data-testid="checkout-btn"]');
await page.fill('[data-testid="card-number"]', '4111111111111111');
await page.fill('[data-testid="card-expiry"]', '12/26');
await page.fill('[data-testid="card-cvv"]', '123');
// Step 3: Submit order
await page.click('[data-testid="place-order-btn"]');
await expect(page.locator('[data-testid="order-confirmation"]')).toBeVisible();
const orderId = await page.locator('[data-testid="order-id"]').textContent();
// Step 4: Assert UI outcome
expect(orderId).toMatch(/^ORD-\d+$/);
// Step 5: Assert on backend trace
const trace = analyzeTrace(await findTrace('order-service', 'POST /orders', startMs));
// All services participated
expect(trace.services).toEqual(
expect.arrayContaining(['order-service', 'inventory-service', 'payment-service'])
);
// No errors in the happy path
expect(trace.hasErrors).toBe(false);
// Order span has the order ID
const orderSpan = trace.spans.find(s => s.operation === 'POST /orders');
expect(orderSpan.tags['order.id']).toBe(orderId);
// Performance: root span within SLO
expect(orderSpan.durationMs).toBeLessThan(1000);
});Testing Failure Scenarios End-to-End
test('payment failure — UI shows error, trace shows payment span error', async ({ page }) => {
const startMs = Date.now();
await page.goto('http://localhost:3000');
await page.click('[data-testid="product-A"]');
await page.click('[data-testid="add-to-cart"]');
await page.click('[data-testid="checkout-btn"]');
// Use a declined test card
await page.fill('[data-testid="card-number"]', '4000000000000002');
await page.fill('[data-testid="card-expiry"]', '12/26');
await page.fill('[data-testid="card-cvv"]', '123');
await page.click('[data-testid="place-order-btn"]');
// UI should show error message
await expect(page.locator('[data-testid="payment-error"]')).toBeVisible();
await expect(page.locator('[data-testid="payment-error"]')).toContainText('card declined');
// Trace should show error on payment span
const trace = analyzeTrace(await findTrace('order-service', 'POST /orders', startMs));
const paymentSpan = trace.spans.find(s => s.service === 'payment-service');
expect(paymentSpan).toBeDefined();
expect(paymentSpan.hasError).toBe(true);
expect(paymentSpan.tags['error.type']).toBe('card_declined');
});Extracting the Trace ID from the Response
For more precise trace lookup, extract the trace ID from the response headers:
// Instrument your app to return trace ID in response header
// app.js
app.use((req, res, next) => {
const span = trace.getActiveSpan();
if (span) {
res.setHeader('X-Trace-Id', span.spanContext().traceId);
}
next();
});// In your test, capture the trace ID via network interception
test('lookup trace by ID from response header', async ({ page }) => {
let traceId;
await page.route('**/orders', async (route) => {
const response = await route.fetch();
traceId = response.headers()['x-trace-id'];
await route.fulfill({ response });
});
await page.goto('http://localhost:3000');
// ... fill form and submit ...
await page.click('[data-testid="place-order-btn"]');
// Now look up by exact trace ID — no timing uncertainty
const res = await fetch(`http://localhost:16686/api/traces/${traceId}`);
const json = await res.json();
const trace = analyzeTrace(json.data[0]);
expect(trace.hasErrors).toBe(false);
});Playwright Fixtures for Trace Assertions
// tests/fixtures.js
const { test: base } = require('@playwright/test');
const { findTrace, analyzeTrace } = require('./helpers/jaeger');
const test = base.extend({
// Fixture that captures test start time and provides trace lookup
withTracing: async ({}, use) => {
const startMs = Date.now();
const getTrace = async (service, operation) => {
const raw = await findTrace(service, operation, startMs);
return analyzeTrace(raw);
};
await use({ getTrace, startMs });
},
});
module.exports = { test };// tests/order.spec.js
const { test } = require('./fixtures');
const { expect } = require('@playwright/test');
test('order placement with trace fixture', async ({ page, withTracing }) => {
const { getTrace } = withTracing;
// ... UI actions ...
await page.click('[data-testid="place-order-btn"]');
await expect(page.locator('[data-testid="order-confirmation"]')).toBeVisible();
const trace = await getTrace('order-service', 'POST /orders');
expect(trace.services).toContain('payment-service');
expect(trace.hasErrors).toBe(false);
});Running in CI
# .github/workflows/e2e.yml
jobs:
e2e:
runs-on: ubuntu-latest
services:
jaeger:
image: jaegertracing/all-in-one:latest
env:
COLLECTOR_OTLP_ENABLED: "true"
ports:
- 16686:16686
- 4318:4318
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npx playwright install --with-deps chromium
- name: Start app
run: |
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318/v1/traces \
OTEL_SERVICE_NAME=order-service \
node server.js &
npx wait-on http://localhost:3000
- run: npx playwright testSummary
Combining Playwright with OpenTelemetry gives you:
- UI correctness — what the user sees is right
- Backend correctness — every service involved, no errors, within latency SLOs
- Failure precision — when a test fails, you know exactly which service and span caused it
The implementation is a helper function + a few extra expect calls. The payoff is that a passing Playwright suite now means both "the UI works" and "the backend distributed execution is correct."
This is the closest you can get to production confidence in a CI environment.