Skip to content
Merged
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
7 changes: 4 additions & 3 deletions packages/plugins/apps/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,14 +97,15 @@ When Datadog rejects a bundle as longer than the `jsFunctionWithActions` `script

## Local execution

`/__dd/executeAction` runs each backend function call the way production does: in a fresh Node process of its own. Calls run one at a time.
`/__dd/executeAction` runs each backend function call the way production does: in a fresh Node process of its own, in parallel with other calls.

- **Fresh state**: module-level variables and globals start over on every call.
- **Concurrency**: calls run in parallel, with no local limit, so a large burst starts that many processes. Production's concurrency limits vary by org, and `dev:verify` runs against them. A call that starts while another call's runtime context (`$`) query is in flight shares that query instead of sending its own.
- **Timeout**: a call that runs past its time budget is killed, even inside a synchronous loop.
- **Sandbox**: Node's [permission model](https://nodejs.org/api/permissions.html) lets a call read the project, its `node_modules` folders and the packages linked from them, Vite and Vite's [`server.fs.allow`](https://vite.dev/config/server-options#server-fs-allow) folders, and write only to its own temp dir (`os.tmpdir()`), which is also its working directory, and `/tmp`, so a project inside `/tmp` is writable too. A refused read or write throws `ERR_ACCESS_DENIED`, not `ENOENT`, so code that tolerates only a missing file fails locally. Modules loaded through Vite, though not `fs` reads or a native `require`, refuse files matching Vite's [`server.fs.deny`](https://vite.dev/config/server-options#server-fs-deny), such as `.env`, and, unless `server.fs.strict` is `false`, files outside those folders. It refuses subprocesses, worker threads and native addons, allows the network, and caps the heap at 256 MB. On a Node without [`module.registerHooks`](https://nodejs.org/api/module.html#moduleregisterhooksoptions), added in Node 22.15, modules get no `import.meta.resolve`, which Vite resolves on a worker thread there, and the dev server warns once. A known gap, from Node's [documented limitations](https://nodejs.org/api/permissions.html#limitations-and-known-issues): "Symbolic links will be followed even to locations outside of the set of paths that access has been granted to." Here, a symlink in a writable folder (the call's temp dir or `/tmp`) lets a call read or write wherever it points, project files included. Local execution is a parity aid, not a security boundary.
- **Environment**: a call sees no variables from the dev server's environment, keys included, except Node's TLS and time zone settings (`NODE_EXTRA_CA_CERTS`, `SSL_CERT_FILE`, `SSL_CERT_DIR`, `TZ`), plus `SystemRoot` on Windows, and no Node options from `NODE_OPTIONS`. Vite still inlines `import.meta.env` and `define` values into the code it serves. `$.Actions` calls go back to the dev server, which holds the credentials and checks each call's connection against the function's allowed connections.
- **Logs**: a call's `stdout` and `stderr` reach the dev server's terminal line by line, as each line ends.
- **Debugging**: when the dev server runs under a debugger (`--inspect`), each call's process listens on a random local port and prints its `Debugger listening on ws://…` URL. Attach to that URL to debug the call; calls don't time out while the dev server is under a debugger.
- **Logs**: a call's `stdout` and `stderr` reach the dev server's terminal line by line, each prefixed with its call, for example `[getGreeting#3]`.
- **Debugging**: when the dev server runs under a debugger (`--inspect`), each call's process listens on a random local port and prints its `Debugger listening on ws://…` URL with its prefix. Attach to that URL to debug the call; calls don't time out while the dev server is under a debugger.
- **Older Node**: on a Node without the permission model, or without `--allow-inspector` while debugging, a call's process runs under in-process guards instead. They refuse writes outside the temp dirs, subprocesses and worker threads, from the module's top-level code on.

Local execution doesn't support Yarn Plug'n'Play, whose packages load through hooks a call's process doesn't get: each call fails with a hint to set `nodeLinker: node-modules` or use `dev:verify`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,41 @@ describe('executeBackendFunction', () => {
);
});

