Merged in feat/sw-2859-set-up-shared-trpc-package (pull request #2319)

feat(SW-2859): Create trpc package

* Add isEdge, safeTry and dataCache to new common package

* Add eslint and move prettier config

* Clean up tests

* Create trpc package and move initialization

* Move errors and a few procedures

* Move telemetry to common package

* Move tokenManager to common package

* Add Sentry to procedures

* Clean up procedures

* Fix self-referencing imports

* Add exports to packages and lint rule to prevent relative imports

* Add env to trpc package

* Add eslint to trpc package

* Apply lint rules

* Use direct imports from trpc package

* Add lint-staged config to trpc

* Move lang enum to common

* Restructure trpc package folder structure

* Fix lang imports


Approved-by: Linus Flood
This commit is contained in:
Anton Gunnarsson
2025-06-18 12:14:20 +00:00
parent 2f38bdf0b1
commit 846fd904a6
211 changed files with 989 additions and 627 deletions

View File

@@ -0,0 +1,8 @@
export enum Lang {
da = "da",
de = "de",
en = "en",
fi = "fi",
no = "no",
sv = "sv",
}

View File

@@ -17,6 +17,9 @@ export const env = createEnv({
? z.string()
: z.string().optional().default("dev"),
GIT_SHA: z.string().optional(),
CURITY_ISSUER_USER: z.string(),
CURITY_CLIENT_ID_SERVICE: z.string().default("scandichotels-web-backend"),
CURITY_CLIENT_SECRET_SERVICE: z.string(),
},
emptyStringAsUndefined: true,
runtimeEnv: {
@@ -25,5 +28,8 @@ export const env = createEnv({
REDIS_API_KEY: process.env.REDIS_API_KEY,
BRANCH: process.env.BRANCH,
GIT_SHA: process.env.GIT_SHA,
CURITY_CLIENT_SECRET_SERVICE: process.env.CURITY_CLIENT_SECRET_SERVICE,
CURITY_ISSUER_USER: process.env.CURITY_ISSUER_USER,
CURITY_CLIENT_ID_SERVICE: process.env.CURITY_CLIENT_ID_SERVICE,
},
})

View File

@@ -10,10 +10,22 @@
"check-types": "tsc --noEmit",
"lint": "eslint . --max-warnings 0 && tsc --noEmit"
},
"exports": {
"./global.d.ts": "./global.d.ts",
"./dataCache": "./dataCache/index.ts",
"./telemetry": "./telemetry/index.ts",
"./tokenManager": "./tokenManager/index.ts",
"./utils/isEdge": "./utils/isEdge.ts",
"./utils/safeTry": "./utils/safeTry.ts",
"./constants/language": "./constants/language.ts"
},
"dependencies": {
"@opentelemetry/api": "^1.9.0",
"@sentry/nextjs": "^8.41.0",
"@t3-oss/env-nextjs": "^0.13.4",
"deepmerge": "^4.3.1",
"flat": "^6.0.1",
"lodash-es": "^4.17.21",
"zod": "^3.24.4"
},
"devDependencies": {
@@ -21,6 +33,7 @@
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.26.0",
"@scandic-hotels/typescript-config": "workspace:*",
"@types/lodash-es": "^4",
"@typescript-eslint/eslint-plugin": "^8.32.0",
"@typescript-eslint/parser": "^8.32.0",
"dotenv": "^16.5.0",

View File

@@ -0,0 +1,135 @@
import { describe, expect, test } from "vitest"
import { sanitize } from "."
describe("sanitize", () => {
test("should handle valid primitive attributes", () => {
const input = {
key1: "value1",
key2: 10,
key3: true,
}
const expected = {
key1: "value1",
key2: 10,
key3: true,
}
expect(sanitize(input)).toEqual(expected)
})
test("should handle valid array attributes", () => {
const input = {
key1: ["value1", "value2"],
key2: [1, 2, 3],
key3: [true, false, true],
key4: [null, undefined, "a", 1, true],
}
const expected = {
"key1.0": "value1",
"key1.1": "value2",
"key2.0": 1,
"key2.1": 2,
"key2.2": 3,
"key3.0": true,
"key3.1": false,
"key3.2": true,
"key4.0": null,
"key4.1": undefined,
"key4.2": "a",
"key4.3": 1,
"key4.4": true,
}
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",
key2: 10,
nested: {
nestedKey1: "Value",
nestedKey2: {
nestedKey2Key1: true,
},
},
}
const expected = {
key1: "Example",
key2: 10,
"nested.nestedKey1": "Value",
"nested.nestedKey2.nestedKey2Key1": true,
}
expect(sanitize(input)).toEqual(expected)
})
test("should handle a mix of valid and non-valid nested attributes", () => {
const input = {
key1: "Example",
key2: 10,
nested: {
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 input = {
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)
})
})

View File

@@ -0,0 +1,309 @@
// 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 type { ZodError } from "zod"
type AttributesInput = Record<string, unknown>
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);
* console.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<AttributesInput>([baseAttrs, attrs])
const finalAttrs = sanitize(mergedAttrs)
counter.add(1, finalAttrs)
console.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<AttributesInput>([baseAttrs, attrs])
const finalAttrs = sanitize(mergedAttrs)
success.add(1, finalAttrs)
console.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<AttributesInput>([
baseAttrs,
attrs,
{
error_type: "data_error",
error: errorMsg,
},
])
const finalAttrs = sanitize(mergedAttrs)
fail.add(1, finalAttrs)
console.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<AttributesInput>([
baseAttrs,
attrs,
{
error_type: "not_found",
},
])
const finalAttrs = sanitize(mergedAttrs)
fail.add(1, finalAttrs)
console.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<AttributesInput>([
baseAttrs,
{
error_type: "validation_error",
error: zodError.format(),
},
])
const finalAttrs = sanitize(mergedAttrs)
fail.add(1, finalAttrs)
console.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<AttributesInput>([
baseAttrs,
{
error_type: "http_error",
error: {
status: res.status,
statusText: res.statusText,
text,
},
},
])
const finalAttrs = sanitize(mergedAttrs)
fail.add(1, finalAttrs)
console.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<AttributesInput>([
baseAttrs,
{
error_type: "error",
error: msg,
},
])
const finalAttrs = sanitize(mergedAttrs)
fail.add(1, finalAttrs)
console.error(`[${fullName}] fail:`, finalAttrs)
},
}
},
}
}

