You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
tracing: emit a span for +server route handlers #17099
With tracing.server enabled, SvelteKit wraps handle/resolve, load, form actions, and remote functions in record_span. When one of those throws an Error or an HttpError with status >= 500, the handler span gets recordException and SpanStatusCode.ERROR.
+server.js handlers (GET, POST, QUERY, fallback, and so on) are not wrapped. render_endpoint resolves mod[method] || mod.fallback (plus HEAD to GET) and calls the handler without a record_span.
So a +server request produces sveltekit.handle.root, optional sveltekit.handle.sequenced.*, and sveltekit.resolve, but no span for the handler itself:
sveltekit.handle.root
└─ sveltekit.handle.sequenced.* (only if you use sequence())
└─ sveltekit.resolve
└─ render_endpoint() → GET/POST/… ← no extra span
If the handler throws, the try/catch in resolve calls handle_fatal_error(...) and returns a Response instead of rethrowing, so the wrapper spans never see the exception. sveltekit.resolve just records http.response.status_code (e.g. 500) from that Response. SvelteKit already flags this:
// respond.js — resolve() catch// HttpError from endpoint can end up here - TODO should it be handled there instead?returnawaithandle_fatal_error(event,state,e);
Two things are missing:
A span for the endpoint handler.
recordException and OTel status ERROR on some span when the handler throws.
load, form actions, sveltekit.remote.call, and sveltekit.remote.form.post all get a handler span. Endpoints are the one remaining first-class HTTP entry point where a thrown failure leaves no ERROR and no recordException. Trace-based debugging misses it, and so does error-biased sampling that keys off span status.
event.tracing.current does exist inside a +server handler, but resolve runs as merge_tracing(event, resolve_span), so current points at the resolve span, not a handler span. The docs for RequestEvent.tracing.current describe it as the span for handle, load, or a form action (the observability docs also mention remotes). Neither mentions +server.
The observability docs and the tracing.server JSDoc both list handle, load, form actions, and remotes, but not +server. The tracing tests in packages/kit/test/apps/basics/test/vitest/server.spec.js only cover page load.
Describe the proposed solution
Wrap the resolved +server handler in record_span, the same way OpenTelemetry's Express instrumentation records a span per route handler.
In render_endpoint, wrap only the resolved handler (mod[method] || mod.fallback, including HEAD to GET). Leave the prerender and method_not_allowed paths alone, and leave the outer Redirect to Response conversion alone. Use the same steps as load and actions:
record_span, then merge_tracing, then with_request_store({ event: traced, state }, …)
That way event.tracing.current and getRequestEvent().tracing.current both point at the handler span. If the store is left on the pre-merge event, current stays the resolve span.
Suggested span name: sveltekit.endpoint, matching the sveltekit.<noun> pattern of sveltekit.load, sveltekit.form_action, and sveltekit.remote.call.
Suggested attributes: http.route, http.method, and whether fallback or HEAD to GET was used.
On throw, the existing record_span rules apply:
Error or HttpError with status >= 500: recordException and ERROR
HttpError < 500: attributes only
Redirect: attributes only
non-Error throw: ERROR without recordException
Right now render_endpoint converts Redirect to a Response before any record_span, so endpoint redirects also leave no span signal. Wrapping the handler call inside the existing try, before that conversion, would record the redirect attributes and then rethrow into the current conversion.
The following things should be also implemented:
Add cases alongside the existing tracing suite: throw Error, error(500), error(404), redirect(...), a non-Error throw (the load suite already covers that last one), and a 200 so the span exists on the happy path.
Update the observability docs, the tracing.server JSDoc, and RequestEvent.tracing.current to include +server. All three currently list only handle, load, actions, and remotes.
Alternatives considered
Per-handler wrapper (Sentry's wrapServerRouteWithSentry): works, but every +server file has to remember to use it, and it still does not give Kit a first-party handler span.
App-level business spans (my current approach): I record failures inside the server logic by using Effect, so most endpoint errors are traced. The gap is a raw throw or error() outside that code. I would rather rely on this upstream fix than a per-endpoint wrapper.
Treat sveltekit.resolve's http.response.status_code as the error signal: the status is there, but the span stays UNSET with no recordException, so error-biased sampling still misses it.
HTTP auto-instrumentation root span (#14333): a separate gap about renaming the incoming HTTP span. It does not add a Kit handler span or an ERROR status for thrown +server failures.
I think for additional spans, it would be great to also capture spans for +server routes (i.e. the GET/POST etc functions). Technically, the handle spans will already tell us by the route id that a +server route was accessed but I'd see a span for the specfic GET/POST/etc handler as quite valuable, similarly to how OTel express instrumentation would record spans for individual request handlers.
Sentry hit the same problem: getsentry/sentry-javascript#13224 ("in +server.ts routes, we don't catch errors thrown via error() … SvelteKit internally already caught and processed the error"), fixed with a manual wrapper (PR #13247).
Not a duplicate of OpenTelemetry: Add route id to the name of the actual root span #14333. That issue is about renaming the incoming HTTP auto-instrumentation root span (GET → GET /route) and/or exposing it. event.tracing.root is only sveltekit.handle.root, so apps cannot rename the HTTP span themselves. This request is a missing Kit child span inside render_endpoint (sveltekit.endpoint), plus recordException/ERROR when the handler throws. Renaming GET does not wrap the handler or set span status; adding sveltekit.endpoint does not rename the HTTP root.
Describe the problem
With
tracing.serverenabled, SvelteKit wrapshandle/resolve,load, form actions, and remote functions inrecord_span. When one of those throws anErroror anHttpErrorwith status >= 500, the handler span getsrecordExceptionandSpanStatusCode.ERROR.+server.jshandlers (GET,POST,QUERY,fallback, and so on) are not wrapped.render_endpointresolvesmod[method] || mod.fallback(plus HEAD to GET) and calls the handler without arecord_span.So a
+serverrequest producessveltekit.handle.root, optionalsveltekit.handle.sequenced.*, andsveltekit.resolve, but no span for the handler itself:If the handler throws, the
try/catchinresolvecallshandle_fatal_error(...)and returns a Response instead of rethrowing, so the wrapper spans never see the exception.sveltekit.resolvejust recordshttp.response.status_code(e.g. 500) from that Response. SvelteKit already flags this:Two things are missing:
recordExceptionand OTel statusERRORon some span when the handler throws.load, form actions,sveltekit.remote.call, andsveltekit.remote.form.postall get a handler span. Endpoints are the one remaining first-class HTTP entry point where a thrown failure leaves noERRORand norecordException. Trace-based debugging misses it, and so does error-biased sampling that keys off span status.event.tracing.currentdoes exist inside a+serverhandler, butresolveruns asmerge_tracing(event, resolve_span), socurrentpoints at the resolve span, not a handler span. The docs forRequestEvent.tracing.currentdescribe it as the span forhandle,load, or a form action (the observability docs also mention remotes). Neither mentions+server.The observability docs and the
tracing.serverJSDoc both list handle, load, form actions, and remotes, but not+server. The tracing tests inpackages/kit/test/apps/basics/test/vitest/server.spec.jsonly cover pageload.Describe the proposed solution
Wrap the resolved
+serverhandler inrecord_span, the same way OpenTelemetry's Express instrumentation records a span per route handler.In
render_endpoint, wrap only the resolved handler (mod[method] || mod.fallback, including HEAD to GET). Leave the prerender andmethod_not_allowedpaths alone, and leave the outerRedirecttoResponseconversion alone. Use the same steps asloadand actions:record_span, thenmerge_tracing, thenwith_request_store({ event: traced, state }, …)That way
event.tracing.currentandgetRequestEvent().tracing.currentboth point at the handler span. If the store is left on the pre-merge event,currentstays the resolve span.Suggested span name:
sveltekit.endpoint, matching thesveltekit.<noun>pattern ofsveltekit.load,sveltekit.form_action, andsveltekit.remote.call.Suggested attributes:
http.route,http.method, and whetherfallbackor HEAD to GET was used.On throw, the existing
record_spanrules apply:ErrororHttpErrorwith status >= 500:recordExceptionandERRORHttpError< 500: attributes onlyRedirect: attributes onlyErrorthrow:ERRORwithoutrecordExceptionRight now
render_endpointconvertsRedirectto aResponsebefore anyrecord_span, so endpoint redirects also leave no span signal. Wrapping the handler call inside the existingtry, before that conversion, would record the redirect attributes and then rethrow into the current conversion.The following things should be also implemented:
Error,error(500),error(404),redirect(...), a non-Errorthrow (the load suite already covers that last one), and a 200 so the span exists on the happy path.tracing.serverJSDoc, andRequestEvent.tracing.currentto include+server. All three currently list only handle, load, actions, and remotes.Alternatives considered
wrapServerRouteWithSentry): works, but every+serverfile has to remember to use it, and it still does not give Kit a first-party handler span.throworerror()outside that code. I would rather rely on this upstream fix than a per-endpoint wrapper.sveltekit.resolve'shttp.response.status_codeas the error signal: the status is there, but the span staysUNSETwith norecordException, so error-biased sampling still misses it.ERRORstatus for thrown+serverfailures.Importance
nice to have
Additional Information
Related Issues:
feat: Add tracing to
load, server actions, andhandle/resolve#13900 added spans forload, actions, andhandle/resolve. Lms24's review asked for a+serverhandler span as well. It was not implemented in that PR:feat: OpenTelemetry Tracing #13899: base tracing work.
breaking: move tracing out of the experimental namespace and remove instrumentation flag #16260: moved tracing out of
experimental(tracing.serveron thesveltekit()Vite plugin) and includedinstrumentation.server.jswhen present. No+serverspan was added.Sentry hit the same problem: getsentry/sentry-javascript#13224 ("in
+server.tsroutes, we don't catch errors thrown viaerror()… SvelteKit internally already caught and processed the error"), fixed with a manual wrapper (PR #13247).Not a duplicate of OpenTelemetry: Add route id to the name of the actual root span #14333. That issue is about renaming the incoming HTTP auto-instrumentation root span (
GET→GET /route) and/or exposing it.event.tracing.rootis onlysveltekit.handle.root, so apps cannot rename the HTTP span themselves. This request is a missing Kit child span insiderender_endpoint(sveltekit.endpoint), plusrecordException/ERRORwhen the handler throws. RenamingGETdoes not wrap the handler or set span status; addingsveltekit.endpointdoes not rename the HTTP root.