sentry-javascript icon indicating copy to clipboard operation
sentry-javascript copied to clipboard

Support Deno.serve instrumentation

Open brc-dd opened this issue 1 year ago • 12 comments

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

brc-dd avatar Jun 11 '24 07:06 brc-dd

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?

Lms24 avatar Jun 11 '24 11:06 Lms24

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 = ...

brc-dd avatar Jun 11 '24 12:06 brc-dd

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 🤔

AbhiPrasad avatar Jun 11 '24 15:06 AbhiPrasad

I wonder if the Deno async context is working properly

Without .clear there, the breadcrumbs have older logs too:

image

Code:

image

brc-dd avatar Jun 11 '24 15:06 brc-dd

Without .clear there, the breadcrumbs have older logs too:

Yeah that's a bug we need to fix! Let me PR.

AbhiPrasad avatar Jun 11 '24 16:06 AbhiPrasad

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 avatar Feb 28 '25 03:02 morpig

@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

lforst avatar Feb 28 '25 09:02 lforst

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

timfish avatar Feb 28 '25 11:02 timfish

I can rebase it but I think isolationScope thing should be fixed first (https://github.com/getsentry/sentry-javascript/issues/12450#issuecomment-2161141286)

brc-dd avatar Feb 28 '25 11:02 brc-dd

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.

timfish avatar Feb 28 '25 14:02 timfish

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

brc-dd avatar Feb 28 '25 14:02 brc-dd

Did you mean Deno?

Yes, sorry. I'm thinking about lots of different problems at once and confusing myself!

timfish avatar Feb 28 '25 14:02 timfish