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..058a293 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 { ATTR_PEER_SERVICE, trace } 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/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 new file mode 100644 index 0000000..eca4a08 --- /dev/null +++ b/packages/utils/src/lib/server/telemetry.ts @@ -0,0 +1,61 @@ +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 traceDecoratorFactory = (name: string, version?: string) => { + const tracer = api.trace.getTracer(name, version); + + 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 { + 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 (err instanceof Error) { + span.recordException(err); + } + + 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 e6849f3..5737f2e 100644 --- a/packages/utils/tsconfig.json +++ b/packages/utils/tsconfig.json @@ -9,7 +9,8 @@ "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 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