diff --git a/apps/web/src/components/sandbox/preview/preview.tsx b/apps/web/src/components/sandbox/preview/preview.tsx
index d49e59981d..42d7da47d9 100644
--- a/apps/web/src/components/sandbox/preview/preview.tsx
+++ b/apps/web/src/components/sandbox/preview/preview.tsx
@@ -85,6 +85,7 @@ import {
type VisualEditorPayload,
} from "./visual-editor-script";
import { CMS_EDITOR_SCRIPT, CmsEditorPayloadSchema } from "./cms-editor-script";
+import { buildSessionReplayScript } from "./session-replay-script";
import { parseSections } from "@/components/sections-editor/parse-sections";
import { resolveSectionCandidates } from "./section-candidates";
import { getPageVariantSectionsAt } from "@/components/sections-editor/page-variants";
@@ -117,6 +118,7 @@ import {
import { PathParamInput } from "./path-param-input";
import { manifestLoaderResolveTypes } from "@/components/sandbox/content/runnable-catalog";
import { track } from "@/lib/posthog-client";
+import { usePublicConfig } from "@/hooks/use-public-config";
import { useSandboxRepoDir } from "../hooks/use-sandbox-repo-dir";
import { useBlocksPreviewWorkspace } from "@/components/sandbox/blocks/blocks-preview-workspace-context";
import { BlocksPanel } from "@/components/sandbox/blocks/blocks-panel";
@@ -207,6 +209,9 @@ export function PreviewContent({ virtualMcpId }: { virtualMcpId: string }) {
const workspace = useBlocksPreviewWorkspace();
// Toggles the bottom terminal drawer (null on surfaces without the provider).
const terminal = useTerminalVisibility();
+ // PostHog project (key/host) for cross-origin session replay in the preview
+ // iframe; null when PostHog isn't configured for this deployment.
+ const posthogConfig = usePublicConfig().posthog;
const goToTab = (main: string) => {
navigate({
@@ -670,6 +675,27 @@ export function PreviewContent({ virtualMcpId }: { virtualMcpId: string }) {
win.postMessage({ type: "cms-editor::deactivate" }, origin);
};
+ // Bootstrap PostHog session replay inside the sandbox preview so the
+ // cross-origin deco site is stitched into the parent's recording (rrweb
+ // skips cross-origin iframes otherwise). Reuses the `visual-editor::activate`
+ // channel the sandbox bootstrap already exposes (`new Function(script)()`) —
+ // present ONLY in sandbox previews, so this never runs against the
+ // production fallback. Re-sent on every onLoad because each in-iframe
+ // navigation is a fresh document; the script self-guards against double init.
+ const injectSessionReplay = () => {
+ if (!posthogConfig) return;
+ const win = previewIframeRef.current?.contentWindow;
+ const origin = previewOrigin(previewUrl);
+ if (!win || !origin) return;
+ win.postMessage(
+ {
+ type: "visual-editor::activate",
+ script: buildSessionReplayScript(posthogConfig.key, posthogConfig.host),
+ },
+ origin,
+ );
+ };
+
const activateEditingMode = (mode: PreviewEditingMode) => {
const previousMode = editingMode;
if (!isMobile && mode !== previousMode) {
@@ -1566,6 +1592,9 @@ export function PreviewContent({ virtualMcpId }: { virtualMcpId: string }) {
// skip the sandbox-only load handling (analytics, path sync,
// editor injection) — none of it applies to it.
if (display.mode !== "sandbox") return;
+ // Stitch this deco site into the parent's session
+ // replay (cross-origin — see session-replay-script.ts).
+ injectSessionReplay();
// This is the VM dev-server preview (sandboxed running app),
// NOT an MCP app. MCP apps render via .
track("vm_preview_loaded", {
diff --git a/apps/web/src/components/sandbox/preview/session-replay-script.ts b/apps/web/src/components/sandbox/preview/session-replay-script.ts
new file mode 100644
index 0000000000..866d0814e5
--- /dev/null
+++ b/apps/web/src/components/sandbox/preview/session-replay-script.ts
@@ -0,0 +1,66 @@
+/**
+ * PostHog session-replay bootstrap for the sandbox Preview iframe.
+ *
+ * PostHog / rrweb only records SAME-origin iframes. Our Preview iframe loads a
+ * deco site on a different origin (`*.preview-studio.decocms.com`), so without
+ * help the preview canvas is a blank rectangle in replays. PostHog's fix is
+ * cross-origin iframe recording: load posthog-js inside the child too, init it
+ * with `recordCrossOriginIframes: true`, and it forwards its rrweb stream to
+ * the parent (which also sets the flag — see `lib/posthog-client.ts`) via
+ * postMessage instead of ingesting on its own. One stitched recording results.
+ *
+ * The script string below is eval'd inside the iframe by the sandbox bootstrap
+ * (`IFRAME_BOOTSTRAP_SCRIPT` in `packages/sandbox/shared.ts`), which listens
+ * for `visual-editor::activate` and runs `new Function(script)()`. That
+ * bootstrap is injected ONLY into sandbox dev-server previews (by the daemon
+ * proxy), never into the production-fallback frame — so this recording is
+ * scoped to deco sites the user is actively editing in Preview, by
+ * construction.
+ *
+ * Privacy: the child mirrors the parent's masking (`maskAllInputs`,
+ * `blockClass: "ph-no-capture"`) and disables autocapture / pageviews /
+ * pageleaves so it ONLY contributes the replay stream — no duplicate product
+ * analytics events from inside the customer's site.
+ */
+
+/** Distinct guard flag on the iframe's window so a re-injection is a no-op. */
+const REPLAY_GUARD = "__phSessionReplayActive";
+
+/**
+ * Build the self-contained bootstrap script for the given PostHog project.
+ *
+ * `key`/`host` come from `/api/config` (`usePublicConfig().posthog`) and are
+ * JSON-encoded so they can't break out of the string literal. `host` is the
+ * same first-party reverse proxy the parent uses; it must serve
+ * `/static/array.js` (the standard PostHog reverse-proxy contract).
+ */
+export function buildSessionReplayScript(key: string, host: string): string {
+ const k = JSON.stringify(key);
+ const h = JSON.stringify(host);
+ return `(function(){
+ if (window.${REPLAY_GUARD}) return;
+ window.${REPLAY_GUARD} = true;
+ try {
+ !function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.crossOrigin="anonymous",p.async=!0,p.src=s.api_host.replace(".i.posthog.com","-assets.i.posthog.com")+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],u.toString=function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e},u.people.toString=function(){return u.toString(1)+".people (stub)"},o="init capture register register_once register_for_session unregister unregister_for_session getFeatureFlag getFeatureFlagPayload isFeatureEnabled reloadFeatureFlags updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures on onFeatureFlags onSessionId getSurveys getActiveMatchingSurveys renderSurvey canRenderSurvey identify setPersonProperties group resetGroups setPersonPropertiesForFlags resetPersonPropertiesForFlags setGroupPropertiesForFlags resetGroupPropertiesForFlags reset get_distinct_id getGroups get_session_id get_session_replay_url alias set_config startSessionRecording stopSessionRecording sessionRecordingStarted captureException loadToolbar get_property getSessionProperty createPersonProfile opt_in_capturing opt_out_capturing has_opted_in_capturing has_opted_out_capturing clear_opt_in_out_capturing debug getPageViewId".split(" "),n=0;n ({
...request,
...(typeof request.name === "string"