Support Deno.serve instrumentation
Problem Statement
similar to https://github.com/getsentry/sentry-javascript/blob/develop/packages/bun/src/integrations/bunserver.ts
Solution Brainstorm
Rough working implementation:
import {
captureException,
continueTrace,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
setHttpStatus,
startSpan,
withIsolationScope,
} from 'https://deno.land/x/[email protected]/index.mjs'
import type { Integration, IntegrationFn, SpanAttributes } from 'npm:@sentry/[email protected]'
type PartialURL = {
host?: string
path?: string
protocol?: string
relative?: string
search?: string
hash?: string
}
/**
* Parses string form of URL into an object
* // borrowed from https://tools.ietf.org/html/rfc3986#appendix-B
* // intentionally using regex and not <a/> href parsing trick because React Native and other
* // environments where DOM might not be available
* @returns parsed URL object
*/
export function parseUrl(url: string): PartialURL {
if (!url) {
return {}
}
const match = url.match(/^(([^:/?#]+):)?(\/\/([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?$/)
if (!match) {
return {}
}
// coerce to undefined values to empty string so we don't get 'undefined'
const query = match[6] || ''
const fragment = match[8] || ''
return {
host: match[4],
path: match[5],
protocol: match[2],
search: query,
hash: fragment,
relative: match[5] + query + fragment, // everything minus origin
}
}
/**
* Takes a URL object and returns a sanitized string which is safe to use as span name
* see: https://develop.sentry.dev/sdk/data-handling/#structuring-data
*/
function getSanitizedUrlString(url: PartialURL): string {
const { protocol, host, path } = url
const filteredHost = (host &&
host
// Always filter out authority
.replace(/^.*@/, '[filtered]:[filtered]@')
// Don't show standard :80 (http) and :443 (https) ports to reduce the noise
// TODO: Use new URL global if it exists
.replace(/(:80)$/, '')
.replace(/(:443)$/, '')) ||
''
return `${protocol ? `${protocol}://` : ''}${filteredHost}${path}`
}
function defineIntegration<Fn extends IntegrationFn>(fn: Fn): (...args: Parameters<Fn>) => Integration {
return fn
}
type RawHandler = (request: Request, info: Deno.ServeHandlerInfo) => Response | Promise<Response>
const INTEGRATION_NAME = 'DenoServer'
const _denoServerIntegration = (() => {
return {
name: INTEGRATION_NAME,
setupOnce() {
instrumentDenoServe()
},
}
}) satisfies IntegrationFn
/**
* Instruments `Deno.serve` to automatically create transactions and capture errors.
*
* ```js
* Sentry.init({
* integrations: [
* Sentry.denoServerIntegration(),
* ],
* })
* ```
*/
export const denoServerIntegration = defineIntegration(_denoServerIntegration)
/**
* Instruments Deno.serve by patching it's options.
*/
export function instrumentDenoServe(): void {
Deno.serve = new Proxy(Deno.serve, {
apply(serveTarget, serveThisArg, serveArgs: any) {
const [arg1, arg2] = serveArgs
let handler: RawHandler | undefined
let type = 0
if (typeof arg1 === 'function') {
handler = arg1
type = 1
} else if (typeof arg2 === 'function') {
handler = arg2
type = 2
} else if (arg1 && typeof arg1 === 'object' && 'handler' in arg1 && typeof arg1.handler === 'function') {
handler = arg1.handler
type = 3
} else if (arg2 && typeof arg2 === 'object' && 'handler' in arg2 && typeof arg2.handler === 'function') {
handler = arg2.handler
type = 4
}
if (handler) {
handler = instrumentDenoServeOptions(handler)
if (type === 1) {
serveArgs[0] = handler
} else if (type === 2) {
serveArgs[1] = handler
} else if (type === 3) {
serveArgs[0].handler = handler
} else if (type === 4) {
serveArgs[1].handler = handler
}
}
return serveTarget.apply(serveThisArg, serveArgs)
},
})
}
/**
* Instruments Deno.serve `fetch` option to automatically create spans and capture errors.
*/
function instrumentDenoServeOptions(handler: RawHandler): RawHandler {
return new Proxy(handler, {
apply(fetchTarget, fetchThisArg, fetchArgs: Parameters<typeof handler>) {
return withIsolationScope((isolationScope) => {
const request = fetchArgs[0]
const upperCaseMethod = request.method.toUpperCase()
if (upperCaseMethod === 'OPTIONS' || upperCaseMethod === 'HEAD') {
return fetchTarget.apply(fetchThisArg, fetchArgs)
}
const parsedUrl = parseUrl(request.url)
const attributes: SpanAttributes = {
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.deno.serve',
'http.request.method': request.method || 'GET',
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url',
}
if (parsedUrl.search) {
attributes['http.query'] = parsedUrl.search
}
const url = getSanitizedUrlString(parsedUrl)
isolationScope.setSDKProcessingMetadata({
request: {
url,
method: request.method,
headers: Object.fromEntries(request.headers),
},
})
return continueTrace({
sentryTrace: request.headers.get('sentry-trace') || '',
baggage: request.headers.get('baggage'),
}, () => {
return startSpan(
{
attributes,
op: 'http.server',
name: `${request.method} ${parsedUrl.path || '/'}`,
},
async (span) => {
try {
const response = await (fetchTarget.apply(fetchThisArg, fetchArgs) as ReturnType<typeof handler>)
if (response && response.status) {
setHttpStatus(span, response.status)
isolationScope.setContext('response', {
headers: Object.fromEntries(response.headers),
status_code: response.status,
})
}
return response
} catch (e) {
captureException(e, {
mechanism: {
type: 'deno',
handled: false,
data: {
function: 'serve',
},
},
})
throw e
}
},
)
})
})
},
})
}
needs requestDataIntegration
Hey @brc-dd thanks for writing in! Sounds like a good idea. However, we're all booked on a lot of other tasks at the moment so I'm gonna backlog this for now. Since you already figured out most the moving parts, are you interested in contributing your instrumentation as a PR?
Ok sure. I'm currently testing this out internally. I'll create a PR in 2-3 days.
I need bit of clarification though, should breadcrumbs be cleared for isolationScope at the starting of withIsolationScope? Because otherwise each message will show console logs from other requests too. Not sure though why it isn't there in bun. Something like this (https://stackoverflow.com/a/54815332/11613622):
return new Proxy(handler, {
apply(fetchTarget, fetchThisArg, fetchArgs: Parameters<typeof handler>) {
return withIsolationScope((isolationScope) => {
isolationScope.clearBreadcrumbs() // or maybe isolationScope.clear()
const request = ...
withIsolationScope should isolate the breadcrumbs to each request, you shouldn't need to clear anything.
I wonder if the Deno async context is working properly 🤔
I wonder if the Deno async context is working properly
Without .clear there, the breadcrumbs have older logs too:
Code:
Without .clear there, the breadcrumbs have older logs too:
Yeah that's a bug we need to fix! Let me PR.
bumping this issue, can't get error reporting to work under deno serve (basic throw under a hono route function)
i wonder if it has something to do with missing support for deno.serve? will open a new one if it's not related.
@morpig Yea I think this might be related. @AbhiPrasad @timfish is there a reason we never merged https://github.com/getsentry/sentry-javascript/pull/12460
is there a reason we never merged https://github.com/getsentry/sentry-javascript/pull/12460
Not that I can see other than the conflicts since we bumped the minimum Deno version to v2
I can rebase it but I think isolationScope thing should be fixed first (https://github.com/getsentry/sentry-javascript/issues/12450#issuecomment-2161141286)
Without .clear there, the breadcrumbs have older logs too
Ah, I think we might also need to fix this before the isolation will work:
- #15455
I don't want to enable this by default though because Cloudflare doesn't support AsyncLocalStroage unless you enable Node compatability.
because Cloudflare doesn't support
Did you mean Deno?
But isn't @sentry/deno now only available with node compatibility (via npm: prefix)? It's not published on JSR and new versions are no longer there on deno.land 👀
Also, it seems to be supported on deno deploy too - https://github.com/denoland/std/issues/2306#issuecomment-1411097653
Did you mean Deno?
Yes, sorry. I'm thinking about lots of different problems at once and confusing myself!