it("should throw with the dev server's error, as a deployed app does", async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: false,
status: 500,
text: async () =>
JSON.stringify({ success: false, error: 'Execution failed: plain error x' }),
});

const error = await executeBackendFunction('testFunction', []).catch((e: unknown) => e);

expect(error).toBeInstanceOf(BackendFunctionError);
expect(error).toMatchObject({
message: 'Execution failed: plain error x',
functionName: 'testFunction',
statusCode: 500,
});
});

it.each([
['a non-string error', JSON.stringify({ success: false, error: 42 })],
['an empty error', JSON.stringify({ success: false, error: '' })],
['a success body', JSON.stringify({ success: true, error: 'unused' })],
['JSON that is not an object', 'null'],
])('should keep the status and raw body for %s', async (_label, body) => {
global.fetch = jest.fn().mockResolvedValue({
ok: false,
status: 500,
text: async () => body,
});

await expect(executeBackendFunction('testFunction', [])).rejects.toThrow(
`Backend function "testFunction" failed with status 500: ${body}`,
);
});

it('should throw BackendFunctionError when response contains error field', async () => {
const mockResponse = {
error: 'Function execution failed',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,26 @@ import { BackendFunctionError } from '../types';

const ENDPOINT = '/__dd/executeAction';

function parseErrorMessage(body: string): string | undefined {
try {
const parsed: unknown = JSON.parse(body);
if (
typeof parsed === 'object' &&
parsed !== null &&
'success' in parsed &&
parsed.success === false &&
'error' in parsed &&
typeof parsed.error === 'string' &&
parsed.error !== ''
) {
return parsed.error;
}
} catch {
return undefined;
}
return undefined;
}

export const devServerTransport: BackendFunctionTransport = async <TData>(
functionName: string,
args: unknown[],
Expand Down Expand Up @@ -39,7 +59,10 @@ export const devServerTransport: BackendFunctionTransport = async <TData>(
let errorMessage = `Backend function "${functionName}" failed with status ${response.status}`;
try {
const errorBody = await response.text();
if (errorBody) {
const parsedMessage = parseErrorMessage(errorBody);
if (parsedMessage !== undefined) {
errorMessage = parsedMessage;
Comment thread
tyffical marked this conversation as resolved.
} else if (errorBody) {
errorMessage += `: ${errorBody}`;
}
} catch {
Expand Down
205 changes: 184 additions & 21 deletions packages/plugins/apps/src/vite/dev-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ import type {
LocalExecutor,
} from '@dd/apps-plugin/vite/local-execution-process';
import { createChildProcessExecutor } from '@dd/apps-plugin/vite/local-execution-process';
import { DEFAULT_TIMEOUT_MS } from '@dd/apps-plugin/vite/local-execution';
import { FunctionThrewError } from '@dd/apps-plugin/vite/local-execution-shared';
import { DEFAULT_TIMEOUT_MS, withTimeout } from '@dd/apps-plugin/vite/local-execution';
import { rmSync } from '@dd/core/helpers/fs';
import type { AuthOptionsWithDefaults, RequestOpts } from '@dd/core/types';
import {
Expand Down Expand Up @@ -229,6 +230,14 @@ function postMessageExecutor(connectionId: string | undefined): LocalExecutor {
};
}

function createDeferred() {
let resolve: () => void = () => {};
const promise = new Promise<void>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}

const previewRuntimeContext = {
Source: {
initiator: { id: 'preview-initiator', orgId: 'preview-org' },
Expand Down Expand Up @@ -1548,6 +1557,67 @@ describe('Dev Server Middleware', () => {
);
});

test('Should send one runtime context query for calls that need it at the same time', async () => {
nock.cleanAll();
const hydration = mockRuntimeContextHydration(1);
const callCount = 3;
const allStarted = createDeferred();
let started = 0;
const executeLocally = jest.fn(async (request: LocalExecutionRequest) => {
started += 1;
if (started === callCount) {
allStarted.resolve();
}
await withTimeout(allStarted.promise, 5000, 'Waiting for every call to start');
const data = await request.getRuntimeContext();
return { data };
});
const childMiddleware = createTestMiddleware({ executeLocally });
const responses = Array.from({ length: callCount }, () => {
const req = createMockRequest('/__dd/executeAction', {
functionName: encodeQueryName(mockFunctions[0]),
args: [],
});
const res = createMockResponse();
childMiddleware(req, res, jest.fn());
return res;
});
const allDone = responses.map((res) => res.done);
await Promise.all(allDone);

for (const res of responses) {
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.getBody()).result).toEqual({
data: previewRuntimeContext,
});
}
expect(hydration.isDone()).toBe(true);
});

test('Should query the runtime context again for a call that starts after the last query ended', async () => {
nock.cleanAll();
const hydration = mockRuntimeContextHydration(2);
const executeLocally = jest.fn(async (request: LocalExecutionRequest) => {
const data = await request.getRuntimeContext();
return { data };
});
const childMiddleware = createTestMiddleware({ executeLocally });
const callAndWait = async () => {
const req = createMockRequest('/__dd/executeAction', {
functionName: encodeQueryName(mockFunctions[0]),
args: [],
});
const res = createMockResponse();
childMiddleware(req, res, jest.fn());
await res.done;
return res.statusCode;
};

expect(await callAndWait()).toBe(200);
expect(await callAndWait()).toBe(200);
expect(hydration.isDone()).toBe(true);
});

test("Should reject a malformed hydrated identity before starting a call's process without exposing the response body", async () => {
nock.cleanAll();
mockRuntimeContextHydration(1, {
Expand Down Expand Up @@ -1607,9 +1677,13 @@ describe('Dev Server Middleware', () => {
nock.cleanAll();
const executeLocally = createTestExecutor();
let hydrationSignal: AbortSignal | undefined;
const recoveringRuntimeContextRequest = makeImmediateRuntimeContextRequest();
const nonAbortableRuntimeContextRequest = <T>(
options: Omit<RequestOpts, 'auth'>,
) => {
if (hydrationSignal) {
return recoveringRuntimeContextRequest(options) as Promise<T>;
}
hydrationSignal = options.signal;
// Models doRequest sleeping in async-retry backoff: the signal is aborted,
// but the request promise does not settle until the retry timer wakes up.
Expand All @@ -1618,7 +1692,9 @@ describe('Dev Server Middleware', () => {
const hangingHydrationMiddleware = createDevServerMiddleware(
mockViteBuild,
() => mockFunctions,
async () => [],
async () => {
throw new Error('reached the connection lookup');
},
mockAuth,
nonAbortableRuntimeContextRequest,
mockLongPolling,
Expand Down Expand Up @@ -1648,31 +1724,15 @@ describe('Dev Server Middleware', () => {
expect(hydrationSignal?.aborted).toBe(true);
expect(forkMock).not.toHaveBeenCalled();

const recoveringRuntimeContextRequest = makeImmediateRuntimeContextRequest();
// Reaching the connection lookup shows the stalled call no longer holds the queue.
const recoveringMiddleware = createDevServerMiddleware(
mockViteBuild,
() => mockFunctions,
async () => {
throw new Error('reached the connection lookup');
},
mockAuth,
recoveringRuntimeContextRequest,
mockLongPolling,
'/project',
mockLog,
'development',
localOnlyHostCheck,
defaultServerConditions,
executeLocally,
);
// The same dev server reaching the connection lookup shows the timed-out query no
// longer holds the shared runtime context.
const recoveringReq = createMockRequest('/__dd/executeAction', {
functionName: encodeQueryName(mockFunctions[0]),
args: [],
});
const recoveringRes = createMockResponse();

recoveringMiddleware(recoveringReq, recoveringRes, jest.fn());
hangingHydrationMiddleware(recoveringReq, recoveringRes, jest.fn());
const recoveringDone = recoveringRes.done;
await jest.advanceTimersByTimeAsync(0);
await recoveringDone;
Expand Down Expand Up @@ -1894,6 +1954,109 @@ describe('Dev Server Middleware', () => {
);
});

describe('What a failed $.Actions call rejects with, as in a deployed app', () => {
const catchingPostMessage: LocalExecutor = async ({ executeAction }) => {
try {
await executeAction(
'com.datadoghq.slack.chat.postMessage',
{ text: 'hi' },
undefined,
);
return { data: 'no error' };
} catch (error) {
return { data: { type: typeof error, value: error } };
}
};
const runAndReadData = async () => {
const actionMiddleware = createTestMiddleware({
executeLocally: catchingPostMessage,
});
const req = createMockRequest('/__dd/executeAction', {
functionName: encodeQueryName(mockFunctions[0]),
args: [],
});
const res = createMockResponse();
actionMiddleware(req, res, jest.fn());
await res.done;
return JSON.parse(res.getBody()).result.data;
};
const mockActionErrors = (errors: Array<{ detail?: string; title?: string }>) =>
nock(DD_API_ORIGIN)
.post('/api/v2/app-builder/queries/preview-async')
.reply(200, { data: { id: 'receipt-action-failure' } })
.get(
'/api/v2/app-builder/queries/execution-long-polling/receipt-action-failure',
)
.reply(200, { errors });

test("Should reject with the platform's bare error detail as a string", async () => {
mockActionErrors([
{ detail: 'rpc error: code = NotFound desc = connection not found' },
]);

await expect(runAndReadData()).resolves.toEqual({
type: 'string',
value: 'rpc error: code = NotFound desc = connection not found',
});
});

test("Should join several of the platform's errors, using a title when there's no detail", async () => {
mockActionErrors([{ detail: 'first' }, { title: 'second' }]);

await expect(runAndReadData()).resolves.toEqual({
type: 'string',
value: 'first; second',
});
});

test('Should reject with a fallback message when no platform error has a detail or a title', async () => {
mockActionErrors([{}, { detail: '' }]);

await expect(runAndReadData()).resolves.toEqual({
type: 'string',
value: 'Query execution failed',
});
expect(mockLogFn).toHaveBeenCalledWith(
'$.Actions call to "com.datadoghq.slack.chat.postMessage" failed: Query execution failed',
'error',
);
});
});

describe('What an uncaught failure reports, as in a deployed app', () => {
const runAndReadError = async (failure: unknown) => {
const failingMiddleware = createTestMiddleware({
executeLocally: jest.fn().mockRejectedValue(failure),
});
const req = createMockRequest('/__dd/executeAction', {
functionName: encodeQueryName(mockFunctions[0]),
args: [],
});
const res = createMockResponse();
failingMiddleware(req, res, jest.fn());
await res.done;
return { statusCode: res.statusCode, error: JSON.parse(res.getBody()).error };
};

test('Should prefix what the function threw with "Execution failed: "', async () => {
const thrown = new FunctionThrewError('plain error x', 'TypeError: plain error x');

await expect(runAndReadError(thrown)).resolves.toEqual({
statusCode: 500,
error: 'Execution failed: TypeError: plain error x',
});
});

test("Should leave the dev server's own failures unprefixed", async () => {
await expect(
runAndReadError(new Error('"getGreeting" is not a function')),
).resolves.toEqual({
statusCode: 500,
error: '"getGreeting" is not a function',
});
});
});

// getAllowedConnectionIds only resolves and transforms modules, so it rejects a banned
// import before any of the entry's code runs.
test("Should never start a call's process when getAllowedConnectionIds rejects", async () => {
Expand Down
Loading
Loading