Capturing Playwright Traces for Flaky Preview Failures
The test failed against the preview, passed on re-run, and there is nothing to look at. Preview-only failures are the hardest kind to diagnose because the environment is gone within the hour β so the evidence has to be captured during the failing run, not reconstructed afterwards.
When to use this pattern
- Tests fail against preview environments and pass locally or on re-run.
- Your current debugging loop starts with βre-run it with more loggingβ.
- Preview environments are torn down before anyone can inspect them by hand.
Prerequisites
What a trace contains, and why it beats logs
A trace is not a log file. It is a recorded timeline of the browser session β every action, a DOM snapshot before and after each one, the network exchange, and the console β replayable in a viewer that lets you step through the failure.
Complete working example
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
// One retry absorbs genuine network transients against a shared preview.
retries: process.env.CI ? 1 : 0,
use: {
baseURL: process.env.BASE_URL,
// on-first-retry keeps the trace for a spec that failed then passed β the
// flaky case, which retain-on-failure discards because the run ended green.
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
// Record request and response bodies: the 502 above is only diagnosable with them.
contextOptions: { recordHar: { mode: 'minimal', content: 'embed' } },
},
// Give every artifact a name that identifies which run produced it.
outputDir: `test-results/${process.env.RUN_LABEL ?? 'local'}`,
reporter: process.env.CI ? [['blob'], ['github']] : [['list']],
});// tests/fixtures.ts β attach the preview's identity to every trace.
import { test as base } from '@playwright/test';
export const test = base.extend({
page: async ({ page }, use, testInfo) => {
// Annotations appear in the trace metadata and in the HTML report.
testInfo.annotations.push(
{ type: 'preview-url', description: process.env.BASE_URL ?? 'unset' },
{ type: 'preview-commit', description: process.env.PREVIEW_SHA ?? 'unset' },
);
// Console errors are the single most useful correlate of a preview-only failure.
const consoleErrors: string[] = [];
page.on('console', (m) => m.type() === 'error' && consoleErrors.push(m.text()));
page.on('pageerror', (e) => consoleErrors.push(`pageerror: ${e.message}`));
await use(page);
if (testInfo.status !== testInfo.expectedStatus && consoleErrors.length) {
await testInfo.attach('console-errors', {
body: consoleErrors.join('\n'), contentType: 'text/plain',
});
}
},
});# .github/workflows/e2e.yml β upload labelled, and only what is useful.
- name: Run tests
run: npx playwright test --shard=${{ matrix.shard }}/4
env:
BASE_URL: ${{ needs.deploy-preview.outputs.url }}
PREVIEW_SHA: ${{ github.event.pull_request.head.sha }}
RUN_LABEL: pr${{ github.event.number }}-s${{ matrix.shard }}-a${{ github.run_attempt }}
- uses: actions/upload-artifact@v4
if: failure() # only when something actually failed
with:
# The name is the whole findability story: pull request, shard, attempt.
name: traces-pr${{ github.event.number }}-s${{ matrix.shard }}-a${{ github.run_attempt }}
path: test-results/
retention-days: 14 # long enough to outlive a review cycle
compression-level: 9Step-by-step walkthrough
on-first-retry, not retain-on-failure. This is the setting that matters for flakiness specifically. retain-on-failure keeps traces for specs that ended red; a spec that failed and then passed ends green and is classified flaky, so its evidence can be dropped. on-first-retry records the retry attempt precisely for that case β which is the case you are trying to diagnose.
Record the network with bodies. A 502 with an empty body tells you the request failed; a 502 with the gatewayβs response tells you which upstream. On a preview environment this is often the entire diagnosis, because the failure is infrastructural rather than in the application.
Annotate with the preview identity. Traces are read days later, by which time the environment is gone and the pull request has moved on several commits. Recording the URL and commit inside the artifact means the trace is self-describing rather than requiring a reconstruction of which deploy it came from.
Capture console and page errors explicitly. Playwrightβs trace records console output, but attaching the errors as a plain-text file means they are visible in the report summary without opening the viewer β often enough to identify the problem from the pull request page alone.
Label the upload with pull request, shard and attempt. Four shards times two attempts is eight artifacts named test-results; finding the right one becomes a chore, and chores get skipped. The name is the difference between a trace being used and a trace merely existing.
Verification
# 1. A failing run produced an artifact, and it contains a trace.
gh run download -n "traces-pr482-s2-a1" -D /tmp/tr
find /tmp/tr -name 'trace.zip' | head
# 2. Open it. This is the whole point β it replays locally, offline.
npx playwright show-trace /tmp/tr/**/trace.zip
# 3. The annotations survived, so the trace identifies its own environment.
unzip -p /tmp/tr/**/trace.zip test.trace 2>/dev/null | head -c 2000 | grep -o 'preview-[a-z]*'Expected from the third command:
preview-url
preview-commitIf no artifact exists for a failed run, check that the upload step uses if: failure() rather than the default β the default is success(), which skips the upload in exactly the situation it was added for. That single mistake accounts for most βwe have traces configured but there is never one to look atβ reports.
Reading a preview failure in the right order
Traces make a failure diagnosable, but only if you look at the tracks in an order that separates the two possibilities: the application is broken, or the environment is.
Start with the network track, not the failing assertion. A non-2xx response, a request that never completed, or a request to an unexpected host tells you immediately that the problem is environmental β a missing configuration value, an unready dependency, or a sandbox credential that was never injected. Only when the network track is clean is the assertion itself worth studying, and then the DOM snapshot immediately before the failure usually shows the answer: an element rendered but hidden, a loading state that never resolved, a different copy than the selector expects.
The habit matters because the default instinct β read the assertion, then the code β leads to a long investigation of application logic for what turns out to be a preview that never received NEXT_PUBLIC_API_URL. Checking the environment first costs thirty seconds and resolves a large fraction of preview-only failures outright, and the ones it does not resolve are then genuinely about the code.
Common pitfalls
trace: 'on'. Recording every spec produces gigabytes per run and slows the suite noticeably. Failure-scoped capture gives the same diagnostic value at a fraction of the cost.
Uploading with the default if condition. actions/upload-artifact runs only on success unless told otherwise, so the artifacts you most want are exactly the ones not uploaded. Use if: failure() or if: always().
One-day retention on failure artifacts. A trace from Friday afternoon is gone by Monday. Debug output generally deserves short retention, as covered in setting artifact retention policies β but failure traces are the documented exception, and two weeks is a reasonable value.
Related
- Running E2E Tests Against Preview Environments β the parent guide on wiring the suite to previews.
- Sharding Playwright Tests Across GitHub Actions Runners β why artifacts need shard-aware names.
- Validating Environment Variables at Build Time β removes the most common cause the network track reveals.
- Preview Environments & Environment Parity β the section overview.
β Back to Running E2E Tests Against Preview Environments