Skip to content

Sentry Error Logging Integration Strategy #3498

@ashfame

Description

@ashfame

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.

  • Runtime: Browser main thread only
  • Build: packages/playground/website/ (Vite, sourcemap: true)
  • SDK: @sentry/react
  • Init location: packages/playground/website/src/main.tsx

Error surfaces:

  • React rendering crashes (add Sentry.ErrorBoundary -- none exist today)
  • 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
  • Build: packages/playground/remote/ (Vite, sourcemap: true)
  • 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
  • The logger's structured metadata (PHP logs, SW metrics) becomes Sentry breadcrumbs

Sensitive Data and PII

  • 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.


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.tsx
  • playground-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.ts
  • playground-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.ts

Part 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-plugin

CLI (packages/playground/cli/package.json):

  • @sentry/node
  • @sentry/cli (as devDependency, for source map upload in build)

Install at workspace root:

npm install @sentry/react @sentry/vite-plugin --workspace=packages/playground/website
npm install @sentry/browser @sentry/vite-plugin --workspace=packages/playground/remote
npm install @sentry/node --workspace=packages/playground/cli
npm install @sentry/cli --save-dev --workspace=packages/playground/cli

2. Environment Variables

New Variables

  • SENTRY_DSN_WEBSITE -- DSN for playground-website project
  • SENTRY_DSN_REMOTE -- DSN for playground-remote project
  • SENTRY_DSN_CLI -- DSN for playground-cli project
  • SENTRY_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'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.

Files to Change

.github/workflows/deploy-website.yml -- add env vars to the npx nx build playground-website step:

- run: npx nx build playground-website
  env:
    CORS_PROXY_URL: ${{ vars.CORS_PROXY_URL }}
    VITE_GOOGLE_ANALYTICS_ID: G-SVTNFCP8T7
    SENTRY_DSN_WEBSITE: ${{ vars.SENTRY_DSN_WEBSITE }}
    SENTRY_DSN_REMOTE: ${{ vars.SENTRY_DSN_REMOTE }}
    SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
    SENTRY_ORG: ${{ vars.SENTRY_ORG }}

packages/playground/website/.env.example -- add:

SENTRY_DSN_WEBSITE=
SENTRY_DSN_REMOTE=
SENTRY_AUTH_TOKEN=
SENTRY_ORG=

3. Release Identifier

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 * as Sentry from '@sentry/react';
// @ts-ignore
import { buildVersion } from 'virtual:website-config';

const dsn = import.meta.env.VITE_SENTRY_DSN_WEBSITE || '';

export function initSentry() {
  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) {
      const error = 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: error instanceof Error ? error.message : String(error),
          level: 'warning',
        });
        return null;
      }

      // Scrub blueprint URLs that might contain auth tokens
      if (event.request?.url) {
        event.request.url = scrubBlueprintTokens(event.request.url);
      }

      return event;
    },

    ignoreErrors: [
      // Browser extensions
      /^ResizeObserver loop/,
      // Expected during navigation
      /^AbortError/,
    ],
  });
}

function isComlinkRelayError(error: unknown): boolean {
  if (!(error instanceof Error)) return false;
  // Comlink-serialized errors from the remote carry these markers
  if ('response' in error && 'source' in error) return true;
  // PHPExecutionFailureError relayed through Comlink
  if (error.name === 'PHPExecutionFailureError') return true;
  // RuntimeError from WASM that bubbled through Comlink
  if (error.name === 'RuntimeError' && error.message?.includes('unreachable')) return true;
  if (error.message?.includes('A call to the remote API has failed')) return true;
  return false;
}

function scrubBlueprintTokens(url: string): string {
  try {
    const u = new URL(url);
    for (const key of ['token', 'access_token', 'auth', 'key']) {
      if (u.searchParams.has(key)) u.searchParams.set(key, '[REDACTED]');
    }
    return u.toString();
  } catch {
    return url;
  }
}

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:

virtualModule({
  name: 'sentry-dsn-website',
  content: `export const sentryDsn = ${JSON.stringify(process.env.SENTRY_DSN_WEBSITE || '')};`,
}),

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 * as Sentry from '@sentry/browser';
// @ts-ignore
import { buildVersion } from 'virtual:remote-config';

const dsn = import.meta.env.VITE_SENTRY_DSN_REMOTE || '';

export function initSentryForContext(
  context: 'main-thread' | 'web-worker' | 'service-worker'
) {
  if (!dsn) return;

  const isFirstParty =
    typeof window !== '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 volume
    sampleRate: isFirstParty ? 1.0 : 0.1,

    initialScope: {
      tags: {
        runtime: context,
        is_first_party: String(isFirstParty),
      },
    },

    beforeSend(event, hint) {
      const error = 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: error instanceof Error ? error.message : String(error),
          level: 'warning',
        });
        return null;
      }

      // Tag with embedding origin for third-party analysis
      if (typeof window !== 'undefined' && window.parent !== window) {
        try {
          event.tags = event.tags || {};
          event.tags.embedding_origin = document.referrer
            ? new URL(document.referrer).origin
            : 'unknown';
        } catch {
          // cross-origin referrer parsing failure
        }
      }

      // Scrub filesystem paths from PHP errors
      scrubPHPFilePaths(event);

      return event;
    },

    ignoreErrors: [
      /^ResizeObserver loop/,
      /^AbortError/,
      // SW update failures when offline are expected
      /Failed to update a ServiceWorker/,
    ],
  });
}

function isWorkerRelayError(error: unknown): boolean {
  if (!(error instanceof Error)) return false;
  if (error.name === 'PHPExecutionFailureError') return true;
  if (error.name === 'RuntimeError') return true;
  if (error.name === 'BlueprintStepExecutionError') return true;
  return false;
}

function scrubPHPFilePaths(event: Sentry.ErrorEvent) {
  if (event.exception?.values) {
    for (const ex of event.exception.values) {
      if (ex.value) {
        // Replace absolute WordPress filesystem paths
        ex.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 * as Sentry from '@sentry/node';

// Inlined at build time by Vite's define
declare const __SENTRY_DSN_CLI__: string;
declare const __SENTRY_RELEASE__: string;

const dsn = typeof __SENTRY_DSN_CLI__ !== 'undefined' ? __SENTRY_DSN_CLI__ : '';

export function initSentry(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 (const ex of event.exception.values) {
          if (ex.value) {
            ex.value = scrubLocalPaths(ex.value);
          }
          if (ex.stacktrace?.frames) {
            for (const frame of ex.stacktrace.frames) {
              if (frame.filename) {
                frame.filename = scrubLocalPaths(frame.filename);
              }
            }
          }
        }
      }
      return event;
    },
  });
}

function scrubLocalPaths(str: string): string {
  const home = process.env.HOME || process.env.USERPROFILE || '';
  if (home) {
    str = str.replace(new RegExp(escapeRegExp(home), 'g'), '~');
  }
  // Scrub temp dir paths
  str = str.replace(/\/tmp\/[a-zA-Z0-9._-]*playground[a-zA-Z0-9._-]*/g, '/tmp/[playground-temp]');
  return str;
}

