// Central place for telemetry // TODO: Replace all of this with proper tracers and events import { type Attributes, type AttributeValue, metrics, } from "@opentelemetry/api" import deepmerge from "deepmerge" import { flatten } from "flat" import { every, isArray, isBoolean, isFunction, isNull, isNumber, isObject, isPlainObject, isString, isUndefined, keys, mapValues, } from "lodash-es" import { logger } from "../logger" import type { ZodError } from "zod" type AttributesInput = Record function isAttributesInput(value: unknown): value is AttributesInput { return ( isObject(value) && !isArray(value) && !isNull(value) && keys(value).length > 0 && every(keys(value), isString) ) } /** * Checks if a given value is a valid OpenTelemetry `AttributeValue`. * An `AttributeValue` can be a `string`, `number`, `boolean`, or a homogenous * array containing only `null`, `undefined`, `string`, `number`, or `boolean`. * * @param value The value to check. * @returns `true` if the value is a valid `AttributeValue`, `false` otherwise. */ export function isValidAttributeValue(value: unknown): value is AttributeValue { if (isString(value) || isNumber(value) || isBoolean(value)) { return true } if (isArray(value)) { return every( value, (item) => isNull(item) || isUndefined(item) || isString(item) || isNumber(item) || isBoolean(item) ) } return false } /** * Sanitizes an input object, ensuring its values are valid OpenTelemetry * `AttributeValue` or `JSON.stringify()` representations as a fallback. * It recursively processes nested objects and flattens the final object to one * level deep with dot delimited keys for nested values. * * @param data The input object to sanitize. * @returns The resulting object. * * @example * ```typescript * import { sanitize } from '@/server/telemetry'; * * const input = { * key1: "Example", * key2: 10, * nested: { * nestedKey1: "Value", * nestedKey2: { * nestedKey2Key1: true, * }, * }, * }; * * const sanitized = sanitize(input); * logger.log(sanitized); * // { * // key1: "Example", * // key2: 10, * // "nested.nestedKey1": "Value", * // "nested.nestedKey2.nestedKey2Key1": true, * // } * ``` */ export function sanitize(data: AttributesInput): Attributes { if (!isPlainObject(data)) { throw new Error(`Input must be an object, got ${JSON.stringify(data)}`) } return flatten( mapValues(data, (value) => { if (isFunction(value)) { throw new Error("Cannot sanitize function") } else if (isValidAttributeValue(value)) { return value } else if (isAttributesInput(value)) { return sanitize(value) } return JSON.stringify(value) }) ) } /** * Creates an object that holds three OpenTelemetry counter instruments. One * that represents the counter itself, one for the success and one for any fail. * The object contains an `init method that acts as a factory to create the a * final object that holds methods to record different types of events one the * appropriate counter. * * @param meterName The name of the OpenTelemetry meter to create. * @param counterName The name of the counter instrument to create. * @returns An object with an `init` method that returns an object * with methods for recording counter events. * * @example * * See the codebase for reference usage. */ export function createCounter(meterName: string, counterName: string) { const meter = metrics.getMeter(meterName) const fullName = `${meterName}.${counterName}` const counter = meter.createCounter(fullName) const success = meter.createCounter(`${fullName}-success`) const fail = meter.createCounter(`${fullName}-fail`) return { /** * Initializes the counter event handlers with a set of base attributes. * These attributes will be included in all recorded events. * * @param baseAttrs - The base attributes to associate with the counter. Defaults to an empty object. * @returns An object with methods to record specific counter events. */ init(baseAttrs: AttributesInput = {}) { return { /** * Records an event for the main counter. * * @param attrs - Additional attributes specific to this 'start' event. Defaults to an empty object. */ start(attrs: AttributesInput = {}) { const mergedAttrs = deepmerge.all([baseAttrs, attrs]) const finalAttrs = sanitize(mergedAttrs) counter.add(1, finalAttrs) logger.info(`[${fullName}] start:`, finalAttrs) }, /** * Records an event for the success counter. * * @param attrs - Additional attributes specific to this 'success' event. Defaults to an empty object. */ success(attrs: AttributesInput = {}) { const mergedAttrs = deepmerge.all([baseAttrs, attrs]) const finalAttrs = sanitize(mergedAttrs) success.add(1, finalAttrs) logger.info(`[${fullName}] success:`, finalAttrs) }, /** * Records an event of type `data_error` for the fail counter. * Used when some dependent data could not be resolved during the * operation. Note that "no data" also exists and might be more * appropriate in certain situations. * * @param errorMsg - A message describing the data error. * @param attrs - Additional attributes specific to this 'dataError' event. Defaults to an empty object. */ dataError(errorMsg: string, attrs: AttributesInput = {}) { const mergedAttrs = deepmerge.all([ baseAttrs, attrs, { error_type: "data_error", error: errorMsg, }, ]) const finalAttrs = sanitize(mergedAttrs) fail.add(1, finalAttrs) logger.error(`[${fullName}] dataError:`, finalAttrs) }, /** * Records an event of type `not_found` for the fail counter. * Used when some dependent data could not be found during the operation. * Note that when there is an error resolving the data, the * `dataError` method might be more appropriate. * * @param attrs - Additional attributes specific to this 'noDataError' event. Defaults to an empty object. */ noDataError(attrs: AttributesInput = {}) { const mergedAttrs = deepmerge.all([ baseAttrs, attrs, { error_type: "not_found", }, ]) const finalAttrs = sanitize(mergedAttrs) fail.add(1, finalAttrs) logger.error(`[${fullName}] noDataError:`, finalAttrs) }, /** * Records an event of type `validation_error` for the fail counter. * Used when a Zod schema fails validation. * * @param zodError - The {@link ZodError} object representing the validation error. */ validationError(zodError: ZodError) { const mergedAttrs = deepmerge.all([ baseAttrs, { error_type: "validation_error", error: zodError.format(), }, ]) const finalAttrs = sanitize(mergedAttrs) fail.add(1, finalAttrs) logger.error(`[${fullName}] validationError:`, finalAttrs) }, /** * Records an event of type `http_error` for the fail counter. * Used when a `fetch(...)` call fails. **Note**: This method must be * `await`ed as it is asynchronous! * The given {@link Response} must be unprocessed and will be cloned * to avoid interfering with its consumption outside this function. * * @param response - The HTTP {@link Response} object. */ async httpError(response: Response) { const res = response.clone() const text = await res.text() const mergedAttrs = deepmerge.all([ baseAttrs, { error_type: "http_error", error: { status: res.status, statusText: res.statusText, text, }, }, ]) const finalAttrs = sanitize(mergedAttrs) fail.add(1, finalAttrs) logger.error(`[${fullName}] httpError:`, finalAttrs) }, /** * Records an event of type `error` for the fail counter. * Used when an error is thrown or an exception is caught, or as a * general-purpose way to record an 'error' on the fail counter. * * @param err - An optional error object or message associated with the * failure. Usually an instance of {@link Error} or a string. */ fail(err?: unknown) { let msg = "unknown" if (err && err instanceof Error) { msg = err.message } else if (typeof err === "string") { msg = err } const mergedAttrs = deepmerge.all([ baseAttrs, { error_type: "error", error: msg, }, ]) const finalAttrs = sanitize(mergedAttrs) fail.add(1, finalAttrs) logger.error(`[${fullName}] fail:`, finalAttrs) }, } }, } }