先月、弊社シクミヤでは資本政策データベースサービス「shihon」の有償招待制β版をリリースしました。
フロントエンドはSPA、バックエンドはWebAPIで構成しています。バックエンドはAWSにサーバーを立てているのですが、アプリログをAWSからDatadogに連携してモニタリングしています。
shihonには4種類のリアルタイム検索画面があります。運営する中で「ユーザーがどういったキーワードで検索しているのかを分析したい」という要望が出ました。そこで、検索画面のAPIに対して、検索キーワードをアプリログへと出力するよう実装しました。
しかしながら、実際にDatadogでログを閲覧しようとすると、以下の図のようにログの一覧で検索画面からの出力を抽出し、さらにひとつひとつの詳細を開いて確認していく必要がありました。
一覧: CONTENTで検索画面のログを絞っている状態
詳細: 赤枠が取得したいキーワード部分
さらに検索画面ではリアルタイム検索を採用しているため、入力途中のログも出力されてしまいます。これではかなり拾うのが大変なため、一覧形式で閲覧できないかと考えたのが本記事の方法です。
実装
ログの取得はDatadog API経由で行い、APIからのデータ取得とスプレッドシートへ書き込むのはGAS(Google Apps Script)で行います。
Datadog APIでのログ取得
まずはDatadog APIでログを取得します。下記リンクのSearch logsのエンドポイントを使います。
こちらのAPIドキュメントを参考にログの取得を実装します。
スプレッドシートへの書き込み
スプレッドシートを作成し「ログ」シートを用意します。
出力日時、ユーザーID、検索キーワード、画面名の項目を設けています。
GASのコード
まずは検索処理のメイン部分です。Datadog APIからデータを取得し、スプレッドシートに書き出しています。
今回の処理では1時間毎に実行されるようトリガーを仕込んでおり、実行時の1時間前からのログを取得するような処理となっています。例)13:40に実行 -> 12:00〜13:00の範囲
/**
* 検索キーワードのログを取得しスプレッドシートへ書き出す
*/
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の設定画面から取得します。
/**
* 画面毎のキーワードと画面名の定数
*/
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を提供しているサービスを選定しているため、こういった仕組みを作りやすくなっていると感じています。
本記事が皆様のサービス運営の一助になれば幸いです。