Merged in feat/sw-3560-sanitize2 (pull request #3001)
feat(SW-3560): send parameters to sentry logs * feat(SW-3560): send parameters to sentry logs * Use flatten instead of sanitize when logging Approved-by: Joakim Jäderberg
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import * as Sentry from "@sentry/nextjs"
|
||||
import { flatten } from "flat"
|
||||
|
||||
const logLevels = ["debug", "info", "warn", "error"] as const
|
||||
const minimumLogLevel = (() => {
|
||||
@@ -23,6 +24,19 @@ function shouldLog(level: (typeof logLevels)[number]) {
|
||||
return logLevels.indexOf(level) >= logLevels.indexOf(minimumLogLevel)
|
||||
}
|
||||
|
||||
function getLogValue(args: unknown[]): Record<string, unknown> | undefined {
|
||||
if (!args || args.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
if (args.length === 1 && typeof args[0] === "object") {
|
||||
return (args[0] as Record<string, unknown>) ?? undefined
|
||||
}
|
||||
if (args.length === 1) {
|
||||
return { value: args[0] }
|
||||
}
|
||||
return flatten(args)
|
||||
}
|
||||
|
||||
export function createLogger(loggerPrefix: string | (() => Promise<string>)) {
|
||||
const asyncWrapper: () => Promise<string> =
|
||||
typeof loggerPrefix === "string" ? async () => loggerPrefix : loggerPrefix
|
||||
@@ -45,9 +59,12 @@ export function createLogger(loggerPrefix: string | (() => Promise<string>)) {
|
||||
return
|
||||
}
|
||||
|
||||
Sentry.logger[level](`${await getLoggerPrefix()} ${message}`.trim(), {
|
||||
...args,
|
||||
})
|
||||
const logValue = getLogValue(args)
|
||||
|
||||
Sentry.logger[level](
|
||||
`${await getLoggerPrefix()} ${message}`.trim(),
|
||||
logValue
|
||||
)
|
||||
console[level](`${await getLoggerPrefix()} ${message}`.trim(), ...args)
|
||||
}
|
||||
|
||||
@@ -55,7 +72,6 @@ export function createLogger(loggerPrefix: string | (() => Promise<string>)) {
|
||||
async debug(message: string, ...args: unknown[]): Promise<void> {
|
||||
await log("debug", message, ...args)
|
||||
},
|
||||
|
||||
async info(message: string, ...args: unknown[]): Promise<void> {
|
||||
await log("info", message, ...args)
|
||||
},
|
||||
|
||||
@@ -42,18 +42,6 @@ describe("sanitize", () => {
|
||||
expect(sanitize(input)).toEqual(expected)
|
||||
})
|
||||
|
||||
test("should stringify non-valid attributes", () => {
|
||||
const input = {
|
||||
key1: new Date("2024-08-08T12:00:00Z"),
|
||||
key2: { nested: "object" },
|
||||
}
|
||||
const expected = {
|
||||
key1: '"2024-08-08T12:00:00.000Z"',
|
||||
"key2.nested": "object",
|
||||
}
|
||||
expect(sanitize(input)).toEqual(expected)
|
||||
})
|
||||
|
||||
test("should handle nested valid attributes", () => {
|
||||
const input = {
|
||||
key1: "Example",
|
||||
@@ -82,54 +70,42 @@ describe("sanitize", () => {
|
||||
nestedKey1: "Value",
|
||||
nestedKey2: {
|
||||
nestedKey2Key1: true,
|
||||
nestedKey2Key2: new Date("2024-08-08T12:00:00Z"),
|
||||
},
|
||||
nestedKey3: {
|
||||
reallyNested: "hello",
|
||||
},
|
||||
},
|
||||
nonPrimitive: new Date("2024-08-08T13:00:00Z"),
|
||||
}
|
||||
const expected = {
|
||||
key1: "Example",
|
||||
key2: 10,
|
||||
"nested.nestedKey1": "Value",
|
||||
"nested.nestedKey2.nestedKey2Key1": true,
|
||||
"nested.nestedKey2.nestedKey2Key2": '"2024-08-08T12:00:00.000Z"',
|
||||
"nested.nestedKey3.reallyNested": "hello",
|
||||
nonPrimitive: '"2024-08-08T13:00:00.000Z"',
|
||||
}
|
||||
expect(sanitize(input)).toEqual(expected)
|
||||
})
|
||||
|
||||
test("should throw an error when a function is passed", () => {
|
||||
const key1 = () => {}
|
||||
const input = {
|
||||
key1: () => {},
|
||||
key1,
|
||||
}
|
||||
const expected = {
|
||||
key1,
|
||||
}
|
||||
expect(() => sanitize(input)).toThrowError("Cannot sanitize function")
|
||||
})
|
||||
|
||||
test("should throw an error when input not an object", () => {
|
||||
// @ts-expect-error: array not allowed. We do this here to make sure the
|
||||
// function not only relies on TS but actively blocks arrays as input.
|
||||
expect(() => sanitize(null)).toThrowError()
|
||||
|
||||
// @ts-expect-error: array not allowed. We do this here to make sure the
|
||||
// function not only relies on TS but actively blocks arrays as input.
|
||||
expect(() => sanitize(undefined)).toThrowError()
|
||||
|
||||
// @ts-expect-error: array not allowed. We do this here to make sure the
|
||||
// function not only relies on TS but actively blocks arrays as input.
|
||||
expect(() => sanitize("")).toThrowError()
|
||||
|
||||
// @ts-expect-error: array not allowed. We do this here to make sure the
|
||||
// function not only relies on TS but actively blocks arrays as input.
|
||||
expect(() => sanitize([1, 2, 3])).toThrowError()
|
||||
})
|
||||
|
||||
test("should handle empty input", () => {
|
||||
const input = {}
|
||||
const expected = {}
|
||||
expect(sanitize(input)).toEqual(expected)
|
||||
})
|
||||
|
||||
test("should handle when input is not an object", () => {
|
||||
expect(sanitize(null)).toEqual({})
|
||||
|
||||
expect(sanitize(undefined)).toEqual({})
|
||||
|
||||
expect(sanitize("")).toEqual({})
|
||||
|
||||
expect(sanitize([1, 2, 3])).toEqual({ "0": 1, "1": 2, "2": 3 })
|
||||
|
||||
expect(sanitize("Test string")).toEqual({ value: "Test string" })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
// Central place for telemetry
|
||||
// TODO: Replace all of this with proper tracers and events
|
||||
|
||||
import {
|
||||
type Attributes,
|
||||
type AttributeValue,
|
||||
metrics,
|
||||
} from "@opentelemetry/api"
|
||||
import { type Attributes, metrics } from "@opentelemetry/api"
|
||||
import deepmerge from "deepmerge"
|
||||
import { flatten } from "flat"
|
||||
|
||||
@@ -13,43 +9,6 @@ import { logger } from "../logger"
|
||||
|
||||
import type { ZodError } from "zod"
|
||||
|
||||
type AttributesInput = Record<string, unknown>
|
||||
|
||||
function isAttributesInput(value: unknown): value is AttributesInput {
|
||||
return (
|
||||
isObject(value) &&
|
||||
!Array.isArray(value) &&
|
||||
!isNull(value) &&
|
||||
Object.keys(value).length > 0 &&
|
||||
Object.keys(value).every(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 (Array.isArray(value)) {
|
||||
return value.every(
|
||||
(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.
|
||||
@@ -84,24 +43,12 @@ export function isValidAttributeValue(value: unknown): value is AttributeValue {
|
||||
* // }
|
||||
* ```
|
||||
*/
|
||||
export function sanitize(data: AttributesInput): Attributes {
|
||||
if (!isPlainObject(data)) {
|
||||
throw new Error(`Input must be an object, got ${JSON.stringify(data)}`)
|
||||
export function sanitize(data: any): Attributes {
|
||||
if (!data) return {}
|
||||
if (typeof data === "string") {
|
||||
return { value: 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)
|
||||
})
|
||||
)
|
||||
return flatten(data)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -137,19 +84,19 @@ export function createCounter(meterName: string, counterName: string) {
|
||||
* @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 = {}) {
|
||||
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: AttributesInput = {}) {
|
||||
const mergedAttrs = deepmerge.all<AttributesInput>([baseAttrs, attrs])
|
||||
start(attrs: object = {}) {
|
||||
const mergedAttrs = deepmerge.all<object>([baseAttrs, attrs])
|
||||
const finalAttrs = sanitize(mergedAttrs)
|
||||
|
||||
counter.add(1, finalAttrs)
|
||||
logger.debug(`[${fullName}] start:`, finalAttrs)
|
||||
logger.debug(`[${fullName}] start`, mergedAttrs)
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -157,12 +104,13 @@ export function createCounter(meterName: string, counterName: string) {
|
||||
*
|
||||
* @param attrs - Additional attributes specific to this 'success' event. Defaults to an empty object.
|
||||
*/
|
||||
success(attrs: AttributesInput = {}) {
|
||||
const mergedAttrs = deepmerge.all<AttributesInput>([baseAttrs, attrs])
|
||||
success(attrs: object = {}) {
|
||||
console.log("telemetry attrs:", attrs, baseAttrs)
|
||||
const mergedAttrs = deepmerge.all<object>([baseAttrs, attrs])
|
||||
const finalAttrs = sanitize(mergedAttrs)
|
||||
|
||||
success.add(1, finalAttrs)
|
||||
logger.debug(`[${fullName}] success:`, finalAttrs)
|
||||
logger.debug(`[${fullName}] success`, mergedAttrs)
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -174,8 +122,8 @@ export function createCounter(meterName: string, counterName: string) {
|
||||
* @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<AttributesInput>([
|
||||
dataError(errorMsg: string, attrs: object = {}) {
|
||||
const mergedAttrs = deepmerge.all<object>([
|
||||
baseAttrs,
|
||||
attrs,
|
||||
{
|
||||
@@ -186,7 +134,7 @@ export function createCounter(meterName: string, counterName: string) {
|
||||
const finalAttrs = sanitize(mergedAttrs)
|
||||
|
||||
fail.add(1, finalAttrs)
|
||||
logger.error(`[${fullName}] dataError:`, finalAttrs)
|
||||
logger.error(`[${fullName}] dataError: ${errorMsg}`, mergedAttrs)
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -197,8 +145,8 @@ export function createCounter(meterName: string, counterName: string) {
|
||||
*
|
||||
* @param attrs - Additional attributes specific to this 'noDataError' event. Defaults to an empty object.
|
||||
*/
|
||||
noDataError(attrs: AttributesInput = {}) {
|
||||
const mergedAttrs = deepmerge.all<AttributesInput>([
|
||||
noDataError(attrs: object = {}) {
|
||||
const mergedAttrs = deepmerge.all<object>([
|
||||
baseAttrs,
|
||||
attrs,
|
||||
{
|
||||
@@ -208,7 +156,7 @@ export function createCounter(meterName: string, counterName: string) {
|
||||
const finalAttrs = sanitize(mergedAttrs)
|
||||
|
||||
fail.add(1, finalAttrs)
|
||||
logger.error(`[${fullName}] noDataError:`, finalAttrs)
|
||||
logger.error(`[${fullName}] noDataError:`, mergedAttrs)
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -218,17 +166,17 @@ export function createCounter(meterName: string, counterName: string) {
|
||||
* @param zodError - The {@link ZodError} object representing the validation error.
|
||||
*/
|
||||
validationError(zodError: ZodError) {
|
||||
const mergedAttrs = deepmerge.all<AttributesInput>([
|
||||
const mergedAttrs = deepmerge.all<object>([
|
||||
baseAttrs,
|
||||
{
|
||||
error_type: "validation_error",
|
||||
error: zodError.format(),
|
||||
...sanitize(zodError.format()),
|
||||
},
|
||||
])
|
||||
const finalAttrs = sanitize(mergedAttrs)
|
||||
|
||||
fail.add(1, finalAttrs)
|
||||
logger.error(`[${fullName}] validationError:`, finalAttrs)
|
||||
logger.error(`[${fullName}] validationError`, mergedAttrs)
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -244,21 +192,22 @@ export function createCounter(meterName: string, counterName: string) {
|
||||
const res = response.clone()
|
||||
const text = await res.text()
|
||||
|
||||
const mergedAttrs = deepmerge.all<AttributesInput>([
|
||||
const mergedAttrs = deepmerge.all<object>([
|
||||
baseAttrs,
|
||||
{
|
||||
error_type: "http_error",
|
||||
error: {
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
text,
|
||||
},
|
||||
"error.status": res.status,
|
||||
"error.statusText": res.statusText,
|
||||
"error.text": text,
|
||||
},
|
||||
])
|
||||
const finalAttrs = sanitize(mergedAttrs)
|
||||
|
||||
fail.add(1, finalAttrs)
|
||||
logger.error(`[${fullName}] httpError:`, finalAttrs)
|
||||
logger.error(
|
||||
`[${fullName}] httpError ${res.status}, ${res.statusText}:`,
|
||||
mergedAttrs
|
||||
)
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -278,7 +227,7 @@ export function createCounter(meterName: string, counterName: string) {
|
||||
msg = err
|
||||
}
|
||||
|
||||
const mergedAttrs = deepmerge.all<AttributesInput>([
|
||||
const mergedAttrs = deepmerge.all<object>([
|
||||
baseAttrs,
|
||||
{
|
||||
error_type: "error",
|
||||
@@ -288,37 +237,9 @@ export function createCounter(meterName: string, counterName: string) {
|
||||
const finalAttrs = sanitize(mergedAttrs)
|
||||
|
||||
fail.add(1, finalAttrs)
|
||||
logger.error(`[${fullName}] fail:`, finalAttrs)
|
||||
logger.error(`[${fullName}] fail message: ${msg}`, mergedAttrs)
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const isBoolean = (v: unknown): v is boolean => typeof v === "boolean"
|
||||
const isFunction = (v: unknown): v is Function => typeof v === "function"
|
||||
const isNull = (v: unknown): v is null => v === null
|
||||
const isNumber = (v: unknown): v is number =>
|
||||
typeof v === "number" && Number.isFinite(v as number)
|
||||
const isObject = (v: unknown): v is Record<string, unknown> =>
|
||||
typeof v === "object" && v !== null
|
||||
const isString = (v: unknown): v is string => typeof v === "string"
|
||||
const isUndefined = (v: unknown): v is undefined => typeof v === "undefined"
|
||||
const isPlainObject = (v: unknown): v is Record<string, unknown> => {
|
||||
if (!isObject(v)) return false
|
||||
const proto = Object.getPrototypeOf(v)
|
||||
return proto === Object.prototype || proto === null
|
||||
}
|
||||
|
||||
const mapValues = <T extends Record<string, any>, R>(
|
||||
obj: T,
|
||||
iteratee: (value: T[keyof T], key: keyof T) => R
|
||||
): Record<keyof T, R> => {
|
||||
const out: Partial<Record<keyof T, R>> = {}
|
||||
for (const k in obj) {
|
||||
if (Object.prototype.hasOwnProperty.call(obj, k)) {
|
||||
out[k] = iteratee(obj[k], k)
|
||||
}
|
||||
}
|
||||
return out as Record<keyof T, R>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user