Datadogのアプリログをスプレッドシートで分析しやすくした話

先月、弊社シクミヤでは資本政策データベースサービス「shihon」の有償招待制β版をリリースしました。

https://www.shihon.com/

フロントエンドはSPA、バックエンドはWebAPIで構成しています。バックエンドはAWSにサーバーを立てているのですが、アプリログをAWSからDatadogに連携してモニタリングしています。

https://www.datadoghq.com/ja/

shihonには4種類のリアルタイム検索画面があります。運営する中で「**ユーザーがどういったキーワードで検索しているのかを分析したい**」という要望が出ました。そこで、検索画面のAPIに対して、検索キーワードをアプリログへと出力するよう実装しました。

しかしながら、実際にDatadogでログを閲覧しようとすると、以下の図のように**ログの一覧で検索画面からの出力を抽出し、さらにひとつひとつの詳細を開いて確認**していく必要がありました。

一覧: CONTENTで検索画面のログを絞っている状態

<img width="800" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/581277/99a15d56-da1f-ac58-90d3-41a36a8bca1e.png">

詳細: 赤枠が取得したいキーワード部分

<img width="500" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/581277/4a1e7814-e290-9289-4465-63b4c8359c0a.png">

さらに検索画面ではリアルタイム検索を採用しているため、入力途中のログも出力されてしまいます。**これではかなり拾うのが大変なため、一覧形式で閲覧できないかと考えた**のが本記事の方法です。

# 実装

ログの取得はDatadog API経由で行い、APIからのデータ取得とスプレッドシートへ書き込むのはGAS(Google Apps Script)で行います。

## Datadog APIでのログ取得

まずはDatadog APIでログを取得します。下記リンクのSearch logsのエンドポイントを使います。

https://docs.datadoghq.com/ja/api/latest/logs/?code-lang=curl#search-logs

こちらのAPIドキュメントを参考にログの取得を実装します。

## スプレッドシートへの書き込み

スプレッドシートを作成し「ログ」シートを用意します。

出力日時、ユーザーID、検索キーワード、画面名の項目を設けています。

<img width="800" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/581277/813100cb-64c2-e316-4a45-3b9f9f56881e.png">

## GASのコード

まずは検索処理のメイン部分です。Datadog APIからデータを取得し、スプレッドシートに書き出しています。

今回の処理では1時間毎に実行されるようトリガーを仕込んでおり、実行時の1時間前からのログを取得するような処理となっています。例）13:40に実行 -> 12:00〜13:00の範囲

```javascript:検索処理のメイン部分
/**
 * 検索キーワードのログを取得しスプレッドシートへ書き出す
 */
function searchLogs() {
  const ddClientFactory = new DatadogClientFactory()
  const ddClient = ddClientFactory.create(HOST, DD_API_KEY, DD_APPLICATION_KEY)

  // 検索クエリ
  const qb = new DatadogSearchQueryBuilder()
  qb.append(SHIHON_SCREEN_INFO.GLOBAL_SEARCH.KEYWORD)
  qb.or(SHIHON_SCREEN_INFO.EVENT_SEARCH.KEYWORD)
  qb.or(SHIHON_SCREEN_INFO.COMPANY_SEARCH.KEYWORD)
  qb.or(SHIHON_SCREEN_INFO.PERSON_SEARCH.KEYWORD)
  const query = qb.toString()
  console.log(query)

  // 検索日付範囲
  const now = new Date()
  const momentInitArray = [now.getFullYear(), now.getMonth(), now.getDate(), now.getHours(), 0, 0, 0]
  const from = Moment.moment(momentInitArray).add(-1, 'h').format()
  const to = Moment.moment(momentInitArray).format()
  console.log(from, to)

  // 該当のログを取得
  const logs = ddClient.searchLogs(query, from, to)

  // スプレッドシートに書き出す
  const ss = SpreadsheetApp.getActiveSpreadsheet()
  const sheet = ss.getSheetByName('ログ')
  for (const entity of logs) {
    sheet.appendRow(entity.toArray())
  }
}
```

以下、検索処理のメイン部分で使用する部品です。
Datadog APIへアクセスするためのホスト名、APIキー、アプリケーションキーはDatadogの設定画面から取得します。

