diff --git a/.changeset/early-taxis-make.md b/.changeset/early-taxis-make.md new file mode 100644 index 000000000000..4508252ef88b --- /dev/null +++ b/.changeset/early-taxis-make.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': minor +--- + +feat: OpenTelemetry tracing for `handle`, `sequence`, form actions, remote functions, and `load` functions running on the server diff --git a/.changeset/whole-bananas-sort.md b/.changeset/whole-bananas-sort.md new file mode 100644 index 000000000000..3fc971d0ffa8 --- /dev/null +++ b/.changeset/whole-bananas-sort.md @@ -0,0 +1,9 @@ +--- +'@sveltejs/adapter-cloudflare': minor +'@sveltejs/adapter-netlify': minor +'@sveltejs/adapter-vercel': minor +'@sveltejs/adapter-node': minor +'@sveltejs/kit': minor +--- + +feat: add `instrumentation.server.ts` for tracing and observability setup diff --git a/documentation/docs/10-getting-started/30-project-structure.md b/documentation/docs/10-getting-started/30-project-structure.md index 64bae0a695c1..1c947a310435 100644 --- a/documentation/docs/10-getting-started/30-project-structure.md +++ b/documentation/docs/10-getting-started/30-project-structure.md @@ -19,7 +19,8 @@ my-project/ │ ├ error.html │ ├ hooks.client.js │ ├ hooks.server.js -│ └ service-worker.js +| ├ service-worker.js +│ └ tracing.server.js ├ static/ │ └ [your static assets] ├ tests/ @@ -54,6 +55,8 @@ The `src` directory contains the meat of your project. Everything except `src/ro - `hooks.client.js` contains your client [hooks](hooks) - `hooks.server.js` contains your server [hooks](hooks) - `service-worker.js` contains your [service worker](service-workers) +- `instrumentation.server.js` contains your [observability](observability) setup and instrumentation code + - Requires adapter support. If your adapter supports it, it is guarnteed to run prior to loading and running your application code. (Whether the project contains `.js` or `.ts` files depends on whether you opt to use TypeScript when you create your project.) diff --git a/documentation/docs/25-build-and-deploy/99-writing-adapters.md b/documentation/docs/25-build-and-deploy/99-writing-adapters.md index a2bfb50cd7b2..56723f7de3f5 100644 --- a/documentation/docs/25-build-and-deploy/99-writing-adapters.md +++ b/documentation/docs/25-build-and-deploy/99-writing-adapters.md @@ -34,6 +34,10 @@ export default function (options) { // Return `true` if the route with the given `config` can use `read` // from `$app/server` in production, return `false` if it can't. // Or throw a descriptive error describing how to configure the deployment + }, + tracing: () => { + // Return `true` if this adapter supports loading `tracing.server.js`. + // Return `false if it can't, or throw a descriptive error. } } }; diff --git a/documentation/docs/30-advanced/68-observability.md b/documentation/docs/30-advanced/68-observability.md new file mode 100644 index 000000000000..047726aa2371 --- /dev/null +++ b/documentation/docs/30-advanced/68-observability.md @@ -0,0 +1,83 @@ +--- +title: Observability +--- + +
+

Available since 2.29

