diff --git a/packages/kit/package.json b/packages/kit/package.json index c139d579f5e5..d1c2ee669e49 100644 --- a/packages/kit/package.json +++ b/packages/kit/package.json @@ -32,6 +32,7 @@ "sirv": "^3.0.0" }, "devDependencies": { + "@opentelemetry/api": "^1.0.0", "@playwright/test": "catalog:", "@sveltejs/vite-plugin-svelte": "catalog:", "@types/connect": "^3.4.38", @@ -47,9 +48,15 @@ }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0", + "@opentelemetry/api": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0" }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + } + }, "bin": { "svelte-kit": "svelte-kit.js" }, diff --git a/packages/kit/src/runtime/telemetry/get_tracer.js b/packages/kit/src/runtime/telemetry/get_tracer.js new file mode 100644 index 000000000000..5c7fac29b412 --- /dev/null +++ b/packages/kit/src/runtime/telemetry/get_tracer.js @@ -0,0 +1,27 @@ +/** @import { Tracer } from '@opentelemetry/api' */ +import { DEV } from 'esm-env'; +import { noop_tracer } from './noop.js'; +import { load_otel } from './load_otel.js'; + +/** + * @param {Object} [options={}] - Configuration options + * @param {boolean} [options.is_enabled=false] - Whether tracing is enabled + * @returns {Promise} The tracer instance + */ +export async function get_tracer({ is_enabled = false } = {}) { + if (!is_enabled) { + return noop_tracer; + } + + const otel = await load_otel(); + if (otel === null) { + if (DEV) { + console.warn( + 'Tracing is enabled, but `@opentelemetry/api` is not available. Have you installed it?' + ); + } + return noop_tracer; + } + + return otel.tracer; +} diff --git a/packages/kit/src/runtime/telemetry/get_tracer.spec.js b/packages/kit/src/runtime/telemetry/get_tracer.spec.js new file mode 100644 index 000000000000..01a8dc74a9b1 --- /dev/null +++ b/packages/kit/src/runtime/telemetry/get_tracer.spec.js @@ -0,0 +1,31 @@ +import { describe, test, expect, beforeEach, vi } from 'vitest'; +import { get_tracer } from './get_tracer.js'; +import { noop_tracer } from './noop.js'; +import * as load_otel from './load_otel.js'; + +describe('get_tracer', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + test('returns noop tracer if tracing is disabled', async () => { + const tracer = await get_tracer({ is_enabled: false }); + expect(tracer).toBe(noop_tracer); + }); + + test('returns noop tracer if @opentelemetry/api is not installed, warning', async () => { + vi.spyOn(load_otel, 'load_otel').mockResolvedValue(null); + const console_warn_spy = vi.spyOn(console, 'warn'); + + const tracer = await get_tracer({ is_enabled: true }); + expect(tracer).toBe(noop_tracer); + expect(console_warn_spy).toHaveBeenCalledWith( + 'Tracing is enabled, but `@opentelemetry/api` is not available. Have you installed it?' + ); + }); + + test('returns otel tracer if @opentelemetry/api is installed', async () => { + const tracer = await get_tracer({ is_enabled: true }); + expect(tracer).not.toBe(noop_tracer); + }); +}); diff --git a/packages/kit/src/runtime/telemetry/load_otel.js b/packages/kit/src/runtime/telemetry/load_otel.js new file mode 100644 index 000000000000..4e37b357857f --- /dev/null +++ b/packages/kit/src/runtime/telemetry/load_otel.js @@ -0,0 +1,18 @@ +/** @import { Tracer, SpanStatusCode } from '@opentelemetry/api' */ + +/** @type {Promise<{ tracer: Tracer, SpanStatusCode: typeof SpanStatusCode } | null> | null} */ +let otel_result = null; + +export function load_otel() { + if (otel_result) return otel_result; + otel_result = import('@opentelemetry/api') + .then((module) => { + const { trace, SpanStatusCode } = module; + return { + tracer: trace.getTracer('sveltekit'), + SpanStatusCode + }; + }) + .catch(() => null); + return otel_result; +} diff --git a/packages/kit/src/runtime/telemetry/noop.js b/packages/kit/src/runtime/telemetry/noop.js new file mode 100644 index 000000000000..47413c5df550 --- /dev/null +++ b/packages/kit/src/runtime/telemetry/noop.js @@ -0,0 +1,81 @@ +/** @import { Tracer, Span, SpanContext } from '@opentelemetry/api' */ + +/** + * Tracer implementation that does nothing (null object). + * @type {Tracer} + */ +export const noop_tracer = { + /** + * @returns {Span} + */ + startSpan() { + return noop_span; + }, + + /** + * @param {unknown} _name + * @param {unknown} arg_1 + * @param {unknown} [arg_2] + * @param {Function} [arg_3] + * @returns {unknown} + */ + startActiveSpan(_name, arg_1, arg_2, arg_3) { + if (typeof arg_1 === 'function') { + return arg_1(noop_span); + } + if (typeof arg_2 === 'function') { + return arg_2(noop_span); + } + if (typeof arg_3 === 'function') { + return arg_3(noop_span); + } + } +}; + +/** + * @type {Span} + */ +export const noop_span = { + spanContext() { + return noop_span_context; + }, + setAttribute() { + return this; + }, + setAttributes() { + return this; + }, + addEvent() { + return this; + }, + setStatus() { + return this; + }, + updateName() { + return this; + }, + end() { + return this; + }, + isRecording() { + return false; + }, + recordException() { + return this; + }, + addLink() { + return this; + }, + addLinks() { + return this; + } +}; + +/** + * @type {SpanContext} + */ +const noop_span_context = { + traceId: '', + spanId: '', + traceFlags: 0 +}; diff --git a/packages/kit/src/runtime/telemetry/record_span.js b/packages/kit/src/runtime/telemetry/record_span.js new file mode 100644 index 000000000000..bcdc3c386e27 --- /dev/null +++ b/packages/kit/src/runtime/telemetry/record_span.js @@ -0,0 +1,65 @@ +/** @import { Attributes, Span, Tracer } from '@opentelemetry/api' */ +import { HttpError, Redirect } from '@sveltejs/kit/internal'; +import { load_otel } from './load_otel.js'; +import { noop_span } from './noop.js'; + +/** + * @template T + * @param {Object} options + * @param {string} options.name + * @param {Tracer} options.tracer + * @param {Attributes} options.attributes + * @param {function(Span): Promise} options.fn + * @returns {Promise} + */ +export async function record_span({ name, tracer, attributes, fn }) { + const otel = await load_otel(); + if (otel === null) { + return fn(noop_span); + } + + const { SpanStatusCode } = otel; + + return tracer.startActiveSpan(name, { attributes }, async (span) => { + try { + const result = await fn(span); + span.end(); + return result; + } catch (error) { + if (error instanceof HttpError) { + span.setAttributes({ + [`${name}.result.type`]: 'known_error', + [`${name}.result.status`]: error.status, + [`${name}.result.message`]: error.body.message + }); + } else if (error instanceof Redirect) { + span.setAttributes({ + [`${name}.result.type`]: 'redirect', + [`${name}.result.status`]: error.status, + [`${name}.result.location`]: error.location + }); + } else if (error instanceof Error) { + span.setAttributes({ + [`${name}.result.type`]: 'unknown_error' + }); + span.recordException({ + name: error.name, + message: error.message, + stack: error.stack + }); + span.setStatus({ + code: SpanStatusCode.ERROR, + message: error.message + }); + } else { + span.setAttributes({ + [`${name}.result.type`]: 'unknown_error' + }); + span.setStatus({ code: SpanStatusCode.ERROR }); + } + span.end(); + + throw error; + } + }); +} diff --git a/packages/kit/src/runtime/telemetry/record_span.spec.js b/packages/kit/src/runtime/telemetry/record_span.spec.js new file mode 100644 index 000000000000..0acf19d49e36 --- /dev/null +++ b/packages/kit/src/runtime/telemetry/record_span.spec.js @@ -0,0 +1,176 @@ +import { describe, test, expect, vi } from 'vitest'; +import { record_span } from './record_span.js'; +import { noop_span, noop_tracer } from './noop.js'; +import * as load_otel from './load_otel.js'; +import { HttpError, Redirect } from '@sveltejs/kit/internal'; + +const create_mock_span = () => + /** @type {import('@opentelemetry/api').Span} */ ( + /** @type {unknown} */ ({ + end: vi.fn(), + setAttributes: vi.fn(), + setStatus: vi.fn(), + recordException: vi.fn() + }) + ); + +/** @type {() => { tracer: import('@opentelemetry/api').Tracer, span: import('@opentelemetry/api').Span } } */ +const create_mock_tracer = () => { + const span = create_mock_span(); + const tracer = { + startActiveSpan: vi.fn().mockImplementation((_name, _options, fn) => { + return fn(span); + }), + startSpan: vi.fn().mockImplementation((_name, _options, fn) => { + return fn(span); + }) + }; + return { tracer, span }; +}; + +describe('record_span', () => { + test('runs function with noop span if @opentelemetry/api is not available', async () => { + const spy = vi.spyOn(load_otel, 'load_otel').mockResolvedValue(null); + const fn = vi.fn().mockResolvedValue('result'); + + const result = await record_span({ name: 'test', tracer: noop_tracer, attributes: {}, fn }); + expect(result).toBe('result'); + expect(fn).toHaveBeenCalledWith(noop_span); + spy.mockRestore(); + }); + + test('runs function with span if @opentelemetry/api is available', async () => { + const fn = vi.fn().mockResolvedValue('result'); + const result = await record_span({ + name: 'test', + tracer: create_mock_tracer().tracer, + attributes: {}, + fn + }); + expect(result).toBe('result'); + expect(fn).not.toHaveBeenCalledWith(noop_span); + }); + + test('successful function returns result, attaching correct attributes', async () => { + const { tracer, span } = create_mock_tracer(); + const fn = vi.fn().mockResolvedValue('result'); + const result = await record_span({ + name: 'test', + tracer, + attributes: { 'test-attribute': true }, + fn + }); + expect(result).toBe('result'); + expect(tracer.startActiveSpan).toHaveBeenCalledWith( + 'test', + { attributes: { 'test-attribute': true } }, + expect.any(Function) + ); + expect(span.end).toHaveBeenCalled(); + }); + + test('HttpError sets correct attributes and re-throws', async () => { + const { tracer, span } = create_mock_tracer(); + const error = new HttpError(404, 'Not found'); + const error_fn = vi.fn().mockRejectedValue(error); + + await expect( + record_span({ + name: 'test', + tracer, + attributes: {}, + fn: error_fn + }) + ).rejects.toBe(error); + + expect(span.setAttributes).toHaveBeenCalledWith({ + 'test.result.type': 'known_error', + 'test.result.status': 404, + 'test.result.message': 'Not found' + }); + expect(span.recordException).toHaveBeenCalledWith({ + name: 'HttpError', + message: 'Not found' + }); + expect(span.setStatus).toHaveBeenCalledWith({ + code: expect.any(Number), + message: 'Not found' + }); + expect(span.end).toHaveBeenCalled(); + }); + + test('Redirect sets correct attributes and re-throws', async () => { + const { tracer, span } = create_mock_tracer(); + const error = new Redirect(302, '/redirect-location'); + const error_fn = vi.fn().mockRejectedValue(error); + + await expect( + record_span({ + name: 'test', + tracer, + attributes: {}, + fn: error_fn + }) + ).rejects.toBe(error); + + expect(span.setAttributes).toHaveBeenCalledWith({ + 'test.result.type': 'redirect', + 'test.result.status': 302, + 'test.result.location': '/redirect-location' + }); + expect(span.setStatus).not.toHaveBeenCalled(); + expect(span.end).toHaveBeenCalled(); + }); + + test('Generic Error sets correct attributes and re-throws', async () => { + const { tracer, span } = create_mock_tracer(); + const error = new Error('Something went wrong'); + const error_fn = vi.fn().mockRejectedValue(error); + + await expect( + record_span({ + name: 'test', + tracer, + attributes: {}, + fn: error_fn + }) + ).rejects.toThrow(error); + + expect(span.setAttributes).toHaveBeenCalledWith({ + 'test.result.type': 'unknown_error' + }); + expect(span.recordException).toHaveBeenCalledWith({ + name: 'Error', + message: 'Something went wrong', + stack: error.stack + }); + expect(span.setStatus).toHaveBeenCalledWith({ + code: expect.any(Number), + message: 'Something went wrong' + }); + expect(span.end).toHaveBeenCalled(); + }); + + test('Non-Error object sets correct attributes and re-throws', async () => { + const { tracer, span } = create_mock_tracer(); + const error = 'string error'; + const error_fn = vi.fn().mockRejectedValue(error); + + await expect( + record_span({ + name: 'test', + tracer, + attributes: {}, + fn: error_fn + }) + ).rejects.toThrow(error); + + expect(span.setAttributes).toHaveBeenCalledWith({ + 'test.result.type': 'unknown_error' + }); + expect(span.setStatus).toHaveBeenCalledWith({ + code: expect.any(Number) + }); + expect(span.end).toHaveBeenCalled(); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 25c3ed18dcd3..2e666f90508f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -413,6 +413,9 @@ importers: specifier: ^3.0.0 version: 3.0.0 devDependencies: + '@opentelemetry/api': + specifier: ^1.0.0 + version: 1.9.0 '@playwright/test': specifier: 'catalog:' version: 1.51.1 @@ -2059,6 +2062,10 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@opentelemetry/api@1.9.0': + resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} + engines: {node: '>=8.0.0'} + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -5407,6 +5414,8 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.17.1 + '@opentelemetry/api@1.9.0': {} + '@pkgjs/parseargs@0.11.0': optional: true