クライアントサイドからGoogle Cloud Loggingにログを書き出す

## 概要

クライアントサイド (Webブラウザ) から、Google Cloudのログ基盤である[Cloud Logging](https://cloud.google.com/logging?hl=ja)にログを書き出す方法について記載します。

昨今のWebシステムではクライアントサイドで複雑な処理を実装することが多く、クライアントサイドで発生した障害の把握や調査が難しくなっています。サーバ側にログを送ることによってこれらが可能になります。

Webブラウザ上で実行されるJavaScriptコードから直接Cloud Loggingに書き出すことはできないため、この記事では[Firebase Functions](https://firebase.google.com/docs/functions?hl=ja)を仲介として使用しています。

```mermaid
flowchart LR
   WB["Webブラウザ\n(JavaScript)"] --> FF[Firebase Functions]
   FF --> CL[Cloud Logging]
```

## Firebase Functions

※ Firebaseプロジェクトの作成方法やFunctionsのデプロイ方法についての説明は割愛します。

ログをCloud Loggingに書き出す関数を作成します。  
言語は任意ですが、以下の例ではnode (TypeScript) を使用しています。

### Cloud LoggingのクライアントSDKをインストール

```
npm install @google-cloud/logging
```

### 関数定義

```index.ts
import * as admin from 'firebase-admin'
import * as functions from 'firebase-functions'
import { Logging } from '@google-cloud/logging'

admin.initializeApp()

export const writeLog = functions
  .region('asia-northeast1')
  .runWith({
    timeoutSeconds: 10,
    memory: '128MB',
    maxInstances: 3,
  })
  .https.onCall(async ({ name, severity, payload }) => {
    const logging = new Logging({
      projectId: process.env.GCP_PROJECT || process.env.GCLOUD_PROJECT,
    })
    const log = logging.log(name)

    const entry = log.entry(
      {
        severity,
      },
      payload
    )

    await log.write(entry)
  })
```

ここでは、`writeLog`という関数を定義しています。

`maxInstances`の指定によって、意図しない量の書き込みによってサーバコストが増大してしまうことを防止します。

パラメータとして、以下の3つが渡されてきます。

* `name`: 任意のログ名 (ログ閲覧時の絞り込みに使う)
* `severity`:  [ログレベル (重大度)](https://cloud.google.com/logging/docs/reference/v2/rest/v2/LogEntry#logseverity)
* `payload`: ログの内容 (任意のオブジェクト)



## クライアントサイド

### Firebase SDKのインストール

```
npm install firebase
```

### Firebaseクライアント初期化処理

```ts
import { initializeApp } from 'firebase/app'

initializeApp({
  // FirebaseのプロジェクトID
  projectId: YOUR_PROJECT_ID,
  // FirebaseのウェブAPIキー
  apiKey: YOUR_API_KEY,
})
```

### ロガーモジュール

※ 以下はFirebase SDK V9の例です。(V8以前とは若干異なります)

```logger.ts
import { getFunctions, httpsCallable } from 'firebase/functions'

const functions = getFunctions()
functions.region = 'asia-northeast1'
const sendLog = httpsCallable(functions, 'writeLog')

const putLog = async (
  position: string,
  severity: 'DEBUG' | 'INFO' | 'WARNING' | 'ERROR',
  message: unknown,
  error?: unknown,
) => {
  const payload: { [index: string]: unknown } = {
    message,
    severity,
    url: location.href,
    position,
    userId: localStorage.getItem('userId'),
    userAgent: navigator.userAgent,
  }

  if (error) {
    if (error instanceof Error) {
      payload.error = error.stack ?? `${error.name}: ${error.message}`
    } else {
      payload.error = String(error)
    }
  }

  sendLog({
    name: 'web_browser',
    severity,
    payload,
  })
}

export default {
  // debugレベルのログを出力する
  debug: (
    position: string,
    message: unknown,
    error?: unknown,
  ) => {
    return putLog(position, 'DEBUG', message, error)
  },
  // infoレベルのログを出力する
  info: (
    position: string,
    message:unknown,
    error?: unknown,
  ) => {
    return putLog(position, 'INFO', message, error)
  },
  // warnレベルのログを出力する
  warn: (
    position: string,
    message: unknown,
    error?: unknown,
  ) => {
    return putLog(position, 'WARNING', message, error)
  },
  // errorレベルのログを出力する
  error: (
    position: string,
    message: unknown,
    error?: unknown,
  ) => {
    return putLog(position, 'ERROR', message, error)
  },
}
```

ログ出力内容は任意ですが、ここでは次の情報を`payload`に渡しています。

* `message`: ログメッセージ (文字列またはオブジェクト)
* `url`: ブラウザが現在表示しているURL
* `position`: ログ出力箇所を出力している情報 (ソースファイル名、メソッド名など)
* `error`: エラー情報 (Errorオブジェクトなど)
* `userId`: ユーザID
* `userAgent`: ブラウザのユーザエージェント

### 使用例

```ts
import logger from './logger'

localStorage.setItem('userId', 'sample')
test()

function test() {
  try {
    // messageに文字列
    logger.debug('app.ts|test', 'ああああ')
  
    // messageにオブジェクト
    logger.info('app.ts|test', {
      foo: 'あいうえお',
      bar: 12345,
    })
  
    throw Error('xxxx')
  } catch (err) {
    // errorにErrorオブジェクト
    logger.error('app.ts|test', '○○○失敗', err)
  }
}
```


## ログの閲覧

GCP管理コンソールの「ログ エクスプローラ」で、書き出されたログを確認することができます。(反映されるまで、数十秒程度のタイムラグがあります)

![2023-06-07_18h41_32.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/27823/47136ad0-79b3-85b6-05e5-e2457a96768b.png)

Cloud Loggingでは、GCP全体のログが集められています。ログ名 (この記事の例では`web_browser`としている) や、重大度 (severity) などによって絞り込むことができます。


## 注意

### コスト

FunctionsやCloud Loggingには無料枠がありますが、一定量を超えると使用量が課せられます。特にBtoC系のシステムでは想定外の量が書き込まれる可能性があるため注意が必要です。

Cloud Loggingより[BigQuery](https://cloud.google.com/bigquery?hl=ja)の方が安価であるため、BigQueryを使う方がコストが抑えられます。(Functionsのコードの書き方はほぼ同じですが、BigQueryにテーブル定義などが必要になります）

また、状況によってはFunctionsの代わりに[Cloud Run](https://cloud.google.com/run?hl=ja)でRESTful APIを実装した方が安くなる場合もあります。

### プライバシー

不用意にユーザの個人情報やクライアント環境の情報を書き出さないようにしましょう。特に外国向けにサービスを提供する場合には、EU一般データ保護規則（GDPR) などに違反しないように注意してください。