```javascript:メイン部分で使用する部品
/**
 * 画面毎のキーワードと画面名の定数
 */
const SHIHON_SCREEN_INFO = {
  'GLOBAL_SEARCH': {
    'KEYWORD': 'global search with keyword',
    'NAME': 'グローバル検索'
  },
  'EVENT_SEARCH': {
    'KEYWORD': 'search events with keyword',
    'NAME': '資本取引一覧'
  },
  'COMPANY_SEARCH': {
    'KEYWORD': 'search companies with keyword',
    'NAME': '企業一覧',
  },
  'PERSON_SEARCH': {
    'KEYWORD': 'search people with keyword',
    'NAME': '個人一覧'
  }
}

/**
 * SHIHON_SCREEN_INFO用のクラス
 */
class ShihonScreenInfo {
  static name(keyword) {
    for (const key in SHIHON_SCREEN_INFO) {
      if (SHIHON_SCREEN_INFO[key]['KEYWORD'] == keyword) {
        return SHIHON_SCREEN_INFO[key]['NAME']
      }
    }
    return false
  }
}


// Datadogクライアントで使用する定数(Datadogから取得)
const HOST = 'YOUR HOST NAME'
const DD_API_KEY = 'YOUR API KEY'
const DD_APPLICATION_KEY = 'YOUR APPLICATION KEY'

/**
 * Datadogクライアントのファクトリークラス
 */
class DatadogClientFactory {
  constructor(isDebug=false) {
    this.isDebug = isDebug
  }
  /**
   * Datadogクライアントのインスタンスを生成して取得
   */
  create(host, apiKey, applicationKey) {
    return new DatadogClient(host, apiKey, applicationKey, this.isDebug)
  }
}

/**
 * Datadogクライアント
 */
class DatadogClient {
  constructor(host, apiKey, applicationKey, isDebug) {
    this.host = host
    this.apiKey = apiKey
    this.applicationKey = applicationKey
    this.isDebug = isDebug
  }

  /**
   * URL作成
   */
  makeUrl(appendUrl) {
    return 'https://'+ this.host +'/api' + appendUrl
  }

  /**
   * HTTPリクエストヘッダー作成
   */
  makeHeaders() {
    return {
      'Accept': 'application/json',
      'DD-API-KEY': this.apiKey,
      'DD-APPLICATION-KEY': this.applicationKey
    }
  }

  /**
   * デバッグ用
   */
  debug(msg) {
    if (this.isDebug) {
      console.log(msg)
    }
  }

  /**
   * ログ検索
   */
  searchLogs(query, from, to) {
    let hasNext, pageAfter
    const logs = []

    do {
      const url = this.makeUrl('/v2/logs/events/search')
      const headers = this.makeHeaders()
      const payload = {
        filter: {
          query: query,
          indexes: [
            "main"
          ],
          from: from,
          to: to,
        },
        sort: "timestamp",
        page: {
          "limit": 1000
        }
      }
      // 次ページがある場合は次ページのキーを追加
      if (pageAfter) {
        payload.page.cursor = pageAfter
      }

      const params = {
        'method': 'POST',
        'headers': headers,
        'contentType': 'application/json',
        'payload' : JSON.stringify(payload)
      }
      const response = UrlFetchApp.fetch(url, params)
      
      // 結果から必要な情報を抽出
      const json = JSON.parse(response.getContentText())
      this.debug(json)
      for(const data of json.data) {
        this.debug(data.attributes)
        logs.push(new DatadogSearchLogEntity(data.attributes))
      }

      // 次のページがあるか判定
      if ('links' in json) {
        hasNext = true
        pageAfter = json.meta.page.after
        Utilities.sleep(10000)
      } else {
        hasNext = false
      }
    } while (hasNext)

    return logs
  }
}

/**
 * Datadogの検索クエリ文字列を構築するビルダークラス
 */
class DatadogSearchQueryBuilder {
  constructor() {
    this.query = ''
  }

  /**
   * キーワード文字列を追加
   */
  append(keyword) {
    this.query += this.escape(keyword)
  }

  /**
   * ORで文字列を接続
   */
  or(keyword) {
    if (this.query !== '') {
      this.query += ' OR '
    }
    this.query += this.escape(keyword)
  }

  /**
   * エスケープ処理
   */
  escape(str) {
    return str.replaceAll(' ', '\ ')
  }

  /**
   * 構築した文字列を返却
   */
  toString() {
    return this.query
  }
}

/**
 * Datadogログ検索のエンティティクラス
 */
class DatadogSearchLogEntity {
  /**
   * コンストラクタ
   * @param {Object} data - data.attributesを渡す
   */
  constructor(data) {
    this.data = data

    this.keyword = data.attributes.Keyword
    this.userId = data.attributes.UserID
    this.time = new Date(data.attributes.time)
    this.content = data.message
    this.screenName = ShihonScreenInfo.name(this.content)
  }

  /**
   * スプレッドシート書き出し用にプロパティを配列で取得
   */
  toArray() {
    return [this.time, this.userId, this.keyword, this.screenName]
  }
}
```

# おわりに

shihonをリリースして以降、様々な課題に直面していますが、WebAPIを提供しているサービスを選定しているため、こういった仕組みを作りやすくなっていると感じています。

本記事が皆様のサービス運営の一助になれば幸いです。