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.
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
lastSeentimestamp. 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_PROJECTvariable 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’sfetchcall will be blocked by the browser and no event will reach Sentry. The Playwright page will show a CSP violation inpage.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.
Related
- CI/CD Source Map Upload and Validation — parent cluster
- Source Map Generation & Stack Trace Debugging — grandparent pillar
- Uploading Source Maps from GitHub Actions to Sentry — the upload step that precedes this verification
- Failing the Build When Source Maps Are Missing — count-based gate that runs before this end-to-end test
- Integrating Observability SDKs — Sentry, Datadog, OpenTelemetry — SDK initialization patterns referenced by this test