Merged in chore/upgrade-sentry (pull request #3191)

feat: upgrade sentry and use metrics

* feat: upgrade sentry and use metrics

* remove ununsed deps

* rename span

* .


Approved-by: Linus Flood
This commit is contained in:
Joakim Jäderberg
2025-11-20 13:24:53 +00:00
parent 5eaaea527f
commit b1d7fbad88
14 changed files with 510 additions and 470 deletions

View File

@@ -27,7 +27,7 @@
"@scandic-hotels/design-system": "workspace:*", "@scandic-hotels/design-system": "workspace:*",
"@scandic-hotels/tracking": "workspace:*", "@scandic-hotels/tracking": "workspace:*",
"@scandic-hotels/trpc": "workspace:*", "@scandic-hotels/trpc": "workspace:*",
"@sentry/nextjs": "^10.11.0", "@sentry/nextjs": "^10.26.0",
"@swc/plugin-formatjs": "^3.2.2", "@swc/plugin-formatjs": "^3.2.2",
"@tanstack/react-query": "^5.75.5", "@tanstack/react-query": "^5.75.5",
"@tanstack/react-query-devtools": "^5.75.5", "@tanstack/react-query-devtools": "^5.75.5",

View File

@@ -21,5 +21,6 @@ async function configureSentry() {
tracesSampleRate: env.SENTRY_SERVER_SAMPLERATE, tracesSampleRate: env.SENTRY_SERVER_SAMPLERATE,
denyUrls: denyUrls, denyUrls: denyUrls,
enableLogs: true, enableLogs: true,
enableMetrics: true,
}) })
} }

View File

@@ -1,6 +1,6 @@
import crypto from "node:crypto" import crypto from "node:crypto"
import Sentry from "@sentry/nextjs" import * as Sentry from "@sentry/nextjs"
import jwt from "jsonwebtoken" import jwt from "jsonwebtoken"
import { type WarmupFunctionsKey } from "@/services/warmup/warmupKeys" import { type WarmupFunctionsKey } from "@/services/warmup/warmupKeys"

View File

@@ -1,4 +1,4 @@
import Sentry from "@sentry/nextjs" import * as Sentry from "@sentry/nextjs"
export const denyUrls: (string | RegExp)[] = [ export const denyUrls: (string | RegExp)[] = [
// Ignore preview urls // Ignore preview urls

View File

@@ -29,12 +29,6 @@
"@netlify/blobs": "^8.1.0", "@netlify/blobs": "^8.1.0",
"@netlify/functions": "^3.0.0", "@netlify/functions": "^3.0.0",
"@netlify/plugin-nextjs": "^5.14.4", "@netlify/plugin-nextjs": "^5.14.4",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/api-logs": "^0.56.0",
"@opentelemetry/instrumentation": "^0.56.0",
"@opentelemetry/resources": "^1.29.0",
"@opentelemetry/sdk-logs": "^0.56.0",
"@opentelemetry/sdk-trace-base": "^1.29.0",
"@radix-ui/react-slot": "^1.2.2", "@radix-ui/react-slot": "^1.2.2",
"@react-aria/ssr": "^3.9.8", "@react-aria/ssr": "^3.9.8",
"@scandic-hotels/booking-flow": "workspace:*", "@scandic-hotels/booking-flow": "workspace:*",
@@ -42,7 +36,7 @@
"@scandic-hotels/design-system": "workspace:*", "@scandic-hotels/design-system": "workspace:*",
"@scandic-hotels/tracking": "workspace:*", "@scandic-hotels/tracking": "workspace:*",
"@scandic-hotels/trpc": "workspace:*", "@scandic-hotels/trpc": "workspace:*",
"@sentry/nextjs": "^10.11.0", "@sentry/nextjs": "^10.26.0",
"@swc/plugin-formatjs": "^3.2.2", "@swc/plugin-formatjs": "^3.2.2",
"@t3-oss/env-nextjs": "^0.13.4", "@t3-oss/env-nextjs": "^0.13.4",
"@tanstack/react-query": "^5.75.5", "@tanstack/react-query": "^5.75.5",

View File

@@ -72,8 +72,7 @@
"./utils/zod/*": "./utils/zod/*.ts" "./utils/zod/*": "./utils/zod/*.ts"
}, },
"dependencies": { "dependencies": {
"@opentelemetry/api": "^1.9.0", "@sentry/nextjs": "^10.26.0",
"@sentry/nextjs": "^10.11.0",
"@t3-oss/env-nextjs": "^0.13.4", "@t3-oss/env-nextjs": "^0.13.4",
"deepmerge": "^4.3.1", "deepmerge": "^4.3.1",
"flat": "^6.0.1", "flat": "^6.0.1",

View File

@@ -1,7 +1,4 @@
// Central place for telemetry import * as Sentry from "@sentry/nextjs"
// TODO: Replace all of this with proper tracers and events
import { type Attributes, metrics } from "@opentelemetry/api"
import deepmerge from "deepmerge" import deepmerge from "deepmerge"
import { flatten } from "flat" import { flatten } from "flat"
@@ -20,7 +17,6 @@ import type { ZodError } from "zod"
* *
* @example * @example
* ```typescript * ```typescript
* import { sanitize } from '@/server/telemetry';
* *
* const input = { * const input = {
* key1: "Example", * key1: "Example",
@@ -43,7 +39,7 @@ import type { ZodError } from "zod"
* // } * // }
* ``` * ```
*/ */
export function sanitize(data: any): Attributes { export function sanitize(data: any): Record<string, string | number | boolean> {
if (!data) return {} if (!data) return {}
if (typeof data === "string") { if (typeof data === "string") {
return { value: data } return { value: data }
@@ -68,14 +64,8 @@ export function sanitize(data: any): Attributes {
* See the codebase for reference usage. * See the codebase for reference usage.
*/ */
export function createCounter(meterName: string, counterName: string) { export function createCounter(meterName: string, counterName: string) {
const meter = metrics.getMeter(meterName)
const fullName = `${meterName}.${counterName}` const fullName = `${meterName}.${counterName}`
const counter = meter.createCounter(fullName)
const success = meter.createCounter(`${fullName}-success`)
const fail = meter.createCounter(`${fullName}-fail`)
return { return {
/** /**
* Initializes the counter event handlers with a set of base attributes. * Initializes the counter event handlers with a set of base attributes.
@@ -91,12 +81,8 @@ export function createCounter(meterName: string, counterName: string) {
* *
* @param attrs - Additional attributes specific to this 'start' event. Defaults to an empty object. * @param attrs - Additional attributes specific to this 'start' event. Defaults to an empty object.
*/ */
start(attrs: object = {}) { start(attrs: object | undefined = undefined) {
const mergedAttrs = deepmerge.all<object>([baseAttrs, attrs]) logger.debug(`[${fullName}] start`, attrs)
const finalAttrs = sanitize(mergedAttrs)
counter.add(1, finalAttrs)
logger.debug(`[${fullName}] start`, mergedAttrs)
}, },
/** /**
@@ -107,8 +93,9 @@ export function createCounter(meterName: string, counterName: string) {
success(attrs: object = {}) { success(attrs: object = {}) {
const mergedAttrs = deepmerge.all<object>([baseAttrs, attrs]) const mergedAttrs = deepmerge.all<object>([baseAttrs, attrs])
const finalAttrs = sanitize(mergedAttrs) const finalAttrs = sanitize(mergedAttrs)
Sentry.metrics.count(fullName, 1, {
success.add(1, finalAttrs) attributes: { ...finalAttrs, status: "success" },
})
logger.debug(`[${fullName}] success`, mergedAttrs) logger.debug(`[${fullName}] success`, mergedAttrs)
}, },
@@ -132,7 +119,9 @@ export function createCounter(meterName: string, counterName: string) {
]) ])
const finalAttrs = sanitize(mergedAttrs) const finalAttrs = sanitize(mergedAttrs)
fail.add(1, finalAttrs) Sentry.metrics.count(fullName, 1, {
attributes: { ...finalAttrs, status: "error" },
})
logger.error(`[${fullName}] dataError: ${errorMsg}`, mergedAttrs) logger.error(`[${fullName}] dataError: ${errorMsg}`, mergedAttrs)
}, },
@@ -154,7 +143,9 @@ export function createCounter(meterName: string, counterName: string) {
]) ])
const finalAttrs = sanitize(mergedAttrs) const finalAttrs = sanitize(mergedAttrs)
fail.add(1, finalAttrs) Sentry.metrics.count(fullName, 1, {
attributes: { ...finalAttrs, status: "error" },
})
logger.error(`[${fullName}] noDataError:`, mergedAttrs) logger.error(`[${fullName}] noDataError:`, mergedAttrs)
}, },
@@ -174,7 +165,9 @@ export function createCounter(meterName: string, counterName: string) {
]) ])
const finalAttrs = sanitize(mergedAttrs) const finalAttrs = sanitize(mergedAttrs)
fail.add(1, finalAttrs) Sentry.metrics.count(fullName, 1, {
attributes: { ...finalAttrs, status: "error" },
})
logger.error(`[${fullName}] validationError`, mergedAttrs) logger.error(`[${fullName}] validationError`, mergedAttrs)
}, },
@@ -198,11 +191,14 @@ export function createCounter(meterName: string, counterName: string) {
"error.status": res.status, "error.status": res.status,
"error.statusText": res.statusText, "error.statusText": res.statusText,
"error.text": text, "error.text": text,
url: res.url,
}, },
]) ])
const finalAttrs = sanitize(mergedAttrs) const finalAttrs = sanitize(mergedAttrs)
fail.add(1, finalAttrs) Sentry.metrics.count(fullName, 1, {
attributes: { ...finalAttrs, status: "error" },
})
logger.error( logger.error(
`[${fullName}] httpError ${res.status}, ${res.statusText}:`, `[${fullName}] httpError ${res.status}, ${res.statusText}:`,
mergedAttrs mergedAttrs
@@ -235,7 +231,9 @@ export function createCounter(meterName: string, counterName: string) {
]) ])
const finalAttrs = sanitize(mergedAttrs) const finalAttrs = sanitize(mergedAttrs)
fail.add(1, finalAttrs) Sentry.metrics.count(fullName, 1, {
attributes: { ...finalAttrs, status: "error" },
})
logger.error(`[${fullName}] fail message: ${msg}`, mergedAttrs) logger.error(`[${fullName}] fail message: ${msg}`, mergedAttrs)
}, },
} }

View File

@@ -1,4 +1,4 @@
import { trace, type Tracer } from "@opentelemetry/api" import * as Sentry from "@sentry/nextjs"
import { getCacheClient } from "../dataCache" import { getCacheClient } from "../dataCache"
import { env } from "../env/server" import { env } from "../env/server"
@@ -11,24 +11,18 @@ interface ServiceTokenResponse {
expires_in: number expires_in: number
} }
export async function getServiceToken() { export async function getServiceToken(): Promise<ServiceTokenResponse> {
const tracer = trace.getTracer("getServiceToken") return Sentry.startSpan({ name: "getServiceToken" }, async () => {
return await tracer.startActiveSpan("getServiceToken", async () => {
const scopes = env.CURITY_CLIENT_SERVICE_SCOPES const scopes = env.CURITY_CLIENT_SERVICE_SCOPES
const cacheKey = getServiceTokenCacheKey(scopes) const cacheKey = getServiceTokenCacheKey(scopes)
const cacheClient = await getCacheClient() const cacheClient = await getCacheClient()
const token = await getOrSetServiceTokenFromCache(cacheKey, scopes, tracer) const token = await getOrSetServiceTokenFromCache(cacheKey, scopes)
if (token.expiresAt < Date.now()) { if (token.expiresAt < Date.now()) {
await cacheClient.deleteKey(cacheKey) await cacheClient.deleteKey(cacheKey)
const newToken = await getOrSetServiceTokenFromCache( const newToken = await getOrSetServiceTokenFromCache(cacheKey, scopes)
cacheKey,
scopes,
tracer
)
return newToken.jwt return newToken.jwt
} }
@@ -38,16 +32,14 @@ export async function getServiceToken() {
async function getOrSetServiceTokenFromCache( async function getOrSetServiceTokenFromCache(
cacheKey: string, cacheKey: string,
scopes: string[], scopes: string[]
tracer: Tracer
) { ) {
const cacheClient = await getCacheClient() const cacheClient = await getCacheClient()
const token = await cacheClient.cacheOrGet( const token = await cacheClient.cacheOrGet(
cacheKey, cacheKey,
async () => { async () => {
return await tracer.startActiveSpan("fetch new token", async () => { return Sentry.startSpan({ name: "fetch new serviceToken" }, async () => {
const newToken = await getJwt(scopes) return await getJwt(scopes)
return newToken
}) })
}, },
"1h" "1h"
@@ -56,19 +48,10 @@ async function getOrSetServiceTokenFromCache(
} }
async function getJwt(scopes: string[]) { async function getJwt(scopes: string[]) {
const getJwtCounter = createCounter("tokenManager", "getJwt")
const metricsGetJwt = getJwtCounter.init({
scopes,
})
metricsGetJwt.start()
const jwt = await fetchServiceToken(scopes) const jwt = await fetchServiceToken(scopes)
const expiresAt = Date.now() + jwt.expires_in * 1000 const expiresAt = Date.now() + jwt.expires_in * 1000
metricsGetJwt.success()
return { expiresAt, jwt } return { expiresAt, jwt }
} }

View File

@@ -0,0 +1,71 @@
import { describe, expect, it } from "vitest"
import { flattenInput } from "./flattenInput"
describe("flattenInput", () => {
it("should return undefined for null input", () => {
expect(flattenInput(null)).toBeUndefined()
})
it("should return undefined for non-object input", () => {
expect(flattenInput("string")).toBeUndefined()
expect(flattenInput(123)).toBeUndefined()
expect(flattenInput(true)).toBeUndefined()
})
it("should return undefined for empty object", () => {
expect(flattenInput({})).toBeUndefined()
})
it("should return undefined for object with no primitive values", () => {
expect(flattenInput({ nested: { deep: "value" } })).toBeUndefined()
expect(flattenInput({ arr: [1, 2, 3] })).toBeUndefined()
expect(flattenInput({ fn: () => {} })).toBeUndefined()
})
it("should flatten object with primitive values", () => {
const input = {
name: "test",
age: 25,
active: true,
}
const result = flattenInput(input)
expect(result).toEqual({
"input.name": "test",
"input.age": 25,
"input.active": true,
})
})
it("should filter out non-primitive values and flatten remaining", () => {
const input = {
name: "test",
count: 42,
nested: { deep: "value" },
valid: false,
array: [1, 2, 3],
}
const result = flattenInput(input)
expect(result).toEqual({
"input.name": "test",
"input.count": 42,
"input.valid": false,
})
})
it("should handle mixed primitive types", () => {
const input = {
str: "hello",
num: 0,
bool: false,
negNum: -5,
}
const result = flattenInput(input)
expect(result).toEqual({
"input.str": "hello",
"input.num": 0,
"input.bool": false,
"input.negNum": -5,
})
})
})

View File

@@ -0,0 +1,29 @@
import { flatten } from "flat"
export function flattenInput(
input: unknown
): Record<string, unknown> | undefined {
if (typeof input !== "object" || input === null) {
return undefined
}
const onlyPrimitives = Object.entries(input).reduce(
(acc, [key, value]) => {
if (
typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean"
) {
acc[key] = value
}
return acc
},
{} as Record<string, unknown>
)
if (onlyPrimitives && Object.keys(onlyPrimitives).length === 0) {
return undefined
}
return flatten({ input: onlyPrimitives })
}

View File

@@ -0,0 +1,28 @@
import * as Sentry from "@sentry/nextjs"
import { middleware } from "../../."
import { flattenInput } from "./flattenInput"
export const durationMiddleware = middleware(
async ({ path, type, next, getRawInput }) => {
const perf = performance.now()
const res = await next()
const duration = performance.now() - perf
const input = await getRawInput()
const primitiveInput = flattenInput(input)
Sentry.metrics.distribution("trpc", duration, {
unit: "milliseconds",
attributes: {
path,
type,
status: res.ok ? "ok" : "error",
error: res.ok ? undefined : res.error.code,
...primitiveInput,
},
})
return res
}
)

View File

@@ -0,0 +1,9 @@
import * as Sentry from "@sentry/nextjs"
import { middleware } from ".."
export const sentryMiddleware = middleware(
Sentry.trpcMiddleware({
attachRpcInput: true,
})
)

View File

@@ -1,10 +1,10 @@
import * as Sentry from "@sentry/nextjs"
import { Lang } from "@scandic-hotels/common/constants/language" import { Lang } from "@scandic-hotels/common/constants/language"
import { logger } from "@scandic-hotels/common/logger" import { logger } from "@scandic-hotels/common/logger"
import { getServiceToken } from "@scandic-hotels/common/tokenManager" import { getServiceToken } from "@scandic-hotels/common/tokenManager"
import { env } from "../env/server" import { env } from "../env/server"
import { durationMiddleware } from "./middlewares/durationMiddleware"
import { sentryMiddleware } from "./middlewares/sentryMiddleware"
import { import {
badRequestError, badRequestError,
internalServerError, internalServerError,
@@ -12,15 +12,11 @@ import {
unauthorizedError, unauthorizedError,
} from "./errors" } from "./errors"
import { langInput } from "./utils" import { langInput } from "./utils"
import { middleware, procedure } from "." import { procedure } from "."
const sentryMiddleware = middleware( export const baseProcedure = procedure
Sentry.trpcMiddleware({ .use(sentryMiddleware)
attachRpcInput: true, .use(durationMiddleware)
})
)
export const baseProcedure = procedure.use(sentryMiddleware)
export const publicProcedure = baseProcedure export const publicProcedure = baseProcedure

728
yarn.lock

File diff suppressed because it is too large Load Diff