Skip to content

[APPS-2792] Wire local Node execution into the real dev server - #473

Draft
tyffical wants to merge 2 commits into
tiffany.trinh/apps-2792-harden-local-executionfrom
tiffany.trinh/apps-2792-wire-local-execution
Draft

[APPS-2792] Wire local Node execution into the real dev server#473
tyffical wants to merge 2 commits into
tiffany.trinh/apps-2792-harden-local-executionfrom
tiffany.trinh/apps-2792-wire-local-execution

Conversation

@tyffical

@tyffical tyffical commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Motivation

Architecture

Browser (frontend)
   │  executeBackendFunction(name, args)
   ▼
Vite dev-server middleware
   │
   ├──────────────────────────┬───────────────────────────────┐
   ▼                          ▼
POST /__dd/executeAction      POST /__dd/executeActionViaCloud
(no auth required)            (auth required, unchanged)
   │                          │
   ▼                          ▼
executeScriptLocally()        executeScriptViaDatadog()
(#461/#471 — forked           (existing preview-async +
 child process, real          long-poll round trip against
 OS-process isolation)        the real Deno prod runtime)
   │                          │
   └──────────┬───────────────┘
              ▼
   Same ExecuteActionResponse contract either way:
   {success:true, result:{data}} | {success:false, error}

/__dd/executeAction is the customer-facing default and is now unconditionally local — no config field, no flag, per the design doc's rollout decision. /__dd/executeActionViaCloud is new and additive: it preserves today's exact cloud round-trip (same executeScriptViaDatadog/pollQueryExecution code, untouched) under a distinctly-purposed endpoint. No npm run dev:verify CLI command exists yet to give customers an ergonomic way to reach it — that's now tracked as Milestone 3 in the kickoff doc, not deferred — and the existing test suite has deep coverage of this path that would otherwise be silently orphaned.

Changes

