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 `