You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Sentry Error Logging Strategy for WordPress Playground
Part A: Strategy and Architecture
Architecture Context
WordPress Playground has six distinct runtime execution contexts spread across three independently-built deployment artifacts:
graph TB
subgraph website ["playground-website (Build 1)"]
MainThread["Browser Main Thread\nReact App + Redux"]
end
subgraph remote ["playground-remote (Build 2)"]
IframeDoc["Iframe Main Thread\nremote.html + boot logic"]
WebWorker["Web Worker\nPHP/WASM Engine"]
ServiceWorker["Service Worker\nFetch routing + cache"]
end
subgraph cli ["playground-cli (Build 3)"]
NodeMain["Node.js Main Thread\nHTTP server + yargs"]
NodeWorker["Node.js worker_threads\nBlueprint execution"]
end
MainThread -->|"Comlink over postMessage"| IframeDoc
IframeDoc -->|"Comlink over Worker"| WebWorker
IframeDoc -->|"postMessage"| ServiceWorker
NodeMain -->|"MessagePort"| NodeWorker
ThirdParty["Any third-party website"] -.->|"iframe embed"| IframeDoc
Loading
Sentry Project Structure: 3 Projects
The project boundaries align with build artifacts (each has its own Vite/build config, independent source maps, and independent release versioning), not with individual threads. Thread/context differentiation is handled via Sentry tags and contexts within each project.
Project 1: playground-website
What it covers: The parent-frame React application at playground.wordpress.net.
Redux state machine failures in boot-site-client.ts
Blueprint client-side handler errors (BlueprintsV1Handler / BlueprintsV2Handler in @wp-playground/client)
OPFS site storage failures (including the Safari dedicated worker at opfs-site-storage-worker-for-safari.ts)
Initial module load failures (the dynamic import('./src/main') in index.html)
Comlink consumeAPI failures when talking to the iframe (the silent retry loop in api.ts that swallows timeouts)
Google Analytics or third-party script errors (filtered via denyUrls)
Key tags/contexts:
browser, browser.version, os (automatic)
wp_version, php_version, blueprint_url (custom, from Redux state)
site_slug (which Playground instance)
Source maps: Upload via @sentry/vite-plugin in packages/playground/website/vite.config.ts with release tied to the deploy commit SHA (already injected via buildVersionPlugin).
Project 2: playground-remote
What it covers: Everything inside the iframe -- the core engine. This is the highest-value project since WASM crashes, PHP errors, and service worker failures all live here.
Runtime: Three contexts within one build -- iframe main thread, dedicated Web Worker, Service Worker
SDK:@sentry/browser (not @sentry/react -- no React in remote)
Init locations:
Iframe main thread: packages/playground/remote/remote.html (inline script)
Web Worker: packages/playground/remote/src/lib/playground-worker-endpoint-blueprints-v1.ts (and v2)
Service Worker: packages/playground/remote/service-worker.ts
Error surfaces by context:
Iframe main thread: Service worker registration/update failures, Comlink relay errors, requestStorageAccess failures, third-party cookie blocks, navigation errors
Web Worker: WASM instantiateStreaming failures (network, MIME), Asyncify/JSPI runtime traps (RuntimeError: unreachable, memory access out of bounds), PHP fatal errors surfaced via PHPExecutionFailureError, blueprint step execution errors (BlueprintStepExecutionError), filesystem (memfs/OPFS) errors, network proxy failures (TCPOverFetchWebsocket errors that are currently .catch(() => {}))
Service Worker: Fetch handler exceptions, cache storage failures, stale deployment mismatches, offline-mode errors
Key tags/contexts:
runtime: main-thread | web-worker | service-worker (critical for filtering)
php_version, wp_version (from boot config)
wasm_mode: asyncify | jspi
embedding_origin: The origin of the parent page (important since any site can embed the remote)
blueprint_step: Which blueprint step was executing when error occurred
is_first_party: Whether embedded on playground.wordpress.net vs third-party
Why one project for three contexts (not three separate projects):
They share a single Vite build and one set of source maps
A WASM crash in the worker often has a correlated error in the iframe main thread (Comlink rejection) -- same project means Sentry can correlate them
Service worker errors often manifest as fetch failures in the main thread -- same project preserves the causal chain
Tags (runtime:web-worker) are sufficient for filtering and alert routing
Source maps: Upload via @sentry/vite-plugin in packages/playground/remote/vite.config.ts.
Project 3: playground-cli
What it covers: The Node.js CLI tool (@wp-playground/cli).
Runtime: Node.js main thread + worker_threads
Build:packages/playground/cli/ (NX build)
SDK:@sentry/node
Init location:packages/playground/cli/src/run-cli.ts (after the JSPI respawn gate in cli.ts)
Error surfaces:
WASM loading/runtime errors (same classes as web worker, but Node binaries)
HTTP server errors (start-server.ts)
Blueprint execution failures (v1 and v2 worker threads)
Filesystem/mount errors (native FS, not OPFS)
Worker thread crashes (unhandled exceptions in worker-thread-v1.ts / worker-thread-v2.ts)
JSPI respawn failures (child_process.spawn in cli.ts)
WordPress install/boot failures
Key tags/contexts:
runtime: main-thread | worker-thread
node_version, platform, arch
php_version, wp_version
wasm_mode: asyncify | jspi
cli_command: server | etc.
has_blueprint: whether a blueprint was provided
Source maps: Post-build sentry-cli sourcemaps upload step in the NX build pipeline.
Cross-Cutting Concerns
Release Tracking
All three projects use the same release identifier (the monorepo deploy commit SHA, already available via buildVersionPlugin). This enables:
Correlating issues across projects for the same deploy
Sentry's "suspect commits" feature working across the monorepo
Unified release health dashboard
The @php-wasm/logger Bridge
The existing @php-wasm/logger package already collects structured errors via collectWindowErrors, collectPhpLogs, etc. Rather than replacing it, bridge it to Sentry:
Add a Sentry "collector" alongside the existing console-oriented ones
This avoids duplicating error collection logic and preserves the existing logging pipeline
Scrub blueprint URLs that may contain tokens or private repo paths
Scrub filesystem paths from PHP errors (may expose user content)
Configure beforeSend in each project to strip PII
The CLI project especially needs path scrubbing (local filesystem paths, home directory)
Third-Party Embedder Noise
Since playground-remote runs inside iframes on third-party sites:
Use allowUrls to only capture errors from Playground's own scripts (not the embedder's page)
Tag with embedding_origin to identify if certain embedders cause disproportionate errors
Sample rate tuning: lower for third-party embeds (0.1), higher for first-party (1.0) on playground.wordpress.net
Error Deduplication Across Boundaries
A single root cause (e.g., WASM OOM) can produce cascading errors across boundaries:
Web Worker: RuntimeError: memory access out of bounds
-> Comlink: PHPExecutionFailureError (in iframe main thread)
-> Comlink: Error (in parent website)
Strategy:
The originating error (in playground-remote, tagged runtime:web-worker) is the source of truth
playground-website errors that are Comlink rejections from the iframe are dropped via beforeSend (isComlinkRelayError)
playground-remote main-thread errors that are Comlink rejections from the worker are dropped via beforeSend (isWorkerRelayError)
Use Sentry's fingerprint to group cascading errors by their root cause when they do slip through
Performance Monitoring (Future)
Sentry's performance/tracing SDK can be added later to measure:
Time from startPlaygroundWeb() call to WordPress ready (spans: iframe load, SW registration, WASM fetch, WASM instantiation, WordPress boot, blueprint execution)
Individual blueprint step durations
CLI server startup time
Service worker cache hit rates
This is a separate phase and should not block error logging rollout.
Build-time only (source map upload): SENTRY_AUTH_TOKEN, SENTRY_ORG. Passed via GitHub Actions secrets. Never in client bundles.
Shipped to client (DSN is public by design): SENTRY_DSN_WEBSITE, SENTRY_DSN_REMOTE. Injected via Vite's define or virtual modules. SENTRY_DSN_CLI is inlined at build time.
Decision: Use virtual modules (consistent with existing corsProxyUrl pattern). Add a sentryDsnPlugin or extend existing virtualModule calls.
All three projects use the same release string: the git commit SHA. This is already computed at build time by buildVersionPlugin which creates a virtual:website-config module exporting buildVersion. The same pattern exists for remote via virtual:remote-config.
Decision: Pass buildVersion (the commit SHA) as the Sentry release value. For CLI, compute it at build time in the sentry config module.
4. Sentry Config Modules
Create one config module per project. These are imported at each init site.
4a. packages/playground/website/src/lib/sentry.ts
import*asSentryfrom'@sentry/react';// @ts-ignoreimport{buildVersion}from'virtual:website-config';constdsn=import.meta.env.VITE_SENTRY_DSN_WEBSITE||'';exportfunctioninitSentry(){if(!dsn)return;Sentry.init({
dsn,release: buildVersion,environment: import.meta.env.MODE,// 'production' | 'development'sampleRate: 1.0,integrations: [Sentry.browserTracingIntegration(),],allowUrls: [/playground\.wordpress\.net/,/wasm\.wordpress\.net/,/localhost/,],denyUrls: [/googletagmanager\.com/,/google-analytics\.com/,],beforeSend(event,hint){consterror=hint.originalException;// Drop errors relayed from the remote iframe via Comlink.// These are already captured by the playground-remote project.if(isComlinkRelayError(error)){Sentry.addBreadcrumb({category: 'relayed-from-remote',message: errorinstanceofError ? error.message : String(error),level: 'warning',});returnnull;}// Scrub blueprint URLs that might contain auth tokensif(event.request?.url){event.request.url=scrubBlueprintTokens(event.request.url);}returnevent;},ignoreErrors: [// Browser extensions/^ResizeObserverloop/,// Expected during navigation/^AbortError/,],});}functionisComlinkRelayError(error: unknown): boolean{if(!(errorinstanceofError))returnfalse;// Comlink-serialized errors from the remote carry these markersif('response'inerror&&'source'inerror)returntrue;// PHPExecutionFailureError relayed through Comlinkif(error.name==='PHPExecutionFailureError')returntrue;// RuntimeError from WASM that bubbled through Comlinkif(error.name==='RuntimeError'&&error.message?.includes('unreachable'))returntrue;if(error.message?.includes('A call to the remote API has failed'))returntrue;returnfalse;}functionscrubBlueprintTokens(url: string): string{try{constu=newURL(url);for(constkeyof['token','access_token','auth','key']){if(u.searchParams.has(key))u.searchParams.set(key,'[REDACTED]');}returnu.toString();}catch{returnurl;}}export{Sentry};
The DSN must be exposed to the client bundle. Add to website/vite.config.ts in the plugins array, after the existing virtualModule for cors-proxy-url:
Then import from 'virtual:sentry-dsn-website' instead of import.meta.env.VITE_SENTRY_DSN_WEBSITE in sentry.ts. However, the simpler approach is Vite's built-in import.meta.env.VITE_* pattern. Decision: use VITE_SENTRY_DSN_WEBSITE env var so no plugin change is needed (Vite auto-exposes VITE_* vars).
4b. packages/playground/remote/src/lib/sentry.ts
import*asSentryfrom'@sentry/browser';// @ts-ignoreimport{buildVersion}from'virtual:remote-config';constdsn=import.meta.env.VITE_SENTRY_DSN_REMOTE||'';exportfunctioninitSentryForContext(context: 'main-thread'|'web-worker'|'service-worker'){if(!dsn)return;constisFirstParty=typeofwindow!=='undefined'&&(window.location.hostname==='playground.wordpress.net'||window.location.hostname==='wasm.wordpress.net'||window.location.hostname==='localhost');Sentry.init({
dsn,release: buildVersion,environment: import.meta.env.MODE,// Lower sample rate for third-party embeds to control volumesampleRate: isFirstParty ? 1.0 : 0.1,initialScope: {tags: {runtime: context,is_first_party: String(isFirstParty),},},beforeSend(event,hint){consterror=hint.originalException;// In the main thread context, drop errors that are Comlink relays// from the web worker -- the worker's Sentry already captured them.if(context==='main-thread'&&isWorkerRelayError(error)){Sentry.addBreadcrumb({category: 'relayed-from-worker',message: errorinstanceofError ? error.message : String(error),level: 'warning',});returnnull;}// Tag with embedding origin for third-party analysisif(typeofwindow!=='undefined'&&window.parent!==window){try{event.tags=event.tags||{};event.tags.embedding_origin=document.referrer
? newURL(document.referrer).origin
: 'unknown';}catch{// cross-origin referrer parsing failure}}// Scrub filesystem paths from PHP errorsscrubPHPFilePaths(event);returnevent;},ignoreErrors: [/^ResizeObserverloop/,/^AbortError/,// SW update failures when offline are expected/FailedtoupdateaServiceWorker/,],});}functionisWorkerRelayError(error: unknown): boolean{if(!(errorinstanceofError))returnfalse;if(error.name==='PHPExecutionFailureError')returntrue;if(error.name==='RuntimeError')returntrue;if(error.name==='BlueprintStepExecutionError')returntrue;returnfalse;}functionscrubPHPFilePaths(event: Sentry.ErrorEvent){if(event.exception?.values){for(constexofevent.exception.values){if(ex.value){// Replace absolute WordPress filesystem pathsex.value=ex.value.replace(/\/wordpress\/wp-content\/[^\s'"]+/g,'/wordpress/wp-content/[REDACTED]');}}}}export{Sentry};
Note on import.meta.env in workers and SW: Vite replaces import.meta.env.VITE_* at build time in all contexts (main, worker, SW) since they all go through the Vite build pipeline. This is confirmed by the remote's vite.config.ts having worker: { plugins: () => plugins }. The VITE_SENTRY_DSN_REMOTE env var works everywhere in the remote build.
4c. packages/playground/cli/src/sentry.ts
import*asSentryfrom'@sentry/node';// Inlined at build time by Vite's definedeclareconst__SENTRY_DSN_CLI__: string;declareconst__SENTRY_RELEASE__: string;constdsn=typeof__SENTRY_DSN_CLI__!=='undefined' ? __SENTRY_DSN_CLI__ : '';exportfunctioninitSentry(command: string){if(!dsn)return;Sentry.init({
dsn,release: typeof__SENTRY_RELEASE__!=='undefined' ? __SENTRY_RELEASE__ : 'unknown',environment: 'production',sampleRate: 1.0,initialScope: {tags: {runtime: 'main-thread',cli_command: command,node_version: process.version,platform: process.platform,arch: process.arch,},},beforeSend(event){// Scrub local filesystem paths (home directory, temp dirs)if(event.exception?.values){for(constexofevent.exception.values){if(ex.value){ex.value=scrubLocalPaths(ex.value);}if(ex.stacktrace?.frames){for(constframeofex.stacktrace.frames){if(frame.filename){frame.filename=scrubLocalPaths(frame.filename);}}}}}returnevent;},});}functionscrubLocalPaths(str: string): string{consthome=process.env.HOME||process.env.USERPROFILE||'';if(home){str=str.replace(newRegExp(escapeRegExp(home),'g'),'~');}// Scrub temp dir pathsstr=str.replace(/\/tmp\/[a-zA-Z0-9._-]*playground[a-zA-Z0-9._-]*/g,'/tmp/[playground-temp]');returnstr;}functionescapeRegExp(s: string){returns.replace(/[.*+?^${}()|[\]\\]/g,'\\$&');}exportfunctionflushSentry(): Promise<boolean>{returnSentry.flush(2000);}export{Sentry};
For the CLI, the DSN and release must be inlined at build time. Add to the CLI's Vite config (packages/playground/cli/vite.config.ts):
import{collectWindowErrors,logger}from'@php-wasm/logger';import{Provider}from'react-redux';importstorefrom'./lib/state/redux/store';import{Layout}from'./components/layout';import{EnsurePlaygroundSite}from'./components/ensure-playground-site';import{initSentry,Sentry}from'./lib/sentry';// Initialize Sentry BEFORE any other error collection.initSentry();collectWindowErrors(logger);constroot=createRoot(document.getElementById('root')!);root.render(<Sentry.ErrorBoundaryfallback={({ error, resetError })=>(<divstyle={{padding: '2rem',fontFamily: 'sans-serif'}}><h1>Somethingwentwrong</h1><p>{error?.message||'An unexpected error occurred.'}</p><buttononClick={resetError}>Tryagain</button></div>)}><Providerstore={store}><EnsurePlaygroundSite><Layout/></EnsurePlaygroundSite></Provider></Sentry.ErrorBoundary>);
Why before collectWindowErrors: Sentry must install its global handlers first so it captures errors before the logger's handler runs. The logger's handler does not call preventDefault(), so both will see the error.
Decision: keep source maps deployed (they are already sourcemap: true and publicly served). Sentry will use the uploaded maps for stack trace symbolication. If the team later decides to strip maps from production, change filesToDeleteAfterUpload to ['**/*.map'].
Add import and plugin. Because remote has both main-thread and worker build plugins (plugins is used in both plugins: and worker: { plugins: () => plugins }), adding sentryVitePlugin to the shared plugins array covers all contexts:
This only runs when SENTRY_AUTH_TOKEN is set (sentry-cli reads it from environment).
7. Error Deduplication: Boundary Wrappers
Core Principle
Errors are owned by the context where they originate. Every IPC boundary must catch downstream errors for UX purposes but not re-throw them into the void where Sentry's automatic unhandledrejection handler would capture them again.
7a. Remote iframe main thread -- wrapping worker API calls
In boot-playground-remote.ts, the phpRemoteApi object proxies many calls to phpWorkerApi. Each of these is a Comlink call that can reject with a worker error. The current code does NOT catch these -- they bubble to the remote Sentry as unhandled rejections.
Decision: Do NOT add individual wrappers to every proxy call in phpRemoteApi. Instead, rely on the beforeSend filter in the remote's Sentry config (isWorkerRelayError) to drop known relay patterns. This is simpler and avoids modifying the extensive proxy surface.
The one exception is the boot() method which already has a try/catch (line 479-491 of boot-playground-remote.ts). The setAPIError(e) call handles UX. Add a breadcrumb there:
In boot-site-client.ts, the startPlaygroundWeb call and subsequent playground.* calls can reject with errors from the remote. The existing code already has error handling in the catch block (around line 150+) which dispatches setActiveSiteError.
Decision: Add beforeSend relay detection in the website config (already done in section 4a). Also add a Sentry.addBreadcrumb in the existing catch:
In run-cli.ts, the spawnWorkerThread function (line 1806) already catches worker errors via worker.once('error', ...) and worker.once('exit', ...). The main thread's catch block (line 1603) wraps boot failures.
Decision: Worker threads in Node share the same process-level Sentry init (unlike browser workers which are separate JS contexts). The @sentry/node SDK automatically captures unhandled exceptions in worker_threads when Sentry.init() is called in the main thread. To avoid duplicates, add a beforeSend check:
In cli/src/sentry.ts, add to beforeSend:
// If this error has a .cause that is the same error (Comlink relay), drop itif(errorinstanceofError&&error.message?.startsWith('Worker failed to load')){// This is a wrapper -- the original error is in .cause and was already capturedevent.fingerprint=['worker-spawn-failure'];}
import{Sentry}from'../../lib/sentry';// or wherever the re-export lands// After playground.isReady() resolves:Sentry.setTag('site_slug',siteSlug);Sentry.setTag('wp_version',site.metadata.wpVersion||'unknown');Sentry.setTag('php_version',site.metadata.phpVersion||'unknown');Sentry.setTag('storage_type',site.metadata.storage);if(blueprint){Sentry.setContext('blueprint',{hasBlueprint: true,stepCount: blueprint.steps?.length||0,});}
import{Sentry}from'./sentry';// Inside createRequestHandler, after loadWebRuntime succeeds:Sentry.setTag('php_version',phpVersion);Sentry.setTag('wasm_mode',/* detect from runtime: */'asyncify'/* or 'jspi' */);
Detecting WASM mode (asyncify vs jspi): Check via the existing wasm-feature-detect import or by inspecting the PHP loader module name pattern (asyncify vs jspi in the URL).
8c. CLI
Already handled in sentry.tsinitialScope.tags (command, node_version, platform, arch). Add after boot:
Set globalThis.__sentryCapture in each project's init. Decision: Use this global callback approach to avoid making @php-wasm/universal depend on any Sentry package.
10b. Blueprint resource resolution .catch(() => {}) in packages/playground/blueprints/src/lib/compile.ts -- This is intentional (avoid unhandled rejection during prefetch). Leave as-is; the actual error will surface when the step awaits the resource.
10c.TCPOverFetchWebsocket pipe .catch(() => {}) -- Add Sentry.captureException with level: 'warning' for non-abort errors. This is in @php-wasm/web and should use the same global callback pattern.
Add SENTRY_DSN_WEBSITE, SENTRY_DSN_REMOTE as GitHub repository variables
Add SENTRY_AUTH_TOKEN as GitHub repository secret
Add SENTRY_ORG as GitHub repository variable
Pass all to the build step env (shown in section 2)
For CLI publishing (via lerna/npm publish), the SENTRY_DSN_CLI and SENTRY_AUTH_TOKEN need to be available in the CI environment during npm run release.
packages/php-wasm/logger/src/index.ts -- export new collector
13. What NOT to Do (Decisions Against)
Do NOT add Sentry to @php-wasm/universal or @php-wasm/web as a dependency. These are shared low-level packages. Use the globalThis.__sentryCapture callback pattern for the rare instrumentation points there.
Do NOT create separate Sentry projects for web worker vs service worker vs iframe main thread. They share one build and one set of source maps. Tags are sufficient.
Do NOT enable Sentry performance/tracing in Phase 1. Add browserTracingIntegration() only for the website (lightweight), but skip tracesSampleRate to avoid cost until we need traces.
Do NOT ship SENTRY_AUTH_TOKEN to the client bundle. It is build-time only, used by sentryVitePlugin and sentry-cli. Only DSNs go to the client (DSNs are public by design).
Do NOT modify collectWindowErrors in @php-wasm/logger. Sentry and the logger both listen to window.onerror / unhandledrejection independently. The logger does not preventDefault(), so both work. No conflict.
Do NOT add Sentry.captureException inside the Comlink exposeAPI/consumeAPI internals. This would capture at the transport layer and create duplicates. Capture at the application layer only.
14. Implementation Phases
Phase 1 -- Foundation: Create three Sentry projects, install SDKs, wire Sentry.init() at each entry point, configure source map uploads in Vite/NX builds, set up release tracking with commit SHA. (Todos: phase1-*)
Phase 2 -- Enrichment: Add custom tags/contexts (php_version, wp_version, wasm_mode, runtime, embedding_origin), bridge @php-wasm/logger to Sentry as breadcrumbs, add Sentry.ErrorBoundary in website React tree, configure beforeSend for PII scrubbing and fingerprinting. (Todos: phase2-*)
Phase 3 -- Coverage Gaps: Instrument the currently-silent error paths -- the Comlink isConnected retry loop, TCPOverFetchWebsocket swallowed catches, blueprint resource .catch(() => {}), sync transport fire-and-forget. Add explicit Sentry.captureException at these known swallow points. (Todos: phase3-*)
Phase 4 -- Alerting and Tuning: Configure alert rules per project, tune sample rates (especially for third-party embeds), set up Slack/PagerDuty integrations, build dashboards for WASM crash rates, blueprint failure rates, and SW health. (Todos: phase4-*)
Sentry Error Logging Strategy for WordPress Playground
Part A: Strategy and Architecture
Architecture Context
WordPress Playground has six distinct runtime execution contexts spread across three independently-built deployment artifacts:
graph TB subgraph website ["playground-website (Build 1)"] MainThread["Browser Main Thread\nReact App + Redux"] end subgraph remote ["playground-remote (Build 2)"] IframeDoc["Iframe Main Thread\nremote.html + boot logic"] WebWorker["Web Worker\nPHP/WASM Engine"] ServiceWorker["Service Worker\nFetch routing + cache"] end subgraph cli ["playground-cli (Build 3)"] NodeMain["Node.js Main Thread\nHTTP server + yargs"] NodeWorker["Node.js worker_threads\nBlueprint execution"] end MainThread -->|"Comlink over postMessage"| IframeDoc IframeDoc -->|"Comlink over Worker"| WebWorker IframeDoc -->|"postMessage"| ServiceWorker NodeMain -->|"MessagePort"| NodeWorker ThirdParty["Any third-party website"] -.->|"iframe embed"| IframeDocSentry Project Structure: 3 Projects
The project boundaries align with build artifacts (each has its own Vite/build config, independent source maps, and independent release versioning), not with individual threads. Thread/context differentiation is handled via Sentry tags and contexts within each project.
Project 1:
playground-websiteWhat it covers: The parent-frame React application at
playground.wordpress.net.packages/playground/website/(Vite,sourcemap: true)@sentry/reactpackages/playground/website/src/main.tsxError surfaces:
Sentry.ErrorBoundary-- none exist today)boot-site-client.tsBlueprintsV1Handler/BlueprintsV2Handlerin@wp-playground/client)opfs-site-storage-worker-for-safari.ts)import('./src/main')inindex.html)consumeAPIfailures when talking to the iframe (the silent retry loop inapi.tsthat swallows timeouts)denyUrls)Key tags/contexts:
browser,browser.version,os(automatic)wp_version,php_version,blueprint_url(custom, from Redux state)site_slug(which Playground instance)Source maps: Upload via
@sentry/vite-plugininpackages/playground/website/vite.config.tswithreleasetied to the deploy commit SHA (already injected viabuildVersionPlugin).Project 2:
playground-remoteWhat it covers: Everything inside the iframe -- the core engine. This is the highest-value project since WASM crashes, PHP errors, and service worker failures all live here.
packages/playground/remote/(Vite,sourcemap: true)@sentry/browser(not@sentry/react-- no React in remote)packages/playground/remote/remote.html(inline script)packages/playground/remote/src/lib/playground-worker-endpoint-blueprints-v1.ts(and v2)packages/playground/remote/service-worker.tsError surfaces by context:
requestStorageAccessfailures, third-party cookie blocks, navigation errorsinstantiateStreamingfailures (network, MIME), Asyncify/JSPI runtime traps (RuntimeError: unreachable,memory access out of bounds), PHP fatal errors surfaced viaPHPExecutionFailureError, blueprint step execution errors (BlueprintStepExecutionError), filesystem (memfs/OPFS) errors, network proxy failures (TCPOverFetchWebsocketerrors that are currently.catch(() => {}))Key tags/contexts:
runtime:main-thread|web-worker|service-worker(critical for filtering)php_version,wp_version(from boot config)wasm_mode:asyncify|jspiembedding_origin: The origin of the parent page (important since any site can embed the remote)blueprint_step: Which blueprint step was executing when error occurredis_first_party: Whether embedded onplayground.wordpress.netvs third-partyWhy one project for three contexts (not three separate projects):
runtime:web-worker) are sufficient for filtering and alert routingSource maps: Upload via
@sentry/vite-plugininpackages/playground/remote/vite.config.ts.Project 3:
playground-cliWhat it covers: The Node.js CLI tool (
@wp-playground/cli).worker_threadspackages/playground/cli/(NX build)@sentry/nodepackages/playground/cli/src/run-cli.ts(after the JSPI respawn gate incli.ts)Error surfaces:
start-server.ts)worker-thread-v1.ts/worker-thread-v2.ts)child_process.spawnincli.ts)Key tags/contexts:
runtime:main-thread|worker-threadnode_version,platform,archphp_version,wp_versionwasm_mode:asyncify|jspicli_command:server| etc.has_blueprint: whether a blueprint was providedSource maps: Post-build
sentry-cli sourcemaps uploadstep in the NX build pipeline.Cross-Cutting Concerns
Release Tracking
All three projects use the same release identifier (the monorepo deploy commit SHA, already available via
buildVersionPlugin). This enables:The
@php-wasm/loggerBridgeThe existing
@php-wasm/loggerpackage already collects structured errors viacollectWindowErrors,collectPhpLogs, etc. Rather than replacing it, bridge it to Sentry:Sensitive Data and PII
beforeSendin each project to strip PIIThird-Party Embedder Noise
Since
playground-remoteruns inside iframes on third-party sites:allowUrlsto only capture errors from Playground's own scripts (not the embedder's page)embedding_originto identify if certain embedders cause disproportionate errorsplayground.wordpress.netError Deduplication Across Boundaries
A single root cause (e.g., WASM OOM) can produce cascading errors across boundaries:
Strategy:
playground-remote, taggedruntime:web-worker) is the source of truthplayground-websiteerrors that are Comlink rejections from the iframe are dropped viabeforeSend(isComlinkRelayError)playground-remotemain-thread errors that are Comlink rejections from the worker are dropped viabeforeSend(isWorkerRelayError)fingerprintto group cascading errors by their root cause when they do slip throughPerformance Monitoring (Future)
Sentry's performance/tracing SDK can be added later to measure:
startPlaygroundWeb()call to WordPress ready (spans: iframe load, SW registration, WASM fetch, WASM instantiation, WordPress boot, blueprint execution)This is a separate phase and should not block error logging rollout.
Project Matrix Summary
playground-website-- SDK:@sentry/react-- Contexts: browser main thread -- Key errors: React crashes, Redux/boot failures, OPFS storage, Comlink timeouts to iframe -- Init:website/src/main.tsxplayground-remote-- SDK:@sentry/browser-- Contexts: iframe main thread, web worker, service worker -- Key errors: WASM crashes, PHP fatals, SW fetch/cache failures, blueprint execution, filesystem errors -- Init:remote/remote.html, worker endpoint files,remote/service-worker.tsplayground-cli-- SDK:@sentry/node-- Contexts: Node.js main thread, worker_threads -- Key errors: WASM crashes (Node), HTTP server errors, blueprint execution, FS/mount errors, JSPI respawn -- Init:cli/src/run-cli.tsPart B: Implementation Details
1. NPM Packages to Install
Website (
packages/playground/website/package.json):@sentry/react(includes browser SDK + React integration)@sentry/vite-plugin(source map upload during build)Remote (
packages/playground/remote/package.json):@sentry/browser(no React in remote)@sentry/vite-pluginCLI (
packages/playground/cli/package.json):@sentry/node@sentry/cli(as devDependency, for source map upload in build)Install at workspace root:
2. Environment Variables
New Variables
SENTRY_DSN_WEBSITE-- DSN for playground-website projectSENTRY_DSN_REMOTE-- DSN for playground-remote projectSENTRY_DSN_CLI-- DSN for playground-cli projectSENTRY_AUTH_TOKEN-- Org-level auth token for source map uploads (build-time only, never shipped to client)SENTRY_ORG-- Sentry organization slug (e.g.wordpress-playground)Where They Go
Build-time only (source map upload):
SENTRY_AUTH_TOKEN,SENTRY_ORG. Passed via GitHub Actions secrets. Never in client bundles.Shipped to client (DSN is public by design):
SENTRY_DSN_WEBSITE,SENTRY_DSN_REMOTE. Injected via Vite'sdefineor virtual modules.SENTRY_DSN_CLIis inlined at build time.Decision: Use virtual modules (consistent with existing
corsProxyUrlpattern). Add asentryDsnPluginor extend existingvirtualModulecalls.Files to Change
.github/workflows/deploy-website.yml-- add env vars to thenpx nx build playground-websitestep:packages/playground/website/.env.example-- add:3. Release Identifier
All three projects use the same release string: the git commit SHA. This is already computed at build time by
buildVersionPluginwhich creates avirtual:website-configmodule exportingbuildVersion. The same pattern exists for remote viavirtual:remote-config.Decision: Pass
buildVersion(the commit SHA) as the Sentryreleasevalue. For CLI, compute it at build time in the sentry config module.4. Sentry Config Modules
Create one config module per project. These are imported at each init site.
4a.
packages/playground/website/src/lib/sentry.tsThe DSN must be exposed to the client bundle. Add to website/vite.config.ts in the
pluginsarray, after the existingvirtualModulefor cors-proxy-url:Then import from
'virtual:sentry-dsn-website'instead ofimport.meta.env.VITE_SENTRY_DSN_WEBSITEinsentry.ts. However, the simpler approach is Vite's built-inimport.meta.env.VITE_*pattern. Decision: useVITE_SENTRY_DSN_WEBSITEenv var so no plugin change is needed (Vite auto-exposesVITE_*vars).4b.
packages/playground/remote/src/lib/sentry.tsNote on
import.meta.envin workers and SW: Vite replacesimport.meta.env.VITE_*at build time in all contexts (main, worker, SW) since they all go through the Vite build pipeline. This is confirmed by the remote'svite.config.tshavingworker: { plugins: () => plugins }. TheVITE_SENTRY_DSN_REMOTEenv var works everywhere in the remote build.4c.
packages/playground/cli/src/sentry.tsFor the CLI, the DSN and release must be inlined at build time. Add to the CLI's Vite config (
packages/playground/cli/vite.config.ts):5. Sentry.init() Wiring -- Exact File Changes
5a. Website:
packages/playground/website/src/main.tsxCurrent:
Change to:
Why before
collectWindowErrors: Sentry must install its global handlers first so it captures errors before the logger's handler runs. The logger's handler does not callpreventDefault(), so both will see the error.5b. Remote iframe main thread:
packages/playground/remote/remote.htmlCurrent inline script:
Change to:
5c. Remote web worker:
packages/playground/remote/src/lib/playground-worker-endpoint-blueprints-v1.tsAdd at the very top, before the existing
self.postMessage('worker-script-started'):Same for
playground-worker-endpoint-blueprints-v2.ts.5d. Remote service worker:
packages/playground/remote/service-worker.tsAdd at the very top, before any other imports:
5e. CLI:
packages/playground/cli/src/run-cli.tsAt the top of
parseOptionsAndRunCLI(), before the try block:Also add
await flushSentry()before everyprocess.exit()call inrun-cli.ts(there are several: lines 693, 519, 520, 651, 747, 1568, 1678).6. Source Map Upload Configuration
6a. Website:
packages/playground/website/vite.config.tsAdd import and plugin:
Add to the
pluginsarray (at the end, afteranalyticsInjectionPlugin()):Decision: keep source maps deployed (they are already
sourcemap: trueand publicly served). Sentry will use the uploaded maps for stack trace symbolication. If the team later decides to strip maps from production, changefilesToDeleteAfterUploadto['**/*.map'].6b. Remote:
packages/playground/remote/vite.config.tsAdd import and plugin. Because remote has both main-thread and worker build plugins (
pluginsis used in bothplugins:andworker: { plugins: () => plugins }), addingsentryVitePluginto the sharedpluginsarray covers all contexts:Add at the end of the
pluginsarray (before it's used):6c. CLI: Post-build source map upload
Add a new target in
packages/playground/cli/project.jsonafterbuild:bundle:This only runs when
SENTRY_AUTH_TOKENis set (sentry-cli reads it from environment).7. Error Deduplication: Boundary Wrappers
Core Principle
Errors are owned by the context where they originate. Every IPC boundary must catch downstream errors for UX purposes but not re-throw them into the void where Sentry's automatic
unhandledrejectionhandler would capture them again.7a. Remote iframe main thread -- wrapping worker API calls
In
boot-playground-remote.ts, thephpRemoteApiobject proxies many calls tophpWorkerApi. Each of these is a Comlink call that can reject with a worker error. The current code does NOT catch these -- they bubble to the remote Sentry as unhandled rejections.Decision: Do NOT add individual wrappers to every proxy call in
phpRemoteApi. Instead, rely on thebeforeSendfilter in the remote's Sentry config (isWorkerRelayError) to drop known relay patterns. This is simpler and avoids modifying the extensive proxy surface.The one exception is the
boot()method which already has a try/catch (line 479-491 ofboot-playground-remote.ts). ThesetAPIError(e)call handles UX. Add a breadcrumb there:7b. Website -- wrapping Comlink calls to iframe
In
boot-site-client.ts, thestartPlaygroundWebcall and subsequentplayground.*calls can reject with errors from the remote. The existing code already has error handling in thecatchblock (around line 150+) which dispatchessetActiveSiteError.Decision: Add
beforeSendrelay detection in the website config (already done in section 4a). Also add aSentry.addBreadcrumbin the existing catch:7c. CLI main thread -- wrapping worker errors
In
run-cli.ts, thespawnWorkerThreadfunction (line 1806) already catches worker errors viaworker.once('error', ...)andworker.once('exit', ...). The main thread's catch block (line 1603) wraps boot failures.Decision: Worker threads in Node share the same process-level Sentry init (unlike browser workers which are separate JS contexts). The
@sentry/nodeSDK automatically captures unhandled exceptions inworker_threadswhenSentry.init()is called in the main thread. To avoid duplicates, add abeforeSendcheck:In
cli/src/sentry.ts, add tobeforeSend:8. Custom Tags and Contexts
8a. Website
After boot completes in
boot-site-client.ts, add:8b. Remote worker
In
playground-worker-endpoint.ts, aftercreateRequestHandlercompletes inboot():And after
finalizeAfterBoot:Detecting WASM mode (asyncify vs jspi): Check via the existing
wasm-feature-detectimport or by inspecting the PHP loader module name pattern (asyncifyvsjspiin the URL).8c. CLI
Already handled in
sentry.tsinitialScope.tags(command, node_version, platform, arch). Add after boot:9. Logger Bridge: Sentry Breadcrumbs
Create
packages/php-wasm/logger/src/lib/collectors/collect-sentry-breadcrumbs.ts:Export from the package index. Wire in website
main.tsx:Wire similarly in
boot-playground-remote.tsfor the remote context.Decision: Do NOT bridge in the CLI initially -- CLI logs go to stdout, and Sentry captures breadcrumbs from console automatically with
@sentry/node.10. Known Silent Error Points to Instrument
These are places where errors are currently swallowed. Add explicit
Sentry.captureExceptionorSentry.captureMessageat each:10a.
packages/php-wasm/universal/src/lib/api.tslines 92-114 --isConnectedretry loop silently swallows timeouts. Add after N retries:Note: This file is in
@php-wasm/universalwhich is shared between all three builds. Import Sentry conditionally or use a callback pattern:Set
globalThis.__sentryCapturein each project's init. Decision: Use this global callback approach to avoid making@php-wasm/universaldepend on any Sentry package.10b. Blueprint resource resolution
.catch(() => {})inpackages/playground/blueprints/src/lib/compile.ts-- This is intentional (avoid unhandled rejection during prefetch). Leave as-is; the actual error will surface when the step awaits the resource.10c.
TCPOverFetchWebsocketpipe.catch(() => {})-- AddSentry.captureExceptionwithlevel: 'warning'for non-abort errors. This is in@php-wasm/weband should use the same global callback pattern.11. Deploy Workflow Changes
.github/workflows/deploy-website.ymlchanges:SENTRY_DSN_WEBSITE,SENTRY_DSN_REMOTEas GitHub repository variablesSENTRY_AUTH_TOKENas GitHub repository secretSENTRY_ORGas GitHub repository variableFor CLI publishing (via lerna/npm publish), the
SENTRY_DSN_CLIandSENTRY_AUTH_TOKENneed to be available in the CI environment duringnpm run release.Part C: Reference
12. Files Changed Summary
New files:
packages/playground/website/src/lib/sentry.tspackages/playground/remote/src/lib/sentry.tspackages/playground/cli/src/sentry.tspackages/php-wasm/logger/src/lib/collectors/collect-sentry-breadcrumbs.tsModified files:
packages/playground/website/src/main.tsx-- Sentry init + ErrorBoundarypackages/playground/website/vite.config.ts-- sentryVitePluginpackages/playground/website/.env.example-- new varspackages/playground/remote/remote.html-- Sentry init in inline scriptpackages/playground/remote/src/lib/playground-worker-endpoint-blueprints-v1.ts-- Sentry initpackages/playground/remote/src/lib/playground-worker-endpoint-blueprints-v2.ts-- Sentry initpackages/playground/remote/service-worker.ts-- Sentry initpackages/playground/remote/vite.config.ts-- sentryVitePluginpackages/playground/remote/src/lib/boot-playground-remote.ts-- breadcrumb in catchpackages/playground/remote/src/lib/playground-worker-endpoint.ts-- tags after bootpackages/playground/cli/src/run-cli.ts-- Sentry init + flushSentry before exitpackages/playground/cli/project.json-- sentry sourcemaps upload targetpackages/playground/website/src/lib/state/redux/boot-site-client.ts-- tags + breadcrumb.github/workflows/deploy-website.yml-- env varspackages/php-wasm/logger/src/index.ts-- export new collector13. What NOT to Do (Decisions Against)
@php-wasm/universalor@php-wasm/webas a dependency. These are shared low-level packages. Use theglobalThis.__sentryCapturecallback pattern for the rare instrumentation points there.browserTracingIntegration()only for the website (lightweight), but skiptracesSampleRateto avoid cost until we need traces.SENTRY_AUTH_TOKENto the client bundle. It is build-time only, used bysentryVitePluginandsentry-cli. Only DSNs go to the client (DSNs are public by design).collectWindowErrorsin@php-wasm/logger. Sentry and the logger both listen towindow.onerror/unhandledrejectionindependently. The logger does notpreventDefault(), so both work. No conflict.Sentry.captureExceptioninside the ComlinkexposeAPI/consumeAPIinternals. This would capture at the transport layer and create duplicates. Capture at the application layer only.14. Implementation Phases
Phase 1 -- Foundation: Create three Sentry projects, install SDKs, wire
Sentry.init()at each entry point, configure source map uploads in Vite/NX builds, set up release tracking with commit SHA. (Todos:phase1-*)Phase 2 -- Enrichment: Add custom tags/contexts (php_version, wp_version, wasm_mode, runtime, embedding_origin), bridge
@php-wasm/loggerto Sentry as breadcrumbs, addSentry.ErrorBoundaryin website React tree, configurebeforeSendfor PII scrubbing and fingerprinting. (Todos:phase2-*)Phase 3 -- Coverage Gaps: Instrument the currently-silent error paths -- the Comlink
isConnectedretry loop,TCPOverFetchWebsocketswallowed catches, blueprint resource.catch(() => {}), sync transport fire-and-forget. Add explicitSentry.captureExceptionat these known swallow points. (Todos:phase3-*)Phase 4 -- Alerting and Tuning: Configure alert rules per project, tune sample rates (especially for third-party embeds), set up Slack/PagerDuty integrations, build dashboards for WASM crash rates, blueprint failure rates, and SW health. (Todos:
phase4-*)