Skip to content

feat: Initial tracing setup (peer deps + utils) #13899

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 17 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
0c3aad6
feat: Initial tracing setup (peer deps + utils)
elliott-with-the-longest-name-on-github Jun 17, 2025
62777fe
Merge branch 'main' into elliott/init-tracing
eltigerchino Jul 22, 2025
78a65ea
feat: Add tracing to `load`, server actions, and `handle`/`resolve` (…
elliott-with-the-longest-name-on-github Jul 23, 2025
f65b78f
Update packages/kit/src/exports/public.d.ts
elliott-with-the-longest-name-on-github Jul 23, 2025
77d447f
chore: Switch to `tracing: { server: boolean }`
elliott-with-the-longest-name-on-github Jul 23, 2025
965bfea
chore: Make enablement / importing of otel make more sense
elliott-with-the-longest-name-on-github Jul 23, 2025
65752bc
chore: merge_tracing function
elliott-with-the-longest-name-on-github Jul 23, 2025
e50615b
fix rich's bad comment that shouldn't have ever existed >:(
elliott-with-the-longest-name-on-github Jul 23, 2025
5b583ec
test stuff
elliott-with-the-longest-name-on-github Jul 23, 2025
90e961d
Update packages/kit/test/utils.js
elliott-with-the-longest-name-on-github Jul 23, 2025
dd516a9
i am truly among the dumbest
elliott-with-the-longest-name-on-github Jul 24, 2025
0cf24bc
Merge branch 'main' into elliott/init-tracing
elliott-with-the-longest-name-on-github Jul 24, 2025
962daf7
types
elliott-with-the-longest-name-on-github Jul 24, 2025
6a81d8b
remove now-useless comment
elliott-with-the-longest-name-on-github Jul 24, 2025
f143f5b
lockfile
elliott-with-the-longest-name-on-github Jul 24, 2025
fc3f734
fix dumb import analysis
elliott-with-the-longest-name-on-github Jul 24, 2025
4b64316
changeset
elliott-with-the-longest-name-on-github Jul 24, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/early-taxis-make.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': minor
---

feat: OpenTelemetry tracing for `handle`, `sequence`, form actions, and `load` functions running on the server
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"@parcel/watcher",
"esbuild",
"netlify-cli",
"protobufjs",
"rolldown",
"sharp",
"svelte-preprocess",
Expand Down
6 changes: 4 additions & 2 deletions packages/kit/kit.vitest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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/**']
Expand All @@ -12,8 +15,7 @@ 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,
isolate: true,
poolOptions: {
threads: {
singleThread: true
Expand Down
7 changes: 7 additions & 0 deletions packages/kit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"sirv": "^3.0.0"
},
"devDependencies": {
"@opentelemetry/api": "^1.0.0",
"@playwright/test": "catalog:",
"@sveltejs/vite-plugin-svelte": "catalog:",
"@types/connect": "^3.4.38",
Expand All @@ -47,9 +48,15 @@
},
"peerDependencies": {
"@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0",
"@opentelemetry/api": "^1.0.0",
"svelte": "^4.0.0 || ^5.0.0-next.0",
"vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0"
},
"peerDependenciesMeta": {
"@opentelemetry/api": {
"optional": true
}
},
"bin": {
"svelte-kit": "svelte-kit.js"
},
Expand Down
70 changes: 70 additions & 0 deletions packages/kit/src/core/config/index.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ const get_defaults = (prefix = '') => ({
publicPrefix: 'PUBLIC_',
privatePrefix: ''
},
experimental: {
tracing: { server: false }
},
files: {
assets: join(prefix, 'static'),
hooks: {
Expand Down Expand Up @@ -404,3 +407,70 @@ test('errors on loading config with incorrect default export', async () => {
'The Svelte config file must have a configuration object as its default export. See https://svelte.dev/docs/kit/configuration'
);
});

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$/);
});
6 changes: 6 additions & 0 deletions packages/kit/src/core/config/options.js
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,12 @@ const options = object(
privatePrefix: string('')
}),

experimental: object({
tracing: object({
server: boolean(false)
})
}),

files: object({
assets: string('static'),
hooks: object({
Expand Down
83 changes: 52 additions & 31 deletions packages/kit/src/exports/hooks/sequence.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
/** @import { Handle, RequestEvent, ResolveOptions } from '@sveltejs/kit' */
/** @import { MaybePromise } from 'types' */
import { with_event } from '../../runtime/app/server/event.js';
import { merge_tracing } from '../../runtime/utils.js';
import { record_span } from '../../runtime/telemetry/record_span.js';

/**
* A helper function for sequencing multiple `handle` calls in a middleware-like manner.
* The behavior for the `handle` options is as follows:
Expand Down Expand Up @@ -66,8 +72,8 @@
* 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;
Expand All @@ -78,44 +84,59 @@ export function sequence(...handlers) {

/**
* @param {number} i
* @param {import('@sveltejs/kit').RequestEvent} event
* @param {import('@sveltejs/kit').ResolveOptions | undefined} parent_options
* @returns {import('types').MaybePromise<Response>}
* @param {RequestEvent} event
* @param {ResolveOptions | undefined} parent_options
* @returns {MaybePromise<Response>}
*/
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 record_span({
name: 'sveltekit.handle.child',
attributes: {
'sveltekit.handle.child.index': i
},
fn: async (current) => {
const traced_event = merge_tracing(event, current);
return await with_event(traced_event, () =>
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
});
}
})
);
}
});
}
Expand Down
19 changes: 9 additions & 10 deletions packages/kit/src/exports/hooks/sequence.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import { installPolyfills } from '../node/polyfills.js';

installPolyfills();

const dummy_event = /** @type {import('@sveltejs/kit').RequestEvent} */ ({
tracing: { root: {} }
});

test('applies handlers in sequence', async () => {
/** @type {string[]} */
const order = [];
Expand All @@ -29,10 +33,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']);
});

Expand All @@ -47,9 +50,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 = '';

Expand Down Expand Up @@ -84,9 +86,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 = '';

Expand Down Expand Up @@ -117,9 +118,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 = '';

Expand Down Expand Up @@ -150,9 +150,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 = '';

Expand Down
Loading
Loading