View File

@@ -0,0 +1,2 @@
import { getServiceToken } from "./tokenManager"
export { getServiceToken }

View File

@@ -0,0 +1,123 @@
import { trace, type Tracer } from "@opentelemetry/api"
import { getCacheClient } from "../dataCache"
import { env } from "../env/server"
import { createCounter } from "../telemetry"
interface ServiceTokenResponse {
access_token: string
scope?: string
token_type: string
expires_in: number
}
export async function getServiceToken() {
const tracer = trace.getTracer("getServiceToken")
return await tracer.startActiveSpan("getServiceToken", async () => {
const scopes = ["profile", "hotel", "booking", "package", "availability"]
const cacheKey = getServiceTokenCacheKey(scopes)
const cacheClient = await getCacheClient()
const token = await getOrSetServiceTokenFromCache(cacheKey, scopes, tracer)
if (token.expiresAt < Date.now()) {
await cacheClient.deleteKey(cacheKey)
const newToken = await getOrSetServiceTokenFromCache(
cacheKey,
scopes,
tracer
)
return newToken.jwt
}
return token.jwt
})
}
async function getOrSetServiceTokenFromCache(
cacheKey: string,
scopes: string[],
tracer: Tracer
) {
const cacheClient = await getCacheClient()
const token = await cacheClient.cacheOrGet(
cacheKey,
async () => {
return await tracer.startActiveSpan("fetch new token", async () => {
const newToken = await getJwt(scopes)
return newToken
})
},
"1h"
)
return token
}
async function getJwt(scopes: string[]) {
const getJwtCounter = createCounter("tokenManager", "getJwt")
const metricsGetJwt = getJwtCounter.init({
scopes,
})
metricsGetJwt.start()
const jwt = await fetchServiceToken(scopes)
const expiresAt = Date.now() + jwt.expires_in * 1000
metricsGetJwt.success()
return { expiresAt, jwt }
}
async function fetchServiceToken(scopes: string[]) {
const fetchServiceTokenCounter = createCounter(
"tokenManager",
"fetchServiceToken"
)
const metricsFetchServiceToken = fetchServiceTokenCounter.init({
scopes,
})
metricsFetchServiceToken.start()
const response = await fetch(`${env.CURITY_ISSUER_USER}/oauth/v2/token`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
grant_type: "client_credentials",
client_id: env.CURITY_CLIENT_ID_SERVICE,
client_secret: env.CURITY_CLIENT_SECRET_SERVICE,
scope: scopes.join(" "),
}),
signal: AbortSignal.timeout(15_000),
})
if (!response.ok) {
await metricsFetchServiceToken.httpError(response)
const text = await response.text()
throw new Error(
`[fetchServiceToken] Failed to obtain service token: ${JSON.stringify({
status: response.status,
statusText: response.statusText,
text,
})}`
)
}
const result = response.json() as Promise<ServiceTokenResponse>
metricsFetchServiceToken.success()
return result
}
function getServiceTokenCacheKey(scopes: string[]): string {
return `serviceToken:${scopes.join(",")}`
}