feat: Refactor SyntheticMonitor to use Playwright for browser sessions and enhance error handling

This commit is contained in:
Nawaz Dhandala
2026-02-27 16:40:15 +00:00
parent 8fbc003a30
commit d797e46f27

View File

@@ -1,12 +1,16 @@
import { PROBE_SYNTHETIC_MONITOR_SCRIPT_TIMEOUT_IN_MS } from "../../../Config";
import ProxyConfig from "../../ProxyConfig";
import SyntheticMonitorSemaphore from "../../SyntheticMonitorSemaphore";
import SyntheticMonitorWorkerPool from "../../SyntheticMonitorWorkerPool";
import BadDataException from "Common/Types/Exception/BadDataException";
import ReturnResult from "Common/Types/IsolatedVM/ReturnResult";
import BrowserType from "Common/Types/Monitor/SyntheticMonitors/BrowserType";
import ScreenSizeType from "Common/Types/Monitor/SyntheticMonitors/ScreenSizeType";
import SyntheticMonitorResponse from "Common/Types/Monitor/SyntheticMonitors/SyntheticMonitorResponse";
import ObjectID from "Common/Types/ObjectID";
import logger from "Common/Server/Utils/Logger";
import VMRunner from "Common/Server/Utils/VM/VMRunner";
import { Browser, BrowserContext, Page, chromium, firefox } from "playwright";
import LocalFile from "Common/Server/Utils/LocalFile";
import os from "os";
export interface SyntheticMonitorOptions {
monitorId?: ObjectID | undefined;
@@ -16,26 +20,24 @@ export interface SyntheticMonitorOptions {
retryCountOnError?: number | undefined;
}
interface WorkerConfig {
script: string;
browserType: BrowserType;
screenSizeType: ScreenSizeType;
timeout: number;
proxy?:
| {
server: string;
username?: string | undefined;
password?: string | undefined;
}
| undefined;
interface BrowserLaunchOptions {
executablePath?: string;
proxy?: {
server: string;
username?: string;
password?: string;
bypass?: string;
};
args?: string[];
headless?: boolean;
devtools?: boolean;
timeout?: number;
}
interface WorkerResult {
logMessages: string[];
scriptError?: string | undefined;
result?: unknown | undefined;
screenshots: Record<string, string>;
executionTimeInMS: number;
interface BrowserSession {
browser: Browser;
context: BrowserContext;
page: Page;
}
export default class SyntheticMonitor {
@@ -56,7 +58,6 @@ export default class SyntheticMonitor {
browserType: browserType,
screenSizeType: screenSizeType,
retryCountOnError: options.retryCountOnError || 0,
monitorId: options.monitorId,
});
if (result) {
@@ -76,58 +77,8 @@ export default class SyntheticMonitor {
screenSizeType: ScreenSizeType;
retryCountOnError: number;
currentRetry?: number;
monitorId?: ObjectID | undefined;
}): Promise<SyntheticMonitorResponse | null> {
const maxRetries: number = options.retryCountOnError;
const monitorIdStr: string | undefined =
options.monitorId?.toString() || undefined;
// Acquire semaphore once for all retries so retries reuse the same slot
let acquired: boolean = false;
try {
acquired = await SyntheticMonitorSemaphore.acquire(monitorIdStr);
} catch (err: unknown) {
logger.error(
`Synthetic monitor semaphore acquire failed: ${(err as Error)?.message}`,
);
return {
logMessages: [],
scriptError: (err as Error)?.message || (err as Error).toString(),
result: undefined,
screenshots: {},
executionTimeInMS: 0,
browserType: options.browserType,
screenSizeType: options.screenSizeType,
};
}
if (!acquired) {
// This monitor is already running or queued — skip duplicate execution
return null;
}
try {
return await this.executeWithRetryInner({
script: options.script,
browserType: options.browserType,
screenSizeType: options.screenSizeType,
retryCountOnError: maxRetries,
currentRetry: options.currentRetry || 0,
});
} finally {
SyntheticMonitorSemaphore.release(monitorIdStr);
}
}
private static async executeWithRetryInner(options: {
script: string;
browserType: BrowserType;
screenSizeType: ScreenSizeType;
retryCountOnError: number;
currentRetry: number;
}): Promise<SyntheticMonitorResponse | null> {
const currentRetry: number = options.currentRetry;
const currentRetry: number = options.currentRetry || 0;
const maxRetries: number = options.retryCountOnError;
const result: SyntheticMonitorResponse | null =
@@ -148,7 +99,7 @@ export default class SyntheticMonitor {
setTimeout(resolve, 1000);
});
return this.executeWithRetryInner({
return this.executeWithRetry({
script: options.script,
browserType: options.browserType,
screenSizeType: options.screenSizeType,
@@ -160,42 +111,13 @@ export default class SyntheticMonitor {
return result;
}
private static getProxyConfig(): WorkerConfig["proxy"] | undefined {
if (!ProxyConfig.isProxyConfigured()) {
return undefined;
}
const httpsProxyUrl: string | null = ProxyConfig.getHttpsProxyUrl();
const httpProxyUrl: string | null = ProxyConfig.getHttpProxyUrl();
const proxyUrl: string | null = httpsProxyUrl || httpProxyUrl;
if (!proxyUrl) {
return undefined;
}
const proxyConfig: WorkerConfig["proxy"] = {
server: proxyUrl,
};
try {
const parsedUrl: globalThis.URL = new URL(proxyUrl);
if (parsedUrl.username && parsedUrl.password) {
proxyConfig.username = parsedUrl.username;
proxyConfig.password = parsedUrl.password;
}
} catch (error) {
logger.warn(`Failed to parse proxy URL for authentication: ${error}`);
}
return proxyConfig;
}
private static async executeByBrowserAndScreenSize(options: {
script: string;
browserType: BrowserType;
screenSizeType: ScreenSizeType;
}): Promise<SyntheticMonitorResponse | null> {
if (!options) {
// this should never happen
options = {
script: "",
browserType: BrowserType.Chromium,
@@ -213,31 +135,385 @@ export default class SyntheticMonitor {
screenSizeType: options.screenSizeType,
};
const timeout: number = PROBE_SYNTHETIC_MONITOR_SCRIPT_TIMEOUT_IN_MS;
const workerConfig: WorkerConfig = {
script: options.script,
browserType: options.browserType,
screenSizeType: options.screenSizeType,
timeout: timeout,
proxy: this.getProxyConfig(),
};
let browserSession: BrowserSession | null = null;
try {
const workerResult: WorkerResult =
await SyntheticMonitorWorkerPool.execute(workerConfig, timeout);
let result: ReturnResult | null = null;
scriptResult.logMessages = workerResult.logMessages;
scriptResult.scriptError = workerResult.scriptError;
scriptResult.result = workerResult.result as typeof scriptResult.result;
scriptResult.screenshots = workerResult.screenshots;
scriptResult.executionTimeInMS = workerResult.executionTimeInMS;
const startTime: [number, number] = process.hrtime();
browserSession = await SyntheticMonitor.getPageByBrowserType({
browserType: options.browserType,
screenSizeType: options.screenSizeType,
});
if (!browserSession) {
throw new BadDataException(
"Could not create Playwright browser session",
);
}
result = await VMRunner.runCodeInSandbox({
code: options.script,
options: {
timeout: PROBE_SYNTHETIC_MONITOR_SCRIPT_TIMEOUT_IN_MS,
args: {},
context: {
browser: browserSession.browser,
page: browserSession.page,
screenSizeType: options.screenSizeType,
browserType: options.browserType,
},
},
});
const endTime: [number, number] = process.hrtime(startTime);
const executionTimeInMS: number = Math.ceil(
(endTime[0] * 1000000000 + endTime[1]) / 1000000,
);
scriptResult.executionTimeInMS = executionTimeInMS;
scriptResult.logMessages = result.logMessages;
if (result.returnValue?.screenshots) {
if (!scriptResult.screenshots) {
scriptResult.screenshots = {};
}
for (const screenshotName in result.returnValue.screenshots) {
if (!result.returnValue.screenshots[screenshotName]) {
continue;
}
// check if this is of type Buffer. If it is not, continue.
if (
!(result.returnValue.screenshots[screenshotName] instanceof Buffer)
) {
continue;
}
const screenshotBuffer: Buffer = result.returnValue.screenshots[
screenshotName
] as Buffer;
scriptResult.screenshots[screenshotName] =
screenshotBuffer.toString("base64"); // convert screenshots to base 64
}
}
scriptResult.result = result?.returnValue?.data;
} catch (err: unknown) {
logger.error(err);
scriptResult.scriptError =
(err as Error)?.message || (err as Error).toString();
} finally {
// Always dispose browser session to prevent zombie processes
await SyntheticMonitor.disposeBrowserSession(browserSession);
}
return scriptResult;
}
private static getViewportHeightAndWidth(options: {
screenSizeType: ScreenSizeType;
}): {
height: number;
width: number;
} {
let viewPortHeight: number = 0;
let viewPortWidth: number = 0;
switch (options.screenSizeType) {
case ScreenSizeType.Desktop:
viewPortHeight = 1080;
viewPortWidth = 1920;
break;
case ScreenSizeType.Mobile:
viewPortHeight = 640;
viewPortWidth = 360;
break;
case ScreenSizeType.Tablet:
viewPortHeight = 768;
viewPortWidth = 1024;
break;
default:
viewPortHeight = 1080;
viewPortWidth = 1920;
break;
}
return { height: viewPortHeight, width: viewPortWidth };
}
private static getPlaywrightBrowsersPath(): string {
return (
process.env["PLAYWRIGHT_BROWSERS_PATH"] ||
`${os.homedir()}/.cache/ms-playwright`
);
}
public static async getChromeExecutablePath(): Promise<string> {
const browsersPath: string = this.getPlaywrightBrowsersPath();
const doesDirectoryExist: boolean =
await LocalFile.doesDirectoryExist(browsersPath);
if (!doesDirectoryExist) {
throw new BadDataException("Chrome executable path not found.");
}
// get list of files in the directory
const directories: string[] =
await LocalFile.getListOfDirectories(browsersPath);
if (directories.length === 0) {
throw new BadDataException("Chrome executable path not found.");
}
const chromeInstallationName: string | undefined = directories.find(
(directory: string) => {
return directory.includes("chromium");
},
);
if (!chromeInstallationName) {
throw new BadDataException("Chrome executable path not found.");
}
const chromeExecutableCandidates: Array<string> = [
`${browsersPath}/${chromeInstallationName}/chrome-linux/chrome`,
`${browsersPath}/${chromeInstallationName}/chrome-linux64/chrome`,
`${browsersPath}/${chromeInstallationName}/chrome64/chrome`,
`${browsersPath}/${chromeInstallationName}/chrome/chrome`,
];
for (const executablePath of chromeExecutableCandidates) {
if (await LocalFile.doesFileExist(executablePath)) {
return executablePath;
}
}
throw new BadDataException("Chrome executable path not found.");
}
public static async getFirefoxExecutablePath(): Promise<string> {
const browsersPath: string = this.getPlaywrightBrowsersPath();
const doesDirectoryExist: boolean =
await LocalFile.doesDirectoryExist(browsersPath);
if (!doesDirectoryExist) {
throw new BadDataException("Firefox executable path not found.");
}
// get list of files in the directory
const directories: string[] =
await LocalFile.getListOfDirectories(browsersPath);
if (directories.length === 0) {
throw new BadDataException("Firefox executable path not found.");
}
const firefoxInstallationName: string | undefined = directories.find(
(directory: string) => {
return directory.includes("firefox");
},
);
if (!firefoxInstallationName) {
throw new BadDataException("Firefox executable path not found.");
}
const firefoxExecutableCandidates: Array<string> = [
`${browsersPath}/${firefoxInstallationName}/firefox/firefox`,
`${browsersPath}/${firefoxInstallationName}/firefox-linux64/firefox`,
`${browsersPath}/${firefoxInstallationName}/firefox64/firefox`,
`${browsersPath}/${firefoxInstallationName}/firefox-64/firefox`,
];
for (const executablePath of firefoxExecutableCandidates) {
if (await LocalFile.doesFileExist(executablePath)) {
return executablePath;
}
}
throw new BadDataException("Firefox executable path not found.");
}
private static async getPageByBrowserType(data: {
browserType: BrowserType;
screenSizeType: ScreenSizeType;
}): Promise<BrowserSession> {
const viewport: {
height: number;
width: number;
} = SyntheticMonitor.getViewportHeightAndWidth({
screenSizeType: data.screenSizeType,
});
// Prepare browser launch options with proxy support
const baseOptions: BrowserLaunchOptions = {};
// Configure proxy if available
if (ProxyConfig.isProxyConfigured()) {
const httpsProxyUrl: string | null = ProxyConfig.getHttpsProxyUrl();
const httpProxyUrl: string | null = ProxyConfig.getHttpProxyUrl();
// Prefer HTTPS proxy, fall back to HTTP proxy
const proxyUrl: string | null = httpsProxyUrl || httpProxyUrl;
if (proxyUrl) {
baseOptions.proxy = {
server: proxyUrl,
};
// Extract username and password if present in proxy URL
try {
const parsedUrl: globalThis.URL = new URL(proxyUrl);
if (parsedUrl.username && parsedUrl.password) {
baseOptions.proxy.username = parsedUrl.username;
baseOptions.proxy.password = parsedUrl.password;
}
} catch (error) {
logger.warn(`Failed to parse proxy URL for authentication: ${error}`);
}
logger.debug(
`Synthetic Monitor using proxy: ${proxyUrl} (HTTPS: ${Boolean(httpsProxyUrl)}, HTTP: ${Boolean(httpProxyUrl)})`,
);
}
}
if (data.browserType === BrowserType.Chromium) {
const browser: Browser = await chromium.launch({
executablePath: await this.getChromeExecutablePath(),
...baseOptions,
});
const context: BrowserContext = await browser.newContext({
viewport: {
width: viewport.width,
height: viewport.height,
},
});
const page: Page = await context.newPage();
return {
browser,
context,
page,
};
}
if (data.browserType === BrowserType.Firefox) {
const browser: Browser = await firefox.launch({
executablePath: await this.getFirefoxExecutablePath(),
...baseOptions,
});
let context: BrowserContext | null = null;
try {
context = await browser.newContext({
viewport: {
width: viewport.width,
height: viewport.height,
},
});
const page: Page = await context.newPage();
return {
browser,
context,
page,
};
} catch (error) {
await SyntheticMonitor.safeCloseBrowserContext(context);
await SyntheticMonitor.safeCloseBrowser(browser);
throw error;
}
}
throw new BadDataException("Invalid Browser Type.");
}
private static async disposeBrowserSession(
session: BrowserSession | null,
): Promise<void> {
if (!session) {
return;
}
await SyntheticMonitor.safeClosePage(session.page);
await SyntheticMonitor.safeCloseBrowserContexts({
browser: session.browser,
});
await SyntheticMonitor.safeCloseBrowser(session.browser);
}
private static async safeClosePage(page?: Page | null): Promise<void> {
if (!page) {
return;
}
try {
if (!page.isClosed()) {
await page.close();
}
} catch (error) {
logger.warn(
`Failed to close Playwright page: ${(error as Error)?.message || error}`,
);
}
}
private static async safeCloseBrowserContext(
context?: BrowserContext | null,
): Promise<void> {
if (!context) {
return;
}
try {
await context.close();
} catch (error) {
logger.warn(
`Failed to close Playwright browser context: ${(error as Error)?.message || error}`,
);
}
}
private static async safeCloseBrowser(
browser?: Browser | null,
): Promise<void> {
if (!browser) {
return;
}
try {
if (browser.isConnected()) {
await browser.close();
}
} catch (error) {
logger.warn(
`Failed to close Playwright browser: ${(error as Error)?.message || error}`,
);
}
}
private static async safeCloseBrowserContexts(data: {
browser: Browser;
}): Promise<void> {
if (!data.browser || !data.browser.contexts) {
return;
}
const contexts: Array<BrowserContext> = data.browser.contexts();
for (const context of contexts) {
await SyntheticMonitor.safeCloseBrowserContext(context);
}
}
}