+
+ +> [!NOTE] This feature is experimental. Expect bugs and breaking changes in minor versions (though we'll do our best to keep those to an absolute minimum). Please provide feedback! + +Sometimes, you may need to observe how your application is behaving in order to improve performance or find the root cause of a pesky bug. To help with this, SvelteKit can emit server-side [OpenTelemetry](https://opentelemetry.io) spans for the following: + +- [`handle`](hooks#Server-hooks-handle) hook (`handle` functions running in a [`sequence`](@sveltejs-kit-hooks#sequence) will show up as children of each other and the root handle hook) +- [`load`](load) functions (includes universal `load` functions when they're run on the server) +- [Form actions](form-actions) +- [Remote functions](remote-functions) + +Just telling SvelteKit to emit spans won't get you far, though — you need to actually collect them somewhere to be able to view them. SvelteKit provides `src/instrumentation.server.ts` as a place to write your tracing setup and instrumentation code. It's guaranteed to be run prior to your application code being imported, providing your deployment platform supports it and your adapter is aware of it. + +To enable both of these features, add the following to your `svelte.config.js`: + +```js +/// file: svelte.config.js +export default { + kit: { + +++experimental: { + tracing: { + server: true + }, + instrumentation: { + server: true + } + }+++ + } +}; +``` + +> [!NOTE] Tracing — and more significantly, observability instrumentation — can have a nontrivial overhead. Before you go all-in on tracing, consider whether or not you really need it, or if it might be more appropriate to turn it on in development and preview environments only. + +## Agumenting SvelteKit's builtin tracing + +SvelteKit provides access to the `root` span and the `current` span on the request event. The root span is the one associated with your root `handle` function, and the current span could be associated with `handle`, `load`, a form action, or a remote function, depending on the context. You can annotate these spans with any attributes you wish to record: + +```js +/// file: $lib/authenticate.ts +async function authenticate() { + const event = getRequestEvent(); + const user = await getAuthenticatedUser(event); + event.tracing.root.setAttribute('userId', user.id); +} +``` + +## Development quickstart + +To view your first trace, you'll need to set up a local collector. We'll use [Jaeger](https://www.jaegertracing.io/docs/getting-started/) in this example, as they provide an easy-to-use quickstart command. Once your collector is running locally: + +- Turn on the experimental flag mentioned above in your `svelte.config.js` file +- Use your package manager to install the dependencies you'll need + ```sh + npm i @opentelemetry/sdk-node @opentelemetry/auto-instrumentations-node @opentelemetry/exporter-trace-oltp-proto import-in-the-middle + ``` +- Create `src/instrumentation.server.ts` with the following: + +```ts +import { NodeSDK } from '@opentelemetry/sdk-node'; +import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node'; +import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto'; +import { createAddHookMessageChannel } from 'import-in-the-middle'; +import { register } from 'module'; + +const { registerOptions } = createAddHookMessageChannel(); +register('import-in-the-middle/hook.mjs', import.meta.url, registerOptions); + +const sdk = new NodeSDK({ + serviceName: 'test-sveltekit-tracing', + traceExporter: new OTLPTraceExporter(), + instrumentations: [getNodeAutoInstrumentations()] +}); + +sdk.start(); +``` + +Any server-side requests will now begin generating traces, which you can view in Jaeger's web console at [localhost:16686](http://localhost:16686). diff --git a/package.json b/package.json index 3d4badcfba1c..1a15e2ef3e2e 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "@parcel/watcher", "esbuild", "netlify-cli", + "protobufjs", "rolldown", "sharp", "svelte-preprocess", diff --git a/packages/adapter-auto/index.js b/packages/adapter-auto/index.js index ba8244e59d90..0bc8faae5c6b 100644 --- a/packages/adapter-auto/index.js +++ b/packages/adapter-auto/index.js @@ -152,6 +152,9 @@ export default () => ({ supports_error( 'The read function imported from $app/server only works in certain environments' ); + }, + instrumentation: () => { + supports_error('`instrumentation.server.js` only works in certain environments'); } } }); diff --git a/packages/adapter-auto/package.json b/packages/adapter-auto/package.json index 2e8dfc62abb2..fefcf2fa9fc7 100644 --- a/packages/adapter-auto/package.json +++ b/packages/adapter-auto/package.json @@ -42,7 +42,7 @@ "devDependencies": { "@sveltejs/kit": "workspace:^", "@sveltejs/vite-plugin-svelte": "catalog:", - "@types/node": "^18.19.119", + "@types/node": "catalog:", "typescript": "^5.3.3", "vitest": "catalog:" }, diff --git a/packages/adapter-cloudflare/index.js b/packages/adapter-cloudflare/index.js index fdbdf9a769ed..999c05c1275e 100644 --- a/packages/adapter-cloudflare/index.js +++ b/packages/adapter-cloudflare/index.js @@ -113,6 +113,12 @@ export default function (options = {}) { ASSETS: assets_binding } }); + if (builder.hasServerInstrumentationFile()) { + builder.instrument({ + entrypoint: worker_dest, + instrumentation: `${builder.getServerDirectory()}/instrumentation.server.js` + }); + } // _headers if (existsSync('_headers')) { @@ -184,7 +190,8 @@ export default function (options = {}) { } return true; - } + }, + instrumentation: () => true } }; } diff --git a/packages/adapter-cloudflare/package.json b/packages/adapter-cloudflare/package.json index a9144a78eb2b..99d50b5481f8 100644 --- a/packages/adapter-cloudflare/package.json +++ b/packages/adapter-cloudflare/package.json @@ -50,7 +50,7 @@ "devDependencies": { "@playwright/test": "catalog:", "@sveltejs/kit": "workspace:^", - "@types/node": "^18.19.119", + "@types/node": "catalog:", "esbuild": "^0.25.4", "typescript": "^5.3.3", "vitest": "catalog:" diff --git a/packages/adapter-netlify/index.js b/packages/adapter-netlify/index.js index 876d57f3372c..0f8b75f5ccdb 100644 --- a/packages/adapter-netlify/index.js +++ b/packages/adapter-netlify/index.js @@ -1,3 +1,4 @@ +/** @import { BuildOptions } from 'esbuild' */ import { appendFileSync, existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname, join, resolve, posix } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -106,7 +107,8 @@ export default function ({ split = false, edge = edge_set_in_env_var } = {}) { } return true; - } + }, + instrumentation: () => true } }; } @@ -174,9 +176,8 @@ async function generate_edge_functions({ builder }) { version: 1 }; - await esbuild.build({ - entryPoints: [`${tmp}/entry.js`], - outfile: '.netlify/edge-functions/render.js', + /** @type {BuildOptions} */ + const esbuild_config = { bundle: true, format: 'esm', platform: 'browser', @@ -194,7 +195,28 @@ async function generate_edge_functions({ builder }) { // https://docs.netlify.com/edge-functions/api/#runtime-environment external: builtinModules.map((id) => `node:${id}`), alias: Object.fromEntries(builtinModules.map((id) => [id, `node:${id}`])) - }); + }; + await Promise.all([ + esbuild.build({ + entryPoints: [`${tmp}/entry.js`], + outfile: '.netlify/edge-functions/render.js', + ...esbuild_config + }), + builder.hasServerInstrumentationFile() && + esbuild.build({ + entryPoints: [`${builder.getServerDirectory()}/instrumentation.server.js`], + outfile: '.netlify/edge/instrumentation.server.js', + ...esbuild_config + }) + ]); + + if (builder.hasServerInstrumentationFile()) { + builder.instrument({ + entrypoint: '.netlify/edge-functions/render.js', + instrumentation: '.netlify/edge/instrumentation.server.js', + start: '.netlify/edge/start.js' + }); + } writeFileSync('.netlify/edge-functions/manifest.json', JSON.stringify(edge_manifest)); } @@ -272,6 +294,16 @@ function generate_lambda_functions({ builder, publish, split }) { writeFileSync(`.netlify/functions-internal/${name}.mjs`, fn); writeFileSync(`.netlify/functions-internal/${name}.json`, fn_config); + if (builder.hasServerInstrumentationFile()) { + builder.instrument({ + entrypoint: `.netlify/functions-internal/${name}.mjs`, + instrumentation: '.netlify/server/instrumentation.server.js', + start: `.netlify/functions-start/${name}.start.mjs`, + module: { + exports: ['handler'] + } + }); + } const redirect = `/.netlify/functions/${name} 200`; redirects.push(`${pattern} ${redirect}`); @@ -286,6 +318,17 @@ function generate_lambda_functions({ builder, publish, split }) { writeFileSync(`.netlify/functions-internal/${FUNCTION_PREFIX}render.json`, fn_config); writeFileSync(`.netlify/functions-internal/${FUNCTION_PREFIX}render.mjs`, fn); + if (builder.hasServerInstrumentationFile()) { + builder.instrument({ + entrypoint: `.netlify/functions-internal/${FUNCTION_PREFIX}render.mjs`, + instrumentation: '.netlify/server/instrumentation.server.js', + start: `.netlify/functions-start/${FUNCTION_PREFIX}render.start.mjs`, + module: { + exports: ['handler'] + } + }); + } + redirects.push(`* /.netlify/functions/${FUNCTION_PREFIX}render 200`); } diff --git a/packages/adapter-netlify/package.json b/packages/adapter-netlify/package.json index f21108301b04..4ef528f0bc2a 100644 --- a/packages/adapter-netlify/package.json +++ b/packages/adapter-netlify/package.json @@ -55,7 +55,7 @@ "@rollup/plugin-node-resolve": "^16.0.0", "@sveltejs/kit": "workspace:^", "@sveltejs/vite-plugin-svelte": "catalog:", - "@types/node": "^18.19.119", + "@types/node": "catalog:", "@types/set-cookie-parser": "^2.4.7", "rollup": "^4.14.2", "typescript": "^5.3.3", diff --git a/packages/adapter-netlify/test/apps/basic/src/instrumentation.server.js b/packages/adapter-netlify/test/apps/basic/src/instrumentation.server.js new file mode 100644 index 000000000000..acc9022e1d64 --- /dev/null +++ b/packages/adapter-netlify/test/apps/basic/src/instrumentation.server.js @@ -0,0 +1 @@ +// this is just here to make sure the changes resulting from it work diff --git a/packages/adapter-netlify/test/apps/basic/svelte.config.js b/packages/adapter-netlify/test/apps/basic/svelte.config.js index 20cd2b3ff5b8..050579db13ba 100644 --- a/packages/adapter-netlify/test/apps/basic/svelte.config.js +++ b/packages/adapter-netlify/test/apps/basic/svelte.config.js @@ -3,7 +3,12 @@ import adapter from '../../../index.js'; /** @type {import('@sveltejs/kit').Config} */ const config = { kit: { - adapter: adapter() + adapter: adapter(), + experimental: { + instrumentation: { + server: true + } + } } }; diff --git a/packages/adapter-node/index.js b/packages/adapter-node/index.js index 9b0b3158ab82..e17408dfaec1 100644 --- a/packages/adapter-node/index.js +++ b/packages/adapter-node/index.js @@ -48,14 +48,21 @@ export default function (opts = {}) { const pkg = JSON.parse(readFileSync('package.json', 'utf8')); + /** @type {Record} */ + const input = { + index: `${tmp}/index.js`, + manifest: `${tmp}/manifest.js` + }; + + if (builder.hasServerInstrumentationFile()) { + input['instrumentation.server'] = `${tmp}/instrumentation.server.js`; + } + // we bundle the Vite output so that deployments only need // their production dependencies. Anything in devDependencies // will get included in the bundled code const bundle = await rollup({ - input: { - index: `${tmp}/index.js`, - manifest: `${tmp}/manifest.js` - }, + input, external: [ // dependencies could have deep exports, so we need a regex ...Object.keys(pkg.dependencies || {}).map((d) => new RegExp(`^${d}(\\/.*)?$`)) @@ -89,10 +96,21 @@ export default function (opts = {}) { ENV_PREFIX: JSON.stringify(envPrefix) } }); + + if (builder.hasServerInstrumentationFile()) { + builder.instrument({ + entrypoint: `${out}/index.js`, + instrumentation: `${out}/server/instrumentation.server.js`, + module: { + exports: ['path', 'host', 'port', 'server'] + } + }); + } }, supports: { - read: () => true + read: () => true, + instrumentation: () => true } }; } diff --git a/packages/adapter-node/package.json b/packages/adapter-node/package.json index 6a7871024b2e..b1cc21f18404 100644 --- a/packages/adapter-node/package.json +++ b/packages/adapter-node/package.json @@ -45,7 +45,7 @@ "@polka/url": "^1.0.0-next.28", "@sveltejs/kit": "workspace:^", "@sveltejs/vite-plugin-svelte": "catalog:", - "@types/node": "^18.19.119", + "@types/node": "catalog:", "polka": "^1.0.0-next.28", "sirv": "^3.0.0", "typescript": "^5.3.3", diff --git a/packages/adapter-static/package.json b/packages/adapter-static/package.json index 30c260417027..aa6b8349763a 100644 --- a/packages/adapter-static/package.json +++ b/packages/adapter-static/package.json @@ -42,7 +42,7 @@ "@playwright/test": "catalog:", "@sveltejs/kit": "workspace:^", "@sveltejs/vite-plugin-svelte": "catalog:", - "@types/node": "^18.19.119", + "@types/node": "catalog:", "sirv": "^3.0.0", "svelte": "^5.35.5", "typescript": "^5.3.3", diff --git a/packages/adapter-vercel/index.js b/packages/adapter-vercel/index.js index 1586ef327d10..58f11ead9d20 100644 --- a/packages/adapter-vercel/index.js +++ b/packages/adapter-vercel/index.js @@ -1,3 +1,4 @@ +/** @import { BuildOptions } from 'esbuild' */ import fs from 'node:fs'; import path from 'node:path'; import process from 'node:process'; @@ -93,13 +94,18 @@ const plugin = function (defaults = {}) { const dir = `${dirs.functions}/${name}.func`; const relativePath = path.posix.relative(tmp, builder.getServerDirectory()); - builder.copy(`${files}/serverless.js`, `${tmp}/index.js`, { replace: { SERVER: `${relativePath}/index.js`, MANIFEST: './manifest.js' } }); + if (builder.hasServerInstrumentationFile()) { + builder.instrument({ + entrypoint: `${tmp}/index.js`, + instrumentation: `${builder.getServerDirectory()}/instrumentation.server.js` + }); + } write( `${tmp}/manifest.js`, @@ -136,9 +142,9 @@ const plugin = function (defaults = {}) { ); try { - const result = await esbuild.build({ - entryPoints: [`${tmp}/edge.js`], - outfile: `${dirs.functions}/${name}.func/index.js`, + const outdir = `${dirs.functions}/${name}.func`; + /** @type {BuildOptions} */ + const esbuild_config = { // minimum Node.js version supported is v14.6.0 that is mapped to ES2019 // https://edge-runtime.vercel.app/features/polyfills // TODO verify the latest ES version the edge runtime supports @@ -168,10 +174,36 @@ const plugin = function (defaults = {}) { '.eot': 'copy', '.otf': 'copy' } + }; + const result = await esbuild.build({ + entryPoints: [`${tmp}/edge.js`], + outfile: `${outdir}/index.js`, + ...esbuild_config }); - if (result.warnings.length > 0) { - const formatted = await esbuild.formatMessages(result.warnings, { + let instrumentation_result; + if (builder.hasServerInstrumentationFile()) { + instrumentation_result = await esbuild.build({ + entryPoints: [`${builder.getServerDirectory()}/instrumentation.server.js`], + outfile: `${outdir}/instrumentation.server.js`, + ...esbuild_config + }); + + builder.instrument({ + entrypoint: `${outdir}/index.js`, + instrumentation: `${outdir}/instrumentation.server.js`, + module: { + generateText: generate_traced_edge_module + } + }); + } + + const warnings = instrumentation_result + ? [...result.warnings, ...instrumentation_result.warnings] + : result.warnings; + + if (warnings.length > 0) { + const formatted = await esbuild.formatMessages(warnings, { kind: 'warning', color: true }); @@ -477,7 +509,8 @@ const plugin = function (defaults = {}) { } return true; - } + }, + instrumentation: () => true } }; }; @@ -804,4 +837,23 @@ function is_prerendered(route) { ); } +/** + * @param {{ instrumentation: string; start: string }} opts + */ +function generate_traced_edge_module({ instrumentation, start }) { + return `\ +import './${instrumentation}'; +const promise = import('./${start}'); + +/** + * @param {import('http').IncomingMessage} req + * @param {import('http').ServerResponse} res + */ +export default async (req, res) => { + const { default: handler } = await promise; + return handler(req, res); +} +`; +} + export default plugin; diff --git a/packages/adapter-vercel/package.json b/packages/adapter-vercel/package.json index f33d14c58209..f0332ea61240 100644 --- a/packages/adapter-vercel/package.json +++ b/packages/adapter-vercel/package.json @@ -46,7 +46,7 @@ "devDependencies": { "@sveltejs/kit": "workspace:^", "@sveltejs/vite-plugin-svelte": "catalog:", - "@types/node": "^18.19.119", + "@types/node": "catalog:", "typescript": "^5.3.3", "vitest": "catalog:" }, diff --git a/packages/enhanced-img/package.json b/packages/enhanced-img/package.json index 24b30cf967ae..ccbdb7334b42 100644 --- a/packages/enhanced-img/package.json +++ b/packages/enhanced-img/package.json @@ -45,7 +45,7 @@ "devDependencies": { "@sveltejs/vite-plugin-svelte": "catalog:", "@types/estree": "^1.0.5", - "@types/node": "^18.19.119", + "@types/node": "catalog:", "rollup": "^4.27.4", "svelte": "^5.35.5", "typescript": "^5.6.3", diff --git a/packages/kit/kit.vitest.config.js b/packages/kit/kit.vitest.config.js index aa93595569df..82502703e574 100644 --- a/packages/kit/kit.vitest.config.js +++ b/packages/kit/kit.vitest.config.js @@ -3,6 +3,9 @@ import { defineConfig } from 'vitest/config'; // this file needs a custom name so that the numerous test subprojects don't all pick it up export default defineConfig({ + define: { + __SVELTEKIT_SERVER_TRACING_ENABLED__: false + }, server: { watch: { ignored: ['**/node_modules/**', '**/.svelte-kit/**'] @@ -12,8 +15,6 @@ export default defineConfig({ alias: { '__sveltekit/paths': fileURLToPath(new URL('./test/mocks/path.js', import.meta.url)) }, - // shave a couple seconds off the tests - isolate: false, poolOptions: { threads: { singleThread: true diff --git a/packages/kit/package.json b/packages/kit/package.json index 61243b13c4b9..97a8402181be 100644 --- a/packages/kit/package.json +++ b/packages/kit/package.json @@ -33,10 +33,11 @@ "sirv": "^3.0.0" }, "devDependencies": { + "@opentelemetry/api": "^1.0.0", "@playwright/test": "catalog:", "@sveltejs/vite-plugin-svelte": "catalog:", "@types/connect": "^3.4.38", - "@types/node": "^18.19.119", + "@types/node": "catalog:", "@types/set-cookie-parser": "^2.4.7", "dts-buddy": "^0.6.2", "rollup": "^4.14.2", @@ -48,9 +49,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/core/adapt/builder.js b/packages/kit/src/core/adapt/builder.js index 69f67ff3a879..3d0de4472e73 100644 --- a/packages/kit/src/core/adapt/builder.js +++ b/packages/kit/src/core/adapt/builder.js @@ -1,6 +1,10 @@ +/** @import { Builder } from '@sveltejs/kit' */ +/** @import { ResolvedConfig } from 'vite' */ +/** @import { RouteDefinition } from '@sveltejs/kit' */ +/** @import { RouteData, ValidatedConfig, BuildData, ServerMetadata, ServerMetadataRoute, Prerendered, PrerenderMap, Logger } from 'types' */ import colors from 'kleur'; import { createReadStream, createWriteStream, existsSync, statSync } from 'node:fs'; -import { extname, resolve } from 'node:path'; +import { extname, resolve, join, dirname, relative } from 'node:path'; import { pipeline } from 'node:stream'; import { promisify } from 'node:util'; import zlib from 'node:zlib'; @@ -12,6 +16,7 @@ import generate_fallback from '../postbuild/fallback.js'; import { write } from '../sync/utils.js'; import { list_files } from '../utils.js'; import { find_server_assets } from '../generate_manifest/find_server_assets.js'; +import { reserved } from '../env.js'; const pipe = promisify(pipeline); const extensions = ['.html', '.js', '.mjs', '.json', '.css', '.svg', '.xml', '.wasm']; @@ -19,16 +24,16 @@ const extensions = ['.html', '.js', '.mjs', '.json', '.css', '.svg', '.xml', '.w /** * Creates the Builder which is passed to adapters for building the application. * @param {{ - * config: import('types').ValidatedConfig; - * build_data: import('types').BuildData; - * server_metadata: import('types').ServerMetadata; - * route_data: import('types').RouteData[]; - * prerendered: import('types').Prerendered; - * prerender_map: import('types').PrerenderMap; - * log: import('types').Logger; - * vite_config: import('vite').ResolvedConfig; + * config: ValidatedConfig; + * build_data: BuildData; + * server_metadata: ServerMetadata; + * route_data: RouteData[]; + * prerendered: Prerendered; + * prerender_map: PrerenderMap; + * log: Logger; + * vite_config: ResolvedConfig; * }} opts - * @returns {import('@sveltejs/kit').Builder} + * @returns {Builder} */ export function create_builder({ config, @@ -40,7 +45,7 @@ export function create_builder({ log, vite_config }) { - /** @type {Map} */ + /** @type {Map} */ const lookup = new Map(); /** @@ -48,11 +53,11 @@ export function create_builder({ * we expose a stable type that adapters can use to group/filter routes */ const routes = route_data.map((route) => { - const { config, methods, page, api } = /** @type {import('types').ServerMetadataRoute} */ ( + const { config, methods, page, api } = /** @type {ServerMetadataRoute} */ ( server_metadata.routes.get(route.id) ); - /** @type {import('@sveltejs/kit').RouteDefinition} */ + /** @type {RouteDefinition} */ const facade = { id: route.id, api, @@ -229,6 +234,53 @@ export function create_builder({ writeServer(dest) { return copy(`${config.kit.outDir}/output/server`, dest); + }, + + hasServerInstrumentationFile() { + return existsSync(`${config.kit.outDir}/output/server/instrumentation.server.js`); + }, + + instrument({ + entrypoint, + instrumentation, + start = join(dirname(entrypoint), 'start.js'), + module = { + exports: ['default'] + } + }) { + if (!existsSync(instrumentation)) { + throw new Error( + `Instrumentation file ${instrumentation} not found. This is probably a bug in your adapter.` + ); + } + if (!existsSync(entrypoint)) { + throw new Error( + `Entrypoint file ${entrypoint} not found. This is probably a bug in your adapter.` + ); + } + + copy(entrypoint, start); + if (existsSync(`${entrypoint}.map`)) { + copy(`${entrypoint}.map`, `${start}.map`); + } + + const relative_instrumentation = relative(dirname(entrypoint), instrumentation); + const relative_start = relative(dirname(entrypoint), start); + + const facade = + 'generateText' in module + ? module.generateText({ + instrumentation: relative_instrumentation, + start: relative_start + }) + : create_instrumentation_facade({ + instrumentation: relative_instrumentation, + start: relative_start, + exports: module.exports + }); + + rimraf(entrypoint); + write(entrypoint, facade); } }; } @@ -254,3 +306,60 @@ async function compress_file(file, format = 'gz') { await pipe(source, compress, destination); } + +/** + * Given a list of exports, generate a facade that: + * - Imports the instrumentation file + * - Imports `exports` from the entrypoint (dynamically, if `tla` is true) + * - Re-exports `exports` from the entrypoint + * + * `default` receives special treatment: It will be imported as `default` and exported with `export default`. + * + * @param {{ instrumentation: string; start: string; exports: string[] }} opts + * @returns {string} + */ +function create_instrumentation_facade({ instrumentation, start, exports }) { + const import_instrumentation = `import './${instrumentation}';`; + + let alias_index = 0; + const aliases = new Map(); + + for (const name of exports.filter((name) => reserved.has(name))) { + /* + * you can do evil things like `export { c as class }`. + * in order to import these, you need to alias them, and then un-alias them when re-exporting + * this map will allow us to generate the following: + * import { class as _1 } from 'entrypoint'; + * export { _1 as class }; + */ + let alias = `_${alias_index++}`; + while (exports.includes(alias)) { + alias = `_${alias_index++}`; + } + + aliases.set(name, alias); + } + + const import_statements = []; + const export_statements = []; + + for (const name of exports) { + const alias = aliases.get(name); + if (alias) { + import_statements.push(`${name}: ${alias}`); + export_statements.push(`${alias} as ${name}`); + } else { + import_statements.push(`${name}`); + export_statements.push(`${name}`); + } + } + + const entrypoint_facade = [ + `const { ${import_statements.join(', ')} } = await import('./${start}');`, + export_statements.length > 0 ? `export { ${export_statements.join(', ')} };` : '' + ] + .filter(Boolean) + .join('\n'); + + return `${import_instrumentation}\n${entrypoint_facade}`; +} diff --git a/packages/kit/src/core/config/index.spec.js b/packages/kit/src/core/config/index.spec.js index 1a979613bba6..3c557a28a13b 100644 --- a/packages/kit/src/core/config/index.spec.js +++ b/packages/kit/src/core/config/index.spec.js @@ -77,6 +77,8 @@ const get_defaults = (prefix = '') => ({ privatePrefix: '' }, experimental: { + tracing: { server: false }, + instrumentation: { server: false }, remoteFunctions: false }, files: { @@ -409,6 +411,73 @@ test('errors on loading config with incorrect default export', async () => { ); }); +test('accepts valid tracing values', () => { + assert.doesNotThrow(() => { + validate_config({ + kit: { + experimental: { + tracing: { server: true } + } + } + }); + }); + + assert.doesNotThrow(() => { + validate_config({ + kit: { + experimental: { + tracing: { server: false } + } + } + }); + }); + + assert.doesNotThrow(() => { + validate_config({ + kit: { + experimental: { + tracing: undefined + } + } + }); + }); +}); + +test('errors on invalid tracing values', () => { + assert.throws(() => { + validate_config({ + kit: { + experimental: { + // @ts-expect-error - given value expected to throw + tracing: true + } + } + }); + }, /^config\.kit\.experimental\.tracing should be an object$/); + + assert.throws(() => { + validate_config({ + kit: { + experimental: { + // @ts-expect-error - given value expected to throw + tracing: 'server' + } + } + }); + }, /^config\.kit\.experimental\.tracing should be an object$/); + + assert.throws(() => { + validate_config({ + kit: { + experimental: { + // @ts-expect-error - given value expected to throw + tracing: { server: 'invalid' } + } + } + }); + }, /^config\.kit\.experimental\.tracing\.server should be true or false, if specified$/); +}); + test('uses src prefix for other kit.files options', async () => { const cwd = join(__dirname, 'fixtures/custom-src'); diff --git a/packages/kit/src/core/config/options.js b/packages/kit/src/core/config/options.js index 8920514dd402..dbf7cf482007 100644 --- a/packages/kit/src/core/config/options.js +++ b/packages/kit/src/core/config/options.js @@ -120,6 +120,12 @@ const options = object( }), experimental: object({ + tracing: object({ + server: boolean(false) + }), + instrumentation: object({ + server: boolean(false) + }), remoteFunctions: boolean(false) }), diff --git a/packages/kit/src/exports/hooks/sequence.js b/packages/kit/src/exports/hooks/sequence.js index 2c0fc3460007..44f569335f45 100644 --- a/packages/kit/src/exports/hooks/sequence.js +++ b/packages/kit/src/exports/hooks/sequence.js @@ -1,3 +1,7 @@ +/** @import { Handle, RequestEvent, ResolveOptions } from '@sveltejs/kit' */ +/** @import { MaybePromise } from 'types' */ +import { merge_tracing, get_request_store, with_request_store } from '@sveltejs/kit/internal'; + /** * A helper function for sequencing multiple `handle` calls in a middleware-like manner. * The behavior for the `handle` options is as follows: @@ -66,56 +70,70 @@ * first post-processing * ``` * - * @param {...import('@sveltejs/kit').Handle} handlers The chain of `handle` functions - * @returns {import('@sveltejs/kit').Handle} + * @param {...Handle} handlers The chain of `handle` functions + * @returns {Handle} */ export function sequence(...handlers) { const length = handlers.length; if (!length) return ({ event, resolve }) => resolve(event); return ({ event, resolve }) => { + const { state } = get_request_store(); return apply_handle(0, event, {}); /** * @param {number} i - * @param {import('@sveltejs/kit').RequestEvent} event - * @param {import('@sveltejs/kit').ResolveOptions | undefined} parent_options - * @returns {import('types').MaybePromise} + * @param {RequestEvent} event + * @param {ResolveOptions | undefined} parent_options + * @returns {MaybePromise} */ function apply_handle(i, event, parent_options) { const handle = handlers[i]; - return handle({ - event, - resolve: (event, options) => { - /** @type {import('@sveltejs/kit').ResolveOptions['transformPageChunk']} */ - const transformPageChunk = async ({ html, done }) => { - if (options?.transformPageChunk) { - html = (await options.transformPageChunk({ html, done })) ?? ''; - } + return state.tracing.record_span({ + name: `sveltekit.handle.sequenced.${handle.name ? handle.name : i}`, + attributes: {}, + fn: async (current) => { + const traced_event = merge_tracing(event, current); + return await with_request_store({ event: traced_event, state }, () => + handle({ + event: traced_event, + resolve: (event, options) => { + /** @type {ResolveOptions['transformPageChunk']} */ + const transformPageChunk = async ({ html, done }) => { + if (options?.transformPageChunk) { + html = (await options.transformPageChunk({ html, done })) ?? ''; + } - if (parent_options?.transformPageChunk) { - html = (await parent_options.transformPageChunk({ html, done })) ?? ''; - } + if (parent_options?.transformPageChunk) { + html = (await parent_options.transformPageChunk({ html, done })) ?? ''; + } - return html; - }; + return html; + }; - /** @type {import('@sveltejs/kit').ResolveOptions['filterSerializedResponseHeaders']} */ - const filterSerializedResponseHeaders = - parent_options?.filterSerializedResponseHeaders ?? - options?.filterSerializedResponseHeaders; + /** @type {ResolveOptions['filterSerializedResponseHeaders']} */ + const filterSerializedResponseHeaders = + parent_options?.filterSerializedResponseHeaders ?? + options?.filterSerializedResponseHeaders; - /** @type {import('@sveltejs/kit').ResolveOptions['preload']} */ - const preload = parent_options?.preload ?? options?.preload; + /** @type {ResolveOptions['preload']} */ + const preload = parent_options?.preload ?? options?.preload; - return i < length - 1 - ? apply_handle(i + 1, event, { - transformPageChunk, - filterSerializedResponseHeaders, - preload - }) - : resolve(event, { transformPageChunk, filterSerializedResponseHeaders, preload }); + return i < length - 1 + ? apply_handle(i + 1, event, { + transformPageChunk, + filterSerializedResponseHeaders, + preload + }) + : resolve(event, { + transformPageChunk, + filterSerializedResponseHeaders, + preload + }); + } + }) + ); } }); } diff --git a/packages/kit/src/exports/hooks/sequence.spec.js b/packages/kit/src/exports/hooks/sequence.spec.js index 0829e90a92e6..293f15155999 100644 --- a/packages/kit/src/exports/hooks/sequence.spec.js +++ b/packages/kit/src/exports/hooks/sequence.spec.js @@ -1,6 +1,33 @@ -import { assert, expect, test } from 'vitest'; +/** @import { RequestEvent } from '@sveltejs/kit' */ +/** @import { RequestState } from 'types' */ +import { assert, expect, test, vi } from 'vitest'; import { sequence } from './sequence.js'; import { installPolyfills } from '../node/polyfills.js'; +import { noop_span } from '../../runtime/telemetry/noop.js'; + +const dummy_event = vi.hoisted( + () => + /** @type {RequestEvent} */ ({ + tracing: { + root: {} + } + }) +); + +vi.mock(import('@sveltejs/kit/internal'), async (actualPromise) => { + const actual = await actualPromise(); + return { + ...actual, + get_request_store: () => ({ + event: dummy_event, + state: /** @type {RequestState} */ ({ + tracing: { + record_span: ({ fn }) => fn(noop_span) + } + }) + }) + }; +}); installPolyfills(); @@ -29,10 +56,9 @@ test('applies handlers in sequence', async () => { } ); - const event = /** @type {import('@sveltejs/kit').RequestEvent} */ ({}); const response = new Response(); - assert.equal(await handler({ event, resolve: () => response }), response); + assert.equal(await handler({ event: dummy_event, resolve: () => response }), response); expect(order).toEqual(['1a', '2a', '3a', '3b', '2b', '1b']); }); @@ -47,9 +73,8 @@ test('uses transformPageChunk option passed to non-terminal handle function', as async ({ event, resolve }) => resolve(event) ); - const event = /** @type {import('@sveltejs/kit').RequestEvent} */ ({}); const response = await handler({ - event, + event: dummy_event, resolve: async (_event, opts = {}) => { let html = ''; @@ -84,9 +109,8 @@ test('merges transformPageChunk option', async () => { } ); - const event = /** @type {import('@sveltejs/kit').RequestEvent} */ ({}); const response = await handler({ - event, + event: dummy_event, resolve: async (_event, opts = {}) => { let html = ''; @@ -117,9 +141,8 @@ test('uses first defined preload option', async () => { } ); - const event = /** @type {import('@sveltejs/kit').RequestEvent} */ ({}); const response = await handler({ - event, + event: dummy_event, resolve: (_event, opts = {}) => { let html = ''; @@ -150,9 +173,8 @@ test('uses first defined filterSerializedResponseHeaders option', async () => { } ); - const event = /** @type {import('@sveltejs/kit').RequestEvent} */ ({}); const response = await handler({ - event, + event: dummy_event, resolve: (_event, opts = {}) => { let html = ''; diff --git a/packages/kit/src/runtime/app/server/event.js b/packages/kit/src/exports/internal/event.js similarity index 63% rename from packages/kit/src/runtime/app/server/event.js rename to packages/kit/src/exports/internal/event.js index 436d6010ac47..f884e6ab6407 100644 --- a/packages/kit/src/runtime/app/server/event.js +++ b/packages/kit/src/exports/internal/event.js @@ -1,9 +1,11 @@ /** @import { RequestEvent } from '@sveltejs/kit' */ +/** @import { RequestStore } from 'types' */ +/** @import { AsyncLocalStorage } from 'node:async_hooks' */ -/** @type {RequestEvent | null} */ -let request_event = null; +/** @type {RequestStore | null} */ +let sync_store = null; -/** @type {import('node:async_hooks').AsyncLocalStorage} */ +/** @type {AsyncLocalStorage | null} */ let als; import('node:async_hooks') @@ -19,10 +21,11 @@ import('node:async_hooks') * * In environments without [`AsyncLocalStorage`](https://nodejs.org/api/async_context.html#class-asynclocalstorage), this must be called synchronously (i.e. not after an `await`). * @since 2.20.0 + * * @returns {RequestEvent} */ export function getRequestEvent() { - const event = request_event ?? als?.getStore(); + const event = try_get_request_store()?.event; if (!event) { let message = @@ -39,16 +42,28 @@ export function getRequestEvent() { return event; } +export function get_request_store() { + const result = try_get_request_store(); + if (!result) { + throw new Error('Could not get the request store. This is an internal error.'); + } + return result; +} + +export function try_get_request_store() { + return sync_store ?? als?.getStore() ?? null; +} + /** * @template T - * @param {RequestEvent | null} event + * @param {RequestStore | null} store * @param {() => T} fn */ -export function with_event(event, fn) { +export function with_request_store(store, fn) { try { - request_event = event; - return als ? als.run(event, fn) : fn(); + sync_store = store; + return als ? als.run(store, fn) : fn(); } finally { - request_event = null; + sync_store = null; } } diff --git a/packages/kit/src/exports/internal/index.js b/packages/kit/src/exports/internal/index.js index c358bca93251..67e9c8e0b0b5 100644 --- a/packages/kit/src/exports/internal/index.js +++ b/packages/kit/src/exports/internal/index.js @@ -62,4 +62,27 @@ export class ActionFailure { } } +export { + with_request_store, + getRequestEvent, + get_request_store, + try_get_request_store +} from './event.js'; + export { validate_remote_functions } from './remote-functions.js'; + +/** + * @template {{ tracing: { enabled: boolean, root: import('@opentelemetry/api').Span, current: import('@opentelemetry/api').Span } }} T + * @param {T} event_like + * @param {import('@opentelemetry/api').Span} current + * @returns {T} + */ +export function merge_tracing(event_like, current) { + return { + ...event_like, + tracing: { + ...event_like.tracing, + current + } + }; +} diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index 00a842867146..bf0286ea9527 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -17,14 +17,15 @@ import { RouteSegment } from '../types/private.js'; import { BuildData, SSRNodeLoader, SSRRoute, ValidatedConfig } from 'types'; -import type { SvelteConfig } from '@sveltejs/vite-plugin-svelte'; -import type { StandardSchemaV1 } from '@standard-schema/spec'; +import { SvelteConfig } from '@sveltejs/vite-plugin-svelte'; +import { StandardSchemaV1 } from '@standard-schema/spec'; import { RouteId as AppRouteId, LayoutParams as AppLayoutParams, ResolvedPathname // @ts-ignore } from '$app/types'; +import { Span } from '@opentelemetry/api'; export { PrerenderOption } from '../types/private.js'; @@ -50,6 +51,12 @@ export interface Adapter { * @param details.config The merged route config */ read?: (details: { config: any; route: { id: string } }) => boolean; + + /** + * Test support for `instrumentation.server.js`. To pass, the adapter must support running `instrumentation.server.js` prior to the application code. + * @since 2.31.0 + */ + instrumentation?: () => boolean; }; /** * Creates an `Emulator`, which allows the adapter to influence the environment @@ -187,6 +194,47 @@ export interface Builder { } ) => string[]; + /** + * Check if the server instrumentation file exists. + * @returns true if the server instrumentation file exists, false otherwise + * @since 2.31.0 + */ + hasServerInstrumentationFile: () => boolean; + + /** + * Instrument `entrypoint` with `instrumentation`. + * + * Renames `entrypoint` to `start` and creates a new module at + * `entrypoint` which imports `instrumentation` and then dynamically imports `start`. This allows + * the module hooks necessary for instrumentation libraries to be loaded prior to any application code. + * + * Caveats: + * - "Live exports" will not work. If your adapter uses live exports, your users will need to manually import the server instrumentation on startup. + * - If `tla` is `false`, OTEL auto-instrumentation may not work properly. Use it if your environment supports it. + * - Use `hasServerInstrumentationFile` to check if the user has a server instrumentation file; if they don't, you shouldn't do this. + * + * @param options an object containing the following properties: + * @param options.entrypoint the path to the entrypoint to trace. + * @param options.instrumentation the path to the instrumentation file. + * @param options.start the name of the start file. This is what `entrypoint` will be renamed to. + * @param options.module configuration for the resulting entrypoint module. + * @param options.module.exports + * @param options.module.generateText a function that receives the relative paths to the instrumentation and start files, and generates the text of the module to be traced. If not provided, the default implementation will be used, which uses top-level await. + * @since 2.31.0 + */ + instrument: (args: { + entrypoint: string; + instrumentation: string; + start?: string; + module?: + | { + exports: string[]; + } + | { + generateText: (args: { instrumentation: string; start: string }) => string; + }; + }) => void; + /** * Compress files in `directory` with gzip and brotli, where appropriate. Generates `.gz` and `.br` files alongside the originals. * @param {string} directory The directory containing the files to be compressed @@ -408,10 +456,34 @@ export interface KitConfig { */ privatePrefix?: string; }; - /** - * Experimental features which are exempt from semantic versioning. These features may be changed or removed at any time. - */ + /** Experimental features. Here be dragons. These are not subject to semantic versioning, so breaking changes or removal can happen in any release. */ experimental?: { + /** + * Options for enabling server-side [OpenTelemetry](https://opentelemetry.io/) tracing for SvelteKit operations including the [`handle` hook](https://svelte.dev/docs/kit/hooks#Server-hooks-handle), [`load` functions](https://svelte.dev/docs/kit/load), [form actions](https://svelte.dev/docs/kit/form-actions), and [remote functions](https://svelte.dev/docs/kit/remote-functions). + * @default { server: false, serverFile: false } + * @since 2.31.0 + */ + tracing?: { + /** + * Enables server-side [OpenTelemetry](https://opentelemetry.io/) span emission for SvelteKit operations including the [`handle` hook](https://svelte.dev/docs/kit/hooks#Server-hooks-handle), [`load` functions](https://svelte.dev/docs/kit/load), [form actions](https://svelte.dev/docs/kit/form-actions), and [remote functions](https://svelte.dev/docs/kit/remote-functions). + * @default false + * @since 2.31.0 + */ + server?: boolean; + }; + + /** + * @since 2.31.0 + */ + instrumentation?: { + /** + * Enables `instrumentation.server.js` for tracing and observability instrumentation. + * @default false + * @since 2.31.0 + */ + server?: boolean; + }; + /** * Whether to enable the experimental remote functions feature. This feature is not yet stable and may be changed or removed at any time. * @default false @@ -1010,6 +1082,19 @@ export interface LoadEvent< * ``` */ untrack: (fn: () => T) => T; + + /** + * Access to spans for tracing. If tracing is not enabled or the function is being run in the browser, these spans will do nothing. + * @since 2.31.0 + */ + tracing: { + /** Whether tracing is enabled. */ + enabled: boolean; + /** The root span for the request. This span is named `sveltekit.handle.root`. */ + root: Span; + /** The span associated with the current `load` function. */ + current: Span; + }; } export interface NavigationEvent< @@ -1288,6 +1373,20 @@ export interface RequestEvent< * `true` for `+server.js` calls coming from SvelteKit without the overhead of actually making an HTTP request. This happens when you make same-origin `fetch` requests on the server. */ isSubRequest: boolean; + + /** + * Access to spans for tracing. If tracing is not enabled, these spans will do nothing. + * @since 2.31.0 + */ + tracing: { + /** Whether tracing is enabled. */ + enabled: boolean; + /** The root span for the request. This span is named `sveltekit.handle.root`. */ + root: Span; + /** The span associated with the current `handle` hook, `load` function, or form action. */ + current: Span; + }; + /** * `true` if the request comes from the client via a remote function. The `url` property will be stripped of the internal information * related to the data request in this case. Use this property instead if the distinction is important to you. @@ -1451,6 +1550,19 @@ export interface ServerLoadEvent< * ``` */ untrack: (fn: () => T) => T; + + /** + * Access to spans for tracing. If tracing is not enabled, these spans will do nothing. + * @since 2.31.0 + */ + tracing: { + /** Whether tracing is enabled. */ + enabled: boolean; + /** The root span for the request. This span is named `sveltekit.handle.root`. */ + root: Span; + /** The span associated with the current server `load` function. */ + current: Span; + }; } /** diff --git a/packages/kit/src/exports/vite/dev/index.js b/packages/kit/src/exports/vite/dev/index.js index a5f211efa9af..e7a27a72e5bb 100644 --- a/packages/kit/src/exports/vite/dev/index.js +++ b/packages/kit/src/exports/vite/dev/index.js @@ -501,6 +501,14 @@ export async function dev(vite, vite_config, svelte_config) { return; } + const resolved_instrumentation = resolve_entry( + path.join(svelte_config.kit.files.src, 'instrumentation.server') + ); + + if (resolved_instrumentation) { + await vite.ssrLoadModule(resolved_instrumentation); + } + // we have to import `Server` before calling `set_assets` const { Server } = /** @type {import('types').ServerModule} */ ( await vite.ssrLoadModule(`${runtime_base}/server/index.js`, { fixStacktrace: true }) diff --git a/packages/kit/src/exports/vite/index.js b/packages/kit/src/exports/vite/index.js index 002ef57b2b57..bbaf6686a63a 100644 --- a/packages/kit/src/exports/vite/index.js +++ b/packages/kit/src/exports/vite/index.js @@ -16,7 +16,13 @@ import { build_service_worker } from './build/build_service_worker.js'; import { assets_base, find_deps, resolve_symlinks } from './build/utils.js'; import { dev } from './dev/index.js'; import { preview } from './preview/index.js'; -import { get_config_aliases, get_env, normalize_id, stackless } from './utils.js'; +import { + error_for_missing_config, + get_config_aliases, + get_env, + normalize_id, + stackless +} from './utils.js'; import { write_client_manifest } from '../../core/sync/write_client_manifest.js'; import prerender from '../../core/postbuild/prerender.js'; import analyse from '../../core/postbuild/analyse.js'; @@ -332,7 +338,8 @@ async function kit({ svelte_config }) { __SVELTEKIT_DEV__: 'false', __SVELTEKIT_EMBEDDED__: s(kit.embedded), __SVELTEKIT_EXPERIMENTAL__REMOTE_FUNCTIONS__: s(kit.experimental.remoteFunctions), - __SVELTEKIT_CLIENT_ROUTING__: kit.router.resolution === 'client' ? 'true' : 'false', + __SVELTEKIT_CLIENT_ROUTING__: s(kit.router.resolution === 'client'), + __SVELTEKIT_SERVER_TRACING_ENABLED__: s(kit.experimental.instrumentation.server), __SVELTEKIT_PAYLOAD__: new_config.build.ssr ? '{}' : `globalThis.__sveltekit_${version_hash}` @@ -347,7 +354,8 @@ async function kit({ svelte_config }) { __SVELTEKIT_DEV__: 'true', __SVELTEKIT_EMBEDDED__: s(kit.embedded), __SVELTEKIT_EXPERIMENTAL__REMOTE_FUNCTIONS__: s(kit.experimental.remoteFunctions), - __SVELTEKIT_CLIENT_ROUTING__: kit.router.resolution === 'client' ? 'true' : 'false', + __SVELTEKIT_CLIENT_ROUTING__: s(kit.router.resolution === 'client'), + __SVELTEKIT_SERVER_TRACING_ENABLED__: s(kit.experimental.instrumentation.server), __SVELTEKIT_PAYLOAD__: 'globalThis.__sveltekit_dev' }; @@ -775,6 +783,25 @@ async function kit({ svelte_config }) { input[name] = path.resolve(file); }); + // ...and the server instrumentation file + const server_instrumentation = resolve_entry( + path.join(kit.files.src, 'instrumentation.server') + ); + if (server_instrumentation) { + const { adapter } = kit; + if (adapter && !adapter.supports?.instrumentation?.()) { + throw new Error(`${server_instrumentation} is unsupported in ${adapter.name}.`); + } + if (!kit.experimental.instrumentation.server) { + error_for_missing_config( + 'instrumentation.server.js', + 'kit.experimental.instrumentation.server', + 'true' + ); + } + input['instrumentation.server'] = server_instrumentation; + } + // ...and every .remote file for (const remote of manifest_data.remotes) { input[`remote/${remote.hash}`] = path.resolve(remote.file); diff --git a/packages/kit/src/exports/vite/utils.js b/packages/kit/src/exports/vite/utils.js index cf6223e6c632..3eb71b0e0852 100644 --- a/packages/kit/src/exports/vite/utils.js +++ b/packages/kit/src/exports/vite/utils.js @@ -4,6 +4,7 @@ import { posixify } from '../../utils/filesystem.js'; import { negotiate } from '../../utils/http.js'; import { filter_private_env, filter_public_env } from '../../utils/env.js'; import { escape_html } from '../../utils/escape.js'; +import { dedent } from '../../core/sync/utils.js'; import { app_server, env_dynamic_private, @@ -172,3 +173,46 @@ export function stackless(message) { } export const strip_virtual_prefix = /** @param {string} id */ (id) => id.replace('\0virtual:', ''); + +/** + * For `error_for_missing_config('instrumentation.server.js', 'kit.experimental.instrumentation.server', true)`, + * returns: + * + * ``` + * To enable `instrumentation.server.js`, add the following to your `svelte.config.js`: + * + *\`\`\`js + * kit: + * experimental: + * instrumentation: + * server: true + * } + * } + * } + *\`\`\` + *``` + * @param {string} feature_name + * @param {string} path + * @param {string} value + * @returns {never} + */ +export function error_for_missing_config(feature_name, path, value) { + const hole = '__HOLE__'; + + const result = path.split('.').reduce((acc, part, i, parts) => { + const indent = ' '.repeat(i); + const rhs = i === parts.length - 1 ? value : `{\n${hole}\n${indent}}`; + + return acc.replace(hole, `${indent}${part}: ${rhs}`); + }, hole); + + throw new Error( + dedent`\ + To enable \`${feature_name}\`, add the following to your \`svelte.config.js\`: + + \`\`\`js + ${result} + \`\`\` + ` + ); +} diff --git a/packages/kit/src/exports/vite/utils.spec.js b/packages/kit/src/exports/vite/utils.spec.js index 45569b13f5c7..bf13485390ad 100644 --- a/packages/kit/src/exports/vite/utils.spec.js +++ b/packages/kit/src/exports/vite/utils.spec.js @@ -2,7 +2,8 @@ import path from 'node:path'; import { expect, test } from 'vitest'; import { validate_config } from '../../core/config/index.js'; import { posixify } from '../../utils/filesystem.js'; -import { get_config_aliases } from './utils.js'; +import { dedent } from '../../core/sync/utils.js'; +import { get_config_aliases, error_for_missing_config } from './utils.js'; test('transform kit.alias to resolve.alias', () => { const config = validate_config({ @@ -37,3 +38,91 @@ test('transform kit.alias to resolve.alias', () => { { find: /^\$regexChar\/(.+)$/.toString(), replacement: 'windows/path/$1' } ]); }); + +test('error_for_missing_config - simple single level config', () => { + expect(() => error_for_missing_config('feature', 'kit.adapter', 'true')).toThrow( + dedent` + To enable \`feature\`, add the following to your \`svelte.config.js\`: + + \`\`\`js + kit: { + adapter: true + } + \`\`\` + ` + ); +}); + +test('error_for_missing_config - nested config', () => { + expect(() => + error_for_missing_config( + 'instrumentation.server.js', + 'kit.experimental.instrumentation.server', + 'true' + ) + ).toThrow( + dedent` + To enable \`instrumentation.server.js\`, add the following to your \`svelte.config.js\`: + + \`\`\`js + kit: { + experimental: { + instrumentation: { + server: true + } + } + } + \`\`\` + ` + ); +}); + +test('error_for_missing_config - deeply nested config', () => { + expect(() => error_for_missing_config('deep feature', 'a.b.c.d.e', '"value"')).toThrow( + dedent` + To enable \`deep feature\`, add the following to your \`svelte.config.js\`: + + \`\`\`js + a: { + b: { + c: { + d: { + e: "value" + } + } + } + } + \`\`\` + ` + ); +}); + +test('error_for_missing_config - two level config', () => { + expect(() => error_for_missing_config('some feature', 'kit.someFeature', 'false')).toThrow( + dedent` + To enable \`some feature\`, add the following to your \`svelte.config.js\`: + + \`\`\`js + kit: { + someFeature: false + } + \`\`\` + ` + ); +}); + +test('error_for_missing_config - handles special characters in feature name', () => { + expect(() => + error_for_missing_config('special-feature.js', 'kit.special', '{ enabled: true }') + ).toThrow( + dedent` + To enable \`special-feature.js\`, add the following to your \`svelte.config.js\`: + + \`\`\`js + kit: { + special: { enabled: true } + } + \`\`\` + ` + ); +}); diff --git a/packages/kit/src/runtime/app/server/index.js b/packages/kit/src/runtime/app/server/index.js index 795715bcbf1e..e2348146e6e2 100644 --- a/packages/kit/src/runtime/app/server/index.js +++ b/packages/kit/src/runtime/app/server/index.js @@ -73,6 +73,6 @@ export function read(asset) { throw new Error(`Asset does not exist: ${file}`); } -export { getRequestEvent } from './event.js'; +export { getRequestEvent } from '@sveltejs/kit/internal'; export { query, prerender, command, form } from './remote/index.js'; diff --git a/packages/kit/src/runtime/app/server/remote/command.js b/packages/kit/src/runtime/app/server/remote/command.js index 3e3a2cba228d..e96025e802b2 100644 --- a/packages/kit/src/runtime/app/server/remote/command.js +++ b/packages/kit/src/runtime/app/server/remote/command.js @@ -1,9 +1,8 @@ /** @import { RemoteCommand } from '@sveltejs/kit' */ /** @import { RemoteInfo, MaybePromise } from 'types' */ /** @import { StandardSchemaV1 } from '@standard-schema/spec' */ -import { getRequestEvent } from '../event.js'; +import { get_request_store } from '@sveltejs/kit/internal'; import { check_experimental, create_validator, run_remote_function } from './shared.js'; -import { get_event_state } from '../../../server/event-state.js'; /** * Creates a remote command. When called from the browser, the function will be invoked on the server via a `fetch` call. @@ -65,7 +64,7 @@ export function command(validate_or_fn, maybe_fn) { /** @type {RemoteCommand & { __: RemoteInfo }} */ const wrapper = (arg) => { - const event = getRequestEvent(); + const { event, state } = get_request_store(); if (!event.isRemoteRequest) { throw new Error( @@ -73,9 +72,9 @@ export function command(validate_or_fn, maybe_fn) { ); } - get_event_state(event).refreshes ??= {}; + state.refreshes ??= {}; - const promise = Promise.resolve(run_remote_function(event, true, arg, validate, fn)); + const promise = Promise.resolve(run_remote_function(event, state, true, arg, validate, fn)); // @ts-expect-error promise.updates = () => { diff --git a/packages/kit/src/runtime/app/server/remote/form.js b/packages/kit/src/runtime/app/server/remote/form.js index 169ce463adf3..caa1f3120ddc 100644 --- a/packages/kit/src/runtime/app/server/remote/form.js +++ b/packages/kit/src/runtime/app/server/remote/form.js @@ -1,8 +1,7 @@ /** @import { RemoteForm } from '@sveltejs/kit' */ /** @import { RemoteInfo, MaybePromise } from 'types' */ -import { getRequestEvent } from '../event.js'; +import { get_request_store } from '@sveltejs/kit/internal'; import { check_experimental, run_remote_function } from './shared.js'; -import { get_event_state } from '../../../server/event-state.js'; /** * Creates a form object that can be spread onto a `
` element. @@ -57,12 +56,11 @@ export function form(fn) { id: '', /** @param {FormData} form_data */ fn: async (form_data) => { - const event = getRequestEvent(); - const state = get_event_state(event); + const { event, state } = get_request_store(); state.refreshes ??= {}; - const result = await run_remote_function(event, true, form_data, (d) => d, fn); + const result = await run_remote_function(event, state, true, form_data, (d) => d, fn); // We don't need to care about args or deduplicating calls, because uneval results are only relevant in full page reloads // where only one form submission is active at the same time @@ -89,7 +87,7 @@ export function form(fn) { Object.defineProperty(instance, 'result', { get() { try { - const { remote_data } = get_event_state(getRequestEvent()); + const { remote_data } = get_request_store().state; return remote_data?.[__.id]; } catch { return undefined; @@ -111,7 +109,7 @@ export function form(fn) { Object.defineProperty(instance, 'for', { /** @type {RemoteForm['for']} */ value: (key) => { - const state = get_event_state(getRequestEvent()); + const { state } = get_request_store(); let instance = (state.form_instances ??= new Map()).get(key); if (!instance) { diff --git a/packages/kit/src/runtime/app/server/remote/prerender.js b/packages/kit/src/runtime/app/server/remote/prerender.js index ea4a3a909fb2..46489e7a0555 100644 --- a/packages/kit/src/runtime/app/server/remote/prerender.js +++ b/packages/kit/src/runtime/app/server/remote/prerender.js @@ -3,7 +3,7 @@ /** @import { StandardSchemaV1 } from '@standard-schema/spec' */ import { error, json } from '@sveltejs/kit'; import { DEV } from 'esm-env'; -import { getRequestEvent } from '../event.js'; +import { get_request_store } from '@sveltejs/kit/internal'; import { create_remote_cache_key, stringify, stringify_remote_arg } from '../../../shared.js'; import { app_dir, base } from '__sveltekit/paths'; import { @@ -13,7 +13,6 @@ import { parse_remote_response, run_remote_function } from './shared.js'; -import { get_event_state } from '../../../server/event-state.js'; /** * Creates a remote prerender function. When called from the browser, the function will be invoked on the server via a `fetch` call. @@ -93,15 +92,14 @@ export function prerender(validate_or_fn, fn_or_options, maybe_options) { const wrapper = (arg) => { /** @type {Promise & Partial>} */ const promise = (async () => { - const event = getRequestEvent(); - const state = get_event_state(event); + const { event, state } = get_request_store(); const payload = stringify_remote_arg(arg, state.transport); const id = __.id; const url = `${base}/${app_dir}/remote/${id}${payload ? `/${payload}` : ''}`; if (!state.prerendering && !DEV && !event.isRemoteRequest) { try { - return await get_response(id, arg, event, async () => { + return await get_response(id, arg, state, async () => { // TODO adapters can provide prerendered data more efficiently than // fetching from the public internet const response = await fetch(new URL(url, event.url.origin).href); @@ -130,8 +128,8 @@ export function prerender(validate_or_fn, fn_or_options, maybe_options) { return /** @type {Promise} */ (state.prerendering.remote_responses.get(url)); } - const promise = get_response(id, arg, event, () => - run_remote_function(event, false, arg, validate, fn) + const promise = get_response(id, arg, state, () => + run_remote_function(event, state, false, arg, validate, fn) ); if (state.prerendering) { diff --git a/packages/kit/src/runtime/app/server/remote/query.js b/packages/kit/src/runtime/app/server/remote/query.js index 270e9f3340f8..4fb38995916a 100644 --- a/packages/kit/src/runtime/app/server/remote/query.js +++ b/packages/kit/src/runtime/app/server/remote/query.js @@ -1,7 +1,7 @@ /** @import { RemoteQuery, RemoteQueryFunction } from '@sveltejs/kit' */ /** @import { RemoteInfo, MaybePromise } from 'types' */ /** @import { StandardSchemaV1 } from '@standard-schema/spec' */ -import { getRequestEvent } from '../event.js'; +import { get_request_store } from '@sveltejs/kit/internal'; import { create_remote_cache_key, stringify_remote_arg } from '../../../shared.js'; import { prerendering } from '__sveltekit/environment'; import { @@ -10,7 +10,6 @@ import { get_response, run_remote_function } from './shared.js'; -import { get_event_state } from '../../../server/event-state.js'; /** * Creates a remote query. When called from the browser, the function will be invoked on the server via a `fetch` call. @@ -78,19 +77,18 @@ export function query(validate_or_fn, maybe_fn) { ); } - const event = getRequestEvent(); + const { event, state } = get_request_store(); /** @type {Promise & Partial>} */ - const promise = get_response(__.id, arg, event, () => - run_remote_function(event, false, arg, validate, fn) + const promise = get_response(__.id, arg, state, () => + run_remote_function(event, state, false, arg, validate, fn) ); promise.catch(() => {}); promise.refresh = async () => { - const event = getRequestEvent(); - const state = get_event_state(event); - const refreshes = state?.refreshes; + const { state } = get_request_store(); + const refreshes = state.refreshes; if (!refreshes) { throw new Error( diff --git a/packages/kit/src/runtime/app/server/remote/shared.js b/packages/kit/src/runtime/app/server/remote/shared.js index 5ad8b433ae86..e030b1ee7dcd 100644 --- a/packages/kit/src/runtime/app/server/remote/shared.js +++ b/packages/kit/src/runtime/app/server/remote/shared.js @@ -1,10 +1,9 @@ /** @import { RequestEvent } from '@sveltejs/kit' */ -/** @import { ServerHooks, MaybePromise } from 'types' */ +/** @import { ServerHooks, MaybePromise, RequestState } from 'types' */ import { parse } from 'devalue'; import { error } from '@sveltejs/kit'; -import { getRequestEvent, with_event } from '../event.js'; +import { with_request_store, get_request_store } from '@sveltejs/kit/internal'; import { create_remote_cache_key, stringify_remote_arg } from '../../../shared.js'; -import { EVENT_STATE, get_event_state } from '../../../server/event-state.js'; /** * @param {any} validate_or_fn @@ -30,8 +29,7 @@ export function create_validator(validate_or_fn, maybe_fn) { if ('~standard' in validate_or_fn) { return async (arg) => { // Get event before async validation to ensure it's available in server environments without AsyncLocalStorage, too - const event = getRequestEvent(); - const state = get_event_state(event); + const { event, state } = get_request_store(); const validate = validate_or_fn['~standard'].validate; const result = await validate(arg); @@ -66,12 +64,11 @@ export function create_validator(validate_or_fn, maybe_fn) { * @template {MaybePromise} T * @param {string} id * @param {any} arg - * @param {RequestEvent} event + * @param {RequestState} state * @param {() => Promise} get_result * @returns {Promise} */ -export function get_response(id, arg, event, get_result) { - const state = get_event_state(event); +export function get_response(id, arg, state, get_result) { const cache_key = create_remote_cache_key(id, stringify_remote_arg(arg, state.transport)); return ((state.remote_data ??= {})[cache_key] ??= get_result()); @@ -104,17 +101,16 @@ export function parse_remote_response(data, transport) { * Like `with_event` but removes things from `event` you cannot see/call in remote functions, such as `setHeaders`. * @template T * @param {RequestEvent} event + * @param {RequestState} state * @param {boolean} allow_cookies * @param {any} arg * @param {(arg: any) => any} validate * @param {(arg?: any) => T} fn */ -export async function run_remote_function(event, allow_cookies, arg, validate, fn) { +export async function run_remote_function(event, state, allow_cookies, arg, validate, fn) { /** @type {RequestEvent} */ const cleansed = { ...event, - // @ts-expect-error this isn't part of the public `RequestEvent` type - [EVENT_STATE]: event[EVENT_STATE], setHeaders: () => { throw new Error('setHeaders is not allowed in remote functions'); }, @@ -148,6 +144,6 @@ export async function run_remote_function(event, allow_cookies, arg, validate, f }; // In two parts, each with_event, so that runtimes without async local storage can still get the event at the start of the function - const validated = await with_event(cleansed, () => validate(arg)); - return with_event(cleansed, () => fn(validated)); + const validated = await with_request_store({ event: cleansed, state }, () => validate(arg)); + return with_request_store({ event: cleansed, state }, () => fn(validated)); } diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index 4486d9228215..037807ee0429 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -39,11 +39,17 @@ import { } from './constants.js'; import { validate_page_exports } from '../../utils/exports.js'; import { compact } from '../../utils/array.js'; -import { INVALIDATED_PARAM, TRAILING_SLASH_PARAM, validate_depends } from '../shared.js'; +import { + INVALIDATED_PARAM, + TRAILING_SLASH_PARAM, + validate_depends, + validate_load_response +} from '../shared.js'; import { get_message, get_status } from '../../utils/error.js'; import { writable } from 'svelte/store'; import { page, update, navigating } from './state.svelte.js'; import { add_data_suffix, add_resolution_suffix } from '../pathname.js'; +import { noop_span } from '../telemetry/noop.js'; import { text_decoder } from '../utils.js'; export { load_css }; @@ -715,6 +721,7 @@ async function load_node({ loader, parent, url, params, route, server_data_node /** @type {import('@sveltejs/kit').LoadEvent} */ const load_input = { + tracing: { enabled: false, root: noop_span, current: noop_span }, route: new Proxy(route, { get: (target, key) => { if (is_tracking) { @@ -807,19 +814,7 @@ async function load_node({ loader, parent, url, params, route, server_data_node try { lock_fetch(); data = (await node.universal.load.call(null, load_input)) ?? null; - if (data != null && Object.getPrototypeOf(data) !== Object.prototype) { - throw new Error( - `a load function related to route '${route.id}' returned ${ - typeof data !== 'object' - ? `a ${typeof data}` - : data instanceof Response - ? 'a Response object' - : Array.isArray(data) - ? 'an array' - : 'a non-plain object' - }, but must return a plain object at the top level (i.e. \`return {...}\`)` - ); - } + validate_load_response(data, `related to route '${route.id}'`); } finally { unlock_fetch(); } diff --git a/packages/kit/src/runtime/server/data/index.js b/packages/kit/src/runtime/server/data/index.js index b87b370bfc73..37fc409ab42d 100644 --- a/packages/kit/src/runtime/server/data/index.js +++ b/packages/kit/src/runtime/server/data/index.js @@ -11,6 +11,7 @@ import { text_encoder } from '../../utils.js'; /** * @param {import('@sveltejs/kit').RequestEvent} event + * @param {import('types').RequestState} event_state * @param {import('types').SSRRoute} route * @param {import('types').SSROptions} options * @param {import('@sveltejs/kit').SSRManifest} manifest @@ -21,6 +22,7 @@ import { text_encoder } from '../../utils.js'; */ export async function render_data( event, + event_state, route, options, manifest, @@ -60,6 +62,7 @@ export async function render_data( // load this. for the child, return as is. for the final result, stream things return load_server_data({ event: new_event, + event_state, state, node, parent: async () => { @@ -107,7 +110,7 @@ export async function render_data( return /** @type {import('types').ServerErrorNode} */ ({ type: 'error', - error: await handle_error_and_jsonify(event, options, error), + error: await handle_error_and_jsonify(event, event_state, options, error), status: error instanceof HttpError || error instanceof SvelteKitError ? error.status @@ -117,7 +120,7 @@ export async function render_data( ) ); - const { data, chunks } = get_data_json(event, options, nodes); + const { data, chunks } = get_data_json(event, event_state, options, nodes); if (!chunks) { // use a normal JSON response where possible, so we get `content-length` @@ -152,7 +155,7 @@ export async function render_data( if (error instanceof Redirect) { return redirect_json_response(error); } else { - return json_response(await handle_error_and_jsonify(event, options, error), 500); + return json_response(await handle_error_and_jsonify(event, event_state, options, error), 500); } } } @@ -187,11 +190,12 @@ export function redirect_json_response(redirect) { * If the serialized data contains promises, `chunks` will be an * async iterable containing their resolutions * @param {import('@sveltejs/kit').RequestEvent} event + * @param {import('types').RequestState} event_state * @param {import('types').SSROptions} options * @param {Array} nodes * @returns {{ data: string, chunks: AsyncIterable | null }} */ -export function get_data_json(event, options, nodes) { +export function get_data_json(event, event_state, options, nodes) { let promise_id = 1; let count = 0; @@ -214,7 +218,7 @@ export function get_data_json(event, options, nodes) { .catch( /** @param {any} e */ async (e) => { key = 'error'; - return handle_error_and_jsonify(event, options, /** @type {any} */ (e)); + return handle_error_and_jsonify(event, event_state, options, /** @type {any} */ (e)); } ) .then( @@ -226,6 +230,7 @@ export function get_data_json(event, options, nodes) { } catch { const error = await handle_error_and_jsonify( event, + event_state, options, new Error(`Failed to serialize promise while rendering ${event.route.id}`) ); diff --git a/packages/kit/src/runtime/server/endpoint.js b/packages/kit/src/runtime/server/endpoint.js index a88a9f4c5a70..124ab9edf0b4 100644 --- a/packages/kit/src/runtime/server/endpoint.js +++ b/packages/kit/src/runtime/server/endpoint.js @@ -1,16 +1,16 @@ -import { Redirect } from '@sveltejs/kit/internal'; +import { Redirect, with_request_store } from '@sveltejs/kit/internal'; import { ENDPOINT_METHODS, PAGE_METHODS } from '../../constants.js'; import { negotiate } from '../../utils/http.js'; -import { with_event } from '../app/server/event.js'; import { method_not_allowed } from './utils.js'; /** * @param {import('@sveltejs/kit').RequestEvent} event + * @param {import('types').RequestState} event_state * @param {import('types').SSREndpoint} mod * @param {import('types').SSRState} state * @returns {Promise} */ -export async function render_endpoint(event, mod, state) { +export async function render_endpoint(event, event_state, mod, state) { const method = /** @type {import('types').HttpMethod} */ (event.request.method); let handler = mod[method] || mod.fallback; @@ -41,7 +41,7 @@ export async function render_endpoint(event, mod, state) { } try { - const response = await with_event(event, () => + const response = await with_request_store({ event, state: event_state }, () => handler(/** @type {import('@sveltejs/kit').RequestEvent>} */ (event)) ); diff --git a/packages/kit/src/runtime/server/event-state.js b/packages/kit/src/runtime/server/event-state.js deleted file mode 100644 index 842c6513334c..000000000000 --- a/packages/kit/src/runtime/server/event-state.js +++ /dev/null @@ -1,40 +0,0 @@ -/** @import { RequestEvent } from '@sveltejs/kit' */ -/** @import { MaybePromise, PrerenderOptions, ServerHooks, SSROptions, SSRState } from 'types' */ - -export const EVENT_STATE = Symbol('remote'); - -/** - * Internal state associated with the current `RequestEvent`, - * used for tracking things like remote function calls - * @typedef {{ - * prerendering: PrerenderOptions | undefined - * transport: ServerHooks['transport']; - * handleValidationError: ServerHooks['handleValidationError']; - * form_instances?: Map; - * remote_data?: Record>; - * refreshes?: Record; - * }} RequestEventState - */ - -/** - * @param {SSRState} state - * @param {SSROptions} options - * @returns {RequestEventState} - */ -export function create_event_state(state, options) { - return { - prerendering: state.prerendering, - transport: options.hooks.transport, - handleValidationError: options.hooks.handleValidationError - }; -} - -/** - * Returns internal state associated with the current `RequestEvent` - * @param {RequestEvent} event - * @returns {RequestEventState} - */ -export function get_event_state(event) { - // @ts-expect-error the symbol isn't exposed on the public `RequestEvent` type - return event[EVENT_STATE]; -} diff --git a/packages/kit/src/runtime/server/page/actions.js b/packages/kit/src/runtime/server/page/actions.js index 6f1d10711d51..cac5f1b0d009 100644 --- a/packages/kit/src/runtime/server/page/actions.js +++ b/packages/kit/src/runtime/server/page/actions.js @@ -1,13 +1,22 @@ +/** @import { RequestEvent, ActionResult, Actions } from '@sveltejs/kit' */ +/** @import { SSROptions, SSRNode, ServerNode, ServerHooks } from 'types' */ import * as devalue from 'devalue'; import { DEV } from 'esm-env'; import { json } from '@sveltejs/kit'; -import { HttpError, Redirect, ActionFailure, SvelteKitError } from '@sveltejs/kit/internal'; +import { + HttpError, + Redirect, + ActionFailure, + SvelteKitError, + with_request_store, + merge_tracing +} from '@sveltejs/kit/internal'; import { get_status, normalize_error } from '../../../utils/error.js'; import { is_form_content_type, negotiate } from '../../../utils/http.js'; import { handle_error_and_jsonify } from '../utils.js'; -import { with_event } from '../../app/server/event.js'; +import { record_span } from '../../telemetry/record_span.js'; -/** @param {import('@sveltejs/kit').RequestEvent} event */ +/** @param {RequestEvent} event */ export function is_action_json_request(event) { const accept = negotiate(event.request.headers.get('accept') ?? '*/*', [ 'application/json', @@ -18,11 +27,12 @@ export function is_action_json_request(event) { } /** - * @param {import('@sveltejs/kit').RequestEvent} event - * @param {import('types').SSROptions} options - * @param {import('types').SSRNode['server'] | undefined} server + * @param {RequestEvent} event + * @param {import('types').RequestState} event_state + * @param {SSROptions} options + * @param {SSRNode['server'] | undefined} server */ -export async function handle_action_json_request(event, options, server) { +export async function handle_action_json_request(event, event_state, options, server) { const actions = server?.actions; if (!actions) { @@ -35,7 +45,7 @@ export async function handle_action_json_request(event, options, server) { return action_json( { type: 'error', - error: await handle_error_and_jsonify(event, options, no_actions_error) + error: await handle_error_and_jsonify(event, event_state, options, no_actions_error) }, { status: no_actions_error.status, @@ -51,7 +61,7 @@ export async function handle_action_json_request(event, options, server) { check_named_default_separate(actions); try { - const data = await call_action(event, actions); + const data = await call_action(event, event_state, actions); if (__SVELTEKIT_DEV__) { validate_action_return(data); @@ -92,7 +102,12 @@ export async function handle_action_json_request(event, options, server) { return action_json( { type: 'error', - error: await handle_error_and_jsonify(event, options, check_incorrect_fail_use(err)) + error: await handle_error_and_jsonify( + event, + event_state, + options, + check_incorrect_fail_use(err) + ) }, { status: get_status(err) @@ -111,7 +126,7 @@ export function check_incorrect_fail_use(error) { } /** - * @param {import('@sveltejs/kit').Redirect} redirect + * @param {Redirect} redirect */ export function action_json_redirect(redirect) { return action_json({ @@ -122,7 +137,7 @@ export function action_json_redirect(redirect) { } /** - * @param {import('@sveltejs/kit').ActionResult} data + * @param {ActionResult} data * @param {ResponseInit} [init] */ function action_json(data, init) { @@ -130,18 +145,19 @@ function action_json(data, init) { } /** - * @param {import('@sveltejs/kit').RequestEvent} event + * @param {RequestEvent} event */ export function is_action_request(event) { return event.request.method === 'POST'; } /** - * @param {import('@sveltejs/kit').RequestEvent} event - * @param {import('types').SSRNode['server'] | undefined} server - * @returns {Promise} + * @param {RequestEvent} event + * @param {import('types').RequestState} event_state + * @param {SSRNode['server'] | undefined} server + * @returns {Promise} */ -export async function handle_action_request(event, server) { +export async function handle_action_request(event, event_state, server) { const actions = server?.actions; if (!actions) { @@ -164,7 +180,7 @@ export async function handle_action_request(event, server) { check_named_default_separate(actions); try { - const data = await call_action(event, actions); + const data = await call_action(event, event_state, actions); if (__SVELTEKIT_DEV__) { validate_action_return(data); @@ -203,7 +219,7 @@ export async function handle_action_request(event, server) { } /** - * @param {import('@sveltejs/kit').Actions} actions + * @param {Actions} actions */ function check_named_default_separate(actions) { if (actions.default && Object.keys(actions).length > 1) { @@ -214,11 +230,12 @@ function check_named_default_separate(actions) { } /** - * @param {import('@sveltejs/kit').RequestEvent} event - * @param {NonNullable} actions + * @param {RequestEvent} event + * @param {import('types').RequestState} event_state + * @param {NonNullable} actions * @throws {Redirect | HttpError | SvelteKitError | Error} */ -async function call_action(event, actions) { +async function call_action(event, event_state, actions) { const url = new URL(event.request.url); let name = 'default'; @@ -247,7 +264,27 @@ async function call_action(event, actions) { ); } - return with_event(event, () => action(event)); + return record_span({ + name: 'sveltekit.form_action', + attributes: { + 'sveltekit.form_action.name': name, + 'http.route': event.route.id || 'unknown' + }, + fn: async (current) => { + const traced_event = merge_tracing(event, current); + const result = await with_request_store({ event: traced_event, state: event_state }, () => + action(traced_event) + ); + if (result instanceof ActionFailure) { + current.setAttributes({ + 'sveltekit.form_action.result.type': 'failure', + 'sveltekit.form_action.result.status': result.status + }); + } + + return result; + } + }); } /** @param {any} data */ @@ -265,7 +302,7 @@ function validate_action_return(data) { * Try to `devalue.uneval` the data object, and if it fails, return a proper Error with context * @param {any} data * @param {string} route_id - * @param {import('types').ServerHooks['transport']} transport + * @param {ServerHooks['transport']} transport */ export function uneval_action_response(data, route_id, transport) { const replacer = (/** @type {any} */ thing) => { @@ -284,7 +321,7 @@ export function uneval_action_response(data, route_id, transport) { * Try to `devalue.stringify` the data object, and if it fails, return a proper Error with context * @param {any} data * @param {string} route_id - * @param {import('types').ServerHooks['transport']} transport + * @param {ServerHooks['transport']} transport */ function stringify_action_response(data, route_id, transport) { const encoders = Object.fromEntries( diff --git a/packages/kit/src/runtime/server/page/index.js b/packages/kit/src/runtime/server/page/index.js index 30e8115f612a..7d24641f062d 100644 --- a/packages/kit/src/runtime/server/page/index.js +++ b/packages/kit/src/runtime/server/page/index.js @@ -25,6 +25,7 @@ const MAX_DEPTH = 10; /** * @param {import('@sveltejs/kit').RequestEvent} event + * @param {import('types').RequestState} event_state * @param {import('types').PageNodeIndexes} page * @param {import('types').SSROptions} options * @param {import('@sveltejs/kit').SSRManifest} manifest @@ -33,7 +34,16 @@ const MAX_DEPTH = 10; * @param {import('types').RequiredResolveOptions} resolve_opts * @returns {Promise} */ -export async function render_page(event, page, options, manifest, state, nodes, resolve_opts) { +export async function render_page( + event, + event_state, + page, + options, + manifest, + state, + nodes, + resolve_opts +) { if (state.depth > MAX_DEPTH) { // infinite request cycle detected return text(`Not found: ${event.url.pathname}`, { @@ -43,7 +53,7 @@ export async function render_page(event, page, options, manifest, state, nodes, if (is_action_json_request(event)) { const node = await manifest._.nodes[page.leaf](); - return handle_action_json_request(event, options, node?.server); + return handle_action_json_request(event, event_state, options, node?.server); } try { @@ -57,11 +67,11 @@ export async function render_page(event, page, options, manifest, state, nodes, if (is_action_request(event)) { const remote_id = get_remote_action(event.url); if (remote_id) { - action_result = await handle_remote_form_post(event, manifest, remote_id); + action_result = await handle_remote_form_post(event, event_state, manifest, remote_id); } else { // for action requests, first call handler in +page.server.js // (this also determines status code) - action_result = await handle_action_request(event, leaf_node.server); + action_result = await handle_action_request(event, event_state, leaf_node.server); } if (action_result?.type === 'redirect') { @@ -133,6 +143,7 @@ export async function render_page(event, page, options, manifest, state, nodes, status, error: null, event, + event_state, options, manifest, state, @@ -163,6 +174,7 @@ export async function render_page(event, page, options, manifest, state, nodes, return await load_server_data({ event, + event_state, state, node, parent: async () => { @@ -189,6 +201,7 @@ export async function render_page(event, page, options, manifest, state, nodes, try { return await load_data({ event, + event_state, fetched, node, parent: async () => { @@ -243,7 +256,7 @@ export async function render_page(event, page, options, manifest, state, nodes, } const status = get_status(err); - const error = await handle_error_and_jsonify(event, options, err); + const error = await handle_error_and_jsonify(event, event_state, options, err); while (i--) { if (page.errors[i]) { @@ -258,6 +271,7 @@ export async function render_page(event, page, options, manifest, state, nodes, return await render_response({ event, + event_state, options, manifest, state, @@ -293,6 +307,7 @@ export async function render_page(event, page, options, manifest, state, nodes, // ndjson format let { data, chunks } = get_data_json( event, + event_state, options, branch.map((node) => node?.server_data) ); @@ -311,6 +326,7 @@ export async function render_page(event, page, options, manifest, state, nodes, return await render_response({ event, + event_state, options, manifest, state, @@ -330,6 +346,7 @@ export async function render_page(event, page, options, manifest, state, nodes, // but the page failed to render, or that a prerendering error occurred return await respond_with_error({ event, + event_state, options, manifest, state, diff --git a/packages/kit/src/runtime/server/page/load_data.js b/packages/kit/src/runtime/server/page/load_data.js index 72a77b5aceb6..bc98735549bc 100644 --- a/packages/kit/src/runtime/server/page/load_data.js +++ b/packages/kit/src/runtime/server/page/load_data.js @@ -1,20 +1,23 @@ import { DEV } from 'esm-env'; import { disable_search, make_trackable } from '../../../utils/url.js'; -import { validate_depends } from '../../shared.js'; +import { validate_depends, validate_load_response } from '../../shared.js'; +import { with_request_store, merge_tracing } from '@sveltejs/kit/internal'; +import { record_span } from '../../telemetry/record_span.js'; +import { get_node_type } from '../utils.js'; import { base64_encode, text_decoder } from '../../utils.js'; -import { with_event } from '../../app/server/event.js'; /** * Calls the user's server `load` function. * @param {{ * event: import('@sveltejs/kit').RequestEvent; + * event_state: import('types').RequestState; * state: import('types').SSRState; * node: import('types').SSRNode | undefined; * parent: () => Promise>; * }} opts * @returns {Promise} */ -export async function load_server_data({ event, state, node, parent }) { +export async function load_server_data({ event, event_state, state, node, parent }) { if (!node?.server) return null; let is_tracking = true; @@ -68,97 +71,111 @@ export async function load_server_data({ event, state, node, parent }) { let done = false; - const result = await with_event(event, () => - load.call(null, { - ...event, - fetch: (info, init) => { - const url = new URL(info instanceof Request ? info.url : info, event.url); - - if (DEV && done && !uses.dependencies.has(url.href)) { - console.warn( - `${node.server_id}: Calling \`event.fetch(...)\` in a promise handler after \`load(...)\` has returned will not cause the function to re-run when the dependency is invalidated` - ); - } - - // Note: server fetches are not added to uses.depends due to security concerns - return event.fetch(info, init); - }, - /** @param {string[]} deps */ - depends: (...deps) => { - for (const dep of deps) { - const { href } = new URL(dep, event.url); - - if (DEV) { - validate_depends(node.server_id || 'missing route ID', dep); - - if (done && !uses.dependencies.has(href)) { + const result = await record_span({ + name: 'sveltekit.load', + attributes: { + 'sveltekit.load.node_id': node.server_id || 'unknown', + 'sveltekit.load.node_type': get_node_type(node.server_id), + 'sveltekit.load.environment': 'server', + 'http.route': event.route.id || 'unknown' + }, + fn: async (current) => { + const traced_event = merge_tracing(event, current); + const result = await with_request_store({ event: traced_event, state: event_state }, () => + load.call(null, { + ...traced_event, + fetch: (info, init) => { + const url = new URL(info instanceof Request ? info.url : info, event.url); + + if (DEV && done && !uses.dependencies.has(url.href)) { console.warn( - `${node.server_id}: Calling \`depends(...)\` in a promise handler after \`load(...)\` has returned will not cause the function to re-run when the dependency is invalidated` + `${node.server_id}: Calling \`event.fetch(...)\` in a promise handler after \`load(...)\` has returned will not cause the function to re-run when the dependency is invalidated` ); } - } - - uses.dependencies.add(href); - } - }, - params: new Proxy(event.params, { - get: (target, key) => { - if (DEV && done && typeof key === 'string' && !uses.params.has(key)) { - console.warn( - `${node.server_id}: Accessing \`params.${String( - key - )}\` in a promise handler after \`load(...)\` has returned will not cause the function to re-run when the param changes` - ); - } - if (is_tracking) { - uses.params.add(key); - } - return target[/** @type {string} */ (key)]; - } - }), - parent: async () => { - if (DEV && done && !uses.parent) { - console.warn( - `${node.server_id}: Calling \`parent(...)\` in a promise handler after \`load(...)\` has returned will not cause the function to re-run when parent data changes` - ); - } + // Note: server fetches are not added to uses.depends due to security concerns + return event.fetch(info, init); + }, + /** @param {string[]} deps */ + depends: (...deps) => { + for (const dep of deps) { + const { href } = new URL(dep, event.url); + + if (DEV) { + validate_depends(node.server_id || 'missing route ID', dep); + + if (done && !uses.dependencies.has(href)) { + console.warn( + `${node.server_id}: Calling \`depends(...)\` in a promise handler after \`load(...)\` has returned will not cause the function to re-run when the dependency is invalidated` + ); + } + } + + uses.dependencies.add(href); + } + }, + params: new Proxy(event.params, { + get: (target, key) => { + if (DEV && done && typeof key === 'string' && !uses.params.has(key)) { + console.warn( + `${node.server_id}: Accessing \`params.${String( + key + )}\` in a promise handler after \`load(...)\` has returned will not cause the function to re-run when the param changes` + ); + } + + if (is_tracking) { + uses.params.add(key); + } + return target[/** @type {string} */ (key)]; + } + }), + parent: async () => { + if (DEV && done && !uses.parent) { + console.warn( + `${node.server_id}: Calling \`parent(...)\` in a promise handler after \`load(...)\` has returned will not cause the function to re-run when parent data changes` + ); + } - if (is_tracking) { - uses.parent = true; - } - return parent(); - }, - route: new Proxy(event.route, { - get: (target, key) => { - if (DEV && done && typeof key === 'string' && !uses.route) { - console.warn( - `${node.server_id}: Accessing \`route.${String( - key - )}\` in a promise handler after \`load(...)\` has returned will not cause the function to re-run when the route changes` - ); + if (is_tracking) { + uses.parent = true; + } + return parent(); + }, + route: new Proxy(event.route, { + get: (target, key) => { + if (DEV && done && typeof key === 'string' && !uses.route) { + console.warn( + `${node.server_id}: Accessing \`route.${String( + key + )}\` in a promise handler after \`load(...)\` has returned will not cause the function to re-run when the route changes` + ); + } + + if (is_tracking) { + uses.route = true; + } + return target[/** @type {'id'} */ (key)]; + } + }), + url, + untrack(fn) { + is_tracking = false; + try { + return fn(); + } finally { + is_tracking = true; + } } + }) + ); - if (is_tracking) { - uses.route = true; - } - return target[/** @type {'id'} */ (key)]; - } - }), - url, - untrack(fn) { - is_tracking = false; - try { - return fn(); - } finally { - is_tracking = true; - } - } - }) - ); + return result; + } + }); if (__SVELTEKIT_DEV__) { - validate_load_response(result, node.server_id); + validate_load_response(result, `in ${node.server_id}`); } done = true; @@ -175,6 +192,7 @@ export async function load_server_data({ event, state, node, parent }) { * Calls the user's `load` function. * @param {{ * event: import('@sveltejs/kit').RequestEvent; + * event_state: import('types').RequestState; * fetched: import('./types.js').Fetched[]; * node: import('types').SSRNode | undefined; * parent: () => Promise>; @@ -187,6 +205,7 @@ export async function load_server_data({ event, state, node, parent }) { */ export async function load_data({ event, + event_state, fetched, node, parent, @@ -203,24 +222,35 @@ export async function load_data({ return server_data_node?.data ?? null; } - // We're adding getRequestEvent context to the universal load function - // in order to be able to use remote calls within it. - const result = await with_event(event, () => - load.call(null, { - url: event.url, - params: event.params, - data: server_data_node?.data ?? null, - route: event.route, - fetch: create_universal_fetch(event, state, fetched, csr, resolve_opts), - setHeaders: event.setHeaders, - depends: () => {}, - parent, - untrack: (fn) => fn() - }) - ); + const result = await record_span({ + name: 'sveltekit.load', + attributes: { + 'sveltekit.load.node_id': node.universal_id || 'unknown', + 'sveltekit.load.node_type': get_node_type(node.universal_id), + 'sveltekit.load.environment': 'server', + 'http.route': event.route.id || 'unknown' + }, + fn: async (current) => { + const traced_event = merge_tracing(event, current); + return with_request_store({ event: traced_event, state: event_state }, () => + load.call(null, { + url: event.url, + params: event.params, + data: server_data_node?.data ?? null, + route: event.route, + fetch: create_universal_fetch(event, state, fetched, csr, resolve_opts), + setHeaders: event.setHeaders, + depends: () => {}, + parent, + untrack: (fn) => fn(), + tracing: traced_event.tracing + }) + ); + } + }); if (__SVELTEKIT_DEV__) { - validate_load_response(result, node.universal_id); + validate_load_response(result, `in ${node.universal_id}`); } return result ?? null; @@ -405,23 +435,3 @@ async function stream_to_string(stream) { } return result; } - -/** - * @param {any} data - * @param {string} [id] - */ -function validate_load_response(data, id) { - if (data != null && Object.getPrototypeOf(data) !== Object.prototype) { - throw new Error( - `a load function in ${id} returned ${ - typeof data !== 'object' - ? `a ${typeof data}` - : data instanceof Response - ? 'a Response object' - : Array.isArray(data) - ? 'an array' - : 'a non-plain object' - }, but must return a plain object at the top level (i.e. \`return {...}\`)` - ); - } -} diff --git a/packages/kit/src/runtime/server/page/render.js b/packages/kit/src/runtime/server/page/render.js index cdff4095788e..1fb5f250b323 100644 --- a/packages/kit/src/runtime/server/page/render.js +++ b/packages/kit/src/runtime/server/page/render.js @@ -15,8 +15,7 @@ import { SVELTE_KIT_ASSETS } from '../../../constants.js'; import { SCHEME } from '../../../utils/url.js'; import { create_server_routing_response, generate_route_object } from './server_routing.js'; import { add_resolution_suffix } from '../../pathname.js'; -import { with_event } from '../../app/server/event.js'; -import { get_event_state } from '../event-state.js'; +import { with_request_store } from '@sveltejs/kit/internal'; import { text_encoder } from '../../utils.js'; // TODO rename this function/module @@ -38,6 +37,7 @@ const updated = { * status: number; * error: App.Error | null; * event: import('@sveltejs/kit').RequestEvent; + * event_state: import('types').RequestState; * resolve_opts: import('types').RequiredResolveOptions; * action_result?: import('@sveltejs/kit').ActionResult; * }} opts @@ -52,6 +52,7 @@ export async function render_response({ status, error = null, event, + event_state, resolve_opts, action_result }) { @@ -190,14 +191,18 @@ export async function render_response({ }; try { - rendered = with_event(event, () => options.root.render(props, render_opts)); + rendered = with_request_store({ event, state: event_state }, () => + options.root.render(props, render_opts) + ); } finally { globalThis.fetch = fetch; paths.reset(); } } else { try { - rendered = with_event(event, () => options.root.render(props, render_opts)); + rendered = with_request_store({ event, state: event_state }, () => + options.root.render(props, render_opts) + ); } finally { paths.reset(); } @@ -288,6 +293,7 @@ export async function render_response({ const { data, chunks } = get_data( event, + event_state, options, branch.map((b) => b.server_data), csp, @@ -377,7 +383,7 @@ export async function render_response({ }`); } - const { remote_data } = get_event_state(event); + const { remote_data } = event_state; if (remote_data) { /** @type {Record} */ @@ -604,13 +610,14 @@ export async function render_response({ * If the serialized data contains promises, `chunks` will be an * async iterable containing their resolutions * @param {import('@sveltejs/kit').RequestEvent} event + * @param {import('types').RequestState} event_state * @param {import('types').SSROptions} options * @param {Array} nodes * @param {import('./csp.js').Csp} csp * @param {string} global * @returns {{ data: string, chunks: AsyncIterable | null }} */ -function get_data(event, options, nodes, csp, global) { +function get_data(event, event_state, options, nodes, csp, global) { let promise_id = 1; let count = 0; @@ -626,7 +633,7 @@ function get_data(event, options, nodes, csp, global) { .then(/** @param {any} data */ (data) => ({ data })) .catch( /** @param {any} error */ async (error) => ({ - error: await handle_error_and_jsonify(event, options, error) + error: await handle_error_and_jsonify(event, event_state, options, error) }) ) .then( @@ -642,6 +649,7 @@ function get_data(event, options, nodes, csp, global) { } catch { error = await handle_error_and_jsonify( event, + event_state, options, new Error(`Failed to serialize promise while rendering ${event.route.id}`) ); diff --git a/packages/kit/src/runtime/server/page/respond_with_error.js b/packages/kit/src/runtime/server/page/respond_with_error.js index bb3e99054507..b1a6ae9ae606 100644 --- a/packages/kit/src/runtime/server/page/respond_with_error.js +++ b/packages/kit/src/runtime/server/page/respond_with_error.js @@ -12,6 +12,7 @@ import { PageNodes } from '../../../utils/page_nodes.js'; /** * @param {{ * event: import('@sveltejs/kit').RequestEvent; + * event_state: import('types').RequestState; * options: import('types').SSROptions; * manifest: import('@sveltejs/kit').SSRManifest; * state: import('types').SSRState; @@ -22,6 +23,7 @@ import { PageNodes } from '../../../utils/page_nodes.js'; */ export async function respond_with_error({ event, + event_state, options, manifest, state, @@ -49,6 +51,7 @@ export async function respond_with_error({ const server_data_promise = load_server_data({ event, + event_state, state, node: default_layout, // eslint-disable-next-line @typescript-eslint/require-await @@ -59,6 +62,7 @@ export async function respond_with_error({ const data = await load_data({ event, + event_state, fetched, node: default_layout, // eslint-disable-next-line @typescript-eslint/require-await @@ -92,10 +96,11 @@ export async function respond_with_error({ csr }, status, - error: await handle_error_and_jsonify(event, options, error), + error: await handle_error_and_jsonify(event, event_state, options, error), branch, fetched, event, + event_state, resolve_opts }); } catch (e) { @@ -108,7 +113,7 @@ export async function respond_with_error({ return static_error_page( options, get_status(e), - (await handle_error_and_jsonify(event, options, e)).message + (await handle_error_and_jsonify(event, event_state, options, e)).message ); } } diff --git a/packages/kit/src/runtime/server/remote.js b/packages/kit/src/runtime/server/remote.js index fea59be00809..326dd257c7af 100644 --- a/packages/kit/src/runtime/server/remote.js +++ b/packages/kit/src/runtime/server/remote.js @@ -1,25 +1,47 @@ /** @import { ActionResult, RemoteForm, RequestEvent, SSRManifest } from '@sveltejs/kit' */ -/** @import { RemoteFunctionResponse, RemoteInfo, SSROptions } from 'types' */ +/** @import { RemoteFunctionResponse, RemoteInfo, RequestState, SSROptions } from 'types' */ import { json, error } from '@sveltejs/kit'; -import { HttpError, Redirect, SvelteKitError } from '@sveltejs/kit/internal'; +import { + HttpError, + Redirect, + SvelteKitError, + with_request_store, + merge_tracing +} from '@sveltejs/kit/internal'; import { app_dir, base } from '__sveltekit/paths'; -import { with_event } from '../app/server/event.js'; import { is_form_content_type } from '../../utils/http.js'; import { parse_remote_arg, stringify } from '../shared.js'; import { handle_error_and_jsonify } from './utils.js'; import { normalize_error } from '../../utils/error.js'; import { check_incorrect_fail_use } from './page/actions.js'; import { DEV } from 'esm-env'; -import { get_event_state } from './event-state.js'; +import { record_span } from '../telemetry/record_span.js'; + +/** @type {typeof handle_remote_call_internal} */ +export async function handle_remote_call(event, state, options, manifest, id) { + return record_span({ + name: 'sveltekit.remote.call', + attributes: { + 'sveltekit.remote.call.id': id + }, + fn: async (current) => { + const traced_event = merge_tracing(event, current); + return with_request_store({ event: traced_event, state }, () => + handle_remote_call_internal(traced_event, state, options, manifest, id) + ); + } + }); +} /** * @param {RequestEvent} event + * @param {RequestState} state * @param {SSROptions} options * @param {SSRManifest} manifest * @param {string} id */ -export async function handle_remote_call(event, options, manifest, id) { +async function handle_remote_call_internal(event, state, options, manifest, id) { const [hash, name, prerender_args] = id.split('/'); const remotes = manifest._.remotes; @@ -34,6 +56,11 @@ export async function handle_remote_call(event, options, manifest, id) { const info = fn.__; const transport = options.hooks.transport; + event.tracing.current.setAttributes({ + 'sveltekit.remote.call.type': info.type, + 'sveltekit.remote.call.name': info.name + }); + /** @type {string[] | undefined} */ let form_client_refreshes; @@ -56,7 +83,7 @@ export async function handle_remote_call(event, options, manifest, id) { form_data.delete('sveltekit:remote_refreshes'); const fn = info.fn; - const data = await with_event(event, () => fn(form_data)); + const data = await with_request_store({ event, state }, () => fn(form_data)); return json( /** @type {RemoteFunctionResponse} */ ({ @@ -71,7 +98,7 @@ export async function handle_remote_call(event, options, manifest, id) { /** @type {{ payload: string, refreshes: string[] }} */ const { payload, refreshes } = await event.request.json(); const arg = parse_remote_arg(payload, transport); - const data = await with_event(event, () => fn(arg)); + const data = await with_request_store({ event, state }, () => fn(arg)); return json( /** @type {RemoteFunctionResponse} */ ({ @@ -90,7 +117,9 @@ export async function handle_remote_call(event, options, manifest, id) { new URL(event.request.url).searchParams.get('payload') ); - const data = await with_event(event, () => fn(parse_remote_arg(payload, transport))); + const data = await with_request_store({ event, state }, () => + fn(parse_remote_arg(payload, transport)) + ); return json( /** @type {RemoteFunctionResponse} */ ({ @@ -110,7 +139,7 @@ export async function handle_remote_call(event, options, manifest, id) { return json( /** @type {RemoteFunctionResponse} */ ({ type: 'error', - error: await handle_error_and_jsonify(event, options, error), + error: await handle_error_and_jsonify(event, state, options, error), status: error instanceof HttpError || error instanceof SvelteKitError ? error.status : 500 }), { @@ -126,7 +155,7 @@ export async function handle_remote_call(event, options, manifest, id) { */ async function serialize_refreshes(client_refreshes) { const refreshes = { - ...get_event_state(event).refreshes, + ...state.refreshes, ...Object.fromEntries( await Promise.all( client_refreshes.map(async (key) => { @@ -141,7 +170,12 @@ export async function handle_remote_call(event, options, manifest, id) { if (!fn) error(400, 'Bad Request'); - return [key, await with_event(event, () => fn(parse_remote_arg(payload, transport)))]; + return [ + key, + await with_request_store({ event, state }, () => + fn(parse_remote_arg(payload, transport)) + ) + ]; }) ) ) @@ -151,13 +185,30 @@ export async function handle_remote_call(event, options, manifest, id) { } } +/** @type {typeof handle_remote_form_post_internal} */ +export async function handle_remote_form_post(event, state, manifest, id) { + return record_span({ + name: 'sveltekit.remote.form.post', + attributes: { + 'sveltekit.remote.form.post.id': id + }, + fn: async (current) => { + const traced_event = merge_tracing(event, current); + return with_request_store({ event: traced_event, state }, () => + handle_remote_form_post_internal(traced_event, state, manifest, id) + ); + } + }); +} + /** * @param {RequestEvent} event + * @param {RequestState} state * @param {SSRManifest} manifest * @param {string} id * @returns {Promise} */ -export async function handle_remote_form_post(event, manifest, id) { +async function handle_remote_form_post_internal(event, state, manifest, id) { const [hash, name, action_id] = id.split('/'); const remotes = manifest._.remotes; const module = await remotes[hash]?.(); @@ -182,14 +233,14 @@ export async function handle_remote_form_post(event, manifest, id) { if (action_id) { // @ts-expect-error - form = with_event(event, () => form.for(JSON.parse(action_id))); + form = with_request_store({ event, state }, () => form.for(JSON.parse(action_id))); } try { const form_data = await event.request.formData(); const fn = /** @type {RemoteInfo & { type: 'form' }} */ (/** @type {any} */ (form).__).fn; - await with_event(event, () => fn(form_data)); + await with_request_store({ event, state }, () => fn(form_data)); // We don't want the data to appear on `let { form } = $props()`, which is why we're not returning it. // It is instead available on `myForm.result`, setting of which happens within the remote `form` function. diff --git a/packages/kit/src/runtime/server/respond.js b/packages/kit/src/runtime/server/respond.js index c14f37ab6406..2e9293dc14ce 100644 --- a/packages/kit/src/runtime/server/respond.js +++ b/packages/kit/src/runtime/server/respond.js @@ -1,6 +1,12 @@ +/** @import { RequestState } from 'types' */ import { DEV } from 'esm-env'; import { json, text } from '@sveltejs/kit'; -import { Redirect, SvelteKitError } from '@sveltejs/kit/internal'; +import { + Redirect, + SvelteKitError, + merge_tracing, + with_request_store +} from '@sveltejs/kit/internal'; import { base, app_dir } from '__sveltekit/paths'; import { is_endpoint_request, render_endpoint } from './endpoint.js'; import { render_page } from './page/index.js'; @@ -34,8 +40,8 @@ import { strip_resolution_suffix } from '../pathname.js'; import { get_remote_id, handle_remote_call } from './remote.js'; -import { with_event } from '../app/server/event.js'; -import { create_event_state, EVENT_STATE } from './event-state.js'; +import { record_span } from '../telemetry/record_span.js'; +import { otel } from '../telemetry/otel.js'; /* global __SVELTEKIT_ADAPTER_NAME__ */ /* global __SVELTEKIT_DEV__ */ @@ -55,6 +61,8 @@ const allowed_page_methods = new Set(['GET', 'HEAD', 'OPTIONS']); let warned_on_devtools_json_request = false; +export const respond = propagate_context(internal_respond); + /** * @param {Request} request * @param {import('types').SSROptions} options @@ -62,7 +70,7 @@ let warned_on_devtools_json_request = false; * @param {import('types').SSRState} state * @returns {Promise} */ -export async function respond(request, options, manifest, state) { +export async function internal_respond(request, options, manifest, state) { /** URL but stripped from the potential `/__data.json` suffix and its search param */ const url = new URL(request.url); @@ -134,9 +142,18 @@ export async function respond(request, options, manifest, state) { url ); + /** @type {RequestState} */ + const event_state = { + prerendering: state.prerendering, + transport: options.hooks.transport, + handleValidationError: options.hooks.handleValidationError, + tracing: { + record_span + } + }; + /** @type {import('@sveltejs/kit').RequestEvent} */ const event = { - [EVENT_STATE]: create_event_state(state, options), cookies, // @ts-expect-error `fetch` needs to be created after the `event` itself fetch: null, @@ -379,32 +396,69 @@ export async function respond(request, options, manifest, state) { disable_search(url); } - const response = await with_event(event, () => - options.hooks.handle({ - event, - resolve: (event, opts) => - // counter-intuitively, we need to clear the event, so that it's not - // e.g. accessible when loading modules needed to handle the request - with_event(null, () => - resolve(event, page_nodes, opts).then((response) => { - // add headers/cookies here, rather than inside `resolve`, so that we - // can do it once for all responses instead of once per `return` - for (const key in headers) { - const value = headers[key]; - response.headers.set(key, /** @type {string} */ (value)); - } - - add_cookies_to_headers(response.headers, Object.values(new_cookies)); - - if (state.prerendering && event.route.id !== null) { - response.headers.set('x-sveltekit-routeid', encodeURI(event.route.id)); - } - - return response; - }) - ) - }) - ); + const response = await record_span({ + name: 'sveltekit.handle.root', + attributes: { + 'http.route': event.route.id || 'unknown', + 'http.method': event.request.method, + 'http.url': event.url.href, + 'sveltekit.is_data_request': is_data_request, + 'sveltekit.is_sub_request': event.isSubRequest + }, + fn: async (root_span) => { + const traced_event = { + ...event, + tracing: { + enabled: __SVELTEKIT_SERVER_TRACING_ENABLED__, + root: root_span, + current: root_span + } + }; + return await with_request_store({ event: traced_event, state: event_state }, () => + options.hooks.handle({ + event: traced_event, + resolve: (event, opts) => { + return record_span({ + name: 'sveltekit.resolve', + attributes: { + 'http.route': event.route.id || 'unknown' + }, + fn: async (resolve_span) => { + // counter-intuitively, we need to clear the event, so that it's not + // e.g. accessible when loading modules needed to handle the request + return with_request_store(null, () => + resolve(merge_tracing(event, resolve_span), page_nodes, opts).then( + (response) => { + // add headers/cookies here, rather than inside `resolve`, so that we + // can do it once for all responses instead of once per `return` + for (const key in headers) { + const value = headers[key]; + response.headers.set(key, /** @type {string} */ (value)); + } + + add_cookies_to_headers(response.headers, Object.values(new_cookies)); + + if (state.prerendering && event.route.id !== null) { + response.headers.set('x-sveltekit-routeid', encodeURI(event.route.id)); + } + + resolve_span.setAttributes({ + 'http.response.status_code': response.status, + 'http.response.body.size': + response.headers.get('content-length') || 'unknown' + }); + + return response; + } + ) + ); + } + }); + } + }) + ); + } + }); // respond with 304 if etag matches if (response.status === 200 && response.headers.has('etag')) { @@ -460,7 +514,7 @@ export async function respond(request, options, manifest, state) { add_cookies_to_headers(response.headers, Object.values(new_cookies)); return response; } - return await handle_fatal_error(event, options, e); + return await handle_fatal_error(event, event_state, options, e); } /** @@ -481,6 +535,7 @@ export async function respond(request, options, manifest, state) { if (options.hash_routing || state.prerendering?.fallback) { return await render_response({ event, + event_state, options, manifest, state, @@ -494,7 +549,7 @@ export async function respond(request, options, manifest, state) { } if (remote_id) { - return await handle_remote_call(event, options, manifest, remote_id); + return await handle_remote_call(event, event_state, options, manifest, remote_id); } if (route) { @@ -506,6 +561,7 @@ export async function respond(request, options, manifest, state) { if (is_data_request) { response = await render_data( event, + event_state, route, options, manifest, @@ -514,13 +570,14 @@ export async function respond(request, options, manifest, state) { trailing_slash ); } else if (route.endpoint && (!route.page || is_endpoint_request(event))) { - response = await render_endpoint(event, await route.endpoint(), state); + response = await render_endpoint(event, event_state, await route.endpoint(), state); } else if (route.page) { if (!page_nodes) { throw new Error('page_nodes not found. This should never happen'); } else if (page_methods.has(method)) { response = await render_page( event, + event_state, route.page, options, manifest, @@ -613,6 +670,7 @@ export async function respond(request, options, manifest, state) { return await respond_with_error({ event, + event_state, options, manifest, state, @@ -634,7 +692,7 @@ export async function respond(request, options, manifest, state) { // and I don't even know how to describe it. need to investigate at some point // HttpError from endpoint can end up here - TODO should it be handled there instead? - return await handle_fatal_error(event, options, e); + return await handle_fatal_error(event, event_state, options, e); } finally { event.cookies.set = () => { throw new Error('Cannot use `cookies.set(...)` after the response has been generated'); @@ -658,3 +716,25 @@ export function load_page_nodes(page, manifest) { manifest._.nodes[page.leaf]() ]); } + +/** + * It's likely that, in a distributed system, there are spans starting outside the SvelteKit server -- eg. + * started on the frontend client, or in a service that calls the SvelteKit server. There are standardized + * ways to represent this context in HTTP headers, so we can extract that context and run our tracing inside of it + * so that when our traces are exported, they are associated with the correct parent context. + * @param {typeof internal_respond} fn + * @returns {typeof internal_respond} + */ +function propagate_context(fn) { + return async (req, ...rest) => { + if (otel === null) { + return fn(req, ...rest); + } + + const { propagation, context } = await otel; + const c = propagation.extract(context.active(), Object.fromEntries(req.headers)); + return context.with(c, async () => { + return await fn(req, ...rest); + }); + }; +} diff --git a/packages/kit/src/runtime/server/utils.js b/packages/kit/src/runtime/server/utils.js index 663584760dd7..4c2d2434c461 100644 --- a/packages/kit/src/runtime/server/utils.js +++ b/packages/kit/src/runtime/server/utils.js @@ -1,12 +1,11 @@ import { DEV } from 'esm-env'; import { json, text } from '@sveltejs/kit'; -import { HttpError } from '@sveltejs/kit/internal'; +import { HttpError, with_request_store } from '@sveltejs/kit/internal'; import { coalesce_to_error, get_message, get_status } from '../../utils/error.js'; import { negotiate } from '../../utils/http.js'; import { fix_stack_trace } from '../shared-server.js'; import { ENDPOINT_METHODS } from '../../constants.js'; import { escape_html } from '../../utils/escape.js'; -import { with_event } from '../app/server/event.js'; /** @param {any} body */ export function is_pojo(body) { @@ -67,13 +66,14 @@ export function static_error_page(options, status, message) { /** * @param {import('@sveltejs/kit').RequestEvent} event + * @param {import('types').RequestState} state * @param {import('types').SSROptions} options * @param {unknown} error */ -export async function handle_fatal_error(event, options, error) { +export async function handle_fatal_error(event, state, options, error) { error = error instanceof HttpError ? error : coalesce_to_error(error); const status = get_status(error); - const body = await handle_error_and_jsonify(event, options, error); + const body = await handle_error_and_jsonify(event, state, options, error); // ideally we'd use sec-fetch-dest instead, but Safari — quelle surprise — doesn't support it const type = negotiate(event.request.headers.get('accept') || 'text/html', [ @@ -92,11 +92,12 @@ export async function handle_fatal_error(event, options, error) { /** * @param {import('@sveltejs/kit').RequestEvent} event + * @param {import('types').RequestState} state * @param {import('types').SSROptions} options * @param {any} error * @returns {Promise} */ -export async function handle_error_and_jsonify(event, options, error) { +export async function handle_error_and_jsonify(event, state, options, error) { if (error instanceof HttpError) { return error.body; } @@ -109,7 +110,7 @@ export async function handle_error_and_jsonify(event, options, error) { const message = get_message(error); return ( - (await with_event(event, () => + (await with_request_store({ event, state }, () => options.hooks.handleError({ error, event, status, message }) )) ?? { message } ); @@ -183,3 +184,16 @@ export function has_prerendered_path(manifest, pathname) { (pathname.at(-1) === '/' && manifest._.prerendered_routes.has(pathname.slice(0, -1))) ); } + +/** + * Returns the filename without the extension. e.g., `+page.server`, `+page`, etc. + * @param {string | undefined} node_id + * @returns {string} + */ +export function get_node_type(node_id) { + const parts = node_id?.split('/'); + const filename = parts?.at(-1); + if (!filename) return 'unknown'; + const dot_parts = filename.split('.'); + return dot_parts.slice(0, -1).join('.'); +} diff --git a/packages/kit/src/runtime/shared.js b/packages/kit/src/runtime/shared.js index edfdc6916986..ae3ee4a06c10 100644 --- a/packages/kit/src/runtime/shared.js +++ b/packages/kit/src/runtime/shared.js @@ -19,6 +19,26 @@ export const INVALIDATED_PARAM = 'x-sveltekit-invalidated'; export const TRAILING_SLASH_PARAM = 'x-sveltekit-trailing-slash'; +/** + * @param {any} data + * @param {string} [location_description] + */ +export function validate_load_response(data, location_description) { + if (data != null && Object.getPrototypeOf(data) !== Object.prototype) { + throw new Error( + `a load function ${location_description} returned ${ + typeof data !== 'object' + ? `a ${typeof data}` + : data instanceof Response + ? 'a Response object' + : Array.isArray(data) + ? 'an array' + : 'a non-plain object' + }, but must return a plain object at the top level (i.e. \`return {...}\`)` + ); + } +} + /** * Try to `devalue.stringify` the data object using the provided transport encoders. * @param {any} data 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/otel.disabled.spec.js b/packages/kit/src/runtime/telemetry/otel.disabled.spec.js new file mode 100644 index 000000000000..d14fddd2d097 --- /dev/null +++ b/packages/kit/src/runtime/telemetry/otel.disabled.spec.js @@ -0,0 +1,10 @@ +import { test, expect, vi } from 'vitest'; +import { otel } from './otel.js'; + +vi.hoisted(() => { + vi.stubGlobal('__SVELTEKIT_SERVER_TRACING_ENABLED__', false); +}); + +test('otel should be null when tracing is disabled', () => { + expect(otel).toBeNull(); +}); diff --git a/packages/kit/src/runtime/telemetry/otel.enabled.spec.js b/packages/kit/src/runtime/telemetry/otel.enabled.spec.js new file mode 100644 index 000000000000..1df7b45dceb6 --- /dev/null +++ b/packages/kit/src/runtime/telemetry/otel.enabled.spec.js @@ -0,0 +1,10 @@ +import { test, expect, vi } from 'vitest'; +import { otel } from './otel.js'; + +vi.hoisted(() => { + vi.stubGlobal('__SVELTEKIT_SERVER_TRACING_ENABLED__', true); +}); + +test('otel should be defined when tracing is enabled', () => { + expect(otel).not.toBeNull(); +}); diff --git a/packages/kit/src/runtime/telemetry/otel.js b/packages/kit/src/runtime/telemetry/otel.js new file mode 100644 index 000000000000..4a4c40dda492 --- /dev/null +++ b/packages/kit/src/runtime/telemetry/otel.js @@ -0,0 +1,21 @@ +/** @import { Tracer, SpanStatusCode, PropagationAPI, ContextAPI } from '@opentelemetry/api' */ + +/** @type {Promise<{ tracer: Tracer, SpanStatusCode: typeof SpanStatusCode, propagation: PropagationAPI, context: ContextAPI }> | null} */ +export let otel = null; + +if (__SVELTEKIT_SERVER_TRACING_ENABLED__) { + otel = import('@opentelemetry/api') + .then((module) => { + return { + tracer: module.trace.getTracer('sveltekit'), + propagation: module.propagation, + context: module.context, + SpanStatusCode: module.SpanStatusCode + }; + }) + .catch(() => { + throw new Error( + 'Tracing is enabled (see `config.kit.experimental.instrumentation.server` in your svelte.config.js), but `@opentelemetry/api` is not available. Have you installed it?' + ); + }); +} diff --git a/packages/kit/src/runtime/telemetry/otel.missing.spec.js b/packages/kit/src/runtime/telemetry/otel.missing.spec.js new file mode 100644 index 000000000000..fb4285aa5ea1 --- /dev/null +++ b/packages/kit/src/runtime/telemetry/otel.missing.spec.js @@ -0,0 +1,16 @@ +import { test, expect, vi } from 'vitest'; + +vi.hoisted(() => { + vi.stubGlobal('__SVELTEKIT_SERVER_TRACING_ENABLED__', true); +}); + +vi.mock('@opentelemetry/api', () => { + throw new Error('Not available'); +}); + +test('otel should throw an error when tracing is enabled but @opentelemetry/api is not available', async () => { + const { otel } = await import('./otel.js'); + await expect(otel).rejects.toThrow( + 'Tracing is enabled (see `config.kit.experimental.instrumentation.server` in your svelte.config.js), but `@opentelemetry/api` is not available. Have you installed it?' + ); +}); diff --git a/packages/kit/src/runtime/telemetry/record_span.disabled.spec.js b/packages/kit/src/runtime/telemetry/record_span.disabled.spec.js new file mode 100644 index 000000000000..7a750391d2b9 --- /dev/null +++ b/packages/kit/src/runtime/telemetry/record_span.disabled.spec.js @@ -0,0 +1,15 @@ +import { test, expect, vi } from 'vitest'; +import { record_span } from './record_span.js'; +import { noop_span } from './noop.js'; + +vi.hoisted(() => { + vi.stubGlobal('__SVELTEKIT_SERVER_TRACING_ENABLED__', false); +}); + +test('it runs function with noop span if @opentelemetry/api is not available', async () => { + const fn = vi.fn().mockResolvedValue('result'); + + const result = await record_span({ name: 'test', attributes: {}, fn }); + expect(result).toBe('result'); + expect(fn).toHaveBeenCalledWith(noop_span); +}); diff --git a/packages/kit/src/runtime/telemetry/record_span.enabled.spec.js b/packages/kit/src/runtime/telemetry/record_span.enabled.spec.js new file mode 100644 index 000000000000..b85a70b812d4 --- /dev/null +++ b/packages/kit/src/runtime/telemetry/record_span.enabled.spec.js @@ -0,0 +1,187 @@ +/** @import { Span, Tracer } from '@opentelemetry/api' */ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import { record_span } from './record_span.js'; +import { HttpError, Redirect } from '@sveltejs/kit/internal'; + +vi.hoisted(() => { + vi.stubGlobal('__SVELTEKIT_SERVER_TRACING_ENABLED__', true); +}); + +const { tracer, span } = vi.hoisted(() => { + const mock_span = /** @type {Span} */ ( + /** @type {unknown} */ ({ + end: vi.fn(), + setAttributes: vi.fn(), + setStatus: vi.fn(), + recordException: vi.fn() + }) + ); + + const mock_tracer = /** @type {Tracer} */ ({ + startActiveSpan: vi.fn((_name, _options, fn) => { + return fn(span); + }), + startSpan: vi.fn((_name, _options, fn) => { + return fn(span); + }) + }); + + return { tracer: mock_tracer, span: mock_span }; +}); + +vi.mock(import('./otel.js'), async (original) => { + const { otel: unresolved_otel } = await original(); + const otel = await unresolved_otel; + + if (otel === null) { + throw new Error('Problem setting up tests; otel is null'); + } + + return { + otel: Promise.resolve({ + tracer, + SpanStatusCode: otel.SpanStatusCode, + propagation: otel.propagation, + context: otel.context + }) + }; +}); + +describe('record_span', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + test('successful function returns result, attaching correct attributes', async () => { + const fn = vi.fn(() => Promise.resolve('result')); + const result = await record_span({ + name: 'test', + 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-throw, does set status for >=500', async () => { + const error = new HttpError(500, 'Found but badly'); + const error_fn = vi.fn(() => Promise.reject(error)); + + await expect( + record_span({ + name: 'test', + attributes: {}, + fn: error_fn + }) + ).rejects.toBe(error); + + expect(span.setAttributes).toHaveBeenCalledWith({ + 'test.result.type': 'known_error', + 'test.result.status': 500, + 'test.result.message': 'Found but badly' + }); + expect(span.recordException).toHaveBeenCalledWith({ + name: 'HttpError', + message: 'Found but badly' + }); + expect(span.setStatus).toHaveBeenCalledWith({ + code: expect.any(Number), + message: 'Found but badly' + }); + expect(span.end).toHaveBeenCalled(); + }); + + test('HttpError sets correct attributes and re-throws, does not set status for <500', async () => { + const error = new HttpError(404, 'Not found'); + const error_fn = vi.fn(() => Promise.reject(error)); + + await expect( + record_span({ + name: 'test', + 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.end).toHaveBeenCalled(); + }); + + test('Redirect sets correct attributes and re-throws', async () => { + const error = new Redirect(302, '/redirect-location'); + const error_fn = vi.fn(() => Promise.reject(error)); + + await expect( + record_span({ + name: 'test', + 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 error = new Error('Something went wrong'); + const error_fn = vi.fn(() => Promise.reject(error)); + + await expect( + record_span({ + name: 'test', + 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 error = 'string error'; + const error_fn = vi.fn(() => Promise.reject(error)); + + await expect( + record_span({ + name: 'test', + 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/packages/kit/src/runtime/telemetry/record_span.js b/packages/kit/src/runtime/telemetry/record_span.js new file mode 100644 index 000000000000..eaabb1ea5e4c --- /dev/null +++ b/packages/kit/src/runtime/telemetry/record_span.js @@ -0,0 +1,65 @@ +/** @import { RecordSpan } from 'types' */ +import { HttpError, Redirect } from '@sveltejs/kit/internal'; +import { noop_span } from './noop.js'; +import { otel } from './otel.js'; + +/** @type {RecordSpan} */ +export async function record_span({ name, attributes, fn }) { + if (otel === null) { + return fn(noop_span); + } + + const { SpanStatusCode, tracer } = await otel; + + return tracer.startActiveSpan(name, { attributes }, async (span) => { + try { + return await fn(span); + } catch (error) { + if (error instanceof HttpError) { + span.setAttributes({ + [`${name}.result.type`]: 'known_error', + [`${name}.result.status`]: error.status, + [`${name}.result.message`]: error.body.message + }); + if (error.status >= 500) { + span.recordException({ + name: 'HttpError', + message: error.body.message + }); + span.setStatus({ + code: SpanStatusCode.ERROR, + 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 }); + } + + throw error; + } finally { + span.end(); + } + }); +} diff --git a/packages/kit/src/types/global-private.d.ts b/packages/kit/src/types/global-private.d.ts index 853062b76dff..9972a2d8bd51 100644 --- a/packages/kit/src/types/global-private.d.ts +++ b/packages/kit/src/types/global-private.d.ts @@ -4,6 +4,8 @@ declare global { const __SVELTEKIT_APP_VERSION_POLL_INTERVAL__: number; const __SVELTEKIT_DEV__: boolean; const __SVELTEKIT_EMBEDDED__: boolean; + /** True if `config.kit.experimental.instrumentation.server` is `true` */ + const __SVELTEKIT_SERVER_TRACING_ENABLED__: boolean; /** true if corresponding config option is set to true */ const __SVELTEKIT_EXPERIMENTAL__REMOTE_FUNCTIONS__: boolean; /** True if `config.kit.router.resolution === 'client'` */ diff --git a/packages/kit/src/types/internal.d.ts b/packages/kit/src/types/internal.d.ts index cad762f2b376..72e18977ff32 100644 --- a/packages/kit/src/types/internal.d.ts +++ b/packages/kit/src/types/internal.d.ts @@ -30,6 +30,7 @@ import { RequestOptions, TrailingSlash } from './private.js'; +import { Span } from '@opentelemetry/api'; export interface ServerModule { Server: typeof InternalServer; @@ -566,5 +567,32 @@ export type RemoteInfo = inputs?: RemotePrerenderInputsGenerator; }; +export type RecordSpan = (options: { + name: string; + attributes: Record; + fn: (current: Span) => Promise; +}) => Promise; + +/** + * Internal state associated with the current `RequestEvent`, + * used for tracking things like remote function calls + */ +export interface RequestState { + prerendering: PrerenderOptions | undefined; + transport: ServerHooks['transport']; + handleValidationError: ServerHooks['handleValidationError']; + tracing: { + record_span: RecordSpan; + }; + form_instances?: Map; + remote_data?: Record>; + refreshes?: Record; +} + +export interface RequestStore { + event: RequestEvent; + state: RequestState; +} + export * from '../exports/index.js'; export * from './private.js'; diff --git a/packages/kit/test/apps/basics/.gitignore b/packages/kit/test/apps/basics/.gitignore index fad4d3e1518d..7bc5d5fff974 100644 --- a/packages/kit/test/apps/basics/.gitignore +++ b/packages/kit/test/apps/basics/.gitignore @@ -1,3 +1,4 @@ /test/errors.json !/.env -/src/routes/routing/symlink-from \ No newline at end of file +/src/routes/routing/symlink-from +/test/spans.jsonl \ No newline at end of file diff --git a/packages/kit/test/apps/basics/package.json b/packages/kit/test/apps/basics/package.json index 7f3b4307ce24..b774ef8491a1 100644 --- a/packages/kit/test/apps/basics/package.json +++ b/packages/kit/test/apps/basics/package.json @@ -17,6 +17,9 @@ "test:server-side-route-resolution:build": "node test/setup.js && rm -rf test/errors.json && PUBLIC_PRERENDERING=false ROUTER_RESOLUTION=server playwright test" }, "devDependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/sdk-node": "^0.203.0", + "@opentelemetry/sdk-trace-node": "^2.0.1", "@sveltejs/kit": "workspace:^", "@sveltejs/vite-plugin-svelte": "catalog:", "svelte": "^5.35.5", diff --git a/packages/kit/test/apps/basics/src/hooks.server.js b/packages/kit/test/apps/basics/src/hooks.server.js index 64fb6bf07f21..de38b0ecd486 100644 --- a/packages/kit/test/apps/basics/src/hooks.server.js +++ b/packages/kit/test/apps/basics/src/hooks.server.js @@ -64,6 +64,14 @@ export const handleValidationError = ({ issues }) => { }; export const handle = sequence( + // eslint-disable-next-line prefer-arrow-callback -- this needs a name for tests + function set_tracing_test_id({ event, resolve }) { + const test_id = !building && event.url.searchParams.get('test_id'); + if (test_id) { + event.tracing.root.setAttribute('test_id', test_id); + } + return resolve(event); + }, ({ event, resolve }) => { event.locals.key = event.route.id; event.locals.params = event.params; diff --git a/packages/kit/test/apps/basics/src/instrumentation.server.js b/packages/kit/test/apps/basics/src/instrumentation.server.js new file mode 100644 index 000000000000..aa4ad0d2f781 --- /dev/null +++ b/packages/kit/test/apps/basics/src/instrumentation.server.js @@ -0,0 +1,48 @@ +/** @import {SpanExporter} from '@opentelemetry/sdk-trace-node' */ +/** @import {SpanData} from '../../../types' */ +import { NodeSDK } from '@opentelemetry/sdk-node'; +import { SimpleSpanProcessor } from '@opentelemetry/sdk-trace-node'; +import fs from 'node:fs'; + +/** @implements {SpanExporter} */ +class FilesystemSpanExporter { + #path; + + constructor(path) { + fs.rmSync(path, { force: true }); + this.#path = path; + } + + /** @param {import('@opentelemetry/sdk-trace-node').ReadableSpan[]} spans */ + export(spans) { + // spans have circular references so they can't be naively json-ified + const serialized_spans = spans.map((span) => { + const span_context = span.spanContext(); + /** @type {SpanData} */ + const span_data = { + name: span.name, + status: span.status, + start_time: span.startTime, + end_time: span.endTime, + attributes: span.attributes, + links: span.links, + trace_id: span_context.traceId, + span_id: span_context.spanId, + parent_span_id: span.parentSpanContext?.spanId + }; + return JSON.stringify(span_data); + }); + + fs.appendFileSync(this.#path, serialized_spans.join('\n') + '\n'); + } + shutdown() { + return Promise.resolve(); + } +} + +const filesystemSpanExporter = new FilesystemSpanExporter('test/spans.jsonl'); +const spanProcessor = new SimpleSpanProcessor(filesystemSpanExporter); +export const sdk = new NodeSDK({ + spanProcessor +}); +sdk.start(); diff --git a/packages/kit/test/apps/basics/src/routes/tracing/http-error/+page.server.js b/packages/kit/test/apps/basics/src/routes/tracing/http-error/+page.server.js new file mode 100644 index 000000000000..cdb7c46a0e31 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/tracing/http-error/+page.server.js @@ -0,0 +1,5 @@ +import { error } from '@sveltejs/kit'; + +export async function load() { + error(500, 'Internal server error from tracing test'); +} diff --git a/packages/kit/test/apps/basics/src/routes/tracing/http-error/+page.svelte b/packages/kit/test/apps/basics/src/routes/tracing/http-error/+page.svelte new file mode 100644 index 000000000000..f32044fa6f36 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/tracing/http-error/+page.svelte @@ -0,0 +1 @@ +

This should not render due to load error

diff --git a/packages/kit/test/apps/basics/src/routes/tracing/non-error-object/+page.server.js b/packages/kit/test/apps/basics/src/routes/tracing/non-error-object/+page.server.js new file mode 100644 index 000000000000..1d678adad533 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/tracing/non-error-object/+page.server.js @@ -0,0 +1,3 @@ +export async function load() { + throw 'string error from tracing test'; +} diff --git a/packages/kit/test/apps/basics/src/routes/tracing/one/two/three/[...four]/+page.svelte b/packages/kit/test/apps/basics/src/routes/tracing/one/two/three/[...four]/+page.svelte new file mode 100644 index 000000000000..13c34178c381 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/tracing/one/two/three/[...four]/+page.svelte @@ -0,0 +1 @@ +

Tracing

diff --git a/packages/kit/test/apps/basics/src/routes/tracing/redirect/+page.server.js b/packages/kit/test/apps/basics/src/routes/tracing/redirect/+page.server.js new file mode 100644 index 000000000000..42f1a7375b87 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/tracing/redirect/+page.server.js @@ -0,0 +1,5 @@ +import { redirect } from '@sveltejs/kit'; + +export async function load({ url }) { + redirect(307, `/tracing/one/two/three/four/five${url.search}`); +} diff --git a/packages/kit/test/apps/basics/src/routes/tracing/regular-error/+page.server.js b/packages/kit/test/apps/basics/src/routes/tracing/regular-error/+page.server.js new file mode 100644 index 000000000000..209e1233ac85 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/tracing/regular-error/+page.server.js @@ -0,0 +1,3 @@ +export async function load() { + throw new Error('Regular error from tracing test'); +} diff --git a/packages/kit/test/apps/basics/svelte.config.js b/packages/kit/test/apps/basics/svelte.config.js index 2410ff83d57f..d9772c05b5d1 100644 --- a/packages/kit/test/apps/basics/svelte.config.js +++ b/packages/kit/test/apps/basics/svelte.config.js @@ -5,7 +5,15 @@ const config = { kit: { adapter: { name: 'test-adapter', - adapt() {}, + adapt(builder) { + builder.instrument({ + entrypoint: `${builder.getServerDirectory()}/index.js`, + instrumentation: `${builder.getServerDirectory()}/instrumentation.server.js`, + module: { + exports: ['Server'] + } + }); + }, emulate() { return { platform({ config, prerender }) { @@ -14,12 +22,19 @@ const config = { }; }, supports: { - read: () => true + read: () => true, + instrumentation: () => true } }, experimental: { - remoteFunctions: true + remoteFunctions: true, + tracing: { + server: true + }, + instrumentation: { + server: true + } }, prerender: { @@ -41,6 +56,7 @@ const config = { version: { name: 'TEST_VERSION' }, + router: { resolution: /** @type {'client' | 'server'} */ (process.env.ROUTER_RESOLUTION) || 'client' } diff --git a/packages/kit/test/apps/basics/test/server.test.js b/packages/kit/test/apps/basics/test/server.test.js index c160f4b09559..91798f8d3e28 100644 --- a/packages/kit/test/apps/basics/test/server.test.js +++ b/packages/kit/test/apps/basics/test/server.test.js @@ -1,3 +1,4 @@ +/** @import { ReadableSpan } from '@opentelemetry/sdk-trace-node' */ import process from 'node:process'; import { expect } from '@playwright/test'; import { test } from '../../../utils.js'; @@ -769,6 +770,463 @@ test.describe('$app/environment', () => { }); }); +test.describe('tracing', () => { + // Helper function to find the resolve.root span deep in the handle.child chain + /** + * @param {ReadableSpan} span + * @returns {ReadableSpan | null} + */ + function find_resolve_root_span(span) { + if (span.name === 'sveltekit.resolve') { + return span; + } + for (const child of span.children || []) { + const found = find_resolve_root_span(child); + if (found) return found; + } + return null; + } + + function rand() { + // node 18 doesn't have crypto.randomUUID() and we run tests in node 18 + return Math.random().toString(36).substring(2, 15); + } + + test('correct spans are created for a regular navigation', async ({ page, read_traces }) => { + const test_id = rand(); + await page.goto(`/tracing/one/two/three/four/five?test_id=${test_id}`); + const traces = read_traces(test_id); + expect(traces.length).toBeGreaterThan(0); + + const trace = traces[0]; + const trace_id = trace.trace_id; + + // Verify root span structure + expect(trace).toEqual({ + name: 'sveltekit.handle.root', + status: { code: 0 }, + start_time: [expect.any(Number), expect.any(Number)], + end_time: [expect.any(Number), expect.any(Number)], + attributes: { + 'http.route': '/tracing/one/two/three/[...four]', + 'http.method': 'GET', + 'http.url': expect.stringContaining(`/tracing/one/two/three/four/five?test_id=${test_id}`), + 'sveltekit.is_data_request': false, + 'sveltekit.is_sub_request': false, + test_id + }, + links: [], + trace_id, + span_id: expect.any(String), + children: expect.arrayContaining([ + expect.objectContaining({ + name: 'sveltekit.handle.sequenced.set_tracing_test_id', + attributes: {} + }) + ]) + }); + + // Find and verify the resolve.root span + const resolve_root_span = find_resolve_root_span(trace); + expect(resolve_root_span).not.toBeNull(); + expect(resolve_root_span).toEqual({ + name: 'sveltekit.resolve', + status: { code: 0 }, + start_time: [expect.any(Number), expect.any(Number)], + end_time: [expect.any(Number), expect.any(Number)], + attributes: { + 'http.route': '/tracing/one/two/three/[...four]', + 'http.response.status_code': 200, + 'http.response.body.size': expect.stringMatching(/^\d+$/) + }, + links: [], + trace_id, + span_id: expect.any(String), + parent_span_id: expect.any(String), + children: [ + { + name: 'sveltekit.load', + status: { code: 0 }, + start_time: [expect.any(Number), expect.any(Number)], + end_time: [expect.any(Number), expect.any(Number)], + attributes: { + 'sveltekit.load.node_id': 'src/routes/+layout.server.js', + 'sveltekit.load.node_type': '+layout.server', + 'sveltekit.load.environment': 'server', + 'http.route': '/tracing/one/two/three/[...four]' + }, + links: [], + trace_id, + span_id: expect.any(String), + parent_span_id: expect.any(String), + children: [] + }, + { + name: 'sveltekit.load', + status: { code: 0 }, + start_time: [expect.any(Number), expect.any(Number)], + end_time: [expect.any(Number), expect.any(Number)], + attributes: { + 'sveltekit.load.node_id': 'src/routes/+layout.js', + 'sveltekit.load.node_type': '+layout', + 'sveltekit.load.environment': 'server', + 'http.route': '/tracing/one/two/three/[...four]' + }, + links: [], + trace_id, + span_id: expect.any(String), + parent_span_id: expect.any(String), + children: [] + } + ] + }); + }); + + test('correct spans are created for HttpError', async ({ page, read_traces }) => { + const test_id = rand(); + const response = await page.goto(`/tracing/http-error?test_id=${test_id}`); + expect(response?.status()).toBe(500); + + const traces = read_traces(test_id); + const trace_id = traces[0].trace_id; + const trace = traces[0]; + + // Verify root span structure + expect(trace).toEqual({ + name: 'sveltekit.handle.root', + status: { code: 0 }, + start_time: [expect.any(Number), expect.any(Number)], + end_time: [expect.any(Number), expect.any(Number)], + attributes: { + 'http.route': '/tracing/http-error', + 'http.method': 'GET', + 'http.url': expect.stringContaining(`/tracing/http-error?test_id=${test_id}`), + 'sveltekit.is_data_request': false, + 'sveltekit.is_sub_request': false, + test_id + }, + links: [], + trace_id, + span_id: expect.any(String), + children: expect.arrayContaining([ + expect.objectContaining({ + name: 'sveltekit.handle.sequenced.set_tracing_test_id', + attributes: {} + }) + ]) + }); + + // Find and verify the resolve.root span + const resolve_root_span = find_resolve_root_span(trace); + expect(resolve_root_span).not.toBeNull(); + expect(resolve_root_span).toEqual({ + name: 'sveltekit.resolve', + status: { code: 0 }, + start_time: [expect.any(Number), expect.any(Number)], + end_time: [expect.any(Number), expect.any(Number)], + attributes: { + 'http.route': '/tracing/http-error', + 'http.response.status_code': 500, + 'http.response.body.size': expect.stringMatching(/^\d+$/) + }, + links: [], + trace_id, + span_id: expect.any(String), + parent_span_id: expect.any(String), + children: expect.arrayContaining([ + expect.objectContaining({ + name: 'sveltekit.load', + status: { code: 2, message: 'Internal server error from tracing test' }, + attributes: expect.objectContaining({ + 'sveltekit.load.node_id': 'src/routes/tracing/http-error/+page.server.js', + 'sveltekit.load.result.type': 'known_error', + 'sveltekit.load.result.status': 500, + 'sveltekit.load.result.message': 'Internal server error from tracing test' + }) + }) + ]) + }); + }); + + test('correct spans are created for Redirect', async ({ page, read_traces }) => { + const test_id = rand(); + const response = await page.goto(`/tracing/redirect?test_id=${test_id}`); + expect(response?.status()).toBe(200); + + const traces = read_traces(test_id); + expect(traces).toHaveLength(2); + const redirect_trace_id = traces[0].trace_id; + const destination_trace_id = traces[1].trace_id; + + const redirect_trace = traces[0]; + const destination_trace = traces[1]; + + // Verify redirect trace root span structure + expect(redirect_trace).toEqual({ + name: 'sveltekit.handle.root', + status: { code: 0 }, + start_time: [expect.any(Number), expect.any(Number)], + end_time: [expect.any(Number), expect.any(Number)], + attributes: { + 'http.route': '/tracing/redirect', + 'http.method': 'GET', + 'http.url': expect.stringContaining(`/tracing/redirect?test_id=${test_id}`), + 'sveltekit.is_data_request': false, + 'sveltekit.is_sub_request': false, + test_id + }, + links: [], + trace_id: redirect_trace_id, + span_id: expect.any(String), + children: expect.arrayContaining([ + expect.objectContaining({ + name: 'sveltekit.handle.sequenced.set_tracing_test_id', + attributes: {} + }) + ]) + }); + + // Find and verify the redirect resolve.root span + const redirect_resolve_root_span = find_resolve_root_span(redirect_trace); + expect(redirect_resolve_root_span).not.toBeNull(); + expect(redirect_resolve_root_span).toEqual({ + name: 'sveltekit.resolve', + status: { code: 0 }, + start_time: [expect.any(Number), expect.any(Number)], + end_time: [expect.any(Number), expect.any(Number)], + attributes: { + 'http.route': '/tracing/redirect', + 'http.response.status_code': 307, + 'http.response.body.size': expect.stringMatching(/^\d+$|^unknown$/) + }, + links: [], + trace_id: redirect_trace_id, + span_id: expect.any(String), + parent_span_id: expect.any(String), + children: expect.arrayContaining([ + expect.objectContaining({ + name: 'sveltekit.load', + status: { code: 0 }, + attributes: expect.objectContaining({ + 'sveltekit.load.node_id': 'src/routes/tracing/redirect/+page.server.js', + 'sveltekit.load.result.type': 'redirect', + 'sveltekit.load.result.status': 307, + 'sveltekit.load.result.location': `/tracing/one/two/three/four/five?test_id=${test_id}` + }) + }) + ]) + }); + + // Verify destination trace root span structure + expect(destination_trace).toEqual({ + name: 'sveltekit.handle.root', + status: { code: 0 }, + start_time: [expect.any(Number), expect.any(Number)], + end_time: [expect.any(Number), expect.any(Number)], + attributes: { + 'http.route': '/tracing/one/two/three/[...four]', + 'http.method': 'GET', + 'http.url': expect.stringContaining(`/tracing/one/two/three/four/five?test_id=${test_id}`), + 'sveltekit.is_data_request': false, + 'sveltekit.is_sub_request': false, + test_id + }, + links: [], + trace_id: destination_trace_id, + span_id: expect.any(String), + children: expect.arrayContaining([ + expect.objectContaining({ + name: 'sveltekit.handle.sequenced.set_tracing_test_id', + attributes: {} + }) + ]) + }); + + // Find and verify the destination resolve.root span + const destination_resolve_root_span = find_resolve_root_span(destination_trace); + expect(destination_resolve_root_span).not.toBeNull(); + expect(destination_resolve_root_span).toEqual({ + name: 'sveltekit.resolve', + status: { code: 0 }, + start_time: [expect.any(Number), expect.any(Number)], + end_time: [expect.any(Number), expect.any(Number)], + attributes: { + 'http.route': '/tracing/one/two/three/[...four]', + 'http.response.status_code': 200, + 'http.response.body.size': expect.stringMatching(/^\d+$/) + }, + links: [], + trace_id: destination_trace_id, + span_id: expect.any(String), + parent_span_id: expect.any(String), + children: [ + { + name: 'sveltekit.load', + status: { code: 0 }, + start_time: [expect.any(Number), expect.any(Number)], + end_time: [expect.any(Number), expect.any(Number)], + attributes: { + 'sveltekit.load.node_id': 'src/routes/+layout.server.js', + 'sveltekit.load.node_type': '+layout.server', + 'sveltekit.load.environment': 'server', + 'http.route': '/tracing/one/two/three/[...four]' + }, + links: [], + trace_id: destination_trace_id, + span_id: expect.any(String), + parent_span_id: expect.any(String), + children: [] + }, + { + name: 'sveltekit.load', + status: { code: 0 }, + start_time: [expect.any(Number), expect.any(Number)], + end_time: [expect.any(Number), expect.any(Number)], + attributes: { + 'sveltekit.load.node_id': 'src/routes/+layout.js', + 'sveltekit.load.node_type': '+layout', + 'sveltekit.load.environment': 'server', + 'http.route': '/tracing/one/two/three/[...four]' + }, + links: [], + trace_id: destination_trace_id, + span_id: expect.any(String), + parent_span_id: expect.any(String), + children: [] + } + ] + }); + }); + + test('correct spans are created for regular Error', async ({ page, read_traces }) => { + const test_id = rand(); + const response = await page.goto(`/tracing/regular-error?test_id=${test_id}`); + expect(response?.status()).toBe(500); + + const traces = read_traces(test_id); + const trace_id = traces[0].trace_id; + const trace = traces[0]; + + // Verify root span structure + expect(trace).toEqual({ + name: 'sveltekit.handle.root', + status: { code: 0 }, + start_time: [expect.any(Number), expect.any(Number)], + end_time: [expect.any(Number), expect.any(Number)], + attributes: { + 'http.route': '/tracing/regular-error', + 'http.method': 'GET', + 'http.url': expect.stringContaining(`/tracing/regular-error?test_id=${test_id}`), + 'sveltekit.is_data_request': false, + 'sveltekit.is_sub_request': false, + test_id + }, + links: [], + trace_id, + span_id: expect.any(String), + children: expect.arrayContaining([ + expect.objectContaining({ + name: 'sveltekit.handle.sequenced.set_tracing_test_id', + attributes: {} + }) + ]) + }); + + // Find and verify the resolve.root span + const resolve_root_span = find_resolve_root_span(trace); + expect(resolve_root_span).not.toBeNull(); + expect(resolve_root_span).toEqual({ + name: 'sveltekit.resolve', + status: { code: 0 }, + start_time: [expect.any(Number), expect.any(Number)], + end_time: [expect.any(Number), expect.any(Number)], + attributes: { + 'http.route': '/tracing/regular-error', + 'http.response.status_code': 500, + 'http.response.body.size': expect.stringMatching(/^\d+$/) + }, + links: [], + trace_id, + span_id: expect.any(String), + parent_span_id: expect.any(String), + children: expect.arrayContaining([ + expect.objectContaining({ + name: 'sveltekit.load', + status: { code: 2, message: 'Regular error from tracing test' }, + attributes: expect.objectContaining({ + 'sveltekit.load.node_id': 'src/routes/tracing/regular-error/+page.server.js', + 'sveltekit.load.result.type': 'unknown_error' + }) + }) + ]) + }); + }); + + test('correct spans are created for non-error object', async ({ page, read_traces }) => { + const test_id = rand(); + const response = await page.goto(`/tracing/non-error-object?test_id=${test_id}`); + expect(response?.status()).toBe(500); + + const traces = read_traces(test_id); + const trace_id = traces[0].trace_id; + const trace = traces[0]; + + // Verify root span structure + expect(trace).toEqual({ + name: 'sveltekit.handle.root', + status: { code: 0 }, + start_time: [expect.any(Number), expect.any(Number)], + end_time: [expect.any(Number), expect.any(Number)], + attributes: { + 'http.route': '/tracing/non-error-object', + 'http.method': 'GET', + 'http.url': expect.stringContaining(`/tracing/non-error-object?test_id=${test_id}`), + 'sveltekit.is_data_request': false, + 'sveltekit.is_sub_request': false, + test_id + }, + links: [], + trace_id, + span_id: expect.any(String), + children: expect.arrayContaining([ + expect.objectContaining({ + name: 'sveltekit.handle.sequenced.set_tracing_test_id', + attributes: {} + }) + ]) + }); + + // Find and verify the resolve.root span + const resolve_root_span = find_resolve_root_span(trace); + expect(resolve_root_span).not.toBeNull(); + expect(resolve_root_span).toEqual({ + name: 'sveltekit.resolve', + status: { code: 0 }, + start_time: [expect.any(Number), expect.any(Number)], + end_time: [expect.any(Number), expect.any(Number)], + attributes: { + 'http.route': '/tracing/non-error-object', + 'http.response.status_code': 500, + 'http.response.body.size': expect.stringMatching(/^\d+$/) + }, + links: [], + trace_id, + span_id: expect.any(String), + parent_span_id: expect.any(String), + children: expect.arrayContaining([ + expect.objectContaining({ + name: 'sveltekit.load', + status: { code: 2 }, + attributes: expect.objectContaining({ + 'sveltekit.load.node_id': 'src/routes/tracing/non-error-object/+page.server.js', + 'sveltekit.load.result.type': 'unknown_error' + }) + }) + ]) + }); + }); +}); + test.describe('remote functions', () => { test("doesn't write bundle to disk when treeshaking prerendered remote functions", () => { test.skip(!!process.env.DEV, 'skip when in dev mode'); diff --git a/packages/kit/test/types.d.ts b/packages/kit/test/types.d.ts index 261c106f3568..7e51a44b2f2e 100644 --- a/packages/kit/test/types.d.ts +++ b/packages/kit/test/types.d.ts @@ -29,6 +29,7 @@ export const test: TestType< * `handleError` defines the shape */ read_errors(href: string): Record; + read_traces(test_id: string): SpanTree[]; start_server( handler: (req: IncomingMessage, res: ServerResponse) => void ): Promise<{ port: number }>; @@ -42,3 +43,25 @@ export const test: TestType< }, PlaywrightWorkerArgs & PlaywrightWorkerOptions >; + +export interface SpanData { + name: string; + status: { + code: number; + message?: string; + }; + start_time: [number, number]; // HrTime tuple: [seconds, nanoseconds] + end_time: [number, number]; // HrTime tuple: [seconds, nanoseconds] + attributes: Record>; + links: Array<{ + context: any; + attributes?: Record>; + }>; + trace_id: string; + span_id: string; + parent_span_id: string | undefined; +} + +export type SpanTree = Omit & { + children: SpanTree[]; +}; diff --git a/packages/kit/test/utils.js b/packages/kit/test/utils.js index 2d91c5545038..0153bf6de689 100644 --- a/packages/kit/test/utils.js +++ b/packages/kit/test/utils.js @@ -1,3 +1,4 @@ +/** @import {SpanData, SpanTree} from './types' */ import fs from 'node:fs'; import http from 'node:http'; import path from 'node:path'; @@ -144,6 +145,24 @@ export const test = base.extend({ await use(read_errors); }, + // eslint-disable-next-line no-empty-pattern -- Playwright doesn't let us use `_` as a parameter name. It must be a destructured object + read_traces: async ({}, use) => { + /** @param {string} test_id */ + function read_traces(test_id) { + const raw = fs.readFileSync('test/spans.jsonl', 'utf8').split('\n').filter(Boolean); + const traces = /** @type {SpanData[]} */ (raw.map((line) => JSON.parse(line))); + + return traces + .filter((t) => t.parent_span_id === undefined && t.attributes.test_id === test_id) + .map((root_trace) => { + const child_traces = traces.filter((span) => span.trace_id === root_trace.trace_id); + return build_span_tree(root_trace, child_traces); + }); + } + + await use(read_traces); + }, + // eslint-disable-next-line no-empty-pattern -- Playwright doesn't let us use `_` as a parameter name. It must be a destructured object start_server: async ({}, use) => { /** @@ -287,3 +306,16 @@ export const config = defineConfig({ testDir: 'test', testMatch: /(.+\.)?(test|spec)\.[jt]s/ }); + +/** + * @param {SpanData} current_span + * @param {SpanData[]} child_spans + * @returns {SpanTree} + */ +function build_span_tree(current_span, child_spans) { + const children = child_spans.filter((span) => span.parent_span_id === current_span.span_id); + return { + ...current_span, + children: children.map((child) => build_span_tree(child, child_spans)) + }; +} diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index efb850a3f367..ccac49395701 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -5,6 +5,7 @@ declare module '@sveltejs/kit' { import type { SvelteConfig } from '@sveltejs/vite-plugin-svelte'; import type { StandardSchemaV1 } from '@standard-schema/spec'; import type { RouteId as AppRouteId, LayoutParams as AppLayoutParams, ResolvedPathname } from '$app/types'; + import type { Span } from '@opentelemetry/api'; /** * [Adapters](https://svelte.dev/docs/kit/adapters) are responsible for taking the production build and turning it into something that can be deployed to a platform of your choosing. */ @@ -27,6 +28,12 @@ declare module '@sveltejs/kit' { * @param details.config The merged route config */ read?: (details: { config: any; route: { id: string } }) => boolean; + + /** + * Test support for `instrumentation.server.js`. To pass, the adapter must support running `instrumentation.server.js` prior to the application code. + * @since 2.31.0 + */ + instrumentation?: () => boolean; }; /** * Creates an `Emulator`, which allows the adapter to influence the environment @@ -164,6 +171,46 @@ declare module '@sveltejs/kit' { } ) => string[]; + /** + * Check if the server instrumentation file exists. + * @returns true if the server instrumentation file exists, false otherwise + * @since 2.31.0 + */ + hasServerInstrumentationFile: () => boolean; + + /** + * Instrument `entrypoint` with `instrumentation`. + * + * Renames `entrypoint` to `start` and creates a new module at + * `entrypoint` which imports `instrumentation` and then dynamically imports `start`. This allows + * the module hooks necessary for instrumentation libraries to be loaded prior to any application code. + * + * Caveats: + * - "Live exports" will not work. If your adapter uses live exports, your users will need to manually import the server instrumentation on startup. + * - If `tla` is `false`, OTEL auto-instrumentation may not work properly. Use it if your environment supports it. + * - Use `hasServerInstrumentationFile` to check if the user has a server instrumentation file; if they don't, you shouldn't do this. + * + * @param options an object containing the following properties: + * @param options.entrypoint the path to the entrypoint to trace. + * @param options.instrumentation the path to the instrumentation file. + * @param options.start the name of the start file. This is what `entrypoint` will be renamed to. + * @param options.module configuration for the resulting entrypoint module. + * @param options.module.generateText a function that receives the relative paths to the instrumentation and start files, and generates the text of the module to be traced. If not provided, the default implementation will be used, which uses top-level await. + * @since 2.31.0 + */ + instrument: (args: { + entrypoint: string; + instrumentation: string; + start?: string; + module?: + | { + exports: string[]; + } + | { + generateText: (args: { instrumentation: string; start: string }) => string; + }; + }) => void; + /** * Compress files in `directory` with gzip and brotli, where appropriate. Generates `.gz` and `.br` files alongside the originals. * @param directory The directory containing the files to be compressed @@ -385,10 +432,34 @@ declare module '@sveltejs/kit' { */ privatePrefix?: string; }; - /** - * Experimental features which are exempt from semantic versioning. These features may be changed or removed at any time. - */ + /** Experimental features. Here be dragons. These are not subject to semantic versioning, so breaking changes or removal can happen in any release. */ experimental?: { + /** + * Options for enabling server-side [OpenTelemetry](https://opentelemetry.io/) tracing for SvelteKit operations including the [`handle` hook](https://svelte.dev/docs/kit/hooks#Server-hooks-handle), [`load` functions](https://svelte.dev/docs/kit/load), [form actions](https://svelte.dev/docs/kit/form-actions), and [remote functions](https://svelte.dev/docs/kit/remote-functions). + * @default { server: false, serverFile: false } + * @since 2.31.0 + */ + tracing?: { + /** + * Enables server-side [OpenTelemetry](https://opentelemetry.io/) span emission for SvelteKit operations including the [`handle` hook](https://svelte.dev/docs/kit/hooks#Server-hooks-handle), [`load` functions](https://svelte.dev/docs/kit/load), [form actions](https://svelte.dev/docs/kit/form-actions), and [remote functions](https://svelte.dev/docs/kit/remote-functions). + * @default false + * @since 2.31.0 + */ + server?: boolean; + }; + + /** + * @since 2.31.0 + */ + instrumentation?: { + /** + * Enables `instrumentation.server.js` for tracing and observability instrumentation. + * @default false + * @since 2.31.0 + */ + server?: boolean; + }; + /** * Whether to enable the experimental remote functions feature. This feature is not yet stable and may be changed or removed at any time. * @default false @@ -987,6 +1058,19 @@ declare module '@sveltejs/kit' { * ``` */ untrack: (fn: () => T) => T; + + /** + * Access to spans for tracing. If tracing is not enabled or the function is being run in the browser, these spans will do nothing. + * @since 2.31.0 + */ + tracing: { + /** Whether tracing is enabled. */ + enabled: boolean; + /** The root span for the request. This span is named `sveltekit.handle.root`. */ + root: Span; + /** The span associated with the current `load` function. */ + current: Span; + }; } export interface NavigationEvent< @@ -1265,6 +1349,20 @@ declare module '@sveltejs/kit' { * `true` for `+server.js` calls coming from SvelteKit without the overhead of actually making an HTTP request. This happens when you make same-origin `fetch` requests on the server. */ isSubRequest: boolean; + + /** + * Access to spans for tracing. If tracing is not enabled, these spans will do nothing. + * @since 2.31.0 + */ + tracing: { + /** Whether tracing is enabled. */ + enabled: boolean; + /** The root span for the request. This span is named `sveltekit.handle.root`. */ + root: Span; + /** The span associated with the current `handle` hook, `load` function, or form action. */ + current: Span; + }; + /** * `true` if the request comes from the client via a remote function. The `url` property will be stripped of the internal information * related to the data request in this case. Use this property instead if the distinction is important to you. @@ -1428,6 +1526,19 @@ declare module '@sveltejs/kit' { * ``` */ untrack: (fn: () => T) => T; + + /** + * Access to spans for tracing. If tracing is not enabled, these spans will do nothing. + * @since 2.31.0 + */ + tracing: { + /** Whether tracing is enabled. */ + enabled: boolean; + /** The root span for the request. This span is named `sveltekit.handle.root`. */ + root: Span; + /** The span associated with the current server `load` function. */ + current: Span; + }; } /** @@ -2243,6 +2354,7 @@ declare module '@sveltejs/kit' { } declare module '@sveltejs/kit/hooks' { + import type { Handle } from '@sveltejs/kit'; /** * A helper function for sequencing multiple `handle` calls in a middleware-like manner. * The behavior for the `handle` options is as follows: @@ -2313,7 +2425,7 @@ declare module '@sveltejs/kit/hooks' { * * @param handlers The chain of `handle` functions * */ - export function sequence(...handlers: import("@sveltejs/kit").Handle[]): import("@sveltejs/kit").Handle; + export function sequence(...handlers: Handle[]): Handle; export {}; } @@ -2654,6 +2766,7 @@ declare module '$app/server' { * * In environments without [`AsyncLocalStorage`](https://nodejs.org/api/async_context.html#class-asynclocalstorage), this must be called synchronously (i.e. not after an `await`). * @since 2.20.0 + * * */ export function getRequestEvent(): RequestEvent; /** diff --git a/packages/package/package.json b/packages/package/package.json index f8b2baa01ff8..7368619b4ad7 100644 --- a/packages/package/package.json +++ b/packages/package/package.json @@ -28,7 +28,7 @@ }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "catalog:", - "@types/node": "^18.19.119", + "@types/node": "catalog:", "@types/semver": "^7.5.6", "prettier": "^3.1.1", "svelte": "^5.35.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8cb188763983..643098904aaa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,6 +12,9 @@ catalogs: '@sveltejs/vite-plugin-svelte': specifier: ^6.0.0-next.3 version: 6.0.0-next.3 + '@types/node': + specifier: ^18.19.119 + version: 18.19.119 vite: specifier: ^6.3.5 version: 6.3.5 @@ -57,7 +60,7 @@ importers: specifier: 'catalog:' version: 6.0.0-next.3(svelte@5.35.5)(vite@6.3.5(@types/node@18.19.119)(jiti@2.4.2)(lightningcss@1.30.1)(yaml@2.8.0)) '@types/node': - specifier: ^18.19.119 + specifier: 'catalog:' version: 18.19.119 typescript: specifier: ^5.3.3 @@ -85,7 +88,7 @@ importers: specifier: workspace:^ version: link:../kit '@types/node': - specifier: ^18.19.119 + specifier: 'catalog:' version: 18.19.119 esbuild: specifier: ^0.25.4 @@ -173,7 +176,7 @@ importers: specifier: 'catalog:' version: 6.0.0-next.3(svelte@5.35.5)(vite@6.3.5(@types/node@18.19.119)(jiti@2.4.2)(lightningcss@1.30.1)(yaml@2.8.0)) '@types/node': - specifier: ^18.19.119 + specifier: 'catalog:' version: 18.19.119 '@types/set-cookie-parser': specifier: ^2.4.7 @@ -249,7 +252,7 @@ importers: specifier: 'catalog:' version: 6.0.0-next.3(svelte@5.35.5)(vite@6.3.5(@types/node@18.19.119)(jiti@2.4.2)(lightningcss@1.30.1)(yaml@2.8.0)) '@types/node': - specifier: ^18.19.119 + specifier: 'catalog:' version: 18.19.119 polka: specifier: ^1.0.0-next.28 @@ -276,7 +279,7 @@ importers: specifier: 'catalog:' version: 6.0.0-next.3(svelte@5.35.5)(vite@6.3.5(@types/node@18.19.119)(jiti@2.4.2)(lightningcss@1.30.1)(yaml@2.8.0)) '@types/node': - specifier: ^18.19.119 + specifier: 'catalog:' version: 18.19.119 sirv: specifier: ^3.0.0 @@ -346,7 +349,7 @@ importers: specifier: 'catalog:' version: 6.0.0-next.3(svelte@5.35.5)(vite@6.3.5(@types/node@18.19.119)(jiti@2.4.2)(lightningcss@1.30.1)(yaml@2.8.0)) '@types/node': - specifier: ^18.19.119 + specifier: 'catalog:' version: 18.19.119 typescript: specifier: ^5.3.3 @@ -389,7 +392,7 @@ importers: specifier: ^1.0.5 version: 1.0.8 '@types/node': - specifier: ^18.19.119 + specifier: 'catalog:' version: 18.19.119 rollup: specifier: ^4.27.4 @@ -449,6 +452,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 @@ -459,7 +465,7 @@ importers: specifier: ^3.4.38 version: 3.4.38 '@types/node': - specifier: ^18.19.119 + specifier: 'catalog:' version: 18.19.119 '@types/set-cookie-parser': specifier: ^2.4.7 @@ -515,6 +521,15 @@ importers: packages/kit/test/apps/basics: devDependencies: + '@opentelemetry/api': + specifier: ^1.9.0 + version: 1.9.0 + '@opentelemetry/sdk-node': + specifier: ^0.203.0 + version: 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-node': + specifier: ^2.0.1 + version: 2.0.1(@opentelemetry/api@1.9.0) '@sveltejs/kit': specifier: workspace:^ version: link:../../.. @@ -1144,7 +1159,7 @@ importers: specifier: 'catalog:' version: 6.0.0-next.3(svelte@5.35.5)(vite@6.3.5(@types/node@18.19.119)(jiti@2.4.2)(lightningcss@1.30.1)(yaml@2.8.0)) '@types/node': - specifier: ^18.19.119 + specifier: 'catalog:' version: 18.19.119 '@types/semver': specifier: ^7.5.6 @@ -1944,6 +1959,15 @@ packages: '@fontsource/libre-barcode-128-text@5.1.0': resolution: {integrity: sha512-MC7foQFRT0NDcsqBWQua2T3gs/fh/uTowTxfoPqGQWjqroiMxRZhQh7jerjnpcI+Xi3yR5bwCo6W2uwCza1FRw==} + '@grpc/grpc-js@1.13.4': + resolution: {integrity: sha512-GsFaMXCkMqkKIvwCQjCrwH+GHbPKBjhwo/8ZuUkWHqbI73Kky9I+pQltrlT0+MWpedCoosda53lgjYfyEPgxBg==} + engines: {node: '>=12.10.0'} + + '@grpc/proto-loader@0.7.15': + resolution: {integrity: sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==} + engines: {node: '>=6'} + hasBin: true + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -2233,6 +2257,9 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@js-sdsl/ordered-map@4.4.2': + resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} + '@lukeed/ms@2.0.2': resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} engines: {node: '>=8'} @@ -2517,10 +2544,172 @@ packages: '@octokit/types@14.1.0': resolution: {integrity: sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==} + '@opentelemetry/api-logs@0.203.0': + resolution: {integrity: sha512-9B9RU0H7Ya1Dx/Rkyc4stuBZSGVQF27WigitInx2QQoj6KUpEFYPKoWjdFTunJYxmXmh17HeBvbMa1EhGyPmqQ==} + engines: {node: '>=8.0.0'} + '@opentelemetry/api@1.8.0': resolution: {integrity: sha512-I/s6F7yKUDdtMsoBWXJe8Qz40Tui5vsuKCWJEWVL+5q9sSWRzzx6v2KeNsOBEwd94j0eWkpWCH4yB6rZg9Mf0w==} engines: {node: '>=8.0.0'} + '@opentelemetry/api@1.9.0': + resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/context-async-hooks@2.0.1': + resolution: {integrity: sha512-XuY23lSI3d4PEqKA+7SLtAgwqIfc6E/E9eAQWLN1vlpC53ybO3o6jW4BsXo1xvz9lYyyWItfQDDLzezER01mCw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/core@2.0.1': + resolution: {integrity: sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/exporter-logs-otlp-grpc@0.203.0': + resolution: {integrity: sha512-g/2Y2noc/l96zmM+g0LdeuyYKINyBwN6FJySoU15LHPLcMN/1a0wNk2SegwKcxrRdE7Xsm7fkIR5n6XFe3QpPw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-logs-otlp-http@0.203.0': + resolution: {integrity: sha512-s0hys1ljqlMTbXx2XiplmMJg9wG570Z5lH7wMvrZX6lcODI56sG4HL03jklF63tBeyNwK2RV1/ntXGo3HgG4Qw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-logs-otlp-proto@0.203.0': + resolution: {integrity: sha512-nl/7S91MXn5R1aIzoWtMKGvqxgJgepB/sH9qW0rZvZtabnsjbf8OQ1uSx3yogtvLr0GzwD596nQKz2fV7q2RBw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-metrics-otlp-grpc@0.203.0': + resolution: {integrity: sha512-FCCj9nVZpumPQSEI57jRAA89hQQgONuoC35Lt+rayWY/mzCAc6BQT7RFyFaZKJ2B7IQ8kYjOCPsF/HGFWjdQkQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-metrics-otlp-http@0.203.0': + resolution: {integrity: sha512-HFSW10y8lY6BTZecGNpV3GpoSy7eaO0Z6GATwZasnT4bEsILp8UJXNG5OmEsz4SdwCSYvyCbTJdNbZP3/8LGCQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-metrics-otlp-proto@0.203.0': + resolution: {integrity: sha512-OZnhyd9npU7QbyuHXFEPVm3LnjZYifuKpT3kTnF84mXeEQ84pJJZgyLBpU4FSkSwUkt/zbMyNAI7y5+jYTWGIg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-prometheus@0.203.0': + resolution: {integrity: sha512-2jLuNuw5m4sUj/SncDf/mFPabUxMZmmYetx5RKIMIQyPnl6G6ooFzfeE8aXNRf8YD1ZXNlCnRPcISxjveGJHNg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-trace-otlp-grpc@0.203.0': + resolution: {integrity: sha512-322coOTf81bm6cAA8+ML6A+m4r2xTCdmAZzGNTboPXRzhwPt4JEmovsFAs+grpdarObd68msOJ9FfH3jxM6wqA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-trace-otlp-http@0.203.0': + resolution: {integrity: sha512-ZDiaswNYo0yq/cy1bBLJFe691izEJ6IgNmkjm4C6kE9ub/OMQqDXORx2D2j8fzTBTxONyzusbaZlqtfmyqURPw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-trace-otlp-proto@0.203.0': + resolution: {integrity: sha512-1xwNTJ86L0aJmWRwENCJlH4LULMG2sOXWIVw+Szta4fkqKVY50Eo4HoVKKq6U9QEytrWCr8+zjw0q/ZOeXpcAQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-zipkin@2.0.1': + resolution: {integrity: sha512-a9eeyHIipfdxzCfc2XPrE+/TI3wmrZUDFtG2RRXHSbZZULAny7SyybSvaDvS77a7iib5MPiAvluwVvbGTsHxsw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/instrumentation@0.203.0': + resolution: {integrity: sha512-ke1qyM+3AK2zPuBPb6Hk/GCsc5ewbLvPNkEuELx/JmANeEp6ZjnZ+wypPAJSucTw0wvCGrUaibDSdcrGFoWxKQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-exporter-base@0.203.0': + resolution: {integrity: sha512-Wbxf7k+87KyvxFr5D7uOiSq/vHXWommvdnNE7vECO3tAhsA2GfOlpWINCMWUEPdHZ7tCXxw6Epp3vgx3jU7llQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-grpc-exporter-base@0.203.0': + resolution: {integrity: sha512-te0Ze1ueJF+N/UOFl5jElJW4U0pZXQ8QklgSfJ2linHN0JJsuaHG8IabEUi2iqxY8ZBDlSiz1Trfv5JcjWWWwQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-transformer@0.203.0': + resolution: {integrity: sha512-Y8I6GgoCna0qDQ2W6GCRtaF24SnvqvA8OfeTi7fqigD23u8Jpb4R5KFv/pRvrlGagcCLICMIyh9wiejp4TXu/A==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/propagator-b3@2.0.1': + resolution: {integrity: sha512-Hc09CaQ8Tf5AGLmf449H726uRoBNGPBL4bjr7AnnUpzWMvhdn61F78z9qb6IqB737TffBsokGAK1XykFEZ1igw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/propagator-jaeger@2.0.1': + resolution: {integrity: sha512-7PMdPBmGVH2eQNb/AtSJizQNgeNTfh6jQFqys6lfhd6P4r+m/nTh3gKPPpaCXVdRQ+z93vfKk+4UGty390283w==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/resources@2.0.1': + resolution: {integrity: sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-logs@0.203.0': + resolution: {integrity: sha512-vM2+rPq0Vi3nYA5akQD2f3QwossDnTDLvKbea6u/A2NZ3XDkPxMfo/PNrDoXhDUD/0pPo2CdH5ce/thn9K0kLw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.4.0 <1.10.0' + + '@opentelemetry/sdk-metrics@2.0.1': + resolution: {integrity: sha512-wf8OaJoSnujMAHWR3g+/hGvNcsC16rf9s1So4JlMiFaFHiE4HpIA3oUh+uWZQ7CNuK8gVW/pQSkgoa5HkkOl0g==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.9.0 <1.10.0' + + '@opentelemetry/sdk-node@0.203.0': + resolution: {integrity: sha512-zRMvrZGhGVMvAbbjiNQW3eKzW/073dlrSiAKPVWmkoQzah9wfynpVPeL55f9fVIm0GaBxTLcPeukWGy0/Wj7KQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace-base@2.0.1': + resolution: {integrity: sha512-xYLlvk/xdScGx1aEqvxLwf6sXQLXCjk3/1SQT9X9AoN5rXRhkdvIFShuNNmtTEPRBqcsMbS4p/gJLNI2wXaDuQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace-node@2.0.1': + resolution: {integrity: sha512-UhdbPF19pMpBtCWYP5lHbTogLWx9N0EBxtdagvkn5YtsAnCBZzL7SjktG+ZmupRgifsHMjwUaCCaVmqGfSADmA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/semantic-conventions@1.36.0': + resolution: {integrity: sha512-TtxJSRD8Ohxp6bKkhrm27JRHAxPczQA7idtcTOMYI+wQRRrfgqxHv1cFbCApcSnNjtXkmzFozn6jQtFrOmbjPQ==} + engines: {node: '>=14'} + '@parcel/watcher-android-arm64@2.5.1': resolution: {integrity: sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==} engines: {node: '>= 10.0.0'} @@ -2637,6 +2826,36 @@ packages: '@polka/url@1.0.0-next.28': resolution: {integrity: sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw==} + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.4': + resolution: {integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==} + + '@protobufjs/eventemitter@1.1.0': + resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==} + + '@protobufjs/fetch@1.1.0': + resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/inquire@1.1.0': + resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.0': + resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} + '@publint/pack@0.1.0': resolution: {integrity: sha512-NvV5jPAQIMCoHvaJ0ZhfouBJ2woFYYf+o6B7dCHGh/tLKSPVoxhjffi35xPuMHgOv65aTOKUzML5XwQF9EkDAA==} engines: {node: '>=18'} @@ -3416,6 +3635,9 @@ packages: citty@0.1.6: resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} + cjs-module-lexer@1.4.3: + resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} + clean-deep@3.4.0: resolution: {integrity: sha512-Lo78NV5ItJL/jl+B5w0BycAisaieJGXK1qYi/9m4SjR8zbqmrUtO7Yhro40wEShGmmxs/aJLI/A+jNhdkXK8mw==} engines: {node: '>=4'} @@ -4557,6 +4779,9 @@ packages: resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} engines: {node: '>=6'} + import-in-the-middle@1.14.2: + resolution: {integrity: sha512-5tCuY9BV8ujfOpwtAGgsTx9CGUapcFMEEyByLv1B+v2+6DhAcw+Zr0nhQT7uwaZ7DiourxFEscghOR8e1aPLQw==} + imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} @@ -4963,6 +5188,9 @@ packages: lodash-es@4.17.21: resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==} + lodash.camelcase@4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + lodash.debounce@4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} @@ -5018,6 +5246,9 @@ packages: resolution: {integrity: sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==} engines: {node: '>= 12.0.0'} + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + loupe@3.1.3: resolution: {integrity: sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==} @@ -5190,6 +5421,9 @@ packages: engines: {node: '>=18'} hasBin: true + module-details-from-path@1.0.4: + resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==} + moize@6.1.6: resolution: {integrity: sha512-vSKdIUO61iCmTqhdoIDrqyrtp87nWZUmBPniNjO0fX49wEYmyDO4lvlnFXiGcaH1JLE/s/9HbiK4LSHsbiUY6Q==} @@ -5715,6 +5949,10 @@ packages: proto-list@1.2.4: resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} + protobufjs@7.5.3: + resolution: {integrity: sha512-sildjKwVqOI2kmFDiXQ6aEB0fjYTafpEvIBs8tOR8qI4spuL9OPROLVu2qZqi/xgCfsHIwVqlaF8JBjWFHnKbw==} + engines: {node: '>=12.0.0'} + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -5854,6 +6092,10 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + require-in-the-middle@7.5.2: + resolution: {integrity: sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ==} + engines: {node: '>=8.6.0'} + require-package-name@2.0.1: resolution: {integrity: sha512-uuoJ1hU/k6M0779t3VMVIYpb2VMJk05cehCaABFhXaibcbvfgR8wKiozLjVFSzJPmQMRqIcO0HMyTFqfV09V6Q==} @@ -7510,6 +7752,18 @@ snapshots: '@fontsource/libre-barcode-128-text@5.1.0': {} + '@grpc/grpc-js@1.13.4': + dependencies: + '@grpc/proto-loader': 0.7.15 + '@js-sdsl/ordered-map': 4.4.2 + + '@grpc/proto-loader@0.7.15': + dependencies: + lodash.camelcase: 4.3.0 + long: 5.3.2 + protobufjs: 7.5.3 + yargs: 17.7.2 + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.6': @@ -7730,6 +7984,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.4 + '@js-sdsl/ordered-map@4.4.2': {} + '@lukeed/ms@2.0.2': {} '@manypkg/find-root@1.1.0': @@ -8298,8 +8554,235 @@ snapshots: dependencies: '@octokit/openapi-types': 25.1.0 + '@opentelemetry/api-logs@0.203.0': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api@1.8.0': {} + '@opentelemetry/api@1.9.0': {} + + '@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + + '@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/semantic-conventions': 1.36.0 + + '@opentelemetry/exporter-logs-otlp-grpc@0.203.0(@opentelemetry/api@1.9.0)': + dependencies: + '@grpc/grpc-js': 1.13.4 + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-grpc-exporter-base': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.203.0(@opentelemetry/api@1.9.0) + + '@opentelemetry/exporter-logs-otlp-http@0.203.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.203.0 + '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.203.0(@opentelemetry/api@1.9.0) + + '@opentelemetry/exporter-logs-otlp-proto@0.203.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.203.0 + '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.0.1(@opentelemetry/api@1.9.0) + + '@opentelemetry/exporter-metrics-otlp-grpc@0.203.0(@opentelemetry/api@1.9.0)': + dependencies: + '@grpc/grpc-js': 1.13.4 + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-metrics-otlp-http': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-grpc-exporter-base': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.0.1(@opentelemetry/api@1.9.0) + + '@opentelemetry/exporter-metrics-otlp-http@0.203.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.0.1(@opentelemetry/api@1.9.0) + + '@opentelemetry/exporter-metrics-otlp-proto@0.203.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-metrics-otlp-http': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.0.1(@opentelemetry/api@1.9.0) + + '@opentelemetry/exporter-prometheus@0.203.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.0.1(@opentelemetry/api@1.9.0) + + '@opentelemetry/exporter-trace-otlp-grpc@0.203.0(@opentelemetry/api@1.9.0)': + dependencies: + '@grpc/grpc-js': 1.13.4 + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-grpc-exporter-base': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.0.1(@opentelemetry/api@1.9.0) + + '@opentelemetry/exporter-trace-otlp-http@0.203.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.0.1(@opentelemetry/api@1.9.0) + + '@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.0.1(@opentelemetry/api@1.9.0) + + '@opentelemetry/exporter-zipkin@2.0.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.36.0 + + '@opentelemetry/instrumentation@0.203.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.203.0 + import-in-the-middle: 1.14.2 + require-in-the-middle: 7.5.2 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/otlp-exporter-base@0.203.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.203.0(@opentelemetry/api@1.9.0) + + '@opentelemetry/otlp-grpc-exporter-base@0.203.0(@opentelemetry/api@1.9.0)': + dependencies: + '@grpc/grpc-js': 1.13.4 + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.203.0(@opentelemetry/api@1.9.0) + + '@opentelemetry/otlp-transformer@0.203.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.203.0 + '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.0.1(@opentelemetry/api@1.9.0) + protobufjs: 7.5.3 + + '@opentelemetry/propagator-b3@2.0.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) + + '@opentelemetry/propagator-jaeger@2.0.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) + + '@opentelemetry/resources@2.0.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.36.0 + + '@opentelemetry/sdk-logs@0.203.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.203.0 + '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.0.1(@opentelemetry/api@1.9.0) + + '@opentelemetry/sdk-metrics@2.0.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.0.1(@opentelemetry/api@1.9.0) + + '@opentelemetry/sdk-node@0.203.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.203.0 + '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-logs-otlp-grpc': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-logs-otlp-http': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-logs-otlp-proto': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-metrics-otlp-grpc': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-metrics-otlp-http': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-metrics-otlp-proto': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-prometheus': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-trace-otlp-grpc': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-trace-otlp-http': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-trace-otlp-proto': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-zipkin': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/propagator-b3': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/propagator-jaeger': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-node': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.36.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.36.0 + + '@opentelemetry/sdk-trace-node@2.0.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/context-async-hooks': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.0.1(@opentelemetry/api@1.9.0) + + '@opentelemetry/semantic-conventions@1.36.0': {} + '@parcel/watcher-android-arm64@2.5.1': optional: true @@ -8395,6 +8878,29 @@ snapshots: '@polka/url@1.0.0-next.28': {} + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.4': {} + + '@protobufjs/eventemitter@1.1.0': {} + + '@protobufjs/fetch@1.1.0': + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/inquire': 1.1.0 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/inquire@1.1.0': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.0': {} + '@publint/pack@0.1.0': {} '@rollup/plugin-commonjs@28.0.1(rollup@4.44.0)': @@ -9262,6 +9768,8 @@ snapshots: dependencies: consola: 3.4.2 + cjs-module-lexer@1.4.3: {} + clean-deep@3.4.0: dependencies: lodash.isempty: 4.4.0 @@ -10524,6 +11032,13 @@ snapshots: parent-module: 1.0.1 resolve-from: 4.0.0 + import-in-the-middle@1.14.2: + dependencies: + acorn: 8.15.0 + acorn-import-attributes: 1.9.5(acorn@8.15.0) + cjs-module-lexer: 1.4.3 + module-details-from-path: 1.0.4 + imurmurhash@0.1.4: {} indent-string@5.0.0: {} @@ -10915,6 +11430,8 @@ snapshots: lodash-es@4.17.21: {} + lodash.camelcase@4.3.0: {} + lodash.debounce@4.0.8: {} lodash.includes@4.3.0: {} @@ -10970,6 +11487,8 @@ snapshots: safe-stable-stringify: 2.5.0 triple-beam: 1.4.1 + long@5.3.2: {} + loupe@3.1.3: {} lower-case@2.0.2: @@ -11111,6 +11630,8 @@ snapshots: ast-module-types: 6.0.1 node-source-walk: 7.0.1 + module-details-from-path@1.0.4: {} + moize@6.1.6: dependencies: fast-equals: 3.0.3 @@ -11711,6 +12232,21 @@ snapshots: proto-list@1.2.4: {} + protobufjs@7.5.3: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.4 + '@protobufjs/eventemitter': 1.1.0 + '@protobufjs/fetch': 1.1.0 + '@protobufjs/float': 1.0.2 + '@protobufjs/inquire': 1.1.0 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.0 + '@types/node': 18.19.119 + long: 5.3.2 + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 @@ -11861,6 +12397,14 @@ snapshots: require-from-string@2.0.2: {} + require-in-the-middle@7.5.2: + dependencies: + debug: 4.4.1(supports-color@10.0.0) + module-details-from-path: 1.0.4 + resolve: 1.22.8 + transitivePeerDependencies: + - supports-color + require-package-name@2.0.1: {} requires-port@1.0.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 79e86ece4e22..157ca9bb6b9e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -14,5 +14,6 @@ packages: catalog: '@playwright/test': '^1.51.1' '@sveltejs/vite-plugin-svelte': '^6.0.0-next.3' + '@types/node': '^18.19.119' 'vitest': '^3.2.3' 'vite': '^6.3.5'