2022年2月13日日曜日

ヤバいエラーには早く気づきたい

みなさん、作ったウェブサービスが吐いているログをちゃんと確認していますか?

サービス監視、ログファイルの出力と集約の設定はやった。でもログの監視をしていなくて、ヤバいエラーが出ていたのに気づかずにユーザーから問い合わせが来て初めて知った・・・みたいなことは割とあるあるです。

これをなんとかした話。とは言っても別に画期的な方法とかではありません。

やばいエラーとは?

500系のエラーです。

ただし、ロードバランサーやアプリケーションサーバーが落ちたとかみたいな障害はサービス監視で検知してくれます。

問題となるのはアプリケーションに起因する500系のエラーです。たとえばDBサーバーにSQLを投げたけどSQL自体にエラーがあって例外が発生したとかそういう場合です。

こういうエラーはサーバーダウンのようにどこにアクセスしても発生するようなものではなく、特定のページに特定のパラメーターでアクセスしたときにだけ発生することが多いのでなかなか気づけず、気づいても再現できなかったりします。

どうやって気づく?

今はやっぱSlackでしょ。

何かあったときにSlackのチャンネルに投げれば気づいてくれるはず。

エラーの捕捉方法はフレームワーク次第ですが、だいたいのフレームワークには「未捕捉の例外をキャッチする関数を定義できる場所」があると思うので、そこに定義します。例えばExpress.jsなら最後に指定したミドルウェアが相当します。

どんな情報を出す?

リクエストを再現する必要があるので、リクエスト情報(リクエストメソッド・パス・リクエストヘッダー・リクエストボディー)はすべて必要です。あとは投げられたエラー情報がわかるといいですね。

これらの情報をJSONとかで出してやればよさそうです。

どんなコード?

例えばこんな感じです。

以下のコードは@slack/web-apiモジュールを使っています。事前にトークンSLACK_TOKENと投稿先のチャンネルSLACK_CHANNELを定義しておいてください。そのチャンネルに参加している全員にメンションが届きます。

import {WebClient} from "@slack/web-api";

const SLACK_TOKEN = "...";          // アクセストークン(chat:write, files:writeを許可設定)
const SLACK_CHANNEL = "#app-alert"; // 投稿先チャンネル

// このミドルウェアを最後に指定
function errorHandler(err, req, res, next) {
  const endpoint = `${req.method} ${req.originalUrl}`; // GET /foo/bar みたいなエンドポイント
  const errorInfo = { // エラー情報
    requestHeader: req.headers,
    requestBody: req.body,
    error: err,
  };

  // エラー情報をファイルとしてアップロード
  const webClient = new WebClient(SLACK_TOKEN);
  await webClient.files.upload({
    channels: SLACK_CHANNEL,
    initial_comment: `<!channel>\n${endpoint}`,
    title: "error.json",
    content: JSON.stringify(errInfo, null, 2),
    filetype: "json",
  });
}

もうちょっと改良

これでも一応想定通りには動きますが、ちょっと問題が。

JavaScriptのErrorオブジェクトにはname, messageプロパティーがあるのですが、これらは列挙可能ではないのでJSON.stringify()で取り出せません。

そこで、Errorオブジェクトのときだけ特別に必要な情報を取り出してやりましょう。

import {WebClient} from "@slack/web-api";

const SLACK_TOKEN = "...";          // アクセストークン(chat:write, files:writeを許可設定)
const SLACK_CHANNEL = "#app-alert"; // 投稿先チャンネル

// このミドルウェアを最後に指定
function errorHandler(err, req, res, next) {
  const endpoint = `${req.method} ${req.originalUrl}`; // GET /foo/bar みたいなエンドポイント
  const errorInfo = { // エラー情報
    requestHeader: req.headers,
    requestBody: req.body,
    error: toPlainError(err), // name, message, stackを取り出す
  };

  // エラー情報をファイルとしてアップロード
  const webClient = new WebClient(SLACK_TOKEN);
  await webClient.files.upload({
    channels: SLACK_CHANNEL,
    initial_comment: `<!channel>\n${endpoint}`,
    title: "error.json",
    content: JSON.stringify(errInfo, null, 2),
    filetype: "json",
  });
}

