Add console logs to code step (#10786)

Works for local and lambda drivers

## After

![image](https://github.com/user-attachments/assets/ec082cf6-4159-4a15-99b6-243c73a13773)

![image](https://github.com/user-attachments/assets/f42e3f43-5ea4-4167-b4b5-9a17826fd224)
This commit is contained in:
martmull
2025-03-12 10:40:59 +01:00
committed by GitHub
parent 4d0450069c
commit 4036933c84
11 changed files with 189 additions and 9 deletions

View File

@ -36,7 +36,7 @@ const documents = {
"\n fragment ServerlessFunctionFields on ServerlessFunction {\n id\n name\n description\n runtime\n timeoutSeconds\n syncStatus\n latestVersion\n latestVersionInputSchema\n publishedVersions\n createdAt\n updatedAt\n }\n": types.ServerlessFunctionFieldsFragmentDoc,
"\n \n mutation CreateOneServerlessFunctionItem(\n $input: CreateServerlessFunctionInput!\n ) {\n createOneServerlessFunction(input: $input) {\n ...ServerlessFunctionFields\n }\n }\n": types.CreateOneServerlessFunctionItemDocument,
"\n \n mutation DeleteOneServerlessFunction($input: ServerlessFunctionIdInput!) {\n deleteOneServerlessFunction(input: $input) {\n ...ServerlessFunctionFields\n }\n }\n": types.DeleteOneServerlessFunctionDocument,
"\n mutation ExecuteOneServerlessFunction(\n $input: ExecuteServerlessFunctionInput!\n ) {\n executeOneServerlessFunction(input: $input) {\n data\n duration\n status\n error\n }\n }\n": types.ExecuteOneServerlessFunctionDocument,
"\n mutation ExecuteOneServerlessFunction(\n $input: ExecuteServerlessFunctionInput!\n ) {\n executeOneServerlessFunction(input: $input) {\n data\n logs\n duration\n status\n error\n }\n }\n": types.ExecuteOneServerlessFunctionDocument,
"\n \n mutation PublishOneServerlessFunction(\n $input: PublishServerlessFunctionInput!\n ) {\n publishServerlessFunction(input: $input) {\n ...ServerlessFunctionFields\n }\n }\n": types.PublishOneServerlessFunctionDocument,
"\n \n mutation UpdateOneServerlessFunction($input: UpdateServerlessFunctionInput!) {\n updateOneServerlessFunction(input: $input) {\n ...ServerlessFunctionFields\n }\n }\n": types.UpdateOneServerlessFunctionDocument,
"\n query FindManyAvailablePackages($input: ServerlessFunctionIdInput!) {\n getAvailablePackages(input: $input)\n }\n": types.FindManyAvailablePackagesDocument,
@ -154,7 +154,7 @@ export function graphql(source: "\n \n mutation DeleteOneServerlessFunction($i
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n mutation ExecuteOneServerlessFunction(\n $input: ExecuteServerlessFunctionInput!\n ) {\n executeOneServerlessFunction(input: $input) {\n data\n duration\n status\n error\n }\n }\n"): (typeof documents)["\n mutation ExecuteOneServerlessFunction(\n $input: ExecuteServerlessFunctionInput!\n ) {\n executeOneServerlessFunction(input: $input) {\n data\n duration\n status\n error\n }\n }\n"];
export function graphql(source: "\n mutation ExecuteOneServerlessFunction(\n $input: ExecuteServerlessFunctionInput!\n ) {\n executeOneServerlessFunction(input: $input) {\n data\n logs\n duration\n status\n error\n }\n }\n"): (typeof documents)["\n mutation ExecuteOneServerlessFunction(\n $input: ExecuteServerlessFunctionInput!\n ) {\n executeOneServerlessFunction(input: $input) {\n data\n logs\n duration\n status\n error\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/

File diff suppressed because one or more lines are too long

View File

@ -45,6 +45,7 @@ export const useTestServerlessFunction = ({
4,
)
: undefined,
logs: result?.data?.executeOneServerlessFunction?.logs || '',
duration: result?.data?.executeOneServerlessFunction?.duration,
status: result?.data?.executeOneServerlessFunction?.status,
error: result?.data?.executeOneServerlessFunction?.error

View File

@ -6,6 +6,7 @@ export const EXECUTE_ONE_SERVERLESS_FUNCTION = gql`
) {
executeOneServerlessFunction(input: $input) {
data
logs
duration
status
error

View File

@ -14,6 +14,7 @@ export type TextAreaProps = {
label?: string;
disabled?: boolean;
minRows?: number;
maxRows?: number;
onChange?: (value: string) => void;
placeholder?: string;
value?: string;
@ -73,12 +74,13 @@ export const TextArea = ({
disabled,
placeholder,
minRows = 1,
maxRows = MAX_ROWS,
value = '',
className,
onChange,
onBlur,
}: TextAreaProps) => {
const computedMinRows = Math.min(minRows, MAX_ROWS);
const computedMinRows = Math.min(minRows, maxRows);
const inputId = useId();
@ -103,7 +105,7 @@ export const TextArea = ({
<StyledTextArea
id={inputId}
placeholder={placeholder}
maxRows={MAX_ROWS}
maxRows={maxRows}
minRows={computedMinRows}
value={value}
onChange={(event) =>

View File

@ -5,6 +5,7 @@ export type ServerlessFunctionTestData = {
input: { [field: string]: any };
output: {
data?: string;
logs: string;
duration?: number;
status?: ServerlessFunctionExecutionStatus;
error?: string;
@ -15,6 +16,7 @@ export type ServerlessFunctionTestData = {
export const DEFAULT_OUTPUT_VALUE = {
data: 'Enter an input above then press "Test"',
logs: '',
status: ServerlessFunctionExecutionStatus.IDLE,
};

View File

@ -37,6 +37,7 @@ import { useRecoilState, useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared';
import { CodeEditor, IconCode, IconPlayerPlay, useIcons } from 'twenty-ui';
import { useDebouncedCallback } from 'use-debounce';
import { TextArea } from '@/ui/input/components/TextArea';
const StyledContainer = styled.div`
display: flex;
@ -340,6 +341,16 @@ export const WorkflowEditActionFormServerlessFunction = ({
isTesting={isTesting}
/>
</StyledCodeEditorContainer>
<StyledCodeEditorContainer>
<InputLabel>Logs</InputLabel>
<TextArea
value={
isTesting ? '' : serverlessFunctionTestData.output.logs
}
maxRows={20}
disabled
/>
</StyledCodeEditorContainer>
</>
)}
</WorkflowStepBody>

View File

@ -10,6 +10,7 @@ export type ServerlessExecuteError = {
export type ServerlessExecuteResult = {
data: object | null;
duration: number;
logs: string;
status: ServerlessFunctionExecutionStatus;
error?: ServerlessExecuteError;
};

View File

@ -17,6 +17,7 @@ import {
PublishLayerVersionCommandInput,
ResourceNotFoundException,
waitUntilFunctionUpdatedV2,
LogType,
} from '@aws-sdk/client-lambda';
import { AssumeRoleCommand, STSClient } from '@aws-sdk/client-sts';
import { isDefined } from 'twenty-shared';
@ -262,6 +263,21 @@ export class LambdaDriver implements ServerlessDriver {
await lambdaBuildDirectoryManager.clean();
}
private extractLogs(logString: string): string {
const formattedLogString = Buffer.from(logString, 'base64')
.toString('utf8')
.split('\t')
.join(' ');
return formattedLogString
.replace(/^(START|END|REPORT).*\n?/gm, '')
.replace(
/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z) [a-f0-9-]+ INFO /gm,
'$1 INFO ',
)
.trim();
}
async execute(
serverlessFunction: ServerlessFunctionEntity,
payload: object,
@ -302,6 +318,7 @@ export class LambdaDriver implements ServerlessDriver {
const params: InvokeCommandInput = {
FunctionName: serverlessFunction.id,
Payload: JSON.stringify(executorPayload),
LogType: LogType.Tail,
};
const command = new InvokeCommand(params);
@ -313,6 +330,8 @@ export class LambdaDriver implements ServerlessDriver {
? JSON.parse(result.Payload.transformToString())
: {};
const logs = result.LogResult ? this.extractLogs(result.LogResult) : '';
const duration = Date.now() - startTime;
if (result.FunctionError) {
@ -321,11 +340,13 @@ export class LambdaDriver implements ServerlessDriver {
duration,
status: ServerlessFunctionExecutionStatus.ERROR,
error: parsedResult,
logs,
};
}
return {
data: parsedResult,
logs,
duration,
status: ServerlessFunctionExecutionStatus.SUCCESS,
};

View File

@ -1,3 +1,4 @@
/* eslint-disable no-console */
import { promises as fs } from 'fs';
import { join } from 'path';
@ -129,6 +130,55 @@ export class LocalDriver implements ServerlessDriver {
}
}
const originalConsole = {
log: console.log,
error: console.error,
warn: console.warn,
info: console.info,
debug: console.debug,
};
const interceptConsole = (
callback: (type: string, message: any[]) => void,
) => {
Object.keys(originalConsole).forEach((method) => {
console[method] = (...args: any[]) => {
callback(method, args);
};
});
};
let logs = '';
interceptConsole((type, args) => {
const formattedArgs = args.map((arg) => {
if (typeof arg === 'object' && arg !== null) {
const seen = new WeakSet();
return JSON.stringify(
arg,
(key, value) => {
if (typeof value === 'object' && value !== null) {
if (seen.has(value)) {
return '[Circular]'; // Handle circular references
}
seen.add(value);
}
return value;
},
2,
);
}
return arg;
});
const formattedType = type === 'log' ? 'info' : type;
logs += `${new Date().toISOString()} ${formattedType.toUpperCase()} ${formattedArgs.join(' ')}\n`;
});
try {
const mainFile = await import(compiledCodeFilePath);
@ -141,12 +191,14 @@ export class LocalDriver implements ServerlessDriver {
return {
data: result,
logs,
duration,
status: ServerlessFunctionExecutionStatus.SUCCESS,
};
} catch (error) {
return {
data: null,
logs,
duration: Date.now() - startTime,
error: {
errorType: 'UnhandledError',
@ -156,6 +208,12 @@ export class LocalDriver implements ServerlessDriver {
status: ServerlessFunctionExecutionStatus.ERROR,
};
} finally {
// Restoring originalConsole
Object.keys(originalConsole).forEach((method) => {
console[method] = (...args: any[]) => {
originalConsole[method](...args);
};
});
await fs.rm(compiledCodeFolderPath, { recursive: true, force: true });
}
}

View File

@ -23,6 +23,9 @@ export class ServerlessFunctionExecutionResultDTO {
})
data?: JSON;
@Field({ description: 'Execution Logs' })
logs: string;
@Field({ description: 'Execution duration in milliseconds' })
duration: number;