Injecting Synthetic Errors in Staging to Verify Symbolication

After the source maps are uploaded and the CDN excludes .map files, one question remains unanswered: does Sentry actually produce a symbolicated stack trace from a real browser error? This page shows how to throw a deterministic synthetic error in the staging environment immediately after each deploy, then assert — programmatically — that the resulting Sentry issue shows original source file paths rather than minified identifiers. It is the final verification stage of CI/CD Source Map Upload and Validation, sitting under Source Map Generation & Stack Trace Debugging.

Synthetic Error Symbolication Verification Sequence Four sequential stages: Deploy to Staging, Trigger Synthetic Error (browser/Node), Poll Sentry API for issue, Assert symbolicated frame. A pass/fail branch shows the assertion outcome. Deploy Staging new bundle + maps Trigger Error __SMOKE_TEST__ flag Poll Sentry API find issue by tag Assert Frame src/ path in stack PASS — deploy FAIL — exit 1 src/ found no src/ path

Symptom / Trigger

The upload validation reports success — all .map counts match — but real user errors still display minified frames. Checking a sample Sentry issue confirms:

TypeError: Cannot read properties of undefined (reading 'userId')
    at t (https://staging.example.com/static/app.4f9a1b2c.js:1:28904)
    at HTMLButtonElement.<anonymous> (https://staging.example.com/static/app.4f9a1b2c.js:1:51200)

No src/ paths appear. The maps were uploaded but symbolication is failing for one of several reasons: the release tag mismatch, wrong url-prefix, the SDK release value does not match the upload, or the SDK dsn points at a different project than the upload target.

Root Cause Explanation

A count match is a necessary but not sufficient condition for correct symbolication. The pipeline can upload all maps with the right count yet still fail if the URL key Sentry constructs during symbolication does not match the artifact key stored during upload:

// BROKEN: SDK initialized with a different release than sentry-cli used
Sentry.init({
  dsn: 'https://[email protected]/123',
  release: 'v1.2.3',              // hardcoded semver — does not match github.sha used in upload
});

// When an event fires, Sentry looks for artifacts keyed to 'v1.2.3'
// but the upload stored them under the 40-char SHA
// → lookup returns null → raw frame displayed

The synthetic error test catches this because it generates a real event with the real SDK configuration, through the real CDN, and queries the Sentry API to inspect the actual resolved frame — not just the artifact list.

Step-by-Step Fix

1. Add a synthetic error trigger to the application

Gate the throw behind an environment variable that only the CI pipeline sets:

// src/smokeTest.js — loaded only in staging; tree-shaken in production
export function runSymbolicationSmokeTest() {
  // This flag is injected by the build tool only for staging smoke-test builds
  if (typeof __SMOKE_TEST__ === 'undefined' || !__SMOKE_TEST__) return;

  // Use a distinctive message so Sentry queries can find exactly this event
  const err = new Error('__SYMBOLICATION_SMOKE_TEST__');
  // Add a tag so the Sentry API query can filter by it
  throw err;
}
// src/index.js — entry point
import { runSymbolicationSmokeTest } from './smokeTest';

// Called after Sentry.init() so the error is captured with the correct release
runSymbolicationSmokeTest();
// webpack.config.js — staging-specific DefinePlugin entry
new DefinePlugin({
  '__SMOKE_TEST__': JSON.stringify(process.env.SMOKE_TEST === 'true'),
  'process.env.SENTRY_RELEASE': JSON.stringify(process.env.SENTRY_RELEASE),
})

2. Deploy a staging build with the smoke-test flag enabled

# .github/workflows/staging-smoke.yml
      - name: Build staging bundle with smoke-test flag
        run: npm run build
        env:
          SMOKE_TEST: 'true'
          SENTRY_RELEASE: ${{ github.sha }}
          NODE_ENV: staging

      - name: Deploy staging bundle
        run: |
          aws s3 sync ./dist s3://${{ secrets.STAGING_CDN_BUCKET }}/static/ \
            --exclude "*.map"

The staging build is a separate artifact from the production build. Never set __SMOKE_TEST__ to true in production — the synthetic throw will fire for every real user loading the page.

3. Capture the synthetic error via a headless browser

Use Playwright to load the staging URL and let the SDK capture the throw:

// scripts/trigger-smoke-test.mjs
import { chromium } from 'playwright';

const STAGING_URL = process.env.STAGING_URL;        // e.g. https://staging.example.com
const WAIT_MS = 8000;                               // Sentry SDK batches events; allow flush time

const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();

// Suppress console noise but capture errors for debugging
page.on('pageerror', (err) => {
  if (!err.message.includes('__SYMBOLICATION_SMOKE_TEST__')) {
    console.warn('Unexpected page error:', err.message);
  }
});

await page.goto(STAGING_URL, { waitUntil: 'networkidle' });

// The smoke-test flag causes the error to throw during page load.
// Wait for the Sentry SDK to flush the event to the ingestion endpoint.
await page.waitForTimeout(WAIT_MS);
await browser.close();

console.log(`Staged synthetic error. Waiting ${WAIT_MS}ms for Sentry ingestion.`);

4. Poll the Sentry API and assert symbolicated frames

#!/usr/bin/env bash
# scripts/assert-symbolication.sh
set -euo pipefail

RELEASE="$SENTRY_RELEASE"
ORG="$SENTRY_ORG"
PROJECT="$SENTRY_PROJECT"
TOKEN="$SENTRY_AUTH_TOKEN"
MAX_ATTEMPTS=10
SLEEP_SEC=15

echo "Polling Sentry for __SYMBOLICATION_SMOKE_TEST__ issue..."

for i in $(seq 1 $MAX_ATTEMPTS); do
  # Query issues with the distinctive message; limit to the current release
  RESULT=$(curl -sf \
    -H "Authorization: Bearer $TOKEN" \
    "https://sentry.io/api/0/projects/$ORG/$PROJECT/issues/?query=__SYMBOLICATION_SMOKE_TEST__+release:$RELEASE&limit=1")

  ISSUE_ID=$(echo "$RESULT" | python3 -c \
    "import sys,json; d=json.load(sys.stdin); print(d[0]['id'] if d else '')" 2>/dev/null || true)

  if [ -z "$ISSUE_ID" ]; then
    echo "Attempt $i/$MAX_ATTEMPTS: issue not yet indexed. Sleeping ${SLEEP_SEC}s..."
    sleep "$SLEEP_SEC"
    continue
  fi

  echo "Found issue $ISSUE_ID. Fetching latest event..."

  # Fetch the most recent event for the issue
  EVENT=$(curl -sf \
    -H "Authorization: Bearer $TOKEN" \
    "https://sentry.io/api/0/issues/$ISSUE_ID/events/latest/")

  # Check if any frame's abs_path contains 'src/' (symbolicated) rather than '.js:1:' (minified)
  SYMBOLICATED=$(echo "$EVENT" | python3 -c "
import sys, json
data = json.load(sys.stdin)
frames = []
for exc in data.get('entries', []):
    if exc.get('type') == 'exception':
        for val in exc.get('data', {}).get('values', []):
            frames.extend(val.get('stacktrace', {}).get('frames', []))
src_frames = [f for f in frames if 'src/' in (f.get('absPath') or f.get('filename') or '')]
print(len(src_frames))
" 2>/dev/null || echo "0")

  if [ "$SYMBOLICATED" -gt 0 ]; then
    echo "OK: $SYMBOLICATED symbolicated frame(s) found with src/ paths."
    exit 0
  else
    echo "ERROR: Issue $ISSUE_ID found but frames are not symbolicated."
    echo "Raw frame sample:"
    echo "$EVENT" | python3 -c "
import sys, json
data = json.load(sys.stdin)
for exc in data.get('entries', []):
    if exc.get('type') == 'exception':
        for val in exc.get('data', {}).get('values', []):
            for f in val.get('stacktrace', {}).get('frames', [])[:3]:
                print('  abs_path:', f.get('absPath'), '| filename:', f.get('filename'))
" 2>/dev/null || true
    exit 1
  fi
done

echo "TIMEOUT: Sentry did not index the smoke-test issue after $MAX_ATTEMPTS attempts."
exit 1

5. Wire trigger and assertion into the workflow

# .github/workflows/staging-smoke.yml (verification section)
      - name: Install Playwright
        run: npx playwright install chromium --with-deps

      - name: Trigger synthetic error in staging
        run: node scripts/trigger-smoke-test.mjs
        env:
          STAGING_URL: ${{ secrets.STAGING_URL }}

      - name: Wait for Sentry ingestion and assert symbolicated frames
        run: bash scripts/assert-symbolication.sh
        env:
          SENTRY_RELEASE: ${{ github.sha }}
          SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
          SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
          SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}

A non-zero exit from assert-symbolication.sh halts the workflow. Gate your production deploy job on this staging job completing successfully:

  production-deploy:
    needs: [staging-smoke-test]    # only runs if staging job exits 0
    runs-on: ubuntu-latest

Verification

Inspect the raw Sentry event directly to confirm the assertion logic matches what you see in the UI:

# Fetch the event JSON and print frame paths
curl -s \
  -H "Authorization: Bearer $SENTRY_AUTH_TOKEN" \
  "https://sentry.io/api/0/issues/$ISSUE_ID/events/latest/" \
  | python3 -c "
import sys, json
data = json.load(sys.stdin)
for exc in data.get('entries', []):
    if exc.get('type') == 'exception':
        for val in exc.get('data', {}).get('values', []):
            for f in val.get('stacktrace', {}).get('frames', []):
                print(f.get('absPath') or f.get('filename'), 'line', f.get('lineNo'))
"
# Symbolicated output:
# src/components/App.tsx line 42
# src/utils/auth.ts line 17

# Minified (broken) output:
# https://staging.example.com/static/app.4f9a1b2c.js line 1

A symbolicated frame contains a src/ path and a real line number above 1. A minified frame shows the CDN URL and line 1 (because the entire bundle is one concatenated line).

Edge Cases & Gotchas

  • Sentry event deduplication — if the same smoke-test error fires twice (e.g. re-runs the same release), Sentry deduplicates it into the existing issue and updates the lastSeen timestamp. The poll script handles this because it queries by message text, not by unique event ID. However, if you reuse the same error message across releases, the query may return the old issue from a previous deploy. Add the commit SHA to the error message: new Error('__SYMBOLICATION_SMOKE_TEST__:' + SENTRY_RELEASE) to guarantee uniqueness.
  • Staging Sentry project isolation — if staging events go to a separate Sentry project from production, the SENTRY_PROJECT variable in the assertion script must point at the staging project, not the production one. Use a distinct secret pair (STAGING_SENTRY_PROJECT, STAGING_SENTRY_AUTH_TOKEN).
  • Sentry event processing latency — Sentry’s ingestion pipeline can take 10–60 seconds under load. The 10-attempt × 15-second poll gives a 2.5-minute window, which is sufficient in normal conditions. During Sentry incidents, this can timeout — check the Sentry status page before escalating.
  • CSP blocking the Sentry SDK — if your staging environment enforces a strict Content-Security-Policy that does not include Sentry’s ingestion endpoint (ingest.sentry.io), the SDK’s fetch call will be blocked by the browser and no event will reach Sentry. The Playwright page will show a CSP violation in page.on('console') output. Fix the CSP header for the staging environment or add an exception for the Sentry DSN host.

FAQ

Can I run this test against production rather than staging? Technically yes, but it creates a real issue in your production Sentry project that engineers may triage. More importantly, a synthetic throw that fires for every production user during the deploy window creates noise in release health metrics. Confine the smoke test to a dedicated staging or canary environment. If you must verify production symbolication, use a feature-flagged test route that only your CI runner can access.

What if my application is a Node.js API, not a browser app? Replace the Playwright step with a curl or node script that POSTs to a dedicated health endpoint that intentionally throws:

// routes/smoke-test.js (Express, gated by secret header)
app.get('/__smoke', (req, res) => {
  if (req.headers['x-smoke-secret'] !== process.env.SMOKE_SECRET) {
    return res.status(403).end();
  }
  throw new Error('__SYMBOLICATION_SMOKE_TEST__');
});

The --auto flag in sentry-cli releases set-commits and the release tag in the Sentry Node SDK work the same way; only the trigger mechanism differs.

How do I clean up the synthetic issue after the test passes? Use the Sentry API to delete the issue:

curl -s -X DELETE \
  -H "Authorization: Bearer $SENTRY_AUTH_TOKEN" \
  "https://sentry.io/api/0/issues/$ISSUE_ID/"

Add this as a final step in the workflow, running even if prior steps fail (if: always()). Some teams prefer to keep the issue as a release audit trail — tagging it synthetic-test instead of deleting it.