diff --git a/Common/Server/Utils/Monitor/MonitorMetricUtil.ts b/Common/Server/Utils/Monitor/MonitorMetricUtil.ts index 960a77fca9..23d1493b9e 100644 --- a/Common/Server/Utils/Monitor/MonitorMetricUtil.ts +++ b/Common/Server/Utils/Monitor/MonitorMetricUtil.ts @@ -13,6 +13,7 @@ import MetricType from "../../../Models/DatabaseModels/MetricType"; import BasicInfrastructureMetrics from "../../../Types/Infrastructure/BasicMetrics"; import Dictionary from "../../../Types/Dictionary"; import { JSONObject } from "../../../Types/JSON"; +import CapturedMetric from "../../../Types/Monitor/CustomCodeMonitor/CapturedMetric"; import MonitorMetricType from "../../../Types/Monitor/MonitorMetricType"; import ProbeMonitorResponse from "../../../Types/Probe/ProbeMonitorResponse"; import ServerMonitorResponse from "../../../Types/Monitor/ServerMonitor/ServerMonitorResponse"; @@ -533,6 +534,91 @@ export default class MonitorMetricUtil { metricType; } + // Process custom metrics from Custom Code and Synthetic Monitor responses + const customCodeMetrics: CapturedMetric[] = + (data.dataToProcess as ProbeMonitorResponse).customCodeMonitorResponse + ?.capturedMetrics || []; + + const syntheticCustomMetrics: CapturedMetric[] = []; + const syntheticResponsesForMetrics: Array = + (data.dataToProcess as ProbeMonitorResponse).syntheticMonitorResponse || + []; + for (const resp of syntheticResponsesForMetrics) { + if (resp.capturedMetrics) { + syntheticCustomMetrics.push(...resp.capturedMetrics); + } + } + + const allCustomMetrics: CapturedMetric[] = [ + ...customCodeMetrics, + ...syntheticCustomMetrics, + ].slice(0, 100); + + const reservedAttributeKeys: Set = new Set([ + "monitorId", + "projectId", + "monitorName", + "probeName", + "probeId", + ]); + + for (const customMetric of allCustomMetrics) { + if ( + !customMetric.name || + typeof customMetric.name !== "string" || + typeof customMetric.value !== "number" || + isNaN(customMetric.value) + ) { + continue; + } + + const prefixedName: string = `custom.monitor.${customMetric.name}`; + + const extraAttributes: JSONObject = { + isCustomMetric: "true", + }; + + if ((data.dataToProcess as ProbeMonitorResponse).probeId) { + extraAttributes["probeId"] = ( + data.dataToProcess as ProbeMonitorResponse + ).probeId.toString(); + } + + if (customMetric.attributes) { + for (const [key, val] of Object.entries(customMetric.attributes)) { + if (typeof val === "string" && !reservedAttributeKeys.has(key)) { + extraAttributes[key] = val; + } + } + } + + const attributes: JSONObject = this.buildMonitorMetricAttributes({ + monitorId: data.monitorId, + projectId: data.projectId, + monitorName: data.monitorName, + probeName: data.probeName, + extraAttributes: extraAttributes, + }); + + const metricRow: JSONObject = await this.buildMonitorMetricRow({ + projectId: data.projectId, + monitorId: data.monitorId, + metricName: prefixedName, + value: customMetric.value, + attributes: attributes, + metricPointType: MetricPointType.Gauge, + }); + + metricRows.push(metricRow); + + const metricType: MetricType = new MetricType(); + metricType.name = prefixedName; + metricType.description = `Custom metric: ${customMetric.name}`; + metricType.unit = ""; + + metricNameServiceNameMap[prefixedName] = metricType; + } + if (metricRows.length > 0) { await MetricService.insertJsonRows(metricRows); } diff --git a/Common/Server/Utils/VM/VMRunner.ts b/Common/Server/Utils/VM/VMRunner.ts index 1990990bcd..264f56925a 100644 --- a/Common/Server/Utils/VM/VMRunner.ts +++ b/Common/Server/Utils/VM/VMRunner.ts @@ -1,3 +1,4 @@ +import CapturedMetric from "../../../Types/Monitor/CustomCodeMonitor/CapturedMetric"; import ReturnResult from "../../../Types/IsolatedVM/ReturnResult"; import { JSONObject, JSONValue } from "../../../Types/JSON"; import axios, { AxiosResponse } from "axios"; @@ -310,6 +311,9 @@ export default class VMRunner { const MAX_LOG_BYTES: number = 1_000_000; // 1MB cap let totalLogBytes: number = 0; + const capturedMetrics: CapturedMetric[] = []; + const MAX_METRICS: number = 100; + // Track timer handles so we can clean them up after execution type TimerHandle = ReturnType; const pendingTimeouts: TimerHandle[] = []; @@ -398,6 +402,47 @@ export default class VMRunner { proxyCache, ); + sandbox["oneuptime"] = createSandboxProxy( + { + captureMetric: ( + name: unknown, + value: unknown, + attributes?: unknown, + ): void => { + if (typeof name !== "string" || name.length === 0) { + return; + } + if (typeof value !== "number" || isNaN(value)) { + return; + } + if (capturedMetrics.length >= MAX_METRICS) { + return; + } + const metric: CapturedMetric = { + name: name.substring(0, 200), + value: value, + }; + if (attributes && typeof attributes === "object") { + const safeAttrs: JSONObject = {}; + for (const [k, v] of Object.entries( + attributes as Record, + )) { + if ( + typeof v === "string" || + typeof v === "number" || + typeof v === "boolean" + ) { + safeAttrs[k] = String(v); + } + } + metric.attributes = safeAttrs; + } + capturedMetrics.push(metric); + }, + }, + proxyCache, + ); + // Wrap any additional context (e.g. Playwright browser/page objects) if (options.context) { for (const key of Object.keys(options.context)) { @@ -450,6 +495,7 @@ export default class VMRunner { return { returnValue: deepUnwrapProxies(returnVal), logMessages, + capturedMetrics, }; } finally { // Clean up any lingering timers to prevent resource leaks @@ -474,6 +520,8 @@ export default class VMRunner { const timeout: number = options.timeout || 5000; const logMessages: string[] = []; + const capturedMetrics: CapturedMetric[] = []; + const MAX_METRICS: number = 100; const isolate: ivm.Isolate = new ivm.Isolate({ memoryLimit: 128 }); @@ -499,6 +547,45 @@ export default class VMRunner { }))}; `); + // oneuptime.captureMetric - fire-and-forget callback + await jail.set( + "_captureMetric", + new ivm.Callback( + (name: string, value: string, attributesJson?: string) => { + if (capturedMetrics.length >= MAX_METRICS) { + return; + } + const numValue: number = Number(value); + if (isNaN(numValue)) { + return; + } + const metric: CapturedMetric = { + name: String(name).substring(0, 200), + value: numValue, + }; + if (attributesJson) { + try { + metric.attributes = JSON.parse(attributesJson) as JSONObject; + } catch { + // ignore invalid JSON + } + } + capturedMetrics.push(metric); + }, + ), + ); + + await context.eval(` + const oneuptime = { + captureMetric: (name, value, attributes) => { + if (typeof name !== 'string' || name.length === 0) return; + if (typeof value !== 'number' || isNaN(value)) return; + const attrJson = attributes ? JSON.stringify(attributes) : undefined; + _captureMetric(String(name), String(value), attrJson); + } + }; + `); + // args - deep copy into isolate if (options.args) { await jail.set("_args", new ivm.ExternalCopy(options.args).copyInto()); @@ -961,6 +1048,7 @@ export default class VMRunner { return { returnValue, logMessages, + capturedMetrics, }; } finally { if (!isolate.isDisposed) { diff --git a/Common/Types/IsolatedVM/ReturnResult.ts b/Common/Types/IsolatedVM/ReturnResult.ts index ac811bf263..88be0f2752 100644 --- a/Common/Types/IsolatedVM/ReturnResult.ts +++ b/Common/Types/IsolatedVM/ReturnResult.ts @@ -1,4 +1,7 @@ +import CapturedMetric from "../Monitor/CustomCodeMonitor/CapturedMetric"; + export default interface ReturnResult { returnValue: any; logMessages: string[]; + capturedMetrics: CapturedMetric[]; } diff --git a/Common/Types/Monitor/CustomCodeMonitor/CapturedMetric.ts b/Common/Types/Monitor/CustomCodeMonitor/CapturedMetric.ts new file mode 100644 index 0000000000..037d6a16ce --- /dev/null +++ b/Common/Types/Monitor/CustomCodeMonitor/CapturedMetric.ts @@ -0,0 +1,7 @@ +import { JSONObject } from "../../JSON"; + +export default interface CapturedMetric { + name: string; + value: number; + attributes?: JSONObject | undefined; +} diff --git a/Common/Types/Monitor/CustomCodeMonitor/CustomCodeMonitorResponse.ts b/Common/Types/Monitor/CustomCodeMonitor/CustomCodeMonitorResponse.ts index 628a30ced4..870973b98c 100644 --- a/Common/Types/Monitor/CustomCodeMonitor/CustomCodeMonitorResponse.ts +++ b/Common/Types/Monitor/CustomCodeMonitor/CustomCodeMonitorResponse.ts @@ -1,8 +1,10 @@ +import CapturedMetric from "./CapturedMetric"; import { JSONObject } from "../../JSON"; export default interface CustomCodeMonitorResponse { result: string | number | boolean | JSONObject | undefined; scriptError?: string | undefined; logMessages: string[]; + capturedMetrics: CapturedMetric[]; executionTimeInMS: number; } diff --git a/Probe/Utils/Monitors/MonitorTypes/CustomCodeMonitor.ts b/Probe/Utils/Monitors/MonitorTypes/CustomCodeMonitor.ts index 23f6668a5b..2a118f13ee 100644 --- a/Probe/Utils/Monitors/MonitorTypes/CustomCodeMonitor.ts +++ b/Probe/Utils/Monitors/MonitorTypes/CustomCodeMonitor.ts @@ -24,6 +24,7 @@ export default class CustomCodeMonitor { const scriptResult: CustomCodeMonitorResponse = { logMessages: [], + capturedMetrics: [], scriptError: undefined, result: undefined, @@ -60,6 +61,7 @@ export default class CustomCodeMonitor { scriptResult.executionTimeInMS = executionTimeInMS; scriptResult.logMessages = result.logMessages; + scriptResult.capturedMetrics = result.capturedMetrics || []; scriptResult.result = result?.returnValue?.data; } catch (err) { diff --git a/Probe/Utils/Monitors/MonitorTypes/SyntheticMonitor.ts b/Probe/Utils/Monitors/MonitorTypes/SyntheticMonitor.ts index dc96cf17fa..a2673962f6 100644 --- a/Probe/Utils/Monitors/MonitorTypes/SyntheticMonitor.ts +++ b/Probe/Utils/Monitors/MonitorTypes/SyntheticMonitor.ts @@ -127,6 +127,7 @@ export default class SyntheticMonitor { const scriptResult: SyntheticMonitorResponse = { logMessages: [], + capturedMetrics: [], scriptError: undefined, result: undefined, screenshots: {}, @@ -179,6 +180,7 @@ export default class SyntheticMonitor { scriptResult.executionTimeInMS = executionTimeInMS; scriptResult.logMessages = result.logMessages; + scriptResult.capturedMetrics = result.capturedMetrics || []; if (result.returnValue?.screenshots) { if (!scriptResult.screenshots) {