From 808a7421acce66af013b4261bff52bdd1f7beb42 Mon Sep 17 00:00:00 2001 From: le0m Date: Tue, 29 Jul 2025 12:25:25 +0200 Subject: [PATCH 1/2] feat: add OpenTelemetry and cache tracing --- .changeset/common-bananas-knock.md | 5 +++ packages/utils/package.json | 2 + packages/utils/src/lib/server/cache/base.ts | 3 ++ packages/utils/src/lib/server/telemetry.ts | 41 +++++++++++++++++++++ packages/utils/tsconfig.json | 1 + pnpm-lock.yaml | 18 +++++++++ 6 files changed, 70 insertions(+) create mode 100644 .changeset/common-bananas-knock.md create mode 100644 packages/utils/src/lib/server/telemetry.ts diff --git a/.changeset/common-bananas-knock.md b/.changeset/common-bananas-knock.md new file mode 100644 index 0000000..ca613b4 --- /dev/null +++ b/.changeset/common-bananas-knock.md @@ -0,0 +1,5 @@ +--- +"@chialab/sveltekit-utils": patch +--- + +Add OpenTelemetry for observability diff --git a/packages/utils/package.json b/packages/utils/package.json index f5d9e89..7ee2db5 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -49,6 +49,8 @@ "@aws-sdk/client-s3": "^3.828.0", "@chialab/isomorphic-dom": "workspace:*", "@heyputer/kv.js": "fquffio/kv.js#fix/strict-mode", + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/semantic-conventions": "^1.36.0", "cookie": "^1.0.2", "html-entities": "^2.6.0", "pino": "^9.5.0", diff --git a/packages/utils/src/lib/server/cache/base.ts b/packages/utils/src/lib/server/cache/base.ts index 3e1b6ce..40c405f 100644 --- a/packages/utils/src/lib/server/cache/base.ts +++ b/packages/utils/src/lib/server/cache/base.ts @@ -1,5 +1,7 @@ import type { JitterFn, JitterMode } from '../../utils/misc.js'; import type { StorageReadWriter } from '../storage.js'; +import { trace, ATTR_PEER_SERVICE } from '../telemetry.js'; +import { SpanKind } from '@opentelemetry/api'; /** * Base class for caching. @@ -75,6 +77,7 @@ export abstract class BaseCache implements StorageReadWriter { ttl?: number | undefined, jitter?: JitterMode | JitterFn | undefined, ): Promise; + @trace({ kind: SpanKind.CLIENT, attributes: { [ATTR_PEER_SERVICE]: 'cache' } }) public async remember( key: string, callback: () => PromiseLike, diff --git a/packages/utils/src/lib/server/telemetry.ts b/packages/utils/src/lib/server/telemetry.ts new file mode 100644 index 0000000..e73b51a --- /dev/null +++ b/packages/utils/src/lib/server/telemetry.ts @@ -0,0 +1,41 @@ +import api, { type Span, type SpanOptions, SpanStatusCode } from '@opentelemetry/api'; + +/** + * Replaces deprecated SEMATTRS_PEER_SERVICE attribute. + * See {@link https://github.com/open-telemetry/opentelemetry-js/blob/main/semantic-conventions/README.md#unstable-semconv}. + */ +export const ATTR_PEER_SERVICE = 'peer.service' as const; + +export const tracer = api.trace.getTracer('@chialab/sveltekit-utils'); + +/** + * Decorator to trace a method or function by wrapping it in a new active span. + * + * @param options Span options + */ +export const trace = + (options: SpanOptions = {}) => + (_: any, method: PropertyKey, descriptor: PropertyDescriptor) => ({ + ...descriptor, + value(...args: any[]) { + return tracer.startActiveSpan( + [this?.constructor?.name, method].filter(Boolean).join('.'), + options, + async (span: Span) => { + try { + // Wait for the wrapped function to end before closing the span and returning + return await descriptor.value.call(this, ...args); + } catch (e) { + span.setStatus({ code: SpanStatusCode.ERROR }); + if (e instanceof Error) { + span.recordException(e); + } + + throw e; + } finally { + span.end(); + } + }, + ); + }, + }); diff --git a/packages/utils/tsconfig.json b/packages/utils/tsconfig.json index e6849f3..b8dec24 100644 --- a/packages/utils/tsconfig.json +++ b/packages/utils/tsconfig.json @@ -4,6 +4,7 @@ "allowJs": true, "checkJs": true, "esModuleInterop": true, + "experimentalDecorators": true, "forceConsistentCasingInFileNames": true, "moduleResolution": "Bundler", "resolveJsonModule": true, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1a697bc..ce5805b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -121,6 +121,12 @@ importers: '@heyputer/kv.js': specifier: fquffio/kv.js#fix/strict-mode version: https://codeload.github.com/fquffio/kv.js/tar.gz/813de43135fe812ec17b50fd8ed3c65184c09722 + '@opentelemetry/api': + specifier: ^1.9.0 + version: 1.9.0 + '@opentelemetry/semantic-conventions': + specifier: ^1.36.0 + version: 1.36.0 cookie: specifier: ^1.0.2 version: 1.0.2 @@ -799,6 +805,14 @@ packages: '@open-draft/until@2.1.0': resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} + '@opentelemetry/api@1.9.0': + resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/semantic-conventions@1.36.0': + resolution: {integrity: sha512-TtxJSRD8Ohxp6bKkhrm27JRHAxPczQA7idtcTOMYI+wQRRrfgqxHv1cFbCApcSnNjtXkmzFozn6jQtFrOmbjPQ==} + engines: {node: '>=14'} + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -4025,6 +4039,10 @@ snapshots: '@open-draft/until@2.1.0': {} + '@opentelemetry/api@1.9.0': {} + + '@opentelemetry/semantic-conventions@1.36.0': {} + '@pkgjs/parseargs@0.11.0': optional: true From 2b50bd25f655eeb406e8f0d266a378f90221decb Mon Sep 17 00:00:00 2001 From: le0m Date: Thu, 31 Jul 2025 16:53:02 +0200 Subject: [PATCH 2/2] fix: trace decorator's types --- packages/utils/src/lib/server/cache/base.ts | 2 +- packages/utils/src/lib/server/index.ts | 1 + packages/utils/src/lib/server/telemetry.ts | 70 +++++++++++++-------- packages/utils/tsconfig.json | 4 +- 4 files changed, 49 insertions(+), 28 deletions(-) diff --git a/packages/utils/src/lib/server/cache/base.ts b/packages/utils/src/lib/server/cache/base.ts index 40c405f..058a293 100644 --- a/packages/utils/src/lib/server/cache/base.ts +++ b/packages/utils/src/lib/server/cache/base.ts @@ -1,6 +1,6 @@ import type { JitterFn, JitterMode } from '../../utils/misc.js'; import type { StorageReadWriter } from '../storage.js'; -import { trace, ATTR_PEER_SERVICE } from '../telemetry.js'; +import { ATTR_PEER_SERVICE, trace } from '../telemetry.js'; import { SpanKind } from '@opentelemetry/api'; /** diff --git a/packages/utils/src/lib/server/index.ts b/packages/utils/src/lib/server/index.ts index 9cc8207..36fd912 100644 --- a/packages/utils/src/lib/server/index.ts +++ b/packages/utils/src/lib/server/index.ts @@ -2,6 +2,7 @@ export * from './cache/index.js'; export * from './hooks/index.js'; export * from './session.js'; export * from './sitemap.js'; +export { traceDecoratorFactory, ATTR_PEER_SERVICE } from './telemetry.js'; export * from './utils.js'; import type { Session } from './session.js'; diff --git a/packages/utils/src/lib/server/telemetry.ts b/packages/utils/src/lib/server/telemetry.ts index e73b51a..eca4a08 100644 --- a/packages/utils/src/lib/server/telemetry.ts +++ b/packages/utils/src/lib/server/telemetry.ts @@ -6,36 +6,56 @@ import api, { type Span, type SpanOptions, SpanStatusCode } from '@opentelemetry */ export const ATTR_PEER_SERVICE = 'peer.service' as const; -export const tracer = api.trace.getTracer('@chialab/sveltekit-utils'); +export const traceDecoratorFactory = (name: string, version?: string) => { + const tracer = api.trace.getTracer(name, version); -/** - * Decorator to trace a method or function by wrapping it in a new active span. - * - * @param options Span options - */ -export const trace = - (options: SpanOptions = {}) => - (_: any, method: PropertyKey, descriptor: PropertyDescriptor) => ({ - ...descriptor, - value(...args: any[]) { - return tracer.startActiveSpan( - [this?.constructor?.name, method].filter(Boolean).join('.'), - options, - async (span: Span) => { + return unknown>(options: SpanOptions = {}) => + (target: Value, ctx: ClassMethodDecoratorContext): Value => { + type R = ReturnType; + + return function (this: This, ...args: Parameters): R { + // @ts-expect-error Typing this properly would be cumbersome. + const className = ctx.static ? this.name : this.constructor.name; + + return tracer.startActiveSpan([className, ctx.name].filter(Boolean).join('.'), options, (span: Span): R => { try { - // Wait for the wrapped function to end before closing the span and returning - return await descriptor.value.call(this, ...args); - } catch (e) { + const result = target.call(this, ...args) as R; + if (result && typeof result === 'object' && 'then' in result && typeof result.then === 'function') { + return result.then( + (result: R) => { + span.end(); + + return result; + }, + (err: unknown) => { + span.setStatus({ code: SpanStatusCode.ERROR }); + if (err instanceof Error) { + span.recordException(err); + } + span.end(); + + throw err; + }, + ); + } + + return result; + } catch (err: unknown) { span.setStatus({ code: SpanStatusCode.ERROR }); - if (e instanceof Error) { - span.recordException(e); + if (err instanceof Error) { + span.recordException(err); } - throw e; + throw err; } finally { span.end(); } - }, - ); - }, - }); + }); + } as Value; + }; +}; + +/** + * Decorator to trace a method or function by wrapping it in a new active span. + */ +export const trace = traceDecoratorFactory('@chialab/sveltekit-utils'); diff --git a/packages/utils/tsconfig.json b/packages/utils/tsconfig.json index b8dec24..5737f2e 100644 --- a/packages/utils/tsconfig.json +++ b/packages/utils/tsconfig.json @@ -4,13 +4,13 @@ "allowJs": true, "checkJs": true, "esModuleInterop": true, - "experimentalDecorators": true, "forceConsistentCasingInFileNames": true, "moduleResolution": "Bundler", "resolveJsonModule": true, "skipLibCheck": true, "sourceMap": true, - "strict": true + "strict": true, + "target": "es2022" }, "exclude": ["./tests/coverage/**"] // Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias