import * as Sentry from "@sentry/nextjs" import deepmerge from "deepmerge" import { flatten } from "flat" import { logger } from "../logger" import type { ZodError } from "zod" /** * 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 * * 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: any): Record { if (!data) return {} if (typeof data === "string") { return { value: data } } return flatten(data) } /** * 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) { 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: object = {}) { return { /** * Records an event for the main counter. * * @param attrs - Additional attributes specific to this 'start' event. Defaults to an empty object. */ start(attrs: object | undefined = undefined) { logger.debug(`[${meterName}] start`, attrs) }, /** * Records an event for the success counter. * * @param attrs - Additional attributes specific to this 'success' event. Defaults to an empty object. */ success(attrs: object = {}) { const mergedAttrs = deepmerge.all([baseAttrs, attrs]) const finalAttrs = sanitize(mergedAttrs) Sentry.metrics.count(meterName, 1, { attributes: { ...finalAttrs, status: "success" }, }) logger.debug(`[${meterName}] success`, mergedAttrs) }, /** * 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: object = {}) { const mergedAttrs = deepmerge.all([ baseAttrs, attrs, { error_type: "data_error", error: errorMsg, }, ]) const finalAttrs = sanitize(mergedAttrs) Sentry.metrics.count(meterName, 1, { attributes: { ...finalAttrs, status: "error" }, }) logger.error(`[${meterName}] dataError: ${errorMsg}`, mergedAttrs) }, /** * 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: object = {}) { const mergedAttrs = deepmerge.all([ baseAttrs, attrs, { error_type: "not_found", }, ]) const finalAttrs = sanitize(mergedAttrs) Sentry.metrics.count(meterName, 1, { attributes: { ...finalAttrs, status: "error" }, }) logger.error(`[${meterName}] noDataError:`, mergedAttrs) }, /** * 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", ...sanitize(zodError.format()), }, ]) const finalAttrs = sanitize(mergedAttrs) Sentry.metrics.count(meterName, 1, { attributes: { ...finalAttrs, status: "error" }, }) logger.error(`[${meterName}] validationError`, mergedAttrs) }, /** * 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, "error.statusText": res.statusText, "error.text": text, url: res.url, }, ]) const finalAttrs = sanitize(mergedAttrs) Sentry.metrics.count(meterName, 1, { attributes: { ...finalAttrs, status: "error" }, }) logger.error( `[${meterName}] httpError ${res.status}, ${res.statusText}:`, mergedAttrs ) }, /** * 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) Sentry.metrics.count(meterName, 1, { attributes: { ...finalAttrs, status: "error" }, }) logger.error(`[${meterName}] fail message: ${msg}`, mergedAttrs) }, } }, } }