mirror of
https://github.com/OneUptime/oneuptime.git
synced 2026-04-06 00:32:12 +02:00
feat: Refactor SyntheticMonitor to use Playwright for browser sessions and enhance error handling
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user