Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions apps/web/src/components/sandbox/preview/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 <MCPAppRenderer/>.
track("vm_preview_loaded", {
Expand Down
66 changes: 66 additions & 0 deletions apps/web/src/components/sandbox/preview/session-replay-script.ts
Original file line number Diff line number Diff line change
@@ -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<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);
window.posthog.init(${k}, {
api_host: ${h},
// Contribute ONLY the replay stream to the parent — no analytics events.
autocapture: false,
capture_pageview: false,
capture_pageleave: false,
capture_performance: false,
capture_exceptions: false,
disable_surveys: true,
person_profiles: "never",
session_recording: {
maskAllInputs: true,
blockClass: "ph-no-capture",
recordCrossOriginIframes: true
}
});
} catch (err) {
window.${REPLAY_GUARD} = false;
console.error("[session-replay] bootstrap failed", err);
}
})();`;
}
8 changes: 8 additions & 0 deletions apps/web/src/lib/posthog-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,14 @@ export function initPostHog(key: string, host: string) {
session_recording: {
maskAllInputs: true,
blockClass: "ph-no-capture",
// Stitch the sandbox Preview iframe (a cross-origin deco site on
// *.preview-studio.decocms.com) into this document's recording. rrweb
// only records same-origin iframes by default, so without this the
// preview canvas shows up as a blank rectangle in replays. The child
// side is bootstrapped by buildSessionReplayScript(), injected ONLY into
// the sandbox preview (never the production fallback) — see
// components/sandbox/preview/session-replay-script.ts.
recordCrossOriginIframes: true,
maskCapturedNetworkRequestFn: (request) => ({
...request,
...(typeof request.name === "string"
Expand Down
Loading