From 28bd8fdf01b0f35d0529af9cda5c8ba16406c0ab Mon Sep 17 00:00:00 2001 From: decobot Date: Fri, 24 Jul 2026 13:54:27 -0300 Subject: [PATCH] feat(preview): capture sandbox preview iframe in PostHog session replay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Preview iframe loads a deco site on a different origin (*.preview-studio.decocms.com), and rrweb only records same-origin iframes — so the preview canvas showed up as a blank rectangle in replays. Enable PostHog cross-origin iframe recording: - set `recordCrossOriginIframes: true` on the parent's session_recording - inject a lean posthog-js bootstrap into the sandbox preview that inits with the same flag, so the child forwards its rrweb stream to the parent instead of ingesting on its own (one stitched recording) The child bootstrap is injected only via the sandbox's existing `visual-editor::activate` channel (present only in sandbox dev-server previews, never the production fallback), and after the `display.mode === "sandbox"` guard — so recording is scoped to deco sites the user is actively editing in Preview. It disables autocapture/pageviews and mirrors the parent's input masking, so it contributes only the replay stream. --- .../components/sandbox/preview/preview.tsx | 29 ++++++++ .../sandbox/preview/session-replay-script.ts | 66 +++++++++++++++++++ apps/web/src/lib/posthog-client.ts | 8 +++ 3 files changed, 103 insertions(+) create mode 100644 apps/web/src/components/sandbox/preview/session-replay-script.ts 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"