Skip to content

Support Deno.serve instrumentation  #12450

Open
@brc-dd

Description

@brc-dd

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

Metadata

Metadata

Assignees

No one assigned

    Projects

    Status

    No status

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions