Browser automation tends to fail in the least convenient way: a selector breaks only in one environment, a network call slows down just enough to trigger a timeout, or a test passes locally but flakes in CI with no obvious difference in the logs. That is a tracing problem as much as it is a testing problem.

If you instrument browser tests with OpenTelemetry spans, trace IDs, and structured logs, you can connect the browser step that failed to the API call, console error, or backend request that made it fail. The result is not just prettier observability, it is faster flake triage and better run correlation across CI jobs, test retries, and application telemetry.

This tutorial shows a vendor-neutral approach. It does not depend on a specific test runner or browser cloud. The same ideas apply whether you use Playwright, Selenium, Cypress, or a custom harness built around your CI system and continuous integration.

What browser test observability is, and what it is not

Browser test observability is the practice of attaching enough structured context to a browser test run that you can answer practical questions later:

  • Which test failed?
  • Which step failed?
  • Which trace or run ID links this test to server-side logs?
  • What browser version, viewport, environment, and retry attempt were involved?
  • What happened immediately before the failure?

That is broader than logging, and narrower than full production observability. A test run does not need every signal a live service needs, but it does need enough correlation data to make failures explainable.

A useful rule is to treat each test case as a small distributed system. The browser, test runner, application under test, and CI job are all parts of the same failure chain.

OpenTelemetry is a good fit because it gives you a standard way to model that chain using spans and attributes, while remaining neutral about where the data is exported. The software testing and test automation layers stay under your control.

The basic model, spans, attributes, logs, and IDs

Before writing code, define the shape of the data you want to collect.

Span hierarchy

Use one root span per test case or per test attempt. Inside it, create child spans for meaningful steps, not every API call.

A practical hierarchy looks like this:

  • Root span, test: checkout completes
    • setup browser context
    • login as standard user
    • add item to cart
    • checkout form submission
    • assert order confirmation

Do not create a span for every assertion or every locator call. That creates noise, overhead, and a timeline that is hard to read.

Attributes

Attributes are where the correlation value lives. Add stable, queryable details such as:

  • test.name
  • test.suite
  • test.file
  • test.retry
  • browser.name
  • browser.version
  • browser.viewport
  • ci.pipeline_id
  • ci.job_id
  • environment
  • app.base_url
  • run.id
  • trace_id

Use attributes for values you will filter on. Put high-cardinality or freeform content, such as full HTML snapshots, into logs or artifacts rather than span attributes.

Structured logs

Logs work well for event-level context, especially when something fails and you need to include the last known URL, selector, or response body snippet. The important part is structure. A JSON log line is much more useful than a plain string if you need to join logs to traces later.

IDs for correlation

To make flake triage practical, every test attempt should emit at least one of these identifiers consistently:

  • trace ID
  • run ID
  • CI job ID
  • attempt number

When possible, propagate the trace context into application requests so backend logs can include the same trace ID. That is where the value compounds.

A minimal instrumentation strategy

There are two common implementation styles.

1. Wrap the test runner

Create a small helper layer that starts and ends spans around each test and step. This is the most maintainable option for teams that own their automation code.

2. Use hooks and fixtures

If your framework already has setup and teardown hooks, initialize telemetry there and expose a helper object to tests. This keeps instrumentation consistent and avoids spread-out tracing code.

The key design goal is the same, centralize the observability plumbing so test authors do not have to think about it in every file.

Example: Playwright with OpenTelemetry spans

Playwright is a common choice for this pattern because it already has a strong concept of tests, fixtures, and steps. The following example uses the OpenTelemetry API to create a span per test attempt and child spans for steps.

import { test, expect } from '@playwright/test';
import { trace, SpanStatusCode } from '@opentelemetry/api';

const tracer = trace.getTracer(‘browser-tests’);

test('checkout completes', async ({ page }, testInfo) => {
  const span = tracer.startSpan('checkout completes', {
    attributes: {
      'test.name': testInfo.title,
      'test.file': testInfo.file,
      'test.retry': testInfo.retry,
      'browser.name': testInfo.project.name,
    },
  });

try { await page.goto(‘https://app.example.com’); await page.getByRole(‘button’, { name: ‘Sign in’ }).click(); await expect(page.getByText(‘Welcome back’)).toBeVisible(); span.setStatus({ code: SpanStatusCode.OK }); } catch (error) { span.recordException(error as Error); span.setStatus({ code: SpanStatusCode.ERROR }); throw error; } finally { span.end(); } });

This example is intentionally small. In a real suite, you would likely wrap the try/catch/finally in a reusable helper or fixture so the pattern is not duplicated across hundreds of tests.

Add step spans when the step boundary matters

When a failure is hard to localize, step-level spans become more useful than a single test span.

typescript

await tracer.startActiveSpan('add item to cart', async (span) => {
  try {
    await page.getByRole('button', { name: 'Add to cart' }).click();
    await expect(page.locator('[data-testid=cart-count]')).toHaveText('1');
  } catch (error) {
    span.recordException(error as Error);
    span.setStatus({ code: SpanStatusCode.ERROR });
    throw error;
  } finally {
    span.end();
  }
});

Capture useful failure context

A trace becomes far more useful when a failed step also records context that would otherwise live only in console output.

catch (error) {
  span.setAttributes({
    'test.url': page.url(),
    'test.viewport': `${page.viewportSize()?.width}x${page.viewportSize()?.height}`,
  });
  span.recordException(error as Error);
  throw error;
}

Do not store entire screenshots or HTML snapshots in span attributes. Upload them as artifacts and attach the artifact path or object key as an attribute.

Example: Selenium with Python and OpenTelemetry

Selenium teams often need a thin wrapper because the driver API is lower level. The same observability pattern still works.

from opentelemetry import trace
from opentelemetry.trace.status import Status, StatusCode

tracer = trace.get_tracer(name)

with tracer.start_as_current_span(“login flow”) as span: try: driver.get(“https://app.example.com”) driver.find_element(“css selector”, “[data-testid=’login’]”).click() span.set_attribute(“test.url”, driver.current_url) except Exception as exc: span.record_exception(exc) span.set_status(Status(StatusCode.ERROR)) raise

With Selenium, you often need to make the span lifetime explicit around page objects or helper methods. That is a tradeoff: more boilerplate, but also more control over what constitutes a meaningful step.

Making run correlation work in CI

Instrumenting the browser is only half the problem. The other half is connecting the test run to the pipeline that executed it.

At minimum, add these fields to every root span and to your logs:

  • CI provider name
  • pipeline ID or run number
  • job ID
  • branch or pull request reference
  • attempt number
  • commit SHA

If your CI platform exposes environment variables, map them once in a shared helper. For example:

const ciAttributes = {
  'ci.pipeline_id': process.env.GITHUB_RUN_ID,
  'ci.job_id': process.env.GITHUB_JOB,
  'git.sha': process.env.GITHUB_SHA,
  'git.ref': process.env.GITHUB_REF,
};

Attach these to the root span and to any failure log line. That way, when a test flakes in a rerun, you can compare attempt one and attempt two without guessing whether they were actually identical.

Handle retries explicitly

Retries are a common source of false confidence. A test that passes on retry is still evidence of instability.

Model retries as separate attempts, not just as a boolean flag. A useful pattern is:

  • run.id stays constant for the logical test execution
  • attempt increments on each retry
  • each attempt gets its own span tree

This lets you query for repeated failures of the same test without losing the distinction between first failure and successful retry.

What to log, what to trace, and what to artifact

A good observability design separates three kinds of data.

Put in spans

Use spans for duration, hierarchy, and searchable attributes.

Good span content:

  • test name
  • step name
  • environment
  • browser details
  • error status
  • trace ID

Put in structured logs

Use logs for event breadcrumbs and high-detail messages.

Good log content:

  • navigation URL
  • selector used
  • response status for a relevant request
  • console error text
  • assertion failure summary

Put in artifacts

Use artifacts for bulky or sensitive diagnostic data.

Good artifact content:

  • screenshot
  • video
  • DOM snapshot
  • HAR file
  • network trace

A common failure mode is to cram every debug signal into one place. Traces become cluttered, logs become noisy, and artifacts become hard to retrieve. Keep the roles separate.

Practical failure patterns and how tracing helps

1. Selector drift

A locator changes in one branch or environment. A trace helps answer whether the failure happened before the click, after the click, or during the assertion. If you log the last matched URL and a short selector summary, triage is much faster.

2. Timing issues

The test is not waiting for the right condition. A span timeline shows whether the delay is in navigation, API response time, rendering, or the assertion itself.

3. Backend regression visible in the browser

The browser test fails, but the root cause is a server error or slow dependency. If your browser requests carry trace context, you can jump from the failing UI step to the backend trace and then to service logs.

4. Environment drift

Failures only happen in one browser version, screen size, or CI worker type. Tags on the root span help isolate these cases quickly.

5. Flake introduced by retries

The first attempt fails, the second passes. Separate attempt spans make the pattern obvious instead of hiding it behind one aggregated pass result.

How to propagate trace context into application requests

If you control the application under test, propagate trace headers from the browser session or from the test harness into backend requests. This is the step that turns browser test observability into distributed run correlation.

For example, if the app makes API requests from the browser, ensure the backend logs capture the same trace context through standard propagation headers. OpenTelemetry defines propagation semantics in its documentation, and most language SDKs support W3C Trace Context out of the box.

If you do not control the application, you can still correlate through a run ID injected into test URLs, headers, or cookies in a staging environment. That is less elegant than true tracing, but still useful.

Keep the instrumentation maintainable

Instrumentation tends to rot if it is scattered across tests. A few maintenance rules help.

Centralize the helper layer

Put span creation, attribute population, and failure handling in one module. Tests should call runStep('name', async () => { ... }) rather than copy-pasting tracing logic.

Keep attributes stable

Do not rename telemetry fields casually. Querying traces depends on consistent keys. Treat the attribute schema like an API.

Avoid over-instrumentation

More spans are not always better. If every helper emits a span, the trace becomes hard to read and expensive to export. Instrument at the boundaries that matter for triage.

Sample or filter aggressively in large suites

Full-fidelity traces for every passing browser test can become expensive in storage and ingestion. Common strategies include:

  • trace every failure
  • trace every flaky test category
  • sample successful runs at a lower rate
  • keep detailed artifacts only for failed attempts

The right policy depends on your suite size and the cost of debugging missing context.

A simple GitHub Actions pattern

A CI job can export trace data to an OpenTelemetry collector or another backend, while preserving run metadata in environment variables.

name: browser-tests
on: [push, pull_request]

jobs: e2e: runs-on: ubuntu-latest env: CI_PIPELINE_ID: $ CI_JOB_ID: $ GIT_SHA: $ steps: - uses: actions/checkout@v4 - run: npm ci - run: npm test

The job does not need to know the tracing implementation details. It only needs to supply stable identifiers that your test helper can read.

Decision criteria for teams

Use this approach when one or more of the following are true:

  • flake triage is consuming too much engineering time
  • the same test passes in one environment and fails in another
  • browser failures need to be correlated with backend telemetry
  • retries mask the real failure pattern
  • multiple teams own the application and the test suite

It may be overkill if your suite is tiny, failures are rare, or the overhead of maintaining telemetry is larger than the debugging value. The tradeoff is not theoretical. Every extra signal has a cost in code, storage, and attention.

A selection guide for implementation depth

  • Basic: one span per test, trace ID in logs, pipeline metadata in attributes
  • Moderate: child spans per major step, structured failure events, artifact links
  • Advanced: propagated trace context into backend services, retry-aware attempts, automated queries for flaky patterns

Most teams should start at the basic or moderate level and expand only when the failure modes justify it.

Minimal checklist for production use

Before rolling this into a shared test framework, confirm the following:

  • root span per test attempt exists
  • trace ID is available in logs and artifacts
  • pipeline ID and commit SHA are attached
  • failed assertions record exception details
  • screenshots or DOM snapshots are attached out of band
  • retry attempts remain distinguishable
  • attribute keys are documented and stable
  • telemetry export does not slow the suite materially

Final takeaway

When teams instrument browser tests with OpenTelemetry, the goal is not to make test output look more modern. The goal is to make failures explainable. Spans give structure, trace IDs give correlation, structured logs give detail, and artifacts give evidence.

That combination shortens the path from “a browser test failed” to “here is the step, the environment, the backend request, and the retry attempt that caused it.” For browser test observability, that is a practical improvement, not an abstract one.

If your current suite leaves engineers scrolling through unstructured console output, adding OpenTelemetry is one of the clearest ways to improve flake triage without tying your workflow to a single vendor or browser platform.