diff --git a/packages/cosmic-swingset/test/prometheus.test.js b/packages/cosmic-swingset/test/prometheus.test.js new file mode 100644 index 00000000000..63c052b1a69 --- /dev/null +++ b/packages/cosmic-swingset/test/prometheus.test.js @@ -0,0 +1,71 @@ +import test from 'ava'; + +import fs from 'node:fs'; +import path from 'node:path'; + +import { q, Fail } from '@endo/errors'; +import tmp from 'tmp'; + +import { promisify } from '@agoric/internal/src/js-utils.js'; +import { makeFakeStorageKit } from '@agoric/internal/src/storage-test-utils.js'; + +import { makeLaunchChain } from '../src/chain-main.js'; +import { defaultBootstrapMessage } from '../tools/test-kit.js'; + +const tmpDir = promisify(tmp.dir); + +test('Prometheus metric definitions', async t => { + const OTEL_EXPORTER_PROMETHEUS_PORT = '12345'; + // @ts-expect-error + const { storagePort } = defaultBootstrapMessage; + const [dbDir, cleanupDB] = await tmpDir({ + prefix: 'testdb', + unsafeCleanup: true, + }); + t.teardown(cleanupDB); + + const fakeStorageKit = makeFakeStorageKit(''); + const { toStorage: handleVstorage } = fakeStorageKit; + const fakeAgcc = { + send: (destPort, msgJson) => { + const msg = JSON.parse(msgJson); + if (destPort === storagePort) return JSON.stringify(handleVstorage(msg)); + throw Fail`port ${q(destPort)} not implemented for message ${msg}`; + }, + }; + const launchChain = makeLaunchChain(fakeAgcc, dbDir, { + env: { + OTEL_EXPORTER_PROMETHEUS_PORT, + CHAIN_BOOTSTRAP_VAT_CONFIG: + '@agoric/vm-config/decentral-core-config.json', + }, + fs, + path, + }); + + const { shutdown } = await launchChain(defaultBootstrapMessage); + t.teardown(shutdown); + + const response = await fetch( + `http://localhost:${OTEL_EXPORTER_PROMETHEUS_PORT}/metrics`, + ); + const text = await response.text(); + // Normalize text: + // https://prometheus.io/docs/instrumenting/exposition_formats/#text-format-details + // * Set telemetry_sdk_version and service_instance_id to "%s". + // * Replace integer values with "%d" and floating-point values with "%f". + // * Replace trailing milliseconds-since-epoch timestamps with "%@". + const normalizedText = text + .replace(/^.*(telemetry_sdk_version|service_instance_id).*$/m, line => + line.replaceAll( + /(telemetry_sdk_version|service_instance_id)="([^""\\]|\\.)*"/g, + '$1="%s"', + ), + ) + .replaceAll( + /^([^#].*?) (?:([0-9]+)|([0-9]*[.][0-9]+))( [0-9]{13,})?$/gm, + (_substring, prefix, intValue, floatValue, timestamp) => + `${prefix} ${intValue ? '%d' : '%f'}${timestamp ? ' %@' : ''}`, + ); + t.snapshot(normalizedText); +}); diff --git a/packages/internal/src/js-utils.js b/packages/internal/src/js-utils.js index 70cd6d19c4b..9c906d99a7b 100644 --- a/packages/internal/src/js-utils.js +++ b/packages/internal/src/js-utils.js @@ -5,6 +5,48 @@ * dependent upon a hardened environment. */ +/** + * Pick the last type from an array. + * + * @template {unknown[]} A + * @typedef {A extends [...infer P, infer X] ? X : never} Last + */ +/** + * Collect up to 8 function overloads into a union. + * + * @template {(...args: any[]) => unknown} F + * @typedef {F extends { + * (...args: infer A1): infer R1; + * (...args: infer A2): infer R2; + * (...args: infer A3): infer R3; + * (...args: infer A4): infer R4; + * (...args: infer A5): infer R5; + * (...args: infer A6): infer R6; + * (...args: infer A7): infer R7; + * } + * ? + * | ((...args: A1) => R1) + * | ((...args: A2) => R2) + * | ((...args: A3) => R3) + * | ((...args: A4) => R4) + * | ((...args: A5) => R5) + * | ((...args: A6) => R6) + * | ((...args: A7) => R7) + * : never} Overloads + */ +/** + * Pick the type of a function's last parameter. + * + * @template {(...args: any[]) => unknown} F + * @typedef {Last>>} LastParameter + */ + +const { defineProperties } = Object; + +/** @type {(name: string, fn: F) => F} */ +export const defineName = (name, fn) => + defineProperties(fn, { name: { value: name } }); + /** * Deep-copy a value by round-tripping it through JSON (which drops * function/symbol/undefined values and properties that are non-enumerable @@ -87,3 +129,45 @@ export const makeMeasureSeconds = currentTimeMillisec => { }; return measureSeconds; }; + +/** + * Generalize Node.js util.promisify to support callbacks with multiple + * post-error parameters, which fulfill the returned promise as an array rather + * than as a single value. + * + * @template {(...args: any[]) => unknown} F function whose last parameter is an + * error-first callback + * @template {Parameters> extends [Error | null, ...infer R] + * ? R + * : never} R + * @param {F} fn + */ +export const promisify = fn => { + /** + * @typedef {Parameters> extends [...infer P, infer CB] + * ? ( + * ...args: P + * ) => Promise< + * Parameters> extends [unknown, unknown] ? R[0] : R + * > + * : never} Promisified + */ + const promisified = /** @type {Promisified} */ ( + (...args) => + // eslint-disable-next-line no-restricted-syntax + new Promise((resolve, reject) => { + /** @type {(err: Error, ...result: R) => void} */ + const callback = (err, ...result) => { + if (err) { + reject(err); + } else if (result.length === 1) { + resolve(/** @type {any} */ (result[0])); + } else { + resolve(result); + } + }; + fn(...args, callback); + }) + ); + return defineName(fn.name && `promisified ${fn.name}`, promisified); +}; diff --git a/packages/internal/test/snapshots/exports.test.js.md b/packages/internal/test/snapshots/exports.test.js.md index 06f69a7e5d7..8dda4e41e1d 100644 --- a/packages/internal/test/snapshots/exports.test.js.md +++ b/packages/internal/test/snapshots/exports.test.js.md @@ -26,6 +26,7 @@ Generated by [AVA](https://avajs.dev). 'deepCopyJsonable', 'deepMapObject', 'deeplyFulfilledObject', + 'defineName', 'forever', 'fromUniqueEntries', 'getMethodNames', @@ -35,6 +36,7 @@ Generated by [AVA](https://avajs.dev). 'mustMatch', 'objectMap', 'objectMetaMap', + 'promisify', 'pureDataMarshaller', 'synchronizedTee', 'untilTrue', diff --git a/packages/internal/test/snapshots/exports.test.js.snap b/packages/internal/test/snapshots/exports.test.js.snap index a952ead7268..2dd2c7b078e 100644 Binary files a/packages/internal/test/snapshots/exports.test.js.snap and b/packages/internal/test/snapshots/exports.test.js.snap differ