getsentry / sentry-javascript

Official Sentry SDKs for JavaScript
https://sentry.io
MIT License
7.89k stars 1.56k forks source link

Support Deno.serve instrumentation #12450

Open brc-dd opened 3 months ago

brc-dd commented 3 months ago

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/sentry@8.8.0/index.mjs'
import type { Integration, IntegrationFn, SpanAttributes } from 'npm:@sentry/types@8.8.0'

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

Lms24 commented 3 months ago

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?

brc-dd commented 3 months ago

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 = ...
AbhiPrasad commented 3 months ago

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 🤔

brc-dd commented 3 months ago

I wonder if the Deno async context is working properly

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

image

Code:

image
AbhiPrasad commented 3 months ago

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

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