function toPlainError(err) {
  // エラーインスタンスでなければそのまま返す
  if(!(err instanceof Error)) {
    return err;
  }

  // name, messageは必須
  const plainError = {
    name: err.name,
    message: err.message,
  };

  // stackはJS標準じゃないけど、エラー原因の究明に便利なのであればつける
  // https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Error/Stack
  if(err.stack !== undefined) {
    plainError.stack = err.stack;
  }

  // その他のプロパティーがあれば追加
  for(const [key, val] of Object.entries(err)) {
    plainError[key] = val;
  }

  return plainError;
}

コメントにある通り、stackプロパティーはJavaScript標準ではありませんがエラー原因を調べるのに便利なので、もしあればつけています。というか大抵の実装にはあります。

これでめでたしめでたし・・・ではありません。一応動きますが、stackが改行コード付きで1行で出てくるので見づらいです。例えばこんな感じ。

{
  "name": "Error",
  "message": "エラーが発生しました",
  "stack": "Error: エラーが発生しました\n    at REPL1:1:7\n    at Script.runInThisContext (vm.js:134:12)\n    at REPLServer.defaultEval (repl.js:488:29)\n    at bound (domain.js:416:15)\n    at REPLServer.runBound [as eval] (domain.js:427:12)\n    at REPLServer.onLine (repl.js:821:10)\n    at REPLServer.emit (events.js:412:35)\n    at REPLServer.emit (domain.js:470:12)\n    at REPLServer.Interface._onLine (readline.js:364:10)\n    at REPLServer.Interface._line (readline.js:700:8)"
}

改行を含んだ文字列でも見やすいのはYAMLです。というわけでYAML形式で出してみましょう。js-yamlモジュールを追加で使用しています。

import {WebClient} from "@slack/web-api";
import yaml from "js-yaml";

const SLACK_TOKEN = "...";          // アクセストークン(chat:write, files:writeを許可設定)
const SLACK_CHANNEL = "#app-alert"; // 投稿先チャンネル

// このミドルウェアを最後に指定
function errorHandler(err, req, res, next) {
  const endpoint = `${req.method} ${req.originalUrl}`; // GET /foo/bar みたいなエンドポイント
  const errorInfo = { // エラー情報
    requestHeader: req.headers,
    requestBody: req.body,
    error: toPlainError(err), // name, message, stackを取り出す
  };

  // エラー情報をファイルとしてアップロード
  const webClient = new WebClient(SLACK_TOKEN);
  await webClient.files.upload({
    channels: SLACK_CHANNEL,
    initial_comment: `<!channel>\n${endpoint}`,
    title: "error.yml",
    content: YAML.dump(errInfo, {lineWidth: -1}), // ※YAML形式で出力
    filetype: "yaml",
  });
}

function toPlainError(err) {
  // エラーインスタンスでなければそのまま返す
  if(!(err instanceof Error)) {
    return err;
  }

  // name, messageは必須
  const plainError = {
    name: err.name,
    message: err.message,
  };

  // stackはJS標準じゃないけど、エラー原因の究明に便利なのであればつける
  // https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Error/Stack
  if(err.stack !== undefined) {
    plainError.stack = err.stack;
  }

  // その他のプロパティーがあれば追加
  for(const [key, val] of Object.entries(err)) {
    plainError[key] = val;
  }

  return plainError;
}

変わったのは"※YAML形式で出力"というコメントのある行だけです。

これならそのまま改行してくれるし、ダブルクォーテーションもつかないし、いい感じにSlackが色分けしてくれるし、JSONに比べてとても読みやすくなります。

ちなみに{lineWidth:-1}をつけないと、行が長くなったときに中途半端な場所で改行されて読みづらくなります。

一応これで必要十分のはずですが、認証情報もSlackに流れてしまうので気になる方はCookieとかAuthorizationヘッダーをマスク("***"とかの適当な文字列で上書き)したほうがいいかもしれません。逆にマスクしてしまうと、リクエストの完全再現ができないので特定のユーザーでしか発生しないエラーに悩まされるかもしれませんが。

それでは快適なエラーライフを。

0 件のコメント:

コメントを投稿