Setting up error logging for Cloudflare Workers using Sentry

26 Sep 2019


Cloudflare Workers are Cloudflare’s implementation of Serverless Computing. It provides a lightweight execution environment, the equivalent of Chrome’s V8 engine. It is a handy environment for logic that runs frequently but needs to be modified rarely.

A critical gotcha is that race conditions exist when it comes to error logging race conditions can exist. For example, adding Sentry’s Raven SDK in your package.json is not enough since the worker finishes execution and terminates before Sentry’s async process can complete. This Cloudflare blog post has a pretty good explanation of why this happens.

That leaves you with no choice other than to come up with a logger of your own. The article I mentioned above contains a rudimentary logger for Sentry. It does not log many of the finer details of an exception, but it is a good starting point to structure the Worker code to capture exceptions. However, if you want a logger that captures request information and shows the stack trace using source maps, it will need a fair amount of setup to be done.

First, we will need to install the webpack-sentry-plugin. The plugin provides an interface to sentry-cli that will allow us to upload source maps to Sentry. The sentry-cli command requires the user’s auth token from Sentry. You might also want to separate the webpack config for development and production environments so that the plugin does not run in development.

// webpack.prod.js
const webpack = require("webpack");
const SentryCliPlugin = require("@sentry/webpack-plugin");
const path = require("path");

module.exports = {
  entry: "./src/index.js",
  mode: "production",
  optimization: {
    minimize: false
  },
  target: "webworker",
  devtool: "source-map",
  performance: {
    hints: false
  },
  output: {
    path: path.join(__dirname, "/dist"),
    publicPath: "dist",
    filename: "worker.js",
    sourceMapFilename: "worker.js.map"
  },
  plugins: [
    new webpack.DefinePlugin({
      "process.env.RAVEN_DSN": JSON.stringify(process.env.RAVEN_DSN)
    }),
    new SentryCliPlugin({
      include: "./dist",
      ignore: ["node_modules", "webpack.config.js"],
      release: process.env.RELEASE
    })
  ]
};

The output file and the source map file must be called worker.js and worker.map.js respectively because that is what the CF names the scripts that run in the worker. A different name will prevent Sentry from mapping the stack trace to the code. Once webpack is set up, for every webpack build that is run, source maps will be uploaded to Sentry.

Now you can add the Logger and a config file that contains values like project id and apiKey that Sentry requires to post events.

const config = require("./config");

export class Logger {
  constructor() {
    this.projectId = config.sentry["projectId"];
    this.apiKey = config.sentry["apiKey"];
    this.secretKey = config.sentry["secretKey"];
    this.headers = new Headers();
    this.headers.append("User-Agent", "CF/1.0");
    this.url = `https://sentry.io/api/${this.projectId}/store/?sentry_version=7&sentry_key=${
      this.apiKey
    }&sentry_secret=${this.secretKey}`;
  }


  logError(ex, request) {
    const errorType = ex.name;
    const errorMessage = ex.message;
    const frames = this.generateFrames(ex).reverse();
    const data = this.generateBody(request);
    let url = new URL(request.url);
    const headersObj = this.generateHeaders(request);

    let body = {
      project: this.projectId,
      logger: "worker",
      platform: "javascript",
      exception: {
        values: [
          {
            type: errorType,
            value: errorMessage,
            stacktrace: { frames: frames }
          }
        ]
      },
      request: {
        url: request.url,
        method: request.method,
        data: data,
        query_string: url.searchParams.toString(),
        cookies: request.headers.get("cookie"),
        headers: headersObj
      },
      server_name: url.host,
      transaction: url.pathname,
      release: RELEASE_VERSION,
      environment: `${config.sentry["env"]}`
    };

    return fetch(this.url, { body: JSON.stringify(body), method: "POST", headers: this.headers });
  }

  generateHeaders(request) {
    let headersObj = {};
    for (var pair of request.headers.entries()) {
      headersObj[pair[0]] = pair[1];
    }
    return headersObj;
  }

  generateFrames(ex) {
    const lines = ex.stack.split("\n");
    lines.splice(0, 2);
    return lines.map(line => {
      const lineMatch = line.match(/at (?:(.+?)\s+\()?(?:(.+?):(\d+)(?::(\d+))?|([^)]+))\)?/);
      const functionName = lineMatch[1];
      const fileName = lineMatch[2];
      const lineNumber = parseInt(lineMatch[3]);
      const columnNumber = parseInt(lineMatch[4]);
      return {
        filename: fileName ? path.join("~/", fileName.toString()) : fileName,
        function: functionName,
        lineno: lineNumber,
        colno: columnNumber
      };
    });
  }

  generateBody(request) {
    var data = request.body;
    if (["GET", "HEAD"].indexOf(request.method) === -1) {
      if (typeof data === "undefined") {
        data = "<unavailable>";
      }
    }

    if (data && typeof data !== "string" && {}.toString.call(data) !== "[object String]") {
      // Make sure the request body is a string
      data = JSON.stringify(data);
    }

    return data;
  }
}

Once the logger has been added, we are now ready to catch exceptions that occur in the worker.

In the addEventListener method that Workers provide, you can add a try..catch statement that will capture any exception and pass it on to the logger.

addEventListener("fetch", e => {
    try {
        // Worker code goes here
    } catch (e) {
        let logger = new Logger();
        e.waitUntil(logger.logError(ex, e.request));
    }
    e.respondWith(handleRequest(e));
})

This will post any error that occurs to Sentry with headers, cookies and complete stack trace

Previous Post