feat: implement captured metrics handling in custom code and synthetic monitors

This commit is contained in:
Nawaz Dhandala
2026-04-01 14:30:01 +01:00
parent c7cfd7aa67
commit 464455eff3
7 changed files with 190 additions and 0 deletions

View File

@@ -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<SyntheticMonitorResponse> =
(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<string> = 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);
}

View File

@@ -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<typeof setTimeout>;
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<string, unknown>,
)) {
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) {

View File

@@ -1,4 +1,7 @@
import CapturedMetric from "../Monitor/CustomCodeMonitor/CapturedMetric";
export default interface ReturnResult {
returnValue: any;
logMessages: string[];
capturedMetrics: CapturedMetric[];
}

View File

@@ -0,0 +1,7 @@
import { JSONObject } from "../../JSON";
export default interface CapturedMetric {
name: string;
value: number;
attributes?: JSONObject | undefined;
}

View File

@@ -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;
}

View File

@@ -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) {

View File

@@ -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) {