Skip to content

Commit 7e20d87

Browse files
committed
Migrate dev server API from workflows to app-builder queries
1 parent 4f85ed9 commit 7e20d87

File tree

2 files changed

+113
-46
lines changed

2 files changed

+113
-46
lines changed

packages/plugins/apps/src/backend/vite/dev-server.test.ts

Lines changed: 67 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ jest.mock('vite', () => ({
1414
build: (...args) => mockViteBuild(...args),
1515
}));
1616

17-
const WORKFLOW_ID = '380e7df1-729c-420c-b15e-a3b8e6347d49';
1817
const DD_SITE = 'datadoghq.com';
1918

2019
const mockFunctions = [
@@ -134,13 +133,13 @@ describe('Dev Server Middleware', () => {
134133

135134
// Mock the Datadog API via nock.
136135
const apiScope = nock(`https://${DD_SITE}`)
137-
.post(`/api/v2/workflows/${WORKFLOW_ID}/single_action_runs`)
138-
.reply(200, { data: { id: 'exec-123' } })
139-
.get(`/api/v2/workflows/${WORKFLOW_ID}/single_action_runs/exec-123`)
136+
.post('/api/v2/app-builder/queries/preview-async')
137+
.reply(200, { data: { id: 'receipt-123' } })
138+
.get('/api/v2/app-builder/queries/execution-long-polling/receipt-123')
140139
.reply(200, {
141140
data: {
142141
attributes: {
143-
state: 'SUCCEEDED',
142+
done: true,
144143
outputs: { result: 'hello' },
145144
},
146145
},
@@ -156,8 +155,8 @@ describe('Dev Server Middleware', () => {
156155
middleware(req, res, next);
157156
expect(next).not.toHaveBeenCalled();
158157

159-
// Wait for async handler + polling to complete.
160-
await new Promise((resolve) => setTimeout(resolve, 2000));
158+
// Wait for async handler to complete.
159+
await new Promise((resolve) => setTimeout(resolve, 100));
161160

162161
expect(res.statusCode).toBe(200);
163162
const body = JSON.parse(res.getBody());
@@ -263,7 +262,7 @@ describe('Dev Server Middleware', () => {
263262
mockViteBuild.mockResolvedValue(mockBuildResult('// code'));
264263

265264
nock(`https://${DD_SITE}`)
266-
.post(`/api/v2/workflows/${WORKFLOW_ID}/single_action_runs`)
265+
.post('/api/v2/app-builder/queries/preview-async')
267266
.reply(403, 'Forbidden');
268267

269268
const req = createMockRequest('/__dd/executeAction', {
@@ -290,11 +289,11 @@ describe('Dev Server Middleware', () => {
290289
'DD-APPLICATION-KEY': 'test-app-key',
291290
},
292291
})
293-
.post(`/api/v2/workflows/${WORKFLOW_ID}/single_action_runs`)
294-
.reply(200, { data: { id: 'exec-1' } })
295-
.get(`/api/v2/workflows/${WORKFLOW_ID}/single_action_runs/exec-1`)
292+
.post('/api/v2/app-builder/queries/preview-async')
293+
.reply(200, { data: { id: 'receipt-1' } })
294+
.get('/api/v2/app-builder/queries/execution-long-polling/receipt-1')
296295
.reply(200, {
297-
data: { attributes: { state: 'SUCCEEDED', outputs: { value: 42 } } },
296+
data: { attributes: { done: true, outputs: { value: 42 } } },
298297
});
299298

300299
const req = createMockRequest('/__dd/executeAction', {
@@ -304,13 +303,68 @@ describe('Dev Server Middleware', () => {
304303
const res = createMockResponse();
305304

306305
middleware(req, res, jest.fn());
307-
await new Promise((resolve) => setTimeout(resolve, 2000));
306+
await new Promise((resolve) => setTimeout(resolve, 100));
308307

309308
expect(res.statusCode).toBe(200);
310309
const body = JSON.parse(res.getBody());
311310
expect(body.success).toBe(true);
312311
expect(body.result).toEqual({ value: 42 });
313312
expect(apiScope.isDone()).toBe(true);
314313
});
314+
315+
test('Should handle errors array from long-polling endpoint', async () => {
316+
mockViteBuild.mockResolvedValue(mockBuildResult('// code'));
317+
318+
nock(`https://${DD_SITE}`)
319+
.post('/api/v2/app-builder/queries/preview-async')
320+
.reply(200, { data: { id: 'receipt-err' } })
321+
.get('/api/v2/app-builder/queries/execution-long-polling/receipt-err')
322+
.reply(200, {
323+
errors: [{ title: 'ExecutionFailed', detail: 'Script threw an error' }],
324+
});
325+
326+
const req = createMockRequest('/__dd/executeAction', {
327+
functionName: 'greet',
328+
args: [],
329+
});
330+
const res = createMockResponse();
331+
332+
middleware(req, res, jest.fn());
333+
await new Promise((resolve) => setTimeout(resolve, 100));
334+
335+
expect(res.statusCode).toBe(500);
336+
const body = JSON.parse(res.getBody());
337+
expect(body.success).toBe(false);
338+
expect(body.error).toContain('Script threw an error');
339+
});
340+
341+
test('Should retry when long-poll returns done: false', async () => {
342+
mockViteBuild.mockResolvedValue(mockBuildResult('// code'));
343+
344+
const apiScope = nock(`https://${DD_SITE}`)
345+
.post('/api/v2/app-builder/queries/preview-async')
346+
.reply(200, { data: { id: 'receipt-retry' } })
347+
.get('/api/v2/app-builder/queries/execution-long-polling/receipt-retry')
348+
.reply(200, { data: { attributes: { done: false } } })
349+
.get('/api/v2/app-builder/queries/execution-long-polling/receipt-retry')
350+
.reply(200, {
351+
data: { attributes: { done: true, outputs: { ok: true } } },
352+
});
353+
354+
const req = createMockRequest('/__dd/executeAction', {
355+
functionName: 'greet',
356+
args: [],
357+
});
358+
const res = createMockResponse();
359+
360+
middleware(req, res, jest.fn());
361+
await new Promise((resolve) => setTimeout(resolve, 100));
362+
363+
expect(res.statusCode).toBe(200);
364+
const body = JSON.parse(res.getBody());
365+
expect(body.success).toBe(true);
366+
expect(body.result).toEqual({ ok: true });
367+
expect(apiScope.isDone()).toBe(true);
368+
});
315369
});
316370
});

packages/plugins/apps/src/backend/vite/dev-server.ts

Lines changed: 46 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
/* eslint-disable no-await-in-loop */
66

77
import type { Logger } from '@dd/core/types';
8+
import { randomUUID } from 'crypto';
89
import type { IncomingMessage, ServerResponse } from 'http';
910
import type { RollupOutput } from 'rollup';
1011

@@ -16,9 +17,6 @@ import { generateDevVirtualEntryContent } from '../virtual-entry';
1617
// generate empty chunks when used as an input entry.
1718
const DEV_VIRTUAL_PREFIX = 'virtual:dd-backend-dev:';
1819

19-
// Hardcoded workflow ID for the development execution environment.
20-
const WORKFLOW_ID = '380e7df1-729c-420c-b15e-a3b8e6347d49';
21-
2220
interface ExecuteActionRequest {
2321
functionName: string;
2422
args?: unknown[];
@@ -136,14 +134,15 @@ async function bundleBackendFunction(
136134
}
137135

138136
/**
139-
* Execute a script via Datadog's single_action_runs API.
137+
* Execute a script via Datadog's app-builder queries API.
140138
*/
141139
async function executeScriptViaDatadog(
142140
scriptBody: string,
141+
functionName: string,
143142
auth: AuthConfig,
144143
log: Logger,
145144
): Promise<unknown> {
146-
const endpoint = `https://${auth.site}/api/v2/workflows/${WORKFLOW_ID}/single_action_runs`;
145+
const endpoint = `https://${auth.site}/api/v2/app-builder/queries/preview-async`;
147146

148147
log.debug(`Calling Datadog API: ${endpoint}`);
149148

@@ -156,10 +155,21 @@ async function executeScriptViaDatadog(
156155
},
157156
body: JSON.stringify({
158157
data: {
159-
type: 'single_action_runs',
158+
type: 'queries',
160159
attributes: {
161-
actionId: 'com.datadoghq.datatransformation.jsFunctionWithActions',
162-
inputs: { script: scriptBody, context: {} },
160+
query: {
161+
id: randomUUID(),
162+
name: functionName,
163+
type: 'action',
164+
properties: {
165+
spec: {
166+
fqn: 'com.datadoghq.datatransformation.jsFunctionWithActions',
167+
inputs: { script: scriptBody },
168+
},
169+
onlyTriggerManually: true,
170+
},
171+
},
172+
template_params: {},
163173
},
164174
},
165175
}),
@@ -171,31 +181,32 @@ async function executeScriptViaDatadog(
171181
}
172182

173183
const initialResult = (await response.json()) as { data?: { id?: string } };
174-
const executionId = initialResult.data?.id;
184+
const receiptId = initialResult.data?.id;
175185

176-
if (!executionId) {
177-
throw new Error('No execution ID returned from Datadog API');
186+
if (!receiptId) {
187+
throw new Error('No receipt ID returned from Datadog API');
178188
}
179189

180-
log.debug(`Action started with ID: ${executionId}`);
190+
log.debug(`Query execution started with receipt: ${receiptId}`);
181191

182-
return pollActionExecution(executionId, auth, log);
192+
return pollQueryExecution(receiptId, auth, log);
183193
}
184194

185195
/**
186-
* Poll Datadog API until the action execution completes or times out.
196+
* Long-poll Datadog API until the query execution completes or times out.
197+
* The server holds the connection open until the result is ready or its own timeout expires.
187198
*/
188-
async function pollActionExecution(
189-
executionId: string,
199+
async function pollQueryExecution(
200+
receiptId: string,
190201
auth: AuthConfig,
191202
log: Logger,
192203
): Promise<unknown> {
193-
const endpoint = `https://${auth.site}/api/v2/workflows/${WORKFLOW_ID}/single_action_runs/${executionId}`;
194-
const maxAttempts = 30;
195-
const pollInterval = 1000;
204+
const endpoint = `https://${auth.site}/api/v2/app-builder/queries/execution-long-polling/${receiptId}`;
205+
// Each long-poll request waits server-side (~30s). Max retries provides a safety net.
206+
const maxRetries = 10;
196207

197-
for (let attempt = 0; attempt < maxAttempts; attempt++) {
198-
log.debug(`Polling attempt ${attempt + 1}/${maxAttempts}...`);
208+
for (let attempt = 0; attempt < maxRetries; attempt++) {
209+
log.debug(`Long-poll attempt ${attempt + 1}/${maxRetries}...`);
199210

200211
const response = await fetch(endpoint, {
201212
method: 'GET',
@@ -212,25 +223,27 @@ async function pollActionExecution(
212223
}
213224

214225
const result = (await response.json()) as {
215-
data?: { attributes?: { state?: string; outputs?: unknown; error?: unknown } };
226+
data?: { attributes?: { done?: boolean; outputs?: unknown } };
227+
errors?: Array<{ detail?: string; title?: string }>;
216228
};
217-
const state = result.data?.attributes?.state;
218-
219-
log.debug(`Execution state: ${state}`);
220229

221-
if (state === 'SUCCEEDED') {
222-
return result.data!.attributes!.outputs;
230+
// Check for error responses.
231+
if (result.errors?.length) {
232+
const details = result.errors.map((e) => e.detail || e.title).join('; ');
233+
throw new Error(`Query execution failed: ${details}`);
223234
}
224235

225-
if (state === 'FAILED' || state === 'EXECUTION_FAILED') {
226-
const errorDetails = result.data?.attributes?.error || result.data?.attributes;
227-
throw new Error(`Action execution failed: ${JSON.stringify(errorDetails)}`);
236+
const attrs = result.data?.attributes;
237+
log.debug(`Long-poll response, done: ${attrs?.done}`);
238+
239+
if (attrs?.done) {
240+
return attrs.outputs;
228241
}
229242

230-
await new Promise((resolve) => setTimeout(resolve, pollInterval));
243+
// done === false means server-side long-poll timed out; retry immediately.
231244
}
232245

233-
throw new Error('Action execution timed out');
246+
throw new Error('Query execution timed out');
234247
}
235248

236249
/**
@@ -306,7 +319,7 @@ async function handleExecuteAction(
306319

307320
const scriptBody = await bundleBackendFunction(func, args, projectRoot, log);
308321

309-
const result = await executeScriptViaDatadog(scriptBody, auth, log);
322+
const result = await executeScriptViaDatadog(scriptBody, functionName, auth, log);
310323

311324
res.statusCode = 200;
312325
res.setHeader('Content-Type', 'application/json');

0 commit comments

Comments
 (0)