evolved.io logotype

#Koa.js #Sentry #Performance-Monitoring #TypeScript

Koa.js Performance Monitoring with Sentry

Discover how to boost your Koa.js application's efficiency and reliability with Sentry Performance Monitoring and TypeScript

Avatar
Dennis GaidelJuly 31, 2023

Sentry Performance Monitoring is an instrumental tool that enhances application reliability and efficiency. It provides real-time error tracking, promptly notifying developers when an issue arises and offering in-depth insights for troubleshooting. Additionally, it yields performance metrics that quantify application functionality, highlighting areas that may benefit from optimization.

While Sentry does provide a guide for implementing performance monitoring in Node.js/Koa.js applications, it does not sufficiently detail the full utilization of this feature. Furthermore, the proposed implementation could benefit from refinements for improved readability and code maintenance.

Dependencies

npm install --save @sentry/node @sentry/utils

General Setup

This block of code initializes Sentry with various configurations and integrations to enable error tracking, performance metrics, and distributed tracing in your application. The integrations with Postgres and HTTP allow Sentry to capture information related to database interactions and HTTP requests, respectively.

import * as Sentry from '@sentry/node'

Sentry.init({
    dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0',
    integrations: [
        new Sentry.Integrations.Postgres(),
        new Sentry.Integrations.Http({ tracing: true })
    ],
    tracePropagationTargets: ['*.example.net'],
    tracesSampleRate: 0.1
})

export { Sentry }
import Koa from 'koa'

import { request, tracing } from './sentry'

// Sentry request handler
app.use(request)

// Sentry trace handler
app.use(tracing)

app.listen(3000)

Let's break down each part:

  1. dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0': The DSN, or Data Source Name, is a configuration string that allows Sentry to identify your application, know where to send events, and authenticate the data sent. In a real application, you would replace it with your unique DSN provided by Sentry.

  2. integrations: This is an array where you add the integrations you want Sentry to use. In this code, two integrations are initialized:

    • new Sentry.Integrations.Postgres(): This integration is set up for the Postgres database. It helps Sentry capture and report errors that happen while your application interacts with your Postgres database.
    • new Sentry.Integrations.Http({ tracing: true }): The HTTP integration helps Sentry track requests your application makes using HTTP protocols. The tracing: true configuration enables the distributed tracing feature, which helps you track how a single user request flows through your application.
  3. tracePropagationTargets: This is an array of URLs for which distributed tracing should be enabled. Here, it includes any URL ending with example.net. For testing, you would need to add localhost (including the port), too.

  4. tracesSampleRate: 0.1: This is the rate at which transactions will be sent to Sentry. A value of 0.1 means that 10% of all transactions will be sent. This is used to control the volume of data sent to Sentry.

Sentry recommends to simply destructure ...Sentry.autoDiscoverNodePerformanceMonitoringIntegrations() to load all the integrations. But as you can see here, it loads all kind of integrations (Postgres, Mongo, GraphQL, ...), that are probably not relevant.

Request Handler

import { Middleware } from 'koa'

import { Sentry } from './sentry'

export const request: Middleware = async (ctx, next) => {
    await Sentry.runWithAsyncContext(async () => {
        const hub = Sentry.getCurrentHub()

        hub.configureScope(scope =>
            scope.addEventProcessor(event =>
                Sentry.addRequestDataToEvent(event, ctx.req)
            )
        )

        await next()
    })
}

The code establishes a middleware, that tailors Sentry's scope for every inbound request. The critical role of this piece of code is to append extra information from the request to all Sentry events.

Using Sentry.runWithAsyncContext, it creates an isolated context that correctly tracks and groups all the ensuing events and spans that derive from this request, including asynchronous operations.

Inside this newly formed context, the current hub is fetched through Sentry.getCurrentHub(). In the context of Sentry, a hub represents a standalone container holding a scope and client pair, tracking the state interchange between your application process and the Sentry SDK.

With the obtained hub, the existing scope is then customized via configureScope. The scope in Sentry is a temporary storage for event-related data, which is not part of the event payload itself. It often contains tags, extra data, level, fingerprint, and user information.

In this scenario, an event processor is attached to the scope using scope.addEventProcessor(). Event processors are functions that have the ability to mutate each event. Here, it applies Sentry.addRequestDataToEvent(event, ctx.req) to every event, consequently incorporating data from the current request.

Following this, the next middleware in the pipeline is invoked. The use of await next() indicates that it will pause until all subsequent middlewares and their respective asynchronous tasks have completed before resuming its own operations.

Tracing

import { Transaction } from '@sentry/node'
import { stripUrlQueryAndFragment } from '@sentry/utils'

import { Middleware, ParameterizedContext } from 'koa'

import { Sentry } from './sentry'

export const tracing: Middleware = async (ctx, next) => {
    const transaction = start(ctx)

    ctx.__sentry_transaction = transaction

    Sentry.getCurrentHub().configureScope(scope => {
        scope.setSpan(transaction)
    })

    try {
        await next()
    } catch (err) {
        ctx.app.emit('error', err, ctx)
    } finally {
        finish(transaction, ctx)
    }
}

The tracing function is a middleware. In Koa.js (and many other Node.js frameworks), a middleware is a function that can interact with the request and the response objects during their lifecycle. In this scenario, it's being used for performance tracing.

When a request is made to the server, the tracing middleware creates a new transaction. This transaction can be thought of as a record detailing the path of the request through your application.

The transaction is then associated with the Koa.js context (ctx). This association allows the transaction to be accessed and manipulated in other parts of your code that can interact with ctx.

The tracing middleware also designates this transaction as the current span of the scope. In distributed tracing, a "span" signifies a unit of work in the tracing. By assigning the transaction as the current span, all other operations occurring while processing this request are registered as part of this transaction.

The await next() statement lets the request continue to the next middleware or route handler. Any code following await next() within this middleware executes after the request has been processed and a response has been prepared, but before the response is sent.

Once the request has been processed and the response is ready, the tracing middleware finishes the transaction. This action notifies Sentry that the request's journey is complete and that the transaction, including all the spans, is ready to be sent to Sentry.

const start = (ctx: ParameterizedContext): Transaction => {
    const reqMethod = (ctx.method || '').toUpperCase()
    const reqUrl = stripUrlQueryAndFragment(ctx.url || '')

    const reqTransactionData = Sentry.extractTraceparentData(
        ctx.get('sentry-trace')
    )

    return Sentry.startTransaction({
        ...reqTransactionData,
        name: `${reqMethod} ${reqUrl}`,
        op: 'http.server'
    })
}

The start function initiates a new transaction for each incoming request.

It begins by retrieving the HTTP method (such as GET, POST, etc.) from the Koa.js context (ctx), and converts it to upper case.

Next, the stripUrlQueryAndFragment function from @sentry/utils package is utilized to clean up the request URL. This function removes any query parameters and fragment from the URL, thereby keeping the transaction data clean and preventing any potential leaks of sensitive information to Sentry.

The next line, Sentry.extractTraceparentData(ctx.get('sentry-trace')), extracts tracing data from the incoming request headers. The sentry-trace header is used by Sentry for distributed tracing and contains the trace ID (which identifies the entire trace) and the parent span ID (which identifies the span that created this new span or transaction). This trace data allows Sentry to connect different transactions and spans together, a key aspect of distributed tracing.

Finally, a new transaction is started with Sentry.startTransaction({ ... }). Here, the name of the transaction is set as the HTTP method and the URL of the request, while the op field is set as 'http.server', implying this is a server-side transaction. Any extracted trace data is also included in the transaction, thereby enabling this transaction to be connected with other transactions in the same trace.

const finish = (transaction: Transaction, ctx: ParameterizedContext): void => {
    ctx.res.once('finish', () => {
        setImmediate(() => {
            if (ctx._matchedRoute) {
                const mountPath = ctx.mountPath || ''

                transaction.setName(
                    `${ctx.method} ${mountPath}${ctx._matchedRoute}`
                )
            }
            transaction.setHttpStatus(ctx.status)
            transaction.finish()
        })
    })
}

The finish function is designed to finalize and complete a transaction once a request has been processed.

It starts by listening for the 'finish' event on the response object (ctx.res) from the Koa.js context (ctx). The 'finish' event is emitted when the response has been sent to the client.

When the 'finish' event is triggered, the code inside setImmediate runs. This is used to schedule the execution of the code after all other operations in the Node.js event loop have completed. This ensures that the transaction is not finished before all other request handling operations have been completed.

Inside this callback, there's a check for ctx._matchedRoute. If a route was matched for the request, the transaction's name is updated to include the HTTP method, the mount path, and the matched route. This gives a more descriptive name for the transaction that includes details about which route was hit.

Then, regardless of whether a route was matched or not, the HTTP status code of the response is attached to the transaction with transaction.setHttpStatus(ctx.status). This is important for later analyzing which transactions resulted in errors (like status code 500) or were successful (like status code 200).

The transaction is marked as finished with transaction.finish(). This signals to Sentry that the transaction is complete and can be sent to Sentry's servers for analysis.

Conclusion

Performance monitoring integration with Sentry is primarily founded on three essential components. Firstly, the initialization of Sentry involves configuring it with the precise settings and integrations tailored for your project. Secondly, the request handler plays a vital role as it accumulates information pertinent to every single call. Lastly, tracing forms the crux of the system. It initiates and terminates transactions while accommodating previous transactions from incoming requests originating from other systems.

By orchestrating these components together, we create a robust and effective monitoring system. It provides in-depth insights into how our application behaves in different scenarios, including interactions with external systems. Furthermore, it empowers developers to proactively detect, diagnose, and fix performance issues before they impact the end users. As such, a well-integrated Sentry performance monitoring system is not merely a nice-to-have; it's a game-changer for maintaining high-quality, reliable applications.