deno deployのログをslackに送信する

この記事は[Deno アドベントカレンダー2022](https://qiita.com/advent-calendar/2022/deno)の10日目の記事です。

## deno deployのログは永続化されない

deno deployのダッシュボードには、こんな感じのログ閲覧タブがあります。
`console.log`や`console.error`で出力したものがここに流れてきます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/678625/c7591fcd-ddef-6de7-f2c2-551e7c8f33e9.png)

ただし、ドキュメントによると、deno deployで`console.log`しても、**そのログを後から見ることはできない**ようです。

> ## Logs
> Applications can generate logs at runtime using the console API. These logs can be viewed in real time by navigating to the Logs panel of a project or deployment. Logs will be streamed directly from an application to the log panel.
>
> **These logs are not persisted. Only logs that are generated after the logs page is opened can be viewed. After closing the logs page, all streamed logs are discarded.**
https://deno.com/deploy/docs/deployments#logs

> (Google翻訳)
> アプリケーションは、コンソール API を使用して実行時にログを生成できます。Logsこれらのログは、プロジェクトまたはデプロイのパネルに移動してリアルタイムで表示できます。ログは、アプリケーションからログ パネルに直接ストリーミングされます。
>
> **これらのログは保持されません。ログページを開いた後に生成されたログのみを表示できます。ログページを閉じると、ストリーミングされたすべてのログが破棄されます。**


どうやら、

- **deno deployのダッシュボードに出力されるのは、ダッシュボードを開いている間に出力されたログのみ**。
- **過去のログは保持されない**。

という仕様のようです。

## deno deployのログをslackへ通知する

過去ログが取れないのは不便という事で、出力されるログをslackのチャンネルに送信するようにしたいと思います。

ただし、現時点では、deno deployにはログのエクスポート機能や、外部連携機能は存在しないようです。
（参考：https://github.com/denoland/deploy_feedback/issues/39）

そのため、ログを外部サービスに転送するには、自前で`console.log`を上書きしてやるのが手っ取り早そうです。

#### 準備：slackアプリの作成

今回はslackアプリの**Incoming Webhooks**機能を使用してログをslack側で受け取ります。
この機能は、特定のURLにPOSTリクエストを送ると、slackチャンネルにメッセージを送信できるというものです。

まず、https://api.slack.com/apps?new_app=1 へ行き、新しいアプリを作成していきます。

「From scratch」を選択してアプリ名と対象ワークスペースを入力します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/678625/204a5b20-eaf1-3b1a-d422-0e1cf0e2fd9f.png)

その後Incoming Webhooksを選択すると、POST先となるURLが表示されます。このURLを後で使用するのでメモっておいてください。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/678625/1e166be9-86a6-c8a0-d7e6-6717fad5dcc6.png)

#### コードの実装

まず、slackにPOSTするコードを作ります。
先ほどslackから取得したPOST先となるURLを使います。
ここではURLを`SLACK_NOTIFICATION_URL`という名前の環境変数に設定し、読み込んでいます。

（環境変数はdeno deployのダッシュボードから設定できます。）

```ts
// logger.ts

import { assert } from "https://deno.land/std@0.167.0/testing/asserts.ts";

// slack送信用のwebhookのURL
const SLACK_NOTIFICATION_URL = Deno.env.get("SLACK_NOTIFICATION_URL");
// 環境変数が未設定の場合はエラーを出す
assert(
  typeof SLACK_NOTIFICATION_URL === "string",
  "environment variable 'SLACK_NOTIFICATION_URL' should be string.",
);
const url = SLACK_NOTIFICATION_URL;

/** 文字列をslackのチャンネルに送信する */
async function sendMessageToSlack(text: string) {
  await fetch(url, {
    method: "post",
    body: JSON.stringify({ text }),
    headers: {
      "Content-type": "application/json",
    },
  });
}

```

次に、`console.log()`や`console.error()`などを上書きし、slackにメッセージが送信されるようにします。
`log`や`error`、`warn`などのメソッドを愚直に定義してやります。

```ts
// logger.ts　(続き)

// デフォルトのconsole
export const originalConcole = globalThis.console;
// slackに送信されるconsole
export const slackConsole: Console = {
  debug(...args) {
    originalConcole.debug(...args);
    sendMessageToSlack(
      "[debug] " + args.map((v) => Deno.inspect(v)).join(" "),
    );
  },
  info(...args) {
    originalConcole.info(...args);
    sendMessageToSlack("[info] " + args.map((v) => Deno.inspect(v)).join(" "));
  },
  log(...args) {
    originalConcole.log(...args);
    sendMessageToSlack("[log] " + args.map((v) => Deno.inspect(v)).join(" "));
  },
  warn(...args) {
    originalConcole.warn(...args);
    sendMessageToSlack("[warn] " + args.map((v) => Deno.inspect(v)).join(" "));
  },
  error(...args) {
    originalConcole.error(...args);
    sendMessageToSlack(
      "[error] " + args.map((v) => Deno.inspect(v)).join(" "),
    );
  },
  dir(...args) {
    originalConcole.dir(...args);
    sendMessageToSlack("[dir] " + args.map((v) => Deno.inspect(v)).join(" "));
  },
  dirxml(...args) {
    originalConcole.dirxml(...args);
    sendMessageToSlack(
      "[dirxml] " + args.map((v) => Deno.inspect(v)).join(" "),
    );
  },
  table(...args) {
    originalConcole.table(...args);
    sendMessageToSlack(
      "[table] " + args.map((v) => Deno.inspect(v)).join(" "),
    );
  },
  assert(...args) {
    originalConcole.assert(...args);
    sendMessageToSlack("[assert] <unimplemented>");
  },
  time(...args) {
    originalConcole.time(...args);
    sendMessageToSlack("[time] <unimplemented>");
  },
  timeEnd(...args) {
    originalConcole.timeEnd(...args);
    sendMessageToSlack("[timeEnd] <unimplemented>");
  },
  timeStamp(...args) {
    originalConcole.timeStamp(...args);
    sendMessageToSlack("[timeStamp] <unimplemented>");
  },
  timeLog(...args) {
    originalConcole.timeLog(...args);
    sendMessageToSlack("[timeLog] <unimplemented>");
  },
  trace(...args) {
    originalConcole.trace(...args);
    sendMessageToSlack("[trace] <unimplemented>");
  },
  group(...args) {
    originalConcole.group(...args);
    sendMessageToSlack("[group] <unimplemented>");
  },
  groupCollapsed(...args) {
    originalConcole.groupCollapsed(...args);
    sendMessageToSlack("[groupCollapsed] <unimplemented>");
  },
  groupEnd(...args) {
    originalConcole.groupEnd(...args);
    sendMessageToSlack("[groupEnd] <unimplemented>");
  },
  clear(...args) {
    originalConcole.clear(...args);
    sendMessageToSlack("[clear] <unimplemented>");
  },
  count(...args) {
    originalConcole.count(...args);
    sendMessageToSlack("[count] <unimplemented>");
  },
  countReset(...args) {
    originalConcole.countReset(...args);
    sendMessageToSlack("[countReset] <unimplemented>");
  },
  profile(...args) {
    originalConcole.profile(...args);
    sendMessageToSlack("[profile] <unimplemented>");
  },
  profileEnd(...args) {
    originalConcole.profileEnd(...args);
    sendMessageToSlack("[profileEnd] <unimplemented>");
  },
};

// hack: 上で指定した以外のconsoleのメソッドが呼ばれた場合、元のconsoleのメソッドが呼ばれるようにする
Object.setPrototypeOf(slackConsole, originalConcole);

// deno deploy環境で動いている場合は、グローバル変数consoleを上書き
if (Deno.env.get("DENO_DEPLOYMENT_ID")) {
  globalThis.console = slackConsole;
}
```

ローカル開発時にはログをslackに出したくないため、deno deploy環境で動いている時のみconsole変数を上書きするようにします。
deno deploy環境か判別するには`Deno.env.get("DENO_DEPLOYMENT_ID")`の値が設定されているかどうかを見てやればいいです。

最後に、ここまでで作成したファイル（`logger.ts`）を、**エントリポイントのファイルの1番上でimportします。**
（この位置でないと、他のファイルのコードが先に実行されてしまいます。）

```ts:serve.ts
import "./logger.ts" // 1番上の行でimportする!
import { serve } from "https://deno.land/std@0.155.0/http/server.ts";
import {...} from "他のファイル";

serve((req) => new Response("200 OK"));
```

以上の手順を踏むことで、無事deno deployのログをslackに送信することができました！

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/678625/9f28fb14-96dc-3c75-c3c7-b8090ab28437.png)


## まとめ

- deno deployは過去ログを保持しない
- `globalThis.console`を上書きすることで、`console.log`したときにslackにログを流すことができる
- Slackへの送信はIncoming Webhooks機能を使う

#### 余談

実は最初、ログの保存に[Google アナリティクス Reporting API v4](https://developers.google.com/analytics/devguides/reporting/core/v4?hl=ja)を使おうとしていたのですが、3時間くらい試行錯誤しても認証が上手くいかなかったのであきらめました。
その後slackへ送信するように方針を切り替えたところ5分くらいでセットアップが完了して神でした。

（Denoからgoogle apiを叩くときは https://googleapis.deno.dev/ & https://github.com/lucacasonato/deno_googleapis というものがあるので誰かチャレンジしてみてください）