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"