What changed File
/__dd/executeAction now runs backend functions via executeScriptLocally (a real forked child process) instead of the cloud round-trip packages/plugins/apps/src/vite/dev-server.ts
/__dd/executeAction requires no auth, since local execution's $.Actions calls are stubbed today (see Follow-ups) packages/plugins/apps/src/vite/dev-server.ts
New /__dd/executeActionViaCloud endpoint preserves the original executeScriptViaDatadog cloud round-trip byte-for-byte, including its auth gate packages/plugins/apps/src/vite/dev-server.ts
Startup log warning updated to reference /__dd/executeActionViaCloud (the endpoint that actually needs credentials) instead of the old /__dd/executeAction name packages/plugins/apps/src/vite/dev-server.ts
Wire killAllLocalExecutionChildren() (from #471) into the dev server's own shutdown via server.httpServer's close event packages/plugins/apps/src/vite/index.ts
Renamed the entire original cloud-round-trip test suite to target /__dd/executeActionViaCloud instead of /__dd/executeAction, preserving full existing coverage packages/plugins/apps/src/vite/dev-server.test.ts
Fixed the routing-level test for /__dd/executeAction to assert against real local-execution bundle content instead of a stale nock mock packages/plugins/apps/src/vite/dev-server.test.ts
Added a companion routing test for /__dd/executeActionViaCloud packages/plugins/apps/src/vite/dev-server.test.ts
Added a new 5-test suite covering the local-execution handler directly (missing functionRef, unknown function, real forked-child result, crash-as-500, no-credentials-configured) packages/plugins/apps/src/vite/dev-server.test.ts
Register local-exec-child.js in buildPlugin.toBuild so it's copied into the published dist/src output next to the compiled bundle packages/plugins/apps/package.json
Local execution's $ context now includes a Source: { initiator, runAsUser } object with a synthetic local-dev identity, so @datadog/apps-backend's injected runtime-context setup no longer throws packages/plugins/apps/src/vite/local-exec-child.js
New integration test reusing the apps_backend_project fixture (real @datadog/apps-backend) confirms a function calling getExecutionUser()/getInitiatingUser() runs cleanly under local execution packages/plugins/apps/src/vite/local-execution.integration.test.ts

QA Instructions

yarn install
BUILD_PLUGINS_ENV=test FORCE_COLOR=true JEST_CONFIG_TRANSPILE_ONLY=true VITE_CJS_IGNORE_WARNING=true NODE_OPTIONS="--experimental-vm-modules" yarn workspace @dd/tests exec jest --config jest.config.ts packages/plugins/apps/src/vite/dev-server.test.ts
# Expected: 26 tests passing ✅ VERIFIED
BUILD_PLUGINS_ENV=test FORCE_COLOR=true JEST_CONFIG_TRANSPILE_ONLY=true VITE_CJS_IGNORE_WARNING=true NODE_OPTIONS="--experimental-vm-modules" yarn workspace @dd/tests exec jest --config jest.config.ts packages/plugins/apps
# Expected: 24 test suites, 298 tests, all passing ✅ VERIFIED
cd ~/dd/build-plugins/packages/plugins/apps && npx tsc -b tsconfig.client.json --emitDeclarationOnly && npx tsc --noEmit
# Expected: no output, clean exit ✅ VERIFIED
yarn eslint packages/plugins/apps/src/vite/dev-server.ts packages/plugins/apps/src/vite/dev-server.test.ts packages/plugins/apps/src/vite/index.ts
# Expected: no output, clean exit ✅ VERIFIED

Manual QA — real dev server, both endpoints

Setup (one app covers every scenario below — @datadog/apps-backend and @datadog/action-catalog are scaffold defaults). Three terminals are used in total: Terminal A builds this branch and is done after that; Terminal B runs the dev server and stays occupied by it; Terminal C is opened later, once the dev server is running, to send requests to it.

Terminal A — folder: this repo (~/dd/build-plugins):

cd ~/dd/build-plugins
yarn install
yarn workspace @datadog/vite-plugin build

Terminal A isn't needed again after this — the built plugin is linked by absolute path below, not by anything left running in this terminal.

Terminal B — folder: a fresh scaffolded app outside this repo (~/qa-app):

# 1. Scaffold a fresh test app under $HOME
npm create @datadog/apps@latest -- ~/qa-app --template vite-react -y
cd ~/qa-app

# 2. Link Terminal A's build in
npm link ~/dd/build-plugins/packages/published/vite-plugin
# If this resolves to unbuilt TS source instead of dist (npm link's known gap with this
# package's workspace-mode `exports` field), edit vite.config.ts's import instead:
#   import { datadogVitePlugin } from '@datadog/vite-plugin/dist/src';

Still Terminal B, still in ~/qa-app:

# 3. Add QA backend functions
cat > src/qaCheck.backend.ts << 'EOF'
export async function qaCheck(n: number) {
    return { doubled: n * 2 };
}
EOF

cat > src/qaIdentity.backend.ts << 'EOF'
import { getExecutionUser, getInitiatingUser } from '@datadog/apps-backend/user';
export async function qaIdentity() {
    const [executionUser, initiatingUser] = await Promise.all([getExecutionUser(), getInitiatingUser()]);
    return { executionUser, initiatingUser };
}
EOF

cat > src/slowCheck.backend.ts << 'EOF'
export async function slowCheck() {
    await new Promise((r) => setTimeout(r, 15000));
    return { done: true };
}
EOF

cat > src/streamLogs.backend.ts << 'EOF'
export async function streamLogs() {
    console.log('[qa] step 1: starting');
    await new Promise((r) => setTimeout(r, 1000));
    console.log('[qa] step 2: one second in');
    await new Promise((r) => setTimeout(r, 1000));
    console.log('[qa] step 3: done');
    return { done: true };
}
EOF

cat > src/doubleNumber.backend.ts << 'EOF'
export async function doubleNumber(n: number) {
    return { doubled: n * 2 };
}
EOF

# Wire the imports so Vite's backend-function transform discovers them (prepended, portable across sed dialects)
{ printf "import './qaCheck.backend';\nimport './qaIdentity.backend';\nimport './slowCheck.backend';\nimport './streamLogs.backend';\nimport './doubleNumber.backend';\n\n"; cat src/App.tsx; } > /tmp/App.tsx.new && mv /tmp/App.tsx.new src/App.tsx

Still Terminal B, still in ~/qa-app — this command blocks and keeps running, so leave it in the foreground here:

# 4. Start local execution — no dd-auth, fixed port
npm run dev -- --port 5180 --strictPort

Terminal C — open a new terminal, folder: ~/qa-app again (Terminal B is now occupied by the running dev server; everything below runs here instead):

cd ~/qa-app

# 5. Warm up the module graph once (Vite transforms lazily on first request)
curl -sS http://localhost:5180/ > /dev/null
curl -sS http://localhost:5180/src/main.tsx > /dev/null
# 6. Capture each function's real hashed name (registered name is {hash}.{exportName}, not the bare export name) — extracted automatically, no copy-paste needed
QACHECK=$(curl -sS http://localhost:5180/src/qaCheck.backend.ts | sed -n 's/.*executeBackendFunction("\([^"]*\)".*/\1/p')
QAIDENTITY=$(curl -sS http://localhost:5180/src/qaIdentity.backend.ts | sed -n 's/.*executeBackendFunction("\([^"]*\)".*/\1/p')
STREAMLOGS=$(curl -sS http://localhost:5180/src/streamLogs.backend.ts | sed -n 's/.*executeBackendFunction("\([^"]*\)".*/\1/p')
DOUBLENUMBER=$(curl -sS http://localhost:5180/src/doubleNumber.backend.ts | sed -n 's/.*executeBackendFunction("\([^"]*\)".*/\1/p')
SLOWCHECK=$(curl -sS http://localhost:5180/src/slowCheck.backend.ts | sed -n 's/.*executeBackendFunction("\([^"]*\)".*/\1/p')
echo "$QACHECK / $QAIDENTITY / $STREAMLOGS / $DOUBLENUMBER / $SLOWCHECK"
# Expected: five non-empty "<hash>.<exportName>" strings, space-separated ✅ VERIFIED

Local execution tests:

curl -sS -X POST http://localhost:5180/__dd/executeAction \
  -H 'Content-Type: application/json' \
  -d "{\"functionName\":\"$QACHECK\",\"args\":[21]}"
# Expected: {"success":true,"result":{"data":{"doubled":42}}} ✅ VERIFIED

curl -sS -X POST http://localhost:5180/__dd/executeAction \
  -H 'Content-Type: application/json' \
  -d "{\"functionName\":\"$QAIDENTITY\",\"args\":[]}"
# Expected: {"success":true,"result":{"data":{"executionUser":{"id":"local-dev-user","orgId":"local-dev-org","email":null,"name":"Local Development"},"initiatingUser":{...}}}} ✅ VERIFIED

curl -sS -X POST http://localhost:5180/__dd/executeAction \
  -H 'Content-Type: application/json' \
  -d "{\"functionName\":\"$STREAMLOGS\",\"args\":[]}"
# Expected: {"success":true,"result":{"data":{"done":true}}} in the response, and the three [qa] step lines print live in the dev-server terminal, one second apart ✅ VERIFIED

Timing check:

curl -sS -X POST http://localhost:5180/__dd/executeAction -H 'Content-Type: application/json' \
  -d "{\"functionName\":\"$DOUBLENUMBER\",\"args\":[21]}" -w '\n⏱  %{time_total}s\n'
# Expected: {"success":true,"result":{"data":{"doubled":42}}}, ⏱ ~0.1s ✅ VERIFIED (0.111s)

Shutdown cleanup — no orphaned child process (still Terminal C, until the Ctrl-C note below):

curl -sS -X POST http://localhost:5180/__dd/executeAction -H 'Content-Type: application/json' \
  -d "{\"functionName\":\"$SLOWCHECK\",\"args\":[]}" &
sleep 1 && pgrep -f local-exec-child.js
# Expected: one matching PID (child running mid-request) ✅ VERIFIED

Switch to Terminal B (where npm run dev is still running in the foreground) and press Ctrl-C to stop it. Then back in Terminal C:

pgrep -f local-exec-child.js
# Expected: no output (child was killed with the dev server) ✅ VERIFIED

Cloud round trip — the same $QACHECK/$DOUBLENUMBER/$STREAMLOGS variables from step 6 stay valid — the registered name only depends on the file's relative path, not the running server instance.

Terminal B — folder: still ~/qa-app — restart with credentials, fixed port:

dd-auth --domain dd.datad0g.com -- npm run dev -- --port 5182 --strictPort

Terminal C — folder: still ~/qa-app:

curl -sS -X POST http://localhost:5182/__dd/executeActionViaCloud \
  -H 'Content-Type: application/json' \
  -d "{\"functionName\":\"$QACHECK\",\"args\":[21]}"
# Expected: {"success":true,"result":{"data":{"doubled":42}}} ✅ VERIFIED

# same server instance — confirms both endpoints coexist
curl -sS -X POST http://localhost:5182/__dd/executeAction \
  -H 'Content-Type: application/json' \
  -d "{\"functionName\":\"$QACHECK\",\"args\":[7]}"
# Expected: {"success":true,"result":{"data":{"doubled":14}}} ✅ VERIFIED

curl -sS -X POST http://localhost:5182/__dd/executeActionViaCloud -H 'Content-Type: application/json' \
  -d "{\"functionName\":\"$DOUBLENUMBER\",\"args\":[21]}" -w '\n⏱  %{time_total}s\n'
# Expected: {"success":true,"result":{"data":{"doubled":42}}}, ⏱ multi-hundred-ms (real network + queue overhead) ✅ VERIFIED (0.725s)

curl -sS -X POST http://localhost:5182/__dd/executeActionViaCloud \
  -H 'Content-Type: application/json' \
  -d "{\"functionName\":\"$STREAMLOGS\",\"args\":[]}"
# Expected: the three [qa] step lines print all at once, only after the response lands — not live ✅ VERIFIED

Action-catalog build-time validation — no server needed, so Ctrl-C Terminal B's dev server first. Runs in either terminal, folder ~/qa-app:

echo "import { postMessage } from '@datadog/action-catalog/slack/chat';" > src/_check.backend.ts
npx tsc --noEmit
# Expected: TS2305 "has no exported member 'postMessage'" ✅ VERIFIED
rm src/_check.backend.ts

Blast Radius

  • This is the first PR in the stack that changes shipping behavior: npm run dev's /__dd/executeAction now executes locally by default, unconditionally, per the design doc's rollout decision — no feature flag, matching the "no toggle" recommendation (Datadog's own internal canary/experiment gate, if wanted before this becomes the default for all customers, is out of scope for this PR; see design doc's Rollout section).
  • The existing cloud round-trip is fully preserved and still reachable, just under a new endpoint name (/__dd/executeActionViaCloud) — no existing caller of the old /__dd/executeAction endpoint is silently broken without an equivalent path forward, though callers relying specifically on cloud execution at the old URL will need to switch to the new one.
  • $.Actions calls made during local execution remain stubbed (per [APPS-2792] Prototype: local Node execution for backend functions #461/[APPS-2792] Harden local Node execution: signal reporting, shutdown cleanup, edge-case coverage #471) — real action side effects are only available via /__dd/executeActionViaCloud today. This is a known, called-out gap tracked against the single-action-execution endpoint dependency, not something this PR silently papers over.
  • The @datadog/apps-backend Source-context gap found during this PR's manual QA (see above) is now fixed and re-verified against a real scaffolded app — local execution is safe as the default for apps using that package. The synthetic local-dev identity means getExecutionUser()/getInitiatingUser() won't reflect a real user locally (no live-session identity is available without the credentials local execution deliberately doesn't require) — acceptable for now since nothing in the design doc's scope depends on that identity being real, but worth knowing if a backend function's own logic ever branches on it.
  • Risk: medium. Low risk to code correctness (thoroughly tested, real manual QA above, including the two gaps found and fixed) but a genuine behavior change to a customer-facing default — flagged as the "no toggle" decision the design doc argues for explicitly, not an accidental side effect.

Out of Scope / Follow-ups

Item Status Next step
Local execution's $.Source uses a synthetic local-dev identity, not a real user's — getExecutionUser()/getInitiatingUser() won't reflect real identity locally Independent No whoami-style endpoint exists in this package's auth layer today; fetching real identity via the already-authenticated parent process (when credentials are configured) is a real, separate enhancement, not a blocker — nothing in scope today depends on the identity being real
npm run dev:verify as a distinctly-named command for the cloud round-trip Tracked (Milestone 3) Now a fully in-scope, gating deliverable — losing easy cloud-path access the moment npm run dev defaults to local execution is a real workflow regression, not just a missing nicety. Two PRs needed: mode-aware routing in this package (packages/plugins/apps/), plus a create-apps template change in web-ui (owned by @DataDog/app-builder-high-code) to add the script and an example backend function. /__dd/executeActionViaCloud is reachable today via a manual curl call in the meantime.
Backward-compatible rollout (bump.yaml major bump + MIGRATIONS.md entry) Not started Now tracked under Product Launch (not a standalone milestone), gated on Milestones 3/4/5/6 landing (Milestone 7, telemetry, expected in place too), per the kickoff doc
Real (non-stubbed) $.Actions execution during local runs Blocked Same dependency as #461/#471 — see the RFC's Open Questions section for the REST-vs-MCP comparison
Internal canary/experiment gate for gradual rollout before this is unconditional for all customers Not started Design doc's Rollout section proposes this as a Datadog-internal gate, invisible to customers — separate from this PR's scope

Documentation

tyffical added 2 commits July 29, 2026 11:13
- npm run dev's /__dd/executeAction now runs backend functions via
  executeScriptLocally (a real forked child process) instead of the cloud
  preview-async round trip, per the design doc's Rollout section: no
  customer-facing toggle, local execution is the new unconditional default.
- Preserves today's cloud round trip as a new, additive
  /__dd/executeActionViaCloud endpoint rather than deleting it -- no
  `npm run dev:verify` script exists yet to replace it (that requires
  create-apps scaffolding template changes, outside this package), and the
  existing dev-server.test.ts suite has extensive coverage of the cloud path
  that would otherwise be silently orphaned.
- /__dd/executeAction no longer requires Datadog credentials to be
  configured, unlike /__dd/executeActionViaCloud -- local execution's
  $.Actions calls are stubbed today (see local-execution.ts), so there's
  nothing for it to authenticate yet.
- Wires killAllLocalExecutionChildren() (from #471) into the dev server's
  own shutdown via server.httpServer's 'close' event, so killing the dev
  server mid-execution doesn't leave an orphaned child process.
- Registers local-exec-child.js in the package's buildPlugin.toBuild config
  (same mechanism apps-runtime.ts already uses) so it's actually copied
  into the published dist/src output next to the compiled bundle.
  local-execution.ts resolves it at runtime via a __dirname-relative path,
  which only works if the file exists on disk next to the bundle -- found
  by running a real npm-linked build against a scaffolded app, since the
  existing jest tests resolve local-execution.ts's source directly and
  never exercise the published package layout.

$.Actions calls remain stubbed until the single-action execution endpoint
resolves (design doc's Open Dependency section) -- this is explicitly not
customer-ready yet, same as the rest of this PR stack.

Milestone 2 of APPS-2792's local Node execution kickoff plan.
@datadog/apps-backend's buildRuntimeFromJsFunctionWithActions is injected
into every backend function's bundle when that package is installed,
regardless of which specific function is called, and requires
$.Source.{initiator,runAsUser} to each be a User object with non-empty
id/orgId strings. Local execution's $ context never included Source,
so any app with @datadog/apps-backend installed (the create-apps scaffold
default) failed on its very first request -- not just functions that
call $.Actions, which was the only gap previously documented.

Confirmed by tracing the real, published package's bundled validation
logic and dd-source's production path: Source is populated upstream of
wf-actions-worker (the app-builder API layer, from the authenticated
caller's identity) before a bundled function ever runs -- it's plain
caller-supplied JSON by the time it reaches the script template, not a
server-signed value the runtime cross-checks. The validator only checks
shape (non-empty id/orgId strings), not a live session, so a synthetic
identity fully satisfies it.

Uses one placeholder identity for both initiator and runAsUser, since
on-behalf-of impersonation isn't meaningful when a single developer is
running their own code locally. No existing whoami-style endpoint exists
in this package's auth layer to fetch a real identity instead; real
identity resolution (if ever wanted) is a separate, non-blocking
enhancement, not a gap in this fix.

Verified against the real npm package (not a mock) via a new integration
test reusing the existing apps_backend_project fixture, and against a
real npm-linked scaffolded app calling getExecutionUser()/
getInitiatingUser() through /__dd/executeAction.
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-wire-local-execution branch from ff7de2b to 7c9ad51 Compare July 29, 2026 18:14
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.

1 participant