Skip to content

[APPS-2947] Add: return connection IDs from debugBundle and attach the bundle to framed backend calls - #566

Open
tyffical wants to merge 2 commits into
masterfrom
tiffany.trinh/apps-2947-debug-bundle-connection-ids
Open

tyffical wants to merge 2 commits into
masterfrom
tiffany.trinh/apps-2947-debug-bundle-connection-ids

Conversation

@tyffical

@tyffical tyffical commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Motivation

  • APPS-2947: the Live Preview pane in the Datadog app runs a backend function's bundle through App Builder's preview-async as the viewer, and its connection checks and consent prompt need that function's allowed connection IDs.
  • The dev server already computes those IDs for /__dd/debugBundle, then drops them; the endpoint returns only plain-text code.
  • For the MVP the framed app attaches its own bundle to each backend call it posts to the parent (ADR); deployed apps ship none of the dev client and send the call as before.
  • Related: [APPS-2947 Fix: fail the build on connection IDs read from objects that calls or aliases write to]([APPS-2947] Fix: fail the build on connection IDs read from objects that calls or aliases write to #619) is stacked on this PR.

Architecture

flowchart LR
    subgraph frame["Preview frame (dev-served page)"]
        T["postMessageTransport"]
        DC["apps-dev-client hook"]
    end
    DS["Dev server: POST /__dd/debugBundle"]
    P["Parent: Live Preview pane"]
    T -- "hook installed? await it, max 120s" --> DC
    DC -- "Accept: application/json" --> DS
    DS -- "code + allowedConnectionIds" --> DC
    DC -- "targetOrigin + bundle" --> T
    T -- "run-query + bundle, exact origin only" --> P
    P -- "run-query:response, from window.parent only" --> T
Loading
// app-builder:run-query, iframe → parent
{ type: 'app-builder:run-query', requestId, queryName, args,
  bundle?: { code: string; allowedConnectionIds: string[] } }
  • Deployed apps: without the hook, the transport posts the same run-query to '*' as before. The hook is untrusted: a missing, failing, stuck (120s), throwing or malformed result posts the call without a bundle, and a bundle goes only to an exact target origin.
  • Dev-only code: the separate apps-dev-client sub-build is served as virtual:dd-apps-dev-client, and a pre-order transformIndexHtml adds its base-prefixed script tag only to dev-served pages.
  • Datadog parent only: https, the default port, and a hostname @dd/core's parseSite accepts.
    • The origin is location.ancestorOrigins[0]. Firefox has none, so the frame pings and uses the origin of the answer from window.parent, which App Builder's bridge sends; with no answer within 2s the call goes out without a bundle and the page warns once.
    • An unanswered ping is retried once, then calls stop pinging; a later answer from window.parent still sets the origin. An opaque ("null") origin gets no bundle and one warning.
  • Content negotiation: JSON only when Accept lists application/json (not q=0); anything else keeps the plain-text response byte-for-byte.
  • IDs: from the bundle's own Rollup build, the same list /__dd/executeActionViaCloud sends to preview-async, so they always match code.
  • Replies: accepted only from window.parent, and from the exact origin when the frame knows it; a malformed one rejects the call with a BackendFunctionError.
  • Bundle fetch: same-origin, from the absolute /__dd/debugBundle, bounded at 60s; any failure sends the call without bundle and warns. On the Edge route that absolute path falls outside the /api/unstable/live-preview/<host>/ prefix, which the Live Preview backend already tracks.

Changes

11 changes across the protocol, dev server, Vite plugin, dev client, transport and tests
What changed File
Add DebugBundleResponse, DEBUG_BUNDLE_PATH, the message-type constants and the dev-client hook types packages/plugins/apps/src/backend/protocol.ts
Return { code, allowedConnectionIds } as JSON when Accept lists application/json; plain text otherwise packages/plugins/apps/src/vite/dev-server.ts
Serve virtual:dd-apps-dev-client only while a dev server runs, and link it from dev-served HTML packages/plugins/apps/src/vite/index.ts
Add the apps-dev-client sub-build and its entry packages/plugins/apps/package.json, packages/plugins/apps/src/built/apps-dev-client.ts
Find a Datadog parent, fetch the bundle, and warn when the call goes out without one packages/plugins/apps/src/backend/client/dev-client/dev-client.ts
Call the hook if present, check its result, and accept replies only from the recipient packages/plugins/apps/src/backend/client/transports/post-message-transport/post-message-transport.ts, types.ts
Share the bundle guards and request ids between the transport and the dev client packages/plugins/apps/src/backend/client/guards.ts, packages/plugins/apps/src/backend/client/request-id.ts
Cover the dev client and the transport dev-client.test.ts, post-message-transport.test.ts
Cover the dev-only injection, JSON negotiation and a real dev server with and without base packages/plugins/apps/src/vite/index.test.ts, dev-server.test.ts, dev-server.integration.test.ts, dev-client-build.test.ts
Add a shared fake window for frame tests packages/tests/src/_jest/helpers/fakeWindow.ts
Point the postMessage note at the new transport tests packages/plugins/apps/src/backend/client/execute-backend-function.test.ts

QA Instructions

yarn build:all
# Expected: exit 0 ✅ VERIFIED

TMPDIR=<a dir under /private/tmp> yarn test:unit packages/plugins/apps/src --testPathIgnorePatterns network-guard
# Expected: Test Suites: 41 passed; Tests: 879 passed ✅ VERIFIED (macOS needs a TMPDIR outside /var; CI on Linux doesn't)

yarn test:unit packages/plugins/apps/src/vite/network-guard.test.ts
# Expected: Tests: 129 passed, 129 total ✅ VERIFIED (run without TMPDIR set)

yarn lint && yarn typecheck:all && yarn cli integrity
# Expected: all exit 0, integrity leaves no extra changes ✅ VERIFIED
  • Live dev server: the built plugin from this branch, qa-server.cjs and qa-server-base.cjs below, with the actionCatalogCall.backend.ts fixture under the app root and no auth.
QN=$(curl -s http://localhost:5199/backend/actionCatalogCall.backend.ts | grep -o '[0-9a-f]\{64\}\.postMessage' | head -1)

curl -s -o plain.txt -w "HTTP %{http_code} %{content_type}\n" -X POST http://localhost:5199/__dd/debugBundle \
  -H 'Content-Type: application/json' -d "{\"functionName\":\"$QN\"}"
# Expected: HTTP 200 text/plain ✅ VERIFIED

curl -s -o json.txt -w "HTTP %{http_code} %{content_type}\n" -X POST http://localhost:5199/__dd/debugBundle \
  -H 'Content-Type: application/json' -H 'Accept: application/json' -d "{\"functionName\":\"$QN\"}"
# Expected: HTTP 200 application/json; keys [code, allowedConnectionIds]; allowedConnectionIds ["conn-1"]; code === plain.txt ✅ VERIFIED

curl -s -w "\nHTTP %{http_code} %{content_type}\n" -X POST http://localhost:5199/__dd/debugBundle \
  -H 'Content-Type: application/json' -H 'Accept: application/json' -d '{"functionName":"nope.nope"}'
# Expected: {"success":false,"error":"Backend function \"nope.nope\" not found"} HTTP 404 application/json ✅ VERIFIED

curl -s http://localhost:5199/index.html | grep -o '<script type="module" src="/@id/virtual:dd-apps-dev-client"></script>'
curl -s -o /dev/null -w "dev client module: HTTP %{http_code} %{content_type}\n" http://localhost:5199/@id/virtual:dd-apps-dev-client
# Expected: the dev client tag is on dev-served HTML, and the module returns HTTP 200 text/javascript ✅ VERIFIED

curl -s http://localhost:5197/myapp/index.html | grep -o '<script type="module" src="[^"]*dd-apps-dev-client"></script>'
curl -s -o /dev/null -w "HTTP %{http_code}\n" http://localhost:5197/myapp/@id/virtual:dd-apps-dev-client
# Expected: src="/myapp/@id/virtual:dd-apps-dev-client" and HTTP 200 under a base path ✅ VERIFIED

curl -s -o /dev/null -w "HTTP %{http_code}\n" -X POST http://localhost:5199/__dd/debugBundle -d "{\"functionName\":\"$QN\"}"
curl -s -o /dev/null -w "HTTP %{http_code}\n" -X POST http://localhost:5199/__dd/debugBundle \
  -H 'Content-Type: application/json' -H 'Origin: http://evil.example' -d "{\"functionName\":\"$QN\"}"
curl -s -o /dev/null -w "HTTP %{http_code}\n" -X POST http://localhost:5199/__dd/debugBundle \
  -H 'Content-Type: application/json' -H 'Origin: http://localhost:5199' -d "{\"functionName\":\"$QN\"}"
# Expected: 415, 403, 200: master's request checks still apply to the debugBundle route, and the dev server's own origin passes ✅ VERIFIED
  • Headless Chromium and Firefox through Playwright 1.49.1, with QA_BROWSER=chromium or QA_BROWSER=firefox. Playwright fulfills the parent page locally, so no request reaches the named host; the page answers app-builder:ping as App Builder's bridge does.
node qa-browser-parent.cjs https://app.datadoghq.com /index.html
# Expected: devClient true; bundleFetches ["http://localhost:5199/__dd/debugBundle"]; hasBundle true; allowedConnectionIds ["conn-1"]; frameResult "reply-from-parent", in both browsers ✅ VERIFIED

node qa-browser-parent.cjs https://app.datadoghq.com /nested/index.html
# Expected: same as above at a nested path, in both browsers ✅ VERIFIED

node qa-browser-parent.cjs https://app.datadoghq.com.evil.example /index.html
# Expected: hasBundle false; frameResult "reply-from-parent"; Chromium fetches nothing, Firefox builds once while pinging and drops it ✅ VERIFIED

node qa-browser.cjs   # parent page served by the dev server itself (http://localhost:5199)
# Expected: hasBundle false; frameResult "reply-from-parent", in both browsers ✅ VERIFIED

node qa-browser-https.cjs https://app.datadoghq.com /index.html   # frame served from https://preview.qa.test, as a real preview is
# Expected: bundleFetches ["https://preview.qa.test/__dd/debugBundle"]; hasBundle true, in both browsers ✅ VERIFIED

node qa-browser-https.cjs https://app.datadoghq.com.evil.example /index.html
# Expected: hasBundle false, in both browsers ✅ VERIFIED

node qa-browser-ping.cjs never   # parent answers run-query but ignores pings; 4 calls
# Expected: Chromium: 0 pings, bundles on every call. Firefox: the first call ≈ 2000 ms, the rest well under it; 1 ping; hasBundle [false, true, true, true]; one warning ✅ VERIFIED

node qa-browser-ping.cjs late    # parent answers each ping 3s late
# Expected: same as "never": the first reply teaches the origin before the late pong ✅ VERIFIED

node qa-build.cjs && wc -c qa-dist/assets/*.js && grep -c "app-builder:ping\|__dd/debugBundle\|datad0g\|dd-apps-dev-client" qa-dist/assets/*.js qa-dist/index.html
# Expected: the production build succeeds, its JS asset is 4179 bytes, and nothing of the dev client is in it (0 matches) ✅ VERIFIED
  • Bundle size: in the built plugin, the apps-runtime.mjs every deployed app loads is 3496 bytes, and apps-dev-client.mjs, which only dev-served pages load, is 3414 bytes.
Captured dev server, browser and build output
HTTP 200 text/plain
HTTP 200 application/json
["code","allowedConnectionIds"] ["conn-1"] code===plain: true
{"success":false,"error":"Backend function \"nope.nope\" not found"}
HTTP 404 application/json
<script type="module" src="/@id/virtual:dd-apps-dev-client"></script>
dev client module: HTTP 200 text/javascript
<script type="module" src="/myapp/@id/virtual:dd-apps-dev-client"></script>
HTTP 200
HTTP 415
HTTP 403
HTTP 200
##### chromium
{"browser":"chromium","parentOrigin":"https://app.datadoghq.com","appPath":"/index.html","devClient":true,"ancestorOrigin":"https://app.datadoghq.com","referrer":"","bundleFetches":["http://localhost:5199/__dd/debugBundle"],"hasBundle":true,"allowedConnectionIds":["conn-1"],"frameResult":"reply-from-parent"}
{"browser":"chromium","parentOrigin":"https://app.datadoghq.com","appPath":"/nested/index.html","devClient":true,"ancestorOrigin":"https://app.datadoghq.com","referrer":"","bundleFetches":["http://localhost:5199/__dd/debugBundle"],"hasBundle":true,"allowedConnectionIds":["conn-1"],"frameResult":"reply-from-parent"}
{"browser":"chromium","parentOrigin":"https://app.datadoghq.com.evil.example","appPath":"/index.html","devClient":true,"ancestorOrigin":"https://app.datadoghq.com.evil.example","referrer":"","bundleFetches":[],"hasBundle":false,"frameResult":"reply-from-parent"}
{"browser":"chromium","type":"app-builder:run-query","bundleFetches":[],"hasBundle":false,"frameResult":"reply-from-parent"}
{"browser":"chromium","parentOrigin":"https://app.datadoghq.com","frameOrigin":"https://preview.qa.test","appPath":"/index.html","devClient":true,"ancestorOrigin":"https://app.datadoghq.com","referrer":"https://app.datadoghq.com/","bundleFetches":["https://preview.qa.test/__dd/debugBundle"],"hasBundle":true,"allowedConnectionIds":["conn-1"],"frameResult":"reply-from-parent"}
{"browser":"chromium","parentOrigin":"https://app.datadoghq.com.evil.example","frameOrigin":"https://preview.qa.test","appPath":"/index.html","devClient":true,"ancestorOrigin":"https://app.datadoghq.com.evil.example","referrer":"https://app.datadoghq.com.evil.example/","bundleFetches":[],"hasBundle":false,"frameResult":"reply-from-parent"}
{"browser":"chromium","mode":"never","callMs":[54,74,56,23],"pings":0,"hasBundle":[true,true,true,true],"bundleFetches":4,"warnings":[]}
{"browser":"chromium","mode":"late","callMs":[18,12,11,24],"pings":0,"hasBundle":[true,true,true,true],"bundleFetches":4,"warnings":[]}
##### firefox
{"browser":"firefox","parentOrigin":"https://app.datadoghq.com","appPath":"/index.html","devClient":true,"ancestorOrigin":"unsupported","referrer":"","bundleFetches":["http://localhost:5199/__dd/debugBundle"],"hasBundle":true,"allowedConnectionIds":["conn-1"],"frameResult":"reply-from-parent"}
{"browser":"firefox","parentOrigin":"https://app.datadoghq.com","appPath":"/nested/index.html","devClient":true,"ancestorOrigin":"unsupported","referrer":"","bundleFetches":["http://localhost:5199/__dd/debugBundle"],"hasBundle":true,"allowedConnectionIds":["conn-1"],"frameResult":"reply-from-parent"}
{"browser":"firefox","parentOrigin":"https://app.datadoghq.com.evil.example","appPath":"/index.html","devClient":true,"ancestorOrigin":"unsupported","referrer":"","bundleFetches":["http://localhost:5199/__dd/debugBundle"],"hasBundle":false,"frameResult":"reply-from-parent"}
{"browser":"firefox","type":"app-builder:run-query","bundleFetches":["http://localhost:5199/__dd/debugBundle"],"hasBundle":false,"frameResult":"reply-from-parent"}
{"browser":"firefox","parentOrigin":"https://app.datadoghq.com","frameOrigin":"https://preview.qa.test","appPath":"/index.html","devClient":true,"ancestorOrigin":"unsupported","referrer":"","bundleFetches":["https://preview.qa.test/__dd/debugBundle"],"hasBundle":true,"allowedConnectionIds":["conn-1"],"frameResult":"reply-from-parent"}
{"browser":"firefox","parentOrigin":"https://app.datadoghq.com.evil.example","frameOrigin":"https://preview.qa.test","appPath":"/index.html","devClient":true,"ancestorOrigin":"unsupported","referrer":"","bundleFetches":["https://preview.qa.test/__dd/debugBundle"],"hasBundle":false,"frameResult":"reply-from-parent"}
{"browser":"firefox","mode":"never","callMs":[2011,10,8,24],"pings":1,"hasBundle":[false,true,true,true],"bundleFetches":4,"warnings":["[dd-apps] Sending \"99d2b5f5801ea60aedc25bca60c1f7963eb4a646f9dc29b1016b2e004988dbe0.postMessage\" without its dev server bundle: no answer came back from the parent page, so it can't be confirmed as a Datadog page"]}
{"browser":"firefox","mode":"late","callMs":[2012,40,42,388],"pings":1,"hasBundle":[false,true,true,true],"bundleFetches":4,"warnings":["[dd-apps] Sending \"99d2b5f5801ea60aedc25bca60c1f7963eb4a646f9dc29b1016b2e004988dbe0.postMessage\" without its dev server bundle: no answer came back from the parent page, so it can't be confirmed as a Datadog page"]}
QA_BUILD_DONE
4179 qa-dist/assets/index-BEhnKJUA.js
qa-dist/assets/index-BEhnKJUA.js:0
qa-dist/index.html:0
  • Live Preview end-to-end in staging: pending on this head. It needs a dev publish of this branch:
    1. Publish a dev version (3.3.2-dev.N) from this head.
    2. In the staging sandbox app, pin @datadog/vite-plugin to it, reinstall, and fully restart Vite.
    3. Confirm /@id/virtual:dd-apps-dev-client is served and the *.backend.ts files load as proxy stubs.
    4. In Bits Code's Live Preview pane, run a backend function that uses a connection: the posted run-query carries bundle.allowedConnectionIds, and the preview-async run succeeds as the viewer (staging audit events).
    5. Repeat in Firefox, where the parent origin comes from the ping.
  • The last staging run used 3.3.2-dev.4, published from an earlier head of this branch; the ADR page records it.
  • The packed-plugin run in a scaffolded @datadog/create-apps app is pending on this head too; the staging run above covers the same path.
QA harness source (dev servers, app and parent pages, browser and build scripts)
  • Layout: qa-app/ holds index.html, nested/index.html (a copy), main.js, multi.html, multi.js, parent.html, parent-ping.html, backend/actionCatalogCall.backend.ts and node_modules/@datadog/action-catalog (both copied with cp -RL from packages/tests/src/_jest/fixtures/apps_backend_project). W is this repo's checkout and S the harness directory, both passed as environment variables; QA_BROWSER picks chromium (default) or firefox.
<!-- qa-app/index.html (and qa-app/nested/index.html) -->
<!doctype html>
<html><body><script type="module" src="/main.js"></script></body></html>
// qa-app/main.js
import { postMessage as runBackend } from './backend/actionCatalogCall.backend.ts';
runBackend().then(
    (result) => { window.__result = result; },
    (error) => { window.__error = String(error); },
);
<!-- qa-app/parent.html -->
<!doctype html>
<html><body>
<iframe src="/index.html"></iframe>
<script>
window.addEventListener('message', (event) => {
    if (event.data && event.data.type === 'app-builder:ping') {
        event.source.postMessage({ type: 'app-builder:pong', requestId: event.data.requestId, availableQueries: [] }, '*');
    }
    if (event.data && event.data.type === 'app-builder:run-query') {
        window.__message = event.data;
        event.source.postMessage({ type: 'app-builder:run-query:response', requestId: event.data.requestId, success: true, result: { data: 'reply-from-parent' } }, '*');
    }
});
</script>
</body></html>
<!-- qa-app/multi.html -->
<!doctype html>
<html><body><script type="module" src="/multi.js"></script></body></html>
// qa-app/multi.js — three back-to-back calls, a 3.5s pause, then a fourth call
import { postMessage as runBackend } from './backend/actionCatalogCall.backend.ts';
const timeCall = async () => {
    const start = performance.now();
    await runBackend();
    return Math.round(performance.now() - start);
};
(async () => {
    const timings = [];
    for (let i = 0; i < 3; i++) {
        timings.push(await timeCall());
    }
    await new Promise((resolve) => setTimeout(resolve, 3500));
    timings.push(await timeCall());
    window.__timings = timings;
})().catch((error) => { window.__error = String(error); });
<!-- qa-app/parent-ping.html: ?mode=never ignores pings; ?mode=late answers each ping 3s late -->
<!doctype html>
<html><body>
<iframe src="/multi.html"></iframe>
<script>
const mode = new URLSearchParams(location.search).get('mode');
window.__pings = 0;
window.__bundles = [];
window.addEventListener('message', (event) => {
    if (event.data && event.data.type === 'app-builder:ping') {
        window.__pings += 1;
        if (mode === 'late') {
            const source = event.source;
            const requestId = event.data.requestId;
            setTimeout(() => source.postMessage({ type: 'app-builder:pong', requestId }, '*'), 3000);
        }
    }
    if (event.data && event.data.type === 'app-builder:run-query') {
        window.__bundles.push('bundle' in event.data);
        event.source.postMessage({ type: 'app-builder:run-query:response', requestId: event.data.requestId, success: true, result: { data: 'ok' } }, '*');
    }
});
</script>
</body></html>
// qa-server.cjs — run: W=<repo> S=<harness dir> node qa-server.cjs
const { W, S } = process.env;
const { createServer } = require(`${W}/node_modules/vite`);
const { datadogVitePlugin } = require(`${W}/packages/published/vite-plugin/dist/src/index.js`);
(async () => {
    const server = await createServer({
        configFile: false,
        root: `${S}/qa-app`,
        cacheDir: `${S}/qa-vite-cache`,
        logLevel: 'warn',
        server: { port: 5199, strictPort: true, hmr: false, allowedHosts: ['preview.qa.test'], fs: { allow: [S, W] } },
        optimizeDeps: { noDiscovery: true },
        plugins: [datadogVitePlugin({ apps: { enable: true } })],
    });
    await server.listen();
    console.log('QA_SERVER_READY');
})();
// qa-server-base.cjs — run: W=<repo> S=<harness dir> node qa-server-base.cjs (qa-server.cjs with base /myapp/ on port 5197)
const { W, S } = process.env;
const { createServer } = require(`${W}/node_modules/vite`);
const { datadogVitePlugin } = require(`${W}/packages/published/vite-plugin/dist/src/index.js`);
(async () => {
    const server = await createServer({
        configFile: false,
        base: '/myapp/',
        root: `${S}/qa-app`,
        cacheDir: `${S}/qa-vite-cache-base`,
        logLevel: 'warn',
        server: { port: 5197, strictPort: true, hmr: false, fs: { allow: [S, W] } },
        optimizeDeps: { noDiscovery: true },
        plugins: [datadogVitePlugin({ apps: { enable: true } })],
    });
    await server.listen();
    console.log('QA_SERVER_READY');
})();
// qa-browser-parent.cjs — run: W=<repo> S=<harness dir> QA_BROWSER=chromium|firefox node qa-browser-parent.cjs <parentOrigin> <appPath>
const fs = require('fs');
const { W, S, QA_BROWSER = 'chromium' } = process.env;
const browserType = require(`${W}/node_modules/playwright`)[QA_BROWSER];
const [parentOrigin, appPath] = process.argv.slice(2);
(async () => {
    const browser = await browserType.launch();
    const page = await browser.newPage();
    const parentHtml = fs.readFileSync(`${S}/qa-app/parent.html`, 'utf8')
        .replace('src="/index.html"', 'src="http://localhost:5199' + appPath + '"');
    const fetches = [];
    page.on('request', (request) => { if (request.url().includes('__dd/debugBundle')) fetches.push(request.url()); });
    await page.route(parentOrigin + '/**', (route) => route.fulfill({ contentType: 'text/html', body: parentHtml }));
    await page.goto(parentOrigin + '/parent.html');
    const frame = page.frames().find((f) => f.url().endsWith(appPath));
    await frame.waitForFunction(() => window.__result !== undefined || window.__error !== undefined, null, { timeout: 60000 });
    const message = await page.evaluate(() => window.__message);
    console.log(JSON.stringify({
        browser: QA_BROWSER,
        parentOrigin,
        appPath,
        devClient: await frame.evaluate(() => typeof globalThis.DD_APPS_PREPARE_CALL === 'function'),
        ancestorOrigin: await frame.evaluate(() => (location.ancestorOrigins ? location.ancestorOrigins[0] : 'unsupported')),
        referrer: await frame.evaluate(() => document.referrer),
        bundleFetches: fetches,
        hasBundle: 'bundle' in message,
        allowedConnectionIds: message.bundle && message.bundle.allowedConnectionIds,
        frameResult: await frame.evaluate(() => window.__result ?? window.__error),
    }));
    await browser.close();
})().catch((error) => { console.error(error); process.exit(1); });
// qa-browser.cjs — run: W=<repo> QA_BROWSER=chromium|firefox node qa-browser.cjs (parent page served by the dev server)
const { W, QA_BROWSER = 'chromium' } = process.env;
const browserType = require(`${W}/node_modules/playwright`)[QA_BROWSER];
(async () => {
    const browser = await browserType.launch();
    const page = await browser.newPage();
    const fetches = [];
    page.on('request', (request) => { if (request.url().includes('__dd/debugBundle')) fetches.push(request.url()); });
    await page.goto('http://localhost:5199/parent.html');
    const frame = page.frames().find((f) => f.url().endsWith('/index.html'));
    await frame.waitForFunction(() => window.__result !== undefined || window.__error !== undefined, null, { timeout: 60000 });
    const message = await page.evaluate(() => window.__message);
    console.log(JSON.stringify({
        browser: QA_BROWSER,
        type: message.type,
        bundleFetches: fetches,
        hasBundle: 'bundle' in message,
        frameResult: await frame.evaluate(() => window.__result ?? window.__error),
    }));
    await browser.close();
})().catch((error) => { console.error(error); process.exit(1); });
// qa-browser-https.cjs — run: W=<repo> S=<harness dir> QA_BROWSER=chromium|firefox node qa-browser-https.cjs <parentOrigin> <appPath>
// Like qa-browser-parent.cjs, but the frame is served from an https origin (proxied to the
// local dev server), as a real preview is.
const fs = require('fs');
const { W, S, QA_BROWSER = 'chromium' } = process.env;
const browserType = require(`${W}/node_modules/playwright`)[QA_BROWSER];
const [parentOrigin, appPath] = process.argv.slice(2);
const FRAME_ORIGIN = 'https://preview.qa.test';
(async () => {
    const browser = await browserType.launch();
    const page = await browser.newPage();
    const parentHtml = fs.readFileSync(`${S}/qa-app/parent.html`, 'utf8')
        .replace('src="/index.html"', 'src="' + FRAME_ORIGIN + appPath + '"');
    const fetches = [];
    page.on('request', (request) => { if (request.url().includes('__dd/debugBundle')) fetches.push(request.url()); });
    await page.route(parentOrigin + '/**', (route) => route.fulfill({ contentType: 'text/html', body: parentHtml }));
    await page.route(FRAME_ORIGIN + '/**', async (route) => {
        const target = route.request().url().replace(FRAME_ORIGIN, 'http://localhost:5199');
        // Keep the browser's Host, as the Live Preview proxy does, so it matches the Origin.
        const headers = { ...route.request().headers(), host: 'preview.qa.test' };
        const response = await route.fetch({ url: target, headers });
        await route.fulfill({ response });
    });
    await page.goto(parentOrigin + '/parent.html');
    const frame = page.frames().find((f) => f.url().endsWith(appPath));
    await frame.waitForFunction(() => window.__result !== undefined || window.__error !== undefined, null, { timeout: 60000 });
    const message = await page.evaluate(() => window.__message);
    console.log(JSON.stringify({
        browser: QA_BROWSER,
        parentOrigin,
        frameOrigin: FRAME_ORIGIN,
        appPath,
        devClient: await frame.evaluate(() => typeof globalThis.DD_APPS_PREPARE_CALL === 'function'),
        ancestorOrigin: await frame.evaluate(() => (location.ancestorOrigins ? location.ancestorOrigins[0] : 'unsupported')),
        referrer: await frame.evaluate(() => document.referrer),
        bundleFetches: fetches,
        hasBundle: 'bundle' in message,
        allowedConnectionIds: message.bundle && message.bundle.allowedConnectionIds,
        frameResult: await frame.evaluate(() => window.__result ?? window.__error),
    }));
    await browser.close();
})().catch((error) => { console.error(error); process.exit(1); });
// qa-browser-ping.cjs — run: W=<repo> S=<harness dir> QA_BROWSER=chromium|firefox node qa-browser-ping.cjs never|late
const fs = require('fs');
const { W, S, QA_BROWSER = 'chromium' } = process.env;
const browserType = require(`${W}/node_modules/playwright`)[QA_BROWSER];
const mode = process.argv[2];
const PARENT = 'https://app.datadoghq.com';
(async () => {
    const browser = await browserType.launch();
    const page = await browser.newPage();
    const parentHtml = fs.readFileSync(`${S}/qa-app/parent-ping.html`, 'utf8')
        .replace('src="/multi.html"', 'src="http://localhost:5199/multi.html"');
    const warnings = [];
    page.on('console', (message) => { if (message.type() === 'warning' && message.text().includes('[dd-apps]')) warnings.push(message.text()); });
    const fetches = [];
    page.on('request', (request) => { if (request.url().includes('__dd/debugBundle')) fetches.push(request.url()); });
    await page.route(PARENT + '/**', (route) => route.fulfill({ contentType: 'text/html', body: parentHtml }));
    await page.goto(`${PARENT}/parent-ping.html?mode=${mode}`);
    const frame = page.frames().find((f) => f.url().endsWith('/multi.html'));
    await frame.waitForFunction(() => window.__timings !== undefined || window.__error !== undefined, null, { timeout: 60000 });
    console.log(JSON.stringify({
        browser: QA_BROWSER,
        mode,
        callMs: await frame.evaluate(() => window.__timings ?? window.__error),
        pings: await page.evaluate(() => window.__pings),
        hasBundle: await page.evaluate(() => window.__bundles),
        bundleFetches: fetches.length,
        warnings,
    }));
    await browser.close();
})().catch((error) => { console.error(error); process.exit(1); });
// qa-build.cjs — run: W=<repo> S=<harness dir> node qa-build.cjs
const { W, S } = process.env;
const { build } = require(`${W}/node_modules/vite`);
const { datadogVitePlugin } = require(`${W}/packages/published/vite-plugin/dist/src/index.js`);
build({
    configFile: false,
    root: `${S}/qa-app`,
    logLevel: 'warn',
    build: { outDir: `${S}/qa-dist`, emptyOutDir: true },
    plugins: [datadogVitePlugin({ apps: { enable: true } })],
}).then(() => console.log('QA_BUILD_DONE'), (error) => { console.error(error); process.exit(1); });

Blast Radius

  • No feature flag. Risk: Low.
  • Production builds: this PR doesn't change the connection ID extractor, so a build that passes today still passes; the extractor's fail-closed change is in [APPS-2947 Fix: fail the build on connection IDs read from objects that calls or aliases write to]([APPS-2947] Fix: fail the build on connection IDs read from objects that calls or aliases write to #619).
  • Deployed apps: the transport only checks for the dev hook and posts the same run-query to '*'. It now ignores replies from windows other than window.parent and rejects a malformed reply instead of hanging; App Builder's bridge always replies from the frame's parent.
  • Dev server: /__dd/debugBundle returns JSON only to Accept: application/json (Axios's default would get JSON; no such caller was found), and dev pages load one extra module.
  • On a dev server framed by a Datadog page, each call first waits for one in-memory Vite build of its function, bounded at 60s.

Out of Scope / Follow-ups

11 items deferred
Item Status Next step
The parent must treat bundle as untrusted app input: any script in the frame can post arbitrary code and allowedConnectionIds Handled in the Live Preview pane in the Datadog app (owner-only); consent deferred there Not needed for the MVP; a consent check is needed before non-owners can run backend functions
The same-origin fetch relies on the pane's allow-same-origin grant, and a referrerpolicy that masks ancestorOrigins[0] as "null" would skip the bundle, since that case doesn't fall back to a ping Holds today Re-check if the pane's sandbox or referrerpolicy changes
In Firefox, the first calls on a dev page wait up to 2s each for a ping answer, until a parent message arrives or two pings go unanswered Accepted None
A failed bundle fetch, or a ping that gets no answer, sends the call without a bundle and only warns in the app's frame Deferred Consider forwarding the cause to the Live Preview pane
The bare Datadog site and any single-label subdomain of it (including www and docs) are accepted as the parent, to keep custom org subdomains working Accepted for the MVP and shared staging; open for production Before production, decide whether to narrow it to App Builder hosts; the bundle carries no credentials and is posted only to that checked origin
acceptsJson honors q=0 but not relative q-values, and the JSON response sets no Vary: Accept Deferred (decided offline): negotiation is kept over a dedicated route None
Concurrent dev calls can post out of call order, since each one waits for its own bundle Deferred (decided offline) None
No total time cap across the ping (2s), the bundle fetch (60s) and the response (120s); a stuck dev hook adds up to 120s more Deferred (decided offline) None
A postMessage throw rejects with the raw error, not a BackendFunctionError (pre-existing) Deferred (decided offline) None
When no Datadog parent origin is known, the call posts with '*', so its reply is checked against the parent window and request id but not an origin Accepted None
A dev-client hook whose result uses custom iterators, getters or Proxies is out of scope; same-page script can already post to the parent Accepted None

Documentation

🤖 Generated with Claude Code

@datadog-prod-us1-5

datadog-prod-us1-5 Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

Pipelines  Tests

🎉 All green!

🧪 All tests passed
❄️ No new flaky tests detected

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: cac186d | Docs | View more details | Give us feedback!

@tyffical
tyffical force-pushed the tiffany.trinh/apps-2947-debug-bundle-connection-ids branch from 5126f2b to 5c7bf06 Compare October 14, 2026 21:45
@tyffical
tyffical marked this pull request as ready for review October 16, 2026 01:06
@tyffical
tyffical requested review from a team as code owners October 16, 2026 01:06
@tyffical
tyffical requested review from Chau-DDOG, duncanista, oliverli and sdkennedy2 and removed request for a team October 16, 2026 01:06
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2947-debug-bundle-connection-ids branch from 5c7bf06 to 43bb75f Compare October 16, 2026 02:08

@oliverli oliverli left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The implementation looks correct. I found no correctness or security problems. The inline comments are about test coverage only, and they do not block this PR.

Comment thread packages/tests/src/_jest/helpers/fakeWindow.ts
POST /__dd/debugBundle answers with { code, allowedConnectionIds } when
the Accept header lists application/json without q=0, and keeps the
plain-text bundle otherwise. The route path and response type live in
the shared backend protocol so the browser client can use them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2947-debug-bundle-connection-ids branch from 403cc08 to 2e8c7d4 Compare October 23, 2026 05:20
A dev client, built as apps-dev-client, installs a hook the postMessage
transport calls before each run-query. When the page is framed by a
Datadog page, found through ancestorOrigins or, on Firefox, from the
origin of the parent's answer to a ping, the hook fetches the function's
bundle from /__dd/debugBundle and returns it with the parent's exact
origin. A hook that fails, throws or stalls never stops the call. A
parent with an opaque ("null") origin gets the call without a bundle,
and the page warns once.

The Vite plugin prepends a module script for virtual:dd-apps-dev-client
to served HTML, ahead of Vite's base rewrite, and loads the built
apps-dev-client file for it. The virtual id resolves only for client
requests while the dev server runs.

The transport posts a bundle only to an exact origin and accepts a
run-query response only from window.parent and, when it posted to an
exact origin, only from that origin. It copies the bundle's
allowedConnectionIds by index into a plain array before checking them,
so an array subclass can't choose what gets posted through
Symbol.species.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants