Use structured logs instead of strings in Preflight Script (#6404)

This commit is contained in:
Kamil Kisiela 2025-01-22 12:09:40 +01:00 committed by GitHub
parent cade7b34cb
commit 403a95e6f7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 141 additions and 134 deletions

View file

@ -76,7 +76,7 @@ describe('Preflight Script Modal', () => {
it('logs show console/error information', () => {
setEditorScript(script);
cy.dataCy('run-preflight-script').click();
cy.dataCy('console-output').should('contain', 'Log: Hello_world (Line: 1, Column: 1)');
cy.dataCy('console-output').should('contain', 'log: Hello_world (1:1)');
setEditorScript(
`console.info(1)
@ -87,16 +87,11 @@ throw new TypeError('Test')`,
cy.dataCy('run-preflight-script').click();
// First log previous log message
cy.dataCy('console-output').should('contain', 'Log: Hello_world (Line: 1, Column: 1)');
cy.dataCy('console-output').should('contain', 'log: Hello_world (1:1)');
// After the new logs
cy.dataCy('console-output').should(
'contain',
[
'Info: 1 (Line: 1, Column: 1)',
'Warn: true (Line: 2, Column: 1)',
'Error: Fatal (Line: 3, Column: 1)',
'TypeError: Test (Line: 4, Column: 7)',
].join(''),
['info: 1 (1:1)', 'warn: true (2:1)', 'error: Fatal (3:1)', 'error: Test (4:7)'].join(''),
);
});
@ -104,7 +99,7 @@ throw new TypeError('Test')`,
setEditorScript(script);
cy.dataCy('run-preflight-script').click();
cy.dataCy('console-output').should('contain', 'Log: Hello_world (Line: 1, Column: 1)');
cy.dataCy('console-output').should('contain', 'log: Hello_world (1:1)');
setEditorScript(
dedent`
@ -118,12 +113,12 @@ throw new TypeError('Test')`,
cy.dataCy('prompt').get('form').submit();
// First log previous log message
cy.dataCy('console-output').should('contain', 'Log: Hello_world (Line: 1, Column: 1)');
cy.dataCy('console-output').should('contain', 'log: Hello_world (1:1)');
// After the new logs
cy.dataCy('console-output').should(
'contain',
dedent`
Info: test-username (Line: 2, Column: 1)
info: test-username (2:1)
`,
);
});
@ -132,7 +127,7 @@ throw new TypeError('Test')`,
setEditorScript(script);
cy.dataCy('run-preflight-script').click();
cy.dataCy('console-output').should('contain', 'Log: Hello_world (Line: 1, Column: 1)');
cy.dataCy('console-output').should('contain', 'log: Hello_world (1:1)');
setEditorScript(
dedent`
@ -146,12 +141,12 @@ throw new TypeError('Test')`,
cy.dataCy('prompt').get('[data-cy="prompt-cancel"]').click();
// First log previous log message
cy.dataCy('console-output').should('contain', 'Log: Hello_world (Line: 1, Column: 1)');
cy.dataCy('console-output').should('contain', 'log: Hello_world (1:1)');
// After the new logs
cy.dataCy('console-output').should(
'contain',
dedent`
Info: null (Line: 2, Column: 1)
info: null (2:1)
`,
);
});
@ -170,10 +165,10 @@ throw new TypeError('Test')`,
it('`crypto-js` can be used for generating hashes', () => {
setEditorScript('console.log(lab.CryptoJS.SHA256("🐝"))');
cy.dataCy('run-preflight-script').click();
cy.dataCy('console-output').should('contain', 'Info: Using crypto-js version:');
cy.dataCy('console-output').should('contain', 'info: Using crypto-js version:');
cy.dataCy('console-output').should(
'contain',
'Log: d5b51e79e4be0c4f4d6b9a14e16ca864de96afe68459e60a794e80393a4809e8',
'log: d5b51e79e4be0c4f4d6b9a14e16ca864de96afe68459e60a794e80393a4809e8',
);
});
@ -339,12 +334,12 @@ describe('Execution', () => {
cy.get('#preflight-script-logs [data-cy="logs"]').should(
'contain',
[
'> start running script: > Start running script',
'info: 1 (Line: 1, Column: 1)',
'warn: true (Line: 2, Column: 1)',
'error: Fatal (Line: 3, Column: 1)',
'error: TypeError: Test (Line: 4, Column: 7)',
'> preflight script failed: > Preflight script failed',
'log: Running script...',
'info: 1 (1:1)',
'warn: true (2:1)',
'error: Fatal (3:1)',
'error: Test (4:7)',
'log: Script failed',
].join(''),
);
});
@ -375,15 +370,16 @@ describe('Execution', () => {
// it's because the button is not fully visible on the screen
force: true,
});
cy.get('#preflight-script-logs [data-cy="logs"]').should(
'contain',
[
'> start running script: > Start running script',
'info: 1 (Line: 1, Column: 1)',
'warn: true (Line: 2, Column: 1)',
'error: Fatal (Line: 3, Column: 1)',
'error: TypeError: Test (Line: 4, Column: 7)',
'> preflight script failed: > Preflight script failed',
'log: Running script...',
'info: 1 (1:1)',
'warn: true (2:1)',
'error: Fatal (3:1)',
'error: Test (4:7)',
'log: Script failed',
].join(''),
);

View file

@ -29,19 +29,12 @@ import { FragmentType, graphql, useFragment } from '@/gql';
import { useLocalStorage, useToggle } from '@/lib/hooks';
import { GraphiQLPlugin } from '@graphiql/react';
import { Editor as MonacoEditor, OnMount, type Monaco } from '@monaco-editor/react';
import {
Cross2Icon,
CrossCircledIcon,
ExclamationTriangleIcon,
InfoCircledIcon,
Pencil1Icon,
TriangleRightIcon,
} from '@radix-ui/react-icons';
import { Cross2Icon, InfoCircledIcon, Pencil1Icon, TriangleRightIcon } from '@radix-ui/react-icons';
import { captureException } from '@sentry/react';
import { useParams } from '@tanstack/react-router';
import { cn } from '../utils';
import labApiDefinitionRaw from './lab-api-declaration?raw';
import type { LogMessage } from './preflight-script-worker';
import { IFrameEvents } from './shared-types';
import { IFrameEvents, LogMessage } from './shared-types';
export const preflightScriptPlugin: GraphiQLPlugin = {
icon: () => (
@ -188,7 +181,13 @@ export function usePreflightScript(args: {
const id = crypto.randomUUID();
setState(PreflightWorkerState.running);
const now = Date.now();
setLogs(prev => [...prev, '> Start running script']);
setLogs(prev => [
...prev,
{
level: 'log',
message: 'Running script...',
},
]);
try {
const contentWindow = iframeRef.current?.contentWindow;
@ -270,7 +269,10 @@ export function usePreflightScript(args: {
latestEnvironmentVariablesRef.current = mergedEnvironmentVariables;
setLogs(logs => [
...logs,
`> End running script. Done in ${(Date.now() - now) / 1000}s`,
{
level: 'log',
message: `Done in ${(Date.now() - now) / 1000}s`,
},
{
type: 'separator' as const,
},
@ -283,8 +285,16 @@ export function usePreflightScript(args: {
const error = ev.data.error;
setLogs(logs => [
...logs,
error,
'> Preflight script failed',
{
level: 'error',
message: error.message,
line: error.line,
column: error.column,
},
{
level: 'log',
message: 'Script failed',
},
{
type: 'separator' as const,
},
@ -323,8 +333,14 @@ export function usePreflightScript(args: {
if (err instanceof Error) {
setLogs(prev => [
...prev,
err,
'> Preflight script failed',
{
level: 'error',
message: err.message,
},
{
level: 'log',
message: 'Script failed',
},
{
type: 'separator' as const,
},
@ -687,43 +703,12 @@ function PreflightScriptModal({
</div>
<section
ref={consoleRef}
className='h-1/2 overflow-hidden overflow-y-scroll bg-[#10151f] py-2.5 pl-[26px] pr-2.5 font-[Menlo,Monaco,"Courier_New",monospace] text-xs/[18px]'
className="h-1/2 overflow-hidden overflow-y-scroll bg-[#10151f] py-2.5 pl-[26px] pr-2.5 font-mono text-xs/[18px]"
data-cy="console-output"
>
{logs.map((log, index) => {
let type = '';
if (log instanceof Error) {
type = 'error';
log = `${log.name}: ${log.message}`;
}
if (typeof log === 'string') {
type ||= log.split(':')[0].toLowerCase();
const ComponentToUse = {
error: CrossCircledIcon,
warn: ExclamationTriangleIcon,
info: InfoCircledIcon,
}[type];
return (
<div
key={index}
className={clsx(
'relative',
{
error: 'text-red-500',
warn: 'text-yellow-500',
info: 'text-green-500',
}[type],
)}
>
{ComponentToUse && <ComponentToUse className={classes.icon} />}
{log}
</div>
);
}
return <hr key={index} className="my-2 border-dashed border-current" />;
})}
{logs.map((log, index) => (
<LogLine key={index} log={log} />
))}
</section>
<EditorTitle className="flex gap-2 p-2">
Environment Variables
@ -768,3 +753,30 @@ function PreflightScriptModal({
</Dialog>
);
}
const LOG_COLORS = {
error: 'text-red-400',
info: 'text-emerald-400',
warn: 'text-yellow-400',
log: 'text-gray-400',
};
export function LogLine({ log }: { log: LogRecord }) {
if ('type' in log && log.type === 'separator') {
return <hr className="my-2 border-dashed border-current" />;
}
if ('level' in log && log.level in LOG_COLORS) {
return (
<div className={LOG_COLORS[log.level]}>
{log.level}: {log.message}
{log.line && log.column ? ` (${log.line}:${log.column})` : ''}
</div>
);
}
captureException(new Error('Unexpected log type in Preflight Script output'), {
extra: { log },
});
return null;
}

View file

@ -2,9 +2,7 @@ import CryptoJS from 'crypto-js';
import CryptoJSPackageJson from 'crypto-js/package.json';
import { ALLOWED_GLOBALS } from './allowed-globals';
import { isJSONPrimitive } from './json';
import { WorkerEvents } from './shared-types';
export type LogMessage = string | Error;
import { LogMessage, WorkerEvents } from './shared-types';
/**
* Unique id for each prompt request.
@ -79,13 +77,22 @@ async function execute(args: WorkerEvents.Incoming.EventData): Promise<void> {
(level: 'log' | 'warn' | 'error' | 'info') =>
(...args: unknown[]) => {
console[level](...args);
let message = `${level.charAt(0).toUpperCase()}${level.slice(1)}: ${args.map(String).join(' ')}`;
message += appendLineAndColumn(new Error(), {
const message = args.map(String).join(' ');
const { line, column } = readLineAndColumn(new Error(), {
columnOffset: 'console.'.length,
});
// The messages should be streamed to the main thread as they occur not gathered and send to
// the main thread at the end of the execution of the preflight script
postMessage({ type: 'log', message });
// const message: LogMessage = { level, message };
postMessage({
type: 'log',
message: {
level,
message,
line,
column,
} satisfies LogMessage,
});
};
function getValidEnvVariable(value: unknown) {
@ -161,10 +168,15 @@ ${script}})()`;
'undefined',
)(labApi, consoleApi);
} catch (error) {
if (error instanceof Error) {
error.message += appendLineAndColumn(error);
}
sendMessage({ type: WorkerEvents.Outgoing.Event.error, error: error as Error });
const { line, column } = error instanceof Error ? readLineAndColumn(error) : {};
sendMessage({
type: WorkerEvents.Outgoing.Event.error,
error: {
message: error instanceof Error ? error.message : String(error),
line,
column,
},
});
return;
}
sendMessage({
@ -173,11 +185,14 @@ ${script}})()`;
});
}
function appendLineAndColumn(error: Error, { columnOffset = 0 } = {}): string {
function readLineAndColumn(error: Error, { columnOffset = 0 } = {}) {
const regex = /<anonymous>:(?<line>\d+):(?<column>\d+)/; // Regex to match the line and column numbers
const { line, column } = error.stack?.match(regex)?.groups || {};
return ` (Line: ${Number(line) - 3}, Column: ${Number(column) - columnOffset})`;
return {
line: Number(line) - 3,
column: Number(column) - columnOffset,
};
}
sendMessage({ type: WorkerEvents.Outgoing.Event.ready });

View file

@ -71,9 +71,9 @@ function handleEvent(data: IFrameEvents.Incoming.EventData) {
postMessage({
type: IFrameEvents.Outgoing.Event.error,
runId,
error: new Error(
`Preflight script execution timed out after ${PREFLIGHT_TIMEOUT / 1000} seconds`,
),
error: {
message: `Preflight script execution timed out after ${PREFLIGHT_TIMEOUT / 1000} seconds`,
},
});
terminate();
}, PREFLIGHT_TIMEOUT);
@ -141,7 +141,9 @@ function handleEvent(data: IFrameEvents.Incoming.EventData) {
postMessage({
type: IFrameEvents.Outgoing.Event.error,
runId,
error: error as Error,
error: {
message: error instanceof Error ? error.message : String(error),
},
});
terminate();
}

View file

@ -2,6 +2,19 @@
type _MessageEvent<T> = MessageEvent<T>;
export type LogMessage = {
level: 'log' | 'warn' | 'error' | 'info';
message: string;
line?: number;
column?: number;
};
export type ErrorMessage = {
message: string;
line?: number;
column?: number;
};
export namespace IFrameEvents {
export namespace Outgoing {
export const enum Event {
@ -25,7 +38,7 @@ export namespace IFrameEvents {
type LogEventData = {
type: Event.log;
runId: string;
log: string | Error;
log: LogMessage;
};
type ResultEventData = {
@ -37,7 +50,7 @@ export namespace IFrameEvents {
type ErrorEventData = {
type: Event.error;
runId: string;
error: Error;
error: ErrorMessage;
};
type PromptEventData = {
@ -100,8 +113,8 @@ export namespace WorkerEvents {
prompt = 'prompt',
}
type LogEventData = { type: Event.log; message: string };
type ErrorEventData = { type: Event.error; error: Error };
type LogEventData = { type: Event.log; message: LogMessage };
type ErrorEventData = { type: Event.error; error: ErrorMessage };
type PromptEventData = {
type: Event.prompt;
promptId: number;

View file

@ -36,6 +36,7 @@ import { useSyncOperationState } from '@/lib/hooks/laboratory/use-sync-operation
import { useOperationFromQueryString } from '@/lib/hooks/laboratory/useOperationFromQueryString';
import { useResetState } from '@/lib/hooks/use-reset-state';
import {
LogLine,
LogRecord,
preflightScriptPlugin,
PreflightScriptProvider,
@ -57,7 +58,6 @@ import 'graphiql/style.css';
import '@graphiql/plugin-explorer/style.css';
import { PromptManager, PromptProvider } from '@/components/ui/prompt';
import { useRedirect } from '@/lib/access/common';
import { captureException } from '@sentry/react';
const explorer = explorerPlugin();
@ -702,13 +702,6 @@ function PreflightScriptLogs(props: { logs: LogRecord[]; onClear: () => void })
consoleEl?.scroll({ top: consoleEl.scrollHeight, behavior: 'smooth' });
}, [props.logs, isOpen]);
const logColor = {
error: 'text-red-400',
info: 'text-emerald-400',
warn: 'text-yellow-400',
log: '', // default
};
return (
<Collapsible
open={isOpen}
@ -768,33 +761,9 @@ function PreflightScriptLogs(props: { logs: LogRecord[]; onClear: () => void })
</div>
) : (
<>
{props.logs.map((log, index) => {
if (typeof log !== 'string' && 'type' in log && log.type === 'separator') {
return <hr key={index} className="my-2 border-dashed border-current" />;
}
let logType: 'error' | 'warn' | 'info' | 'log' = 'log';
let logMessage = '';
if (log instanceof Error) {
logType = 'error';
logMessage = `${log.name}: ${log.message}`;
} else if (typeof log === 'string') {
logType = log.split(':')[0].toLowerCase() as 'error' | 'warn' | 'info' | 'log';
logMessage = log.substring(log.indexOf(':') + 1).trim();
} else {
captureException(new Error('Unexpected log type in Preflight Script Logs'), {
extra: { log },
});
return null;
}
return (
<div key={index} className={logColor[logType] ?? ''}>
{logType}: {logMessage}
</div>
);
})}
{props.logs.map((log, index) => (
<LogLine key={index} log={log} />
))}
</>
)}
</CollapsibleContent>