function escapeRegExp(s: string) {
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

export function flushSentry(): Promise<boolean> {
  return Sentry.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):

define: {
  __SENTRY_DSN_CLI__: JSON.stringify(process.env.SENTRY_DSN_CLI || ''),
  __SENTRY_RELEASE__: JSON.stringify(
    (() => { try { return execSync('git rev-parse HEAD').toString().trim(); } catch { return 'unknown'; } })()
  ),
},

5. Sentry.init() Wiring -- Exact File Changes

5a. Website: packages/playground/website/src/main.tsx

Current:

import { collectWindowErrors, logger } from '@php-wasm/logger';
import { Provider } from 'react-redux';
import store from './lib/state/redux/store';
import { Layout } from './components/layout';
import { EnsurePlaygroundSite } from './components/ensure-playground-site';

collectWindowErrors(logger);

const root = createRoot(document.getElementById('root')!);
root.render(
  <Provider store={store}>
    <EnsurePlaygroundSite>
      <Layout />
    </EnsurePlaygroundSite>
  </Provider>
);

Change to:

import { collectWindowErrors, logger } from '@php-wasm/logger';
import { Provider } from 'react-redux';
import store from './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);

const root = createRoot(document.getElementById('root')!);
root.render(
  <Sentry.ErrorBoundary
    fallback={({ error, resetError }) => (
      <div style={{ padding: '2rem', fontFamily: 'sans-serif' }}>
        <h1>Something went wrong</h1>
        <p>{error?.message || 'An unexpected error occurred.'}</p>
        <button onClick={resetError}>Try again</button>
      </div>
    )}
  >
    <Provider store={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.

5b. Remote iframe main thread: packages/playground/remote/remote.html

Current inline script:

import { bootPlaygroundRemote } from './src/index';
try {
  window.playground = await bootPlaygroundRemote();
} catch (e) {
  // ... error UI ...
}

Change to:

import { initSentryForContext } from './src/lib/sentry';
initSentryForContext('main-thread');

import { bootPlaygroundRemote } from './src/index';
try {
  window.playground = await bootPlaygroundRemote();
} catch (e) {
  // ... existing error UI unchanged ...
}

5c. Remote web worker: packages/playground/remote/src/lib/playground-worker-endpoint-blueprints-v1.ts

Add at the very top, before the existing self.postMessage('worker-script-started'):

import { initSentryForContext } from './sentry';
initSentryForContext('web-worker');

Same for playground-worker-endpoint-blueprints-v2.ts.

5d. Remote service worker: packages/playground/remote/service-worker.ts

Add at the very top, before any other imports:

import { initSentryForContext } from './src/lib/sentry';
initSentryForContext('service-worker');

5e. CLI: packages/playground/cli/src/run-cli.ts

At the top of parseOptionsAndRunCLI(), before the try block:

export async function parseOptionsAndRunCLI(argsToParse: string[]) {
  // Determine command early for Sentry tagging (before yargs parsing)
  const firstArg = argsToParse.find(a => !a.startsWith('-')) || 'unknown';
  
  const { initSentry, flushSentry } = await import('./sentry');
  initSentry(firstArg);

  try {
    // ... existing code ...
  } catch (e) {
    // ... existing error handling ...
    
    // Flush Sentry events before process.exit
    await flushSentry();
    process.exit(1);
  }
}

Also add await flushSentry() before every process.exit() call in run-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.ts

Add import and plugin:

import { sentryVitePlugin } from '@sentry/vite-plugin';

Add to the plugins array (at the end, after analyticsInjectionPlugin()):

process.env.SENTRY_AUTH_TOKEN
  ? sentryVitePlugin({
      org: process.env.SENTRY_ORG || 'wordpress-playground',
      project: 'playground-website',
      authToken: process.env.SENTRY_AUTH_TOKEN,
      release: {
        name: (() => {
          try {
            return require('child_process').execSync('git rev-parse HEAD').toString().trim();
          } catch { return undefined; }
        })(),
      },
      sourcemaps: {
        filesToDeleteAfterUpload: [], // Keep source maps for debugging
      },
    })
  : null,

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'].

6b. Remote: packages/playground/remote/vite.config.ts

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:

import { sentryVitePlugin } from '@sentry/vite-plugin';

Add at the end of the plugins array (before it's used):

if (process.env.SENTRY_AUTH_TOKEN) {
  plugins.push(
    sentryVitePlugin({
      org: process.env.SENTRY_ORG || 'wordpress-playground',
      project: 'playground-remote',
      authToken: process.env.SENTRY_AUTH_TOKEN,
      release: {
        name: (() => {
          try {
            return require('child_process').execSync('git rev-parse HEAD').toString().trim();
          } catch { return undefined; }
        })(),
      },
    })
  );
}

6c. CLI: Post-build source map upload

Add a new target in packages/playground/cli/project.json after build:bundle:

"build:sentry-sourcemaps": {
  "executor": "nx:run-commands",
  "options": {
    "commands": [
      "npx sentry-cli sourcemaps upload --org $SENTRY_ORG --project playground-cli --release $(git rev-parse HEAD) dist/packages/playground/cli"
    ]
  },
  "dependsOn": ["build:bundle"]
}

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:

} catch (e) {
  Sentry.addBreadcrumb({
    category: 'boot-failure',
    message: e instanceof Error ? e.message : String(e),
    level: 'error',
  });
  setAPIError(e as Error);
  throw e;
}

7b. Website -- wrapping Comlink calls to iframe

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:

} catch (error) {
  Sentry.addBreadcrumb({
    category: 'site-boot-failure',
    message: error instanceof Error ? error.message : String(error),
    level: 'error',
    data: { siteSlug },
  });
  // ... existing error handling ...
}

7c. CLI main thread -- wrapping worker errors

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 it
if (error instanceof Error && error.message?.startsWith('Worker failed to load')) {
  // This is a wrapper -- the original error is in .cause and was already captured
  event.fingerprint = ['worker-spawn-failure'];
}

8. Custom Tags and Contexts

8a. Website

After boot completes in boot-site-client.ts, add:

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,
  });
}

8b. Remote worker

In playground-worker-endpoint.ts, after createRequestHandler completes in boot():

import { Sentry } from './sentry';

// Inside createRequestHandler, after loadWebRuntime succeeds:
Sentry.setTag('php_version', phpVersion);
Sentry.setTag('wasm_mode', /* detect from runtime: */ 'asyncify' /* or 'jspi' */);

And after finalizeAfterBoot:

Sentry.setTag('wp_version', this.loadedWordPressVersion || 'unknown');
Sentry.setTag('scope', this.scope || 'unknown');

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.ts initialScope.tags (command, node_version, platform, arch). Add after boot:

Sentry.setTag('php_version', args.php || RecommendedPHPVersion);
Sentry.setTag('wp_version', args.wp || 'latest');
Sentry.setTag('has_blueprint', String(!!args.blueprint));
Sentry.setTag('worker_count', String(targetWorkerCount));

9. Logger Bridge: Sentry Breadcrumbs

Create packages/php-wasm/logger/src/lib/collectors/collect-sentry-breadcrumbs.ts:

import type { Logger } from '../logger';

export function collectSentryBreadcrumbs(
  loggerInstance: Logger,
  addBreadcrumb: (crumb: {
    category: string;
    message: string;
    level: string;
  }) => void
) {
  loggerInstance.addListener((logEntry) => {
    const severityMap: Record<number, string> = {
      0: 'debug',
      1: 'info',
      2: 'warning',
      3: 'error',
      4: 'fatal',
    };
    addBreadcrumb({
      category: 'php-wasm-logger',
      message: typeof logEntry.message === 'string'
        ? logEntry.message
        : String(logEntry.message),
      level: severityMap[logEntry.severity] || 'info',
    });
  });
}

Export from the package index. Wire in website main.tsx:

import { collectSentryBreadcrumbs } from '@php-wasm/logger';
collectSentryBreadcrumbs(logger, Sentry.addBreadcrumb.bind(Sentry));

Wire similarly in boot-playground-remote.ts for 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.captureException or Sentry.captureMessage at each:

10a. packages/php-wasm/universal/src/lib/api.ts lines 92-114 -- isConnected retry loop silently swallows timeouts. Add after N retries:

let retryCount = 0;
while (true) {
  try {
    await runWithTimeout(api.isConnected(), 200);
    break;
  } catch {
    retryCount++;
    if (retryCount === 50) { // ~10 seconds
      Sentry.captureMessage('isConnected retry loop exceeded 50 attempts', 'warning');
    }
  }
}

Note: This file is in @php-wasm/universal which is shared between all three builds. Import Sentry conditionally or use a callback pattern:

if (retryCount === 50 && typeof globalThis.__sentryCapture === 'function') {
  globalThis.__sentryCapture('isConnected retry loop exceeded 50 attempts', 'warning');
}

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.


11. Deploy Workflow Changes

.github/workflows/deploy-website.yml changes:

  • 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.



Part C: Reference

12. Files Changed Summary

New files:

  • packages/playground/website/src/lib/sentry.ts
  • packages/playground/remote/src/lib/sentry.ts
  • packages/playground/cli/src/sentry.ts
  • packages/php-wasm/logger/src/lib/collectors/collect-sentry-breadcrumbs.ts

Modified files:

  • packages/playground/website/src/main.tsx -- Sentry init + ErrorBoundary
  • packages/playground/website/vite.config.ts -- sentryVitePlugin
  • packages/playground/website/.env.example -- new vars
  • packages/playground/remote/remote.html -- Sentry init in inline script
  • packages/playground/remote/src/lib/playground-worker-endpoint-blueprints-v1.ts -- Sentry init
  • packages/playground/remote/src/lib/playground-worker-endpoint-blueprints-v2.ts -- Sentry init
  • packages/playground/remote/service-worker.ts -- Sentry init
  • packages/playground/remote/vite.config.ts -- sentryVitePlugin
  • packages/playground/remote/src/lib/boot-playground-remote.ts -- breadcrumb in catch
  • packages/playground/remote/src/lib/playground-worker-endpoint.ts -- tags after boot
  • packages/playground/cli/src/run-cli.ts -- Sentry init + flushSentry before exit
  • packages/playground/cli/project.json -- sentry sourcemaps upload target
  • packages/playground/website/src/lib/state/redux/boot-site-client.ts -- tags + breadcrumb
  • .github/workflows/deploy-website.yml -- env vars
  • 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-*)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions