実務でのテーブル結合時のケア(重複排除など)について

こんにちは、TVerでデータ分析をしている高橋です。
弊社の分析業務の多くは BigQuery に蓄積されているログを使った分析で、大量のログを扱うため前処理から集計まで全てSQLで行っています。
本記事では、SQLを書く上で特に気を付けているテーブル結合時のケアについて紹介します。

分析業務の一例

「ホーム画面を開いてから10分以内にコンテンツを再生した割合を知りたい」という依頼が来ました1
この集計は訪問ログ視聴ログを使い、ホーム画面に訪問したログを10分以内に再生した or 再生してないの2種類に分ければできそうです。
ここで、集計に用いるテーブルを簡単に紹介します。

訪問ログ (view_logs)

ホーム、マイページ、番組ページ、エピソードページなどに訪問したタイミングで発報されるログです。
ユーザー毎に時系列順に並べることで、サービス内でのページ遷移が分かります。

-- view_logs サンプルデータ
SELECT TIMESTAMP("2024-03-01 19:30:00") AS viewed_at, "hoge" AS user_id, "/home" AS url, 
UNION ALL SELECT TIMESTAMP("2024-03-01 19:32:00"), "hoge", "/mypage/fav"
UNION ALL SELECT TIMESTAMP("2024-03-01 19:35:00"), "hoge", "/episodes"
UNION ALL SELECT TIMESTAMP("2024-03-01 21:45:00"), "fuga", "/home"
UNION ALL SELECT TIMESTAMP("2024-03-01 22:25:00"), "piyo", "/home"
UNION ALL SELECT TIMESTAMP("2024-03-01 22:30:00"), "hogera", "/home"
UNION ALL SELECT TIMESTAMP("2024-03-01 22:32:00"), "hogera", "/search"
UNION ALL SELECT TIMESTAMP("2024-03-01 22:34:00"), "hogera", "/home"
viewed_at user_id url
2024-03-01 19:30:00 hoge /home
2024-03-01 19:32:00 hoge /mypage/fav
2024-03-01 19:35:00 hoge /episodes
2024-03-01 21:45:00 fuga /home
2024-03-01 22:25:00 piyo /home
2024-03-01 22:30:00 hogera /home
2024-03-01 22:32:00 hogera /search
2024-03-01 22:34:00 hogera /home

視聴ログ (play_logs)

視聴中の行動が記録されているログです。これを見るとシークバー移動のタイミングなどが分かります。
今回は視聴開始した時刻の情報だけ使用します。

-- サンプルデータ
SELECT TIMESTAMP("2024-03-01 19:35:30") AS begin_at, "hoge" AS user_id, "begin" AS action,
UNION ALL SELECT TIMESTAMP("2024-03-01 22:26:00"), "piyo", "begin",
UNION ALL SELECT TIMESTAMP("2024-03-01 22:27:00"), "piyo", "begin",
UNION ALL SELECT TIMESTAMP("2024-03-01 22:35:00"), "hogera", "begin"
begin_at user_id action
2024-03-01 19:35:30 hoge begin
2024-03-01 22:26:00 piyo begin
2024-03-01 22:27:00 piyo begin
2024-03-01 22:35:00 hogera begin

集計用クエリを書いてみる

今回の集計を行うには、どのようなクエリを書けばよいでしょうか?
素朴にやるなら、

  1. 訪問ログからホーム画面(/home)のログを抽出する
  2. 1に視聴ログを LEFT JOIN する

でしょうか。書いてみましょう。

SELECT
    view_logs.*,
    play_logs.begin_at,
    play_logs.begin_at IS NOT NULL AS has_played,
FROM (
    SELECT
        *
    FROM
        view_logs
    WHERE
        url = "/home"
) AS view_logs
LEFT OUTER JOIN
    play_logs
ON
    view_logs.user_id = play_logs.user_id
    -- 訪問後 10 分以内に再生してるか
    AND play_logs.begin_at BETWEEN view_logs.viewed_at AND TIMESTAMP_ADD(view_logs.viewed_at, INTERVAL 10 MINUTE)
ORDER BY
    viewed_at
viewed_at user_id url begin_at has_played
2024-03-01 19:30:00 hoge /home 2024-03-01 19:35:30 true
2024-03-01 21:45:00 fuga /home false
2024-03-01 22:25:00 piyo /home 2024-03-01 22:26:00 true
2024-03-01 22:25:00 piyo /home 2024-03-01 22:27:00 true
2024-03-01 22:30:00 hogera /home 2024-03-01 22:35:00 true
2024-03-01 22:34:00 hogera /home 2024-03-01 22:35:00 true

一見良さそうに見えますが、以下の問題があります。

  • user_id=piyo の 1回の/home訪問に2回の再生が紐づいている(JOIN によってレコードが増えた、いわゆる重複)
    • 分母となるホーム画面の訪問数が増えてしまう
  • user_id=hogera の2回の/home訪問に1回の再生が紐づいている
    • 時系列で考えると再生に直接寄与したのは2回目の/home訪問だと考えられるが、1回目の/home訪問も再生に寄与したと見なされてしまう

このまま集計すると間違った示唆を与えてしまうおそれがあります。
順番に解決していきましょう。

1回の訪問に2回の再生が紐づいているケース

このケースは短時間で複数回再生した場合に発生します(ザッピング的な再生など)。
今回の集計で興味があるのは10分以内の再生有無だけなので、/home訪問後最初の視聴が紐づくログだけ残すようにしましょう。
この処理は以下のようなQUALIFY句によって実現することができます。

QUALIFY
  -- 最初の視聴だけ残す
    ROW_NUMBER() OVER(PARTITION BY user_id, viewed_at ORDER BY begin_at ASC) = 1

ちなみに viewed_at, user_id, url をキーとしてGROUP BYしても同様の処理が可能です。
個人的にはSELECT文を変更する必要がない QUALIFY句を使用することが多いです。

2回の訪問に1回の再生が紐づいているケース

短時間で回遊したのちに再生した場合などでしばしば発生します。
このケースは視聴ログの突合条件に次の/home訪問までに再生しているかという条件を追加することで排除できそうです。
はじめに、view_logsに次の/home訪問した時刻の列を追加します。

SELECT
    *,
    -- 次の /home 訪問時刻
    LEAD(viewed_at, 1) OVER(PARTITION BY user_id ORDER BY viewed_at) AS next_viewed_at,
FROM
    view_logs
WHERE
    url = "/home"

この列を使い、以下のような処理を突合部分に追加します。

-- 次の /home 訪問までに再生してるか (次の /home 訪問がなければ無視)
AND IF(view_logs.next_viewed_at IS NOT NULL, play_logs.begin_at < view_logs.next_viewed_at, TRUE)

改良版クエリ

最終的にこのようになりました。

SELECT
    view_logs.* EXCEPT(next_viewed_at),
    play_logs.begin_at,
    play_logs.begin_at IS NOT NULL AS has_played,
FROM (
    SELECT
        *,
        -- 次の /home 訪問時刻
        LEAD(viewed_at, 1) OVER(PARTITION BY user_id ORDER BY viewed_at) AS next_viewed_at,
    FROM
        view_logs
    WHERE
        url = "/home"
) AS view_logs
LEFT OUTER JOIN
    play_logs
ON
    view_logs.user_id = play_logs.user_id
    -- 訪問後 10 分以内に再生してるか
    AND play_logs.begin_at BETWEEN view_logs.viewed_at AND TIMESTAMP_ADD(view_logs.viewed_at, INTERVAL 10 MINUTE)
    -- 次の /home 訪問までに再生してるか (次の /home 訪問がなければ無視)
    AND IF(view_logs.next_viewed_at IS NOT NULL, play_logs.begin_at < view_logs.next_viewed_at, TRUE)
-- 最初の視聴だけ残す
QUALIFY
    ROW_NUMBER() OVER(PARTITION BY user_id, viewed_at ORDER BY begin_at ASC) = 1
ORDER BY
    viewed_at
viewed_at user_id url begin_at has_played
2024-03-01 19:30:00 hoge /home 2024-03-01 19:35:30 true
2024-03-01 21:45:00 fuga /home false
2024-03-01 22:25:00 piyo /home 2024-03-01 22:26:00 true
2024-03-01 22:30:00 hogera /home false
2024-03-01 22:34:00 hogera /home 2024-03-01 22:35:00 true

viewed_at, begin_at どちらも一意化することができました。
このCTE を GROUP BYすることで目的の集計をすることができます。

最後に

テーブル結合時のケアについて2つの事例を紹介しましたが、これらの事象の発生を集計値だけ見て気付くことは非常に難しいです。
そのためレビューの際にはクエリのロジックを確認することは勿論のこと、中間テーブルの出力を確認したり個票チェックをしたりすることで集計ロジックの確からしさを検証しています。
同時に「レビューしやすいクエリ」を書くために、ロジックを考えたり社内ルール整備をしたりなどを日々行っています。この取り組みは分析チームが一丸となり、相当な力を入れて取り組んでいます2

採用のこと

TVerでは一緒に分析業務をしてくれる方を募集しています。
カジュアル面談も受け付けていますので、「こういう取り組みのことをもっと知りたい」「普段どんな分析してるか知りたい」と思った方は以下よりお気軽にご連絡ください。お待ちしております!

herp.careers


1: 一例なのでかなり大味な依頼になっており、この集計結果を受けて具体的なアクションを行うことが難しいと予想されます。実際にこのような依頼が来た場合は、ホーム画面から再生までのユーザーの振る舞いや遷移についての仮説出しをしてからクエリを作成することが望ましいです。

2: テックブログにておいおい紹介する予定です。

AWS LambdaとSlackを連携してツールを作った話

こんにちは。
アドテク領域のエンジニアをしています安部です。
こちらは TVer Advent Calendar 2023 の14日目の記事です。

13日目の記事で「ツールを作成した」という話をちらっと書きました。
今回はそのツールについて備忘として書きます。
ツールは作成時は半自動状態(起動トリガーが手動)、12月に全自動化となりました。

ツールを作ったきっかけ

4月には広告入稿システムのリプレイスがありました。
このシステムと対向システムでデータの同期しているのですが、データに差分が出ていないか確認する必要があります。
そのため対向システムから不整合が発生している可能性のあるデータを連携していただき、確認するという定常業務がありました。
不整合の可能性があるデータは平均20件ほどあり、毎日手動で確認することは現実的ではありませんでした。
どうにか自動化できないかとツールを作成することにしました。

ツール作成時の条件

  1. 連携されるデータはテキストデータとして受領する(Slackでメッセージの受信ができない)
  2. 対向システム側に対応をお願いすることはできない(S3に直接アップロードしてください、特定の形式のファイルでくださいなど)
  3. 項目が多すぎると全て連携されないことがある(途中で途切れたメッセージになる)

1と2がネックとなりデータを自動でLambdaへ取り込むことができませんでした。
そのため

  • データを取得し.mdファイルを作成してS3に置く
    →手動
  • ファイルを読み込み、データを突合し、その結果をSlackの特定のチャンネルに投稿
    →Lambdaで自動化

という形式の半自動化ツールを作成しました。

なぜAWS、Lambdaを選んだのか

  • 広告入稿システムが乗っているAWSと同じ場所に作ることで自分以外の人もメンテナンスできる
  • RDSとの連携が容易
  • Pythonでさくっと書きたい
    の3点が主な理由です。

システム構成図

・半自動

・全自動

ツールの詳細

①S3のバケットからファイルを取得

ライブラリ(boto3)をありがたく活用しました。

s3_client = boto3.client('s3')
# ファイルを読み込み、使用できる状態にする
response = s3_client.get_object(Bucket=MD_BUCKET_NAME, Key=md_key_name)
lines = response['Body'].readlines()

SQLの作成

ファイルを1行ずつ読み込みながらRDSへ投げるSQLを作成します。

③RDS接続・確認

RDSの接続情報についてはSecretsManagerに設定されているので、SecretsManagerから情報を取得しRDSへ接続します。
 SecretsManagerからの取得はこんな感じ
 REGION_NAMEにはリージョン名を、SECRETE_NAMEにはシークレットの名前を入れます。
シークレットキー(DB_USER_NAME,DB_USER_PASSWORD)を指定することでシークレットの値を取得することができます。

# Create a Secrets Manager client
session = boto3.session.Session()
client = session.client(
  service_name='secretsmanager',
  region_name=REGION_NAME
)

try:
  get_secret_value_response = client.get_secret_value(
    SecretId=SECRETE_NAME
  )
  except ClientError as e:
    raise e

# Decrypts secret using the associated KMS key.
secret_data = get_secret_value_response['SecretString']
secret = ast.literal_eval(secret_data)
# 接続情報設定
db_user = secret['DB_USER_NAME']
db_pass = secret['DB_USER_PASSWORD']

この接続情報を用いてRDSへ接続します。 RDS接続の際はVPC設定が必要です。
こちらを参考に作成しました。
SQLの結果で不整合データの有り無しを確認します。

④Slackへ結果を送信

Incoming Webhook URLを使用してSlackへ結果をポストします。
他の投稿と差別化したかったので引用マークが付くように設定しています。

 # SlackのwebhookURL
WEB_HOOK_URL = 'WEBHOOK_URL'
# alert-adcas-app-error宛
CHANNEL_ID = 'CHANNEL_ID' 

# Slack投稿の情報
send_data = {
  'channel': CHANNEL_ID,
  'username': 'CHECK_TOOL',
  'icon_emoji': ':beer:',
  'attachments':[{        #投稿の引用マーク部分の設定
    'color': '#ffb6c1',
    'text': '投稿テストです。', 
  }],
}
send_text = json.dumps(send_data)
request = urllib.request.Request(
  WEB_HOOK_URL, 
  data=send_text.encode('utf-8'), 
  method="POST"
)
with urllib.request.urlopen(request) as response:
  response_body = response.read().decode('utf-8')

こんな感じで投稿されます。

⑤起動トリガーの設定

最後に起動トリガーを設定します。
S3にファイルを置いたタイミングで起動してほしいので、トリガーにS3を設定します。
関数の概要部分の「+トリガーを追加」からS3を選択し、ファイルを置くバケットを選択します。
ファイルの配置と同時になのでEvent typesはPUT(一応POSTも)、アップロードするファイルサイズが大きくなるとマルチパートになるらしいので、念のためMultipart upload completedも設定します。
Suffixに「.md」が設定してあるのは、mdファイルを読み込むという条件にしているためです。

これでファイルを配置と同時にツールが実行されるようになりました。

ファイルアップロードが手動というのがもったいないですが、しばらくこれで運用していきます。

全自動化

12月、ついにデータがSlackのメッセージで受信できるようになりました。
これで取り込みが自動化できます。

半自動化している部分は活用したいので、メッセージを取得しS3にファイルを配置するという関数を作成します。

①Slack Appの準備

Slack Appの作成方法は多くの方が書いていると思うのでそこに譲りまして、メッセージ取得・送信に必要なOAuthの設定だけご紹介します。
今回はBotとして使用するためBot Token Scopesに必要なScopeを追加します。
※画像の黒線部分はSlack Appのアプリ名が表示されています。 - groups:history
取得したいメッセージがプライベートチャンネルに投稿される場合の設定です。
パブリックチャンネルの場合は「channels:history」を追加します。 - chat:write
メッセージを投稿するための設定です。
- chat:write.customize
メッセージ投稿をする際にユーザー名やユーザーのアイコンを自由に設定できるようにするための設定です。
カスタマイズしない場合は「Basic Information」の「Display Information」で設定しているApp Nameとアイコンが表示されます。

②メッセージの取得

conversations.history を使用して取得します。
こんな感じ

def get_slack_message():
  SLACK_BOT_TOKEN = os.environ['SLACK_BOT_TOKEN']
  CHANNEL_ID = os.environ['CHANNEL_ID']
  slackGetMessageUrl = 'https://slack.com/api/conversations.history'
  
  dt_today = datetime.combine(date.today(),time(0,0,0))
  d_ut = datetime.timestamp(dt_today)
  data = {
    'channel': CHANNEL_ID,
    'oldest': d_ut
  }
  post_data = urllib.parse.urlencode(data)
  req = urllib.request.Request(slackGetMessageUrl, 
  data=post_data.encode())
  req.add_header('Authorization','Bearer ' + SLACK_BOT_TOKEN)
  with urllib.request.urlopen(req, timeout=1) as response:
      response_data = json.loads(response.read())
  print(response_data)

トークンとメッセージを取得したいチャンネルのチャンネルIDはlambdaの環境変数に設定してそこから取得しています。
conversations.history には色々オプションを設定できますが、実行当日のメッセージのみ取得したいのでoldestに当日の0:00のunixtimeを設定しています。
オプションについてはドキュメントに書いてあるので必要に応じて追加します。

api.slack.com

確認できるようにprintでメッセージを表示するようにしています。

③S3へ配置

取得したメッセージをこねこねして元のツールが読み取れるファイルを作成し、S3へ配置します。
実行ロールにS3のput権限をつけることをお忘れなく!
(設定タブ > アクセス権限 > ロール名を押すとIAMに飛ぶので許可を追加できます)

expost_data = '';
for line_data in reversed(response_data['messages']):
  #こねこねしてexport_dataに1行ずつ追加
  expost_data += line_data['text'];

S3_CLIENT = boto3.client('s3')
response = S3_CLIENT.put_object(
  Body=expost_data,
  Bucket=MD_BUCKET_NAME,
  Key=MD_FILENAME
)

この1行ずつ読み取るタイミングで

3.項目が多すぎると全て連携されないことがある(途中で途切れたメッセージになる)

のチェックも行っています。 連携の最後にendのプレフィックスを設定していただいているので、そのプレフィックスが存在しない場合はエラー通知をSlackに送信しファイル配置と読み取りの後続処理が動かないようにしています。

④Slack投稿部分のSlack API

最後に、Slack投稿部分をwebhookからSlack APIに変更します。
権限は①で付与済みなのでリクエスト送信部分をちょっと変えるだけ。

slackPostMessageUrl = 'https://slack.com/api/chat.postMessage';
   
# Slack投稿の情報(ここは変わらず)
send_data = {
  'channel': CHANNEL_ID,
  'username': 'CHECK_TOOL',
  'icon_emoji': ':beer:',
  'attachments':[{        #投稿の引用マーク部分の設定
      'color': '#ffb6c1',
      'text': '投稿テストです。', 
  }],
}
    
send_text = json.dumps(send_data)
request = urllib.request.Request(
  slackPostMessageUrl, 
  data=send_text.encode('utf-8'), 
  method="POST"
)
# トークン情報だけ追加
req.add_header('Authorization','Bearer ' + SLACK_BOT_TOKEN)
with urllib.request.urlopen(request) as response:
  response_body = response.read().decode('utf-8')

⑤起動トリガーの設定

EventBridgeで定時起動するように設定します。
Lambdaの設定タブ > トリガー > トリガーを追加 でEventBridgeを選択し、cron形式で書きました。
UTC時刻で記載することに注意です。
私はJSTで設定し、11:30に動かしたいのに20:30に起動したことがありました。
お恥ずかしい。

これで夢の全自動化完了です。

ツール作成してどうだった?

  1. 気持ちが楽になった
    そもそも最初はSQL手動実行だったので、手作業でミスしないかというプレッシャーがありました。
    ツール化することで手動でミスする不安から開放されました。 また、ファイル作って、AWSにアクセスして、S3にファイルを置いて…という時間にして3分もかからない対応ですがやるのとやらないのでは気持ちが違います。
    長期休みのときもチェック漏れが起こらないので安心です。

  2. Python好きだなぁ
    このツールでほぼ初めてPythonを触りましたが、個人的に書きやすくてもっと使いたいなと思いました。
    Pythonを使っているシステムはあまりないのですが、ツール職人するときは積極的に使っていこうと思います。

  3. 知見が広がった
    業務で使わないAWSの部分(EventBridgeなど)も触ることができて勉強になりました。
    もっと使いこなせるようになりたい!

GCP版Dataformで冪等性を担保する設計ポイント3つ

データエンジニアの遠藤です。

TVer Advent Calendar 2023の24日目の記事になります。

はじめに

本年(2023年)、Google Cloudのビッグデータ基盤として展開されるBigQueryでは、データガバナンスツールであるDataformがGA(Generally Avaialble)になりました。

cloud.google.com

このDataformの登場により、BigQuery上でデータを利活用しやすいように変換する(データマートを生成する)システムの構築が容易になりました。

本記事では、Dataform上において、定常実行やリトライ実行を容易にするために、冪等性が担保される設計のテクニックを3点紹介します。(Dataformの基本的な使い方については触れませんのでご注意ください)

1. SQLX内のクエリに変数を用いる

DataformはSQLXと呼ばれるファイルでデータ変換処理内容を管理します。 SQLXファイルにはデータ更新設定とクエリを記載しますが、クエリ中で変数を利用することができます。

まず、 includes/dataform.jsonvars 内でSQLXファイル内で用いる変数を定義します。

{
  "warehouse": "bigquery",
  "defaultDatabase": "dev-project",
  "defaultLocation": "us-central1",
  "vars": {
    "certain_date": "2023-01-01"
  }
}

dataform.jsonで定義した変数は以下のようにSQLXのクエリ内に表記します(以下の例ではWHERE句の中でdataform.jsonで定義した変数 certain_date を用いるように設定しています)。

config {
    type: "table",
    schema: "result_dataset",
    name: "result_table",
}

SELECT column_a,
       column_b,
       column_c,
       partition_column
FROM ${ref("source_table")}
WHERE partition_column = '${ dataform.projectConfig.vars.certain_date }'

これにより、パラメータによって可変になるクエリ表現が可能になります。

2. 定期的な実行はDataform APIから行う

一般的に、Google Cloud上のDataformは以下の方法で実行することができます。

  • ワークスペース内のGUIで「実行を開始」をクリックする
  • リリース構成・ワークフロー構成を設定する
  • Dataform APIにジョブ実行リクエストを送る

処理を冪等にするためには、SQLX内の変数のようなパラメータを毎回変えながら定期的に実行する仕組みが必要です。

この要件を十分に満たすには、上記で3番目に挙げた実行方法「Dataform APIからの実行」が最適です。

なぜなら、「Dataform APIからの実行」は、APIでのリクエスト情報を適切に設定することで、dataform.json内の設定をオーバーライドして実行することが可能であるからです。

cloud.google.com

「Dataform APIからの実行」は、以下の2つをDataform APIで処理することで実現します。

まず、コンパイル結果を作成するため、以下のDataform API「compilationResults.create」を実行します。

cloud.google.com

API「compilationResults.create」は、「CodeCompilationConfig」オブジェクトに以下の情報を設定することで、「CodeCompilationConfig」内の設定情報を優先しながらコンパイル結果を作成します。

  • 出力結果格納先BigQueryプロジェクト(defaultDatabase)
  • 出力結果格納先データセット名の接尾辞(schemaSuffix)
  • 出力結果格納先テーブル名の接頭辞(tablePrefix)
  • 変数(vars)

このAPIリクエストでは、コンパイル結果が正常に作成されると、compilationResult というコンパイル結果IDが返されます。

次に、このコンパイル結果IDを用いて、以下のDataform API「workflowInvocations.create」を実行すると、作成したコンパイル結果でジョブを実行します。

cloud.google.com

このように、「Dataform APIからの実行」は、パラメータ制御の自由度が高くなるため、backfill実行も容易に行うことが可能です。

なお、Google Cloudでは、Dataform APIによる一連の実行はCloud Composer・Cloud Workflowsといった他のジョブ管理ツールから行うように推奨されています。

3. クエリ結果を積み上げる場合はpre_operations処理を追加する

Dataformにおける出力結果の格納方法は以下の方法が可能です(SQLXのconfigで設定します)。

  • VIEW化(結果の出力はせずにVIEWで設定するのみにとどめる)
  • 洗い替え(格納先のテーブルにデータが存在する場合、上書きして更新する)
  • 積み上げ(格納先のテーブル上の既存データはそのままにしながら、クエリ結果を新たに追加する)

結果格納の設定が「積み上げ」の場合、同じジョブが複数回実行されると、出力結果が重複して格納されてしまいます。

そのため、ジョブを複数回実行しても出力結果が冪等になるためには、SQLX内に新規にpre_operations項目を設けてそこにDELETE文を設定することで解決します。

config {
    type: "incremental",
    schema: "result_dataset",
    name: "result_incremental_table",
}

pre_operations {
    DELETE FROM ${self()} WHERE partition_column = '${ dataform.projectConfig.vars.certain_date }'
}

SELECT column_a,
       column_b,
       column_c,
       partition_column
FROM ${ref("source_table")}
WHERE partition_column = '${ dataform.projectConfig.vars.certain_date }'

pre_operations内のDELETE文は、1回目の実行では何も影響がないですが、2回目以降の実行では重複を避ける処理として効果を発揮します。

おわりに

本記事では、GCP版Dataform上に載せるシステムにおいてジョブが冪等になるためのポイント3点を紹介させていただきました。

  1. SQLX内のクエリに変数を用いる
  2. 定期的な実行はDataform APIから行う
  3. クエリ結果を積み上げる場合はpre_operations処理を追加する

以上の3点を考慮すれば、Dataform運用の効率性が高まるかと思いますので、ぜひ参考にしてみてください。

レコメンドエンジンで日本を元気に

こんにちは、TVer レコメンドエンジン担当の由井です。 こちらは TVer Advent Calendar 2023 の23日目の記事です。

なぜレコメンドなのか?

今年の5月からTVerにジョインして、レコメンドエンジンの開発に携わらせて頂いていますが、そもそもなぜ自分がレコメンド開発に携わることになったのかや、レコメンドエンジンにかける想いを、初心を忘れないためにも、つらつらと書かせてもらえたらと思います。 ただのポエムですのでイブ前ということで気軽に読んで頂けたらと思います。

ヨーロッパでの再発見

自分は、元々、ヨーロッパの歴史や新しい事を経験する事が好きだったため、あまり計画せずに現地企業に転職したという過去がありまして、数年間の経験を通じて現地の文化や価値観に触れることができ、その素晴らしさを経験してきましたが、同時に日本の持つ素晴らしさも再発見することができました。例えば、日本は周りの人間と上手く協力して行動することができたり、新しい価値観を受け入れる寛容さを持ち合わせていると思います。そこから派生したアニメや漫画、その他伝統文化といった日本発祥のコンテンツは、今、世界中から高い支持を得ることができています。

TVerとの出会い

そんな日本に貢献したいという思いが強くなり、日本国内で大きなインパクトを持つ企業で働きたいと考えていた時、

「テレビを開放して、もっとワクワクする未来を。TVerと新しい世界を、一緒に」 という理念を持つTVerの素晴らしいメンバーに出会う機会がありました。

レコメンドエンジンで日本を元気に

今、日本は直近30年の統計で見ると、先進諸外国と同様の成長率を維持できていない現状があります。

私見ではありますが、日本では、かつてのような期待に胸膨らむような夢のある明るいニュースよりも暗い気持ちになってしまうニュースがどうしても増えてしまっているのではと感じています。

日本にはアニメや漫画など、まだまだ世界に誇れる素晴らしいものが確かに存在しているにもかかわらず、必ずしも良い結果に結びついていない原因としては、

ユーザーと情報、ユーザーとユーザー、情報と情報といった因果の無限の網の目のような複雑な関係が絡み合っていて、それら一つ一つの火花ともベクトルとも言えるような小さな方向性の単位が、大きな社会の方向性を作り出しているんだと思います。

そしてその大きな川の流れのような社会の方向性が、日本の行く先を決定づけているのだとするならば、それらの火花の方向性を変えるためには、小さな火花の方向を少しずつ揃えていく必要があって、自分が携わるレコメンドエンジンは、個人的には、それぞれの火花であるベクトルの方向性を揃えていくことで、やがてはユーザー全体の大きな流れを良いベクトルに揃えることができる素晴らしい技術だと考えています。

自分はレコメンドエンジン開発を通じて、小さな火花を揃えて、より大きな火花にしていき、ひいては日本のユーザー全体の火花を揃えることで、日本全体がもっとわくわくできて、もっと元気になるような日本のエンジンを作りたいです。

MLOpsへの取り組み

一方、話は変わって、自分がPLとしてAWS上で現在取り組んでいるMLOpsは、多くのメンバーが関わる必要のある非常に大きな枠組みですので、自分ひとりでは成し遂げることができず、だからこそ、スペシャリストであるメンバー一人一人の力が必要だと考えています。

幸い、今、自分の周りには、リスペクトに値するようなスキルとマインドを持った本当に素晴らしいスペシャリストの方々がいらっしゃって、様々な想いを持って業務に従事されています。

それらの想いは、個別の数値目標として表現されているのかもしれませんが、ここで視点を1歩引いて、その想い一つ一つを深掘りしてみると、それぞれが、「サービスを良くしてユーザーに良いものを届けたい。TVerを良いサービスだと思ってもらいたい」というピュアな熱い想いを持って行動されている方たちなんだなという気づきがありました。

チーム1人1人の想いの火花を同じ方向に揃えることができれば、1人では決して成し得ない大きな力となり、1つ1つはたしかに小さな1歩しかもしれませんが、私達のその1歩1歩が、やがてユーザーの心に響くサービスの形として繋がっていく。だからこそ、小さな火花を大事にしながら毎日の作業に励んで行きたいと思います。

カンパニーという言葉の語源について

話は変わりますが、私自身、歴オタでして、それが高じてヨーロッパに移住を決意したことがあるような無計画なタイプの人間なのですが、特に語源学(Etymology)という、それぞれの単語がどのような歴史的な経緯で生まれ、変遷していったのかを研究する学問なのですが、自分をそれを追いかけるのが週末の楽しみという珍しい趣味を持っています。

その中で、自分が好きな言葉の一つに、カンパニー「Company」という言葉があります。日本では「会社」という意味が最も一般的に通じる意味かもしれません。 カンパニー(company)は、12世紀中頃、「共にパンを食べる仲間」という意味の後期ラテン語のcompanioから派生した言葉であり、これは、ラテン語の「共に、一緒に」を意味するcomという言葉と、「パン」を意味するpanisを組み合わせた言葉となります。

com + panis = copanio(共にパンを食べる仲間)

ここでの「パン」とは、教会のミサで食べるパンのことであり、companyとは、同じ教会に通い、同じ信仰を持ち、苦難を共にして助け合う人たちの集団のことを、元来指していた言葉でした。

その後、大航海時代に入ると、同じ船に乗り、リスクを承知で海の向こうに出かけて貿易をする夢追い人たちが現れました。

彼らは、未知の海に漕ぎ出すリスクもそれを乗り越えた後の莫大な利益も公平に分け合う仲間たちという意味で、自分たちのことを「カンパニー」と呼びました。 それがやがて「会社」という意味に転じていったそうです。

つまり、「カンパニー」というのは、ただの「会社」ではなく、「リスクに対して、それぞれができることを力を合わせて乗り越え、共に利益を得ていく集団」という意味を持っているんだと思います。

TVerの中では、

「我々は仲間である。1人で早く。よりも、みんなで遠くへ。」

という言葉があります。 自分が好きな言葉で、カンパニーという言葉にも通じる良い言葉だと思います。

最後にレコメンドへの抱負

時は流れ、カンパニーという言葉の定義は移り変わっていき、本来の意味でのカンパニーと呼べる気概を持った企業が、果たしてどれほど存在するのかは定かではありませんが、 だからこそ私達は、大きな変化の時代の中でも、メンバーそれぞれの火花の方向性を揃えて同じ船に乗り、共に困難を乗り越えていくことで、今後もユーザーに素晴らしい価値を提案し、社会から選ばれるカンパニーであり続けられたらと思います。

テレビとTVerと私

1、はじめに

皆さん、こんにちは、3月からコネクテッドTVタスクにJoinし、ビジネス領域を担当している井出と申します。

この記事は、TVerアドベントカレンダー22日目の記事です。

qiita.com

2、今日は何の話を?

突然ですが、皆さんはテレビという言葉からどういった言葉を連想されますでしょうか。

いわゆる、ドラマやバラエティなどの地上波の番組を思い浮かべる方もいれば、テレビという機器を思い浮かべる方もいるのではないでしょうか。 (昨今は、テレビを持っていない方や、モニターや、プロジェクターなどで代用している方もいると思います。)

今回は、いわゆる地上波を指すテレビではなく、 機器でいうテレビ、その中でも、 『コネクテッドTV』についてお話ししたいと思います。

このブログに書いてあること

  • コネクテッドTVの定義や、概要
  • TVerにおけるコネクテッドTVに位置付け
  • 私の部署の業務内容や今後の展望

3、コネクテッドTVとは

コネクテッドTVとは、テレビ自体がインターネットに接続されているスマートテレビはもちろん、 Fire TV、Chromecastなどのストリーミングメディアプレイヤー、 PlayStationXboxなどのゲーム機、プロジェクター、セットトップボックスなどの外部機器を接続している場合のテレビも含んでいるものを指します。 (※TVerはゲーム機にはまだ対応しておりません、ごめんなさい!)

自分の家のテレビや、モニターがインターネット接続に対応していない場合も、外部機器を接続することで、コネクテッドTVとして、動画サービスなどのエンタメを堪能することができます。

コネクテッドTVの広告について

下記の記事を引用させていただくと、 www.screens-lab.jp

米国では、

先行している米国では、2020年のeMarketerの調査によると、2024年までには182.9億ドル(約2兆3800億円※)に達すると予測されています。 ちなみに2021年の米国の総広告費はおよそ2800億ドルと推計されており、コネクテッドTV広告はその4%程度を占めることになります。

日本では、

日本におけるコネクテッドTVの広告市場も順調に拡大しています。今年2022年の3月に発表されたSMN、AJA、デジタルインファクトによる調査結果によると、2021年のコネクテッドTVの広告市場規模は344億円。 米国に比べると金額的にはまだまだ小さいものですが、前年比で337%の成長をみせています。新型コロナウイルスの流行で、コネクテッドTVの視聴が拡大したこの年、広告市場も大きく伸びました。 今後もさらなる成長が見込まれています。2025年には1695億円と、約5倍もの成長が予測されています。

と、期待されている広告出稿先であるといえそうです。

4、TVerとコネクテッドTV

それでは、TVerにおけるコネクテッドTVとの関わり方についてご紹介したいと思います。

対応状況は?

TVerがコネクテッドTVに対応したのは、2019年4月で、当初はFire TVシリーズソニー社製のAndroid TVだけでしたが、 スマートテレビで9社、ストリーミングメディアプレイヤーで2社、プロジェクターで3社、セットトップボックスで3社と 大幅に増え、 現在では、リンク先の通り多くの機器に対応しています。 現在も日々対応機器を拡大すべく、取り組んでおります。 過去に遡って対応させていただくこともあるので、もしかしたら、今まで諦めていたあなたの機器も対応しているかもしれません。 ぜひチェックしてみてくださいませ! https://help.tver.jp/hc/ja/articles/222120868

こちらでは接続方法などもご案内しています! tver.jp

TVerにおけるコネクテッドTVの立ち位置

今では、リリース当初1.9%ほどだったデバイス別再生割合は、2023年1月には、31%達し、15倍以上に成長しました。

tver.co.jp

TVerを使う3人に1人は、コネクテッドTVを利用して、視聴しているということになります。 現在では、 コネクテッドTV領域でのビジネスをさらに強化すべく、コネクテッドTV専門部署もできました(冒頭にも触れましたが、私もそこに所属しております)

5、コネクテッドTVタスクとして

タスクとはTVer内の用語で部署を指しています。

ここまでコネクテッドTV、また、コネクテッドTVとTVerについて触れてきました。 次に、コネクテッドTVタスクとして、個人として、今後どうコネクテッドTVと関わっていくかについて書いていきたいと思います。

コネクテッドTVタスクが取り組んでいること

コネクテッドTVタスクは、コネクテッドTVの普及率の増加も伴い、テレビ業界のビジネスをより発展させていく中で、これからは配信にも力を入れていく必要があり、スマートフォンタブレットやPCとは別に、コネクテッドTVを独立した領域として切り出し、専門的に対応していこうと、チームが立ち上がりました。 一つの部署にCTVの交渉役や、ビジネスを検討するなどをするビジネス担当と、開発のディレクターによる技術担当に分かれておりまして、簡単にいうと、 マーケティングと開発を1部署で行なっている小さな会社のような構成になっています。

ビジネスチーム(仮)が何をやっているか

※明確なチームとして分かれているわけではなく、担当しているだけなので、(仮)と書いています。

開発チームについては、ここで詳しく話しているようなので、この場では、ビジネス担当側が何をやっているかについて話しておきたいです。 note.com

ビジネスチームは、

  • 各メーカー様とアライアンスの調整
  • コネクテッドTV領域の外部へのマーケティング活動全般
  • 社内のCRM担当との調整

などを行なっております。

全てを説明すると多岐にわたっていきますし、一般的なマーケティングの話と変わらない部分もあるので、 特殊性がありそうな部分だけ触れていきたいと思います。

コネクテッドTVのマーケティングについて

1、SNS広告や、パートナー様出面での訴求について

コネクテッドTVを擁する各メーカーさんとは、協業によりTVerを使ってもらえるユーザーさんの獲得を行っています。 獲得するための枠は、多岐に渡りますが、 認知獲得を目的にする場合とユーザーさん自体の獲得を目的とするものに、大別されます。

YouTubeや、Xなどを活用して、認知を獲得することを試みたり、ユーザー獲得をすることまでは、一般的なWEBサービスなどのマーケティング手法と同様です。

ただ、YouTube、Xなどは、スマートフォンアプリや、PCサイトなどに直接遷移することで獲得までできますが、 コネクテッドTVでの利用を目的とする場合、なかなか直接遷移させることができません。 そこで、登場してくるのが、 アライアンスパートナーであるデバイスメーカーさんが持っている媒体(アプリが並んでいるHOME画面など)です。

例えば、こんな媒体があり、

advertising.amazon.com

TVerとしては、 単一の作品を訴求したり、TVerサービス自体を訴求することで、TVerをお使いいただけるようトライアンドエラーを繰り返しています。

もう少し、私が考えていることに触れると、 そもそも、TVerをテレビで使いたいなと思っているか、 もっというとTVerをテレビで使えると知っていないと、 コネクテッドTV上の広告で、TVerの広告をみても、 ピンときません(TVで見られると思っていないので、起動しようという動機付けに至りません)。

コネクテッドTVでTVerが利用できることの認知向上は、 何もせず、そのまま待っていても、 爆発的な何かが起きない限り、大きく伸ばすことはできません。 そのため、認知向上施策を日々実行、検討しております。 その件はいつかまた書き残したいと思います。

2、各メーカー様リモコンボタンについて

皆さんのお持ちの端末にも、動画配信アプリへダイレクトにアクセスできるボタンがついている場合もあるのではないでしょうか。 いつからそう言った取り組みが始まったのかと調べたところ、 どうやら、日本では、2015年から取り組まれているようです。 toyokeizai.net

テレビリモコンに専用ボタンを設置した先駆けはアメリカのネットフリックスだ。同社は2011年、北米市場で発売されたスマートテレビなどのリモコンに自社ボタンの設置を開始。日本市場向けにも、日本でのサービス開始前の2015年2月から、東芝が販売する一部機種が早くも「ネットフリックスボタン」に対応した。

TVerでは、9社のデバイスメーカーさんの端末にボタンを掲載していただいています。(2023年12月現在) マーケティング活動として、家にいつも置いてあるリモコンに掲載されることで、テレビで利用できるという認知、気付いた時にすぐ起動できることで、TVCMや、SNS広告などをみた後すぐ視聴に繋げられることがメリットです。 リテンションとして、すでにTVerを利用していただいている方には、 より、簡単にTVerを利用いただけるようになるといったメリットがあります。

リモコンボタンへの掲載について、今後も推進していく所存でございますので、皆さんのお家にもTVerのロゴがついたリモコンボタンが来る日が来るかもしれません。 ぜひその際は、たくさんPushして、TVerをお楽しみいただけると嬉しいです。

6、おわりに

簡単にコネクテッドTVのビジネス領域の話をしてきました。 最後に少しだけ、展望について書いてみたいと思います。

自分自身の思いとして、 自分の仕事を通して、エンドユーザーに、 感動体験を届けることを仕事の軸にしています。 TVerでもたくさんの感動をユーザーに届けられるよう仕事に取り組んでいます。 UIUXの改善、リモコンボタンからのダイレクト起動、作品との出会い、など TVerのコネクテッドTV体験を今よりもよく、 そして、テレビを解放するために、日々邁進してまいります。 ぜひ、コネクテッドTVの領域にも注目してみてください!

ちなみに採用も頑張っています、ご興味ある方はぜひ! カジュアル面談しましょう! herp.careers

以上です。長々と稚拙な文章にお付き合いいただきありがとうございました。 また、別の話題でどこかで書いてみたいと思います。 引き続き、TVerを宜しくお願いします!

URL_PARSE 再発明

日々、データ分析をしている森藤です。遅くなってしまいすみません。本記事は TVer アドベントカレンダー 17日目の記事です。 (10日の記事も今度書きます)

qiita.com


TVer のデータを分析の中で大きな割合を占めるものにユーザジャーニーの分析や外部からの流入の分析があります。 これらはどちらも URL の解析が必要になるのですが、 URL はだいたいにおいて Google Analytics の utm パラメタや hash の値が乗っており、 Facebook などは fbclid みたいなのが乗ったりと、これらを削除する作業が必要になります。

具体的には内部の回遊としては、

  • https://tver.jp/episodes/XXXXXX?utm_campaign=C1&utm_medium=M2&utm_source=S3#V1

などのログがあったり、

  • https://example.jp:8080/dir1/dir2/index.hml?param1=val1&param2=val2#hash_value

など外部からの流入があります。

これを BigQuery でパースする際、 BigQuery にも プリイン関数 があるのですが、いまいちリッチではなく、 HOST くらいしかないのです・・・

他にもコミュニティベースの URL_PARSE もあるのですがこちらも、いまいち使い勝手が良くないんですよね

SELECT 
    bqutil.fn.url_parse(url, "QUERY") AS query,
FROM
    UNNEST([
      url
    ]) AS url

僕は「クエリパラメタ」も欲しいんです! というか JavaScriptnew URL() で手に入るオブジェクトくらい全部が欲しいのです!

developer.mozilla.org

JS URL で手に入る結果
JS URL で手に入る結果

顧客が本当にほしかったもの

理想的にはこちらがほしいです

SELECT 
   url,
   URL_PARSE(url).protocol,
   URL_PARSE(url).host,
   URL_PARSE(url).port,
   URL_PARSE(url).pathname,
   URL_PARSE(url).search,
FROM
    UNNEST([
      url
    ]) AS url

まとめ

これを使うと MDN と同じように URL の好きなところを切り出したり取り出すことができます。 searchParam もとれますので、

(SELECT param.value FROM UNNEST(URL_PARSE(url).searchParam) AS param WHERE param.key = "utm_source") 

として取り出したりもできます! もちろん、コミュニティ UDF を使っても取り出せますよ! get_value

bqutil.fn.get_value("utm_source", URL_PARSE(url).searchParam)

URL の処理を楽しんでやっていきましょう!

実際のクエリ

作成したクエリ

以下のようなクエリを作成しました。コミュニティに投げ込みます。 ちょっと正規表現が鼻につく感じは自覚しているのですが、テストケースは全部通っています

CREATE TEMP FUNCTION url_parse(url STRING)
AS (
    STRUCT(
        REGEXP_EXTRACT(url, r"^([^:]+:)") AS protocol,
        IF(
            REGEXP_CONTAINS(url, r"^[^:]+://[^@/:]+:[^@/]+@"),
            REGEXP_EXTRACT(url, r"^[^:]+://([^@/:]+):[^@/]+@"),
            NULL
        ) AS username,
        IF(
            REGEXP_CONTAINS(url, r"^[^:]+://[^@/:]+:[^@/]+@"),
            REGEXP_EXTRACT(url, r"^[^:]+://[^@/:]+:([^@/]+)@"),
            NULL
        ) AS password,
        CONCAT(
            IF(
                REGEXP_CONTAINS(url, r"^[^:]+://[^@/:]+:[^@/]+@"),
                REGEXP_EXTRACT(url, r"^[^:]+://[^@/:]+:[^@/]+@([^:/?#]+)"),
                REGEXP_EXTRACT(url, r"^[^:]+://([^:/?#]+)")
            ),
            IFNULL(IF(
                REGEXP_CONTAINS(url, r"^[^:]+://[^@/:]+:[^@/]+@"),
                REGEXP_EXTRACT(url, r"^[^:]+://[^@/:]+:[^@/]+@[^/?#]+(:\d+)"),
                REGEXP_EXTRACT(url, r"^[^:]+://[^/?#]+(:\d+)")
            ), "")
        ) AS host,
        IF(
            REGEXP_CONTAINS(url, r"^[^:]+://[^@/:]+:[^@/]+@"),
            REGEXP_EXTRACT(url, r"^[^:]+://[^@/:]+:[^@/]+@([^:/?#]+)"),
            REGEXP_EXTRACT(url, r"^[^:]+://([^:/?#]+)")
        ) AS hostname,
        IF(
            REGEXP_CONTAINS(url, r"^[^:]+://[^@/:]+:[^@/]+@"),
            REGEXP_EXTRACT(url, r"^[^:]+://[^@/:]+:[^@/]+@[^/?#]+:(\d+)"),
            REGEXP_EXTRACT(url, r"^[^:]+://[^/?#]+:(\d+)")
        ) AS port,
        url AS href,
        REGEXP_EXTRACT(REGEXP_REPLACE(url, r"^[^:]+://([^@/:]+:[^@/]+@)", REGEXP_EXTRACT(url, r"^[^:]+://")), r"^([^:]+://[^/?#]+)") AS origin,
        IFNULL(NULLIF(REGEXP_EXTRACT(url, r"^[^:]+://[^/]+(/[^?#]*)"), ""), "/") AS pathname,
        REGEXP_EXTRACT(url, r"[^?#]\?([^#]+)") AS search,
        (SELECT ARRAY_AGG(STRUCT(
            SPLIT(param, "=")[SAFE_OFFSET(0)] AS key, 
            SPLIT(param, "=")[SAFE_OFFSET(1)] AS value
        )) FROM UNNEST(SPLIT(REGEXP_EXTRACT(url, r"[^?#]\?([^#]+)"), "&")) AS param) AS searchParam,
        REGEXP_EXTRACT(url, r"[^?#]\#(.+)") AS hashval
    )
);

テストケースも合わせて掲載しておきます

WITH 
test_data AS (
    SELECT 
        FORMAT("%s//%s%s%s%s%s%s", protocol, basicauth, hostname, port, pathname, search, hashval) AS url,
        protocol,
        basicauth,
        hostname,
        port,
        pathname,
        search,
        hashval
    FROM
        UNNEST(["tverapp:", "https:"]) AS protocol
    INNER JOIN
        UNNEST(["", "hoge:fuga@"]) AS basicauth
    ON
        TRUE
    INNER JOIN
        UNNEST(["example.com"]) AS hostname
    ON
        TRUE
    INNER JOIN
        UNNEST(["", ":8080"]) AS port
    ON
        TRUE
    INNER JOIN
        UNNEST(["", "/", "/index.html", "/dir1", "/dir1/", "/dir1/index.html"]) AS pathname
    ON
        TRUE
    INNER JOIN
        UNNEST(["", "?param1=val1"]) AS search
    ON
        TRUE
    INNER JOIN
        UNNEST(["", "#hash"]) AS hashval
    ON
        TRUE
)

New Relic Change Trackingを使ってアプリケーションのパフォーマンスが変化した要因を特定しやすくする

TVer広告事業本部の髙品です。 こちらはTVer Advent Calendar 2023の21日目の記事です。

本記事では、New RelicのChange Trackingという機能について書きたいと思います。

本記事を書く背景

Change Trackingを説明する前に、本記事を書く背景をお話させてください。

私は、2023年11月にTVer広告事業本部のエンジニアチームに参加しました。広告事業本部のエンジニアチームは、主に「TVer」で配信される広告プロダクト「TVer広告」の配信システム・広告周辺領域のシステムを開発・保守しています。TVerのエンジニア組織に関心がある方は、弊社採用サイトのプロダクト職向けのページをご覧ください。

さて、入社したばかりの私は現在オンボーディングの最中でして、広告のドメイン用語や既存システムの構成をやっと覚えたばかりなので、業務を絡めた記事を書くことができない状況です。そんな中で、アドベントカレンダーに参加するために捻り出したネタが、いまチームで導入を進めているNew RelicのChange Trackingでした。

New Relicを使って私が目指すチームのあり方

私はこれまでインフラエンジニア、SREとして働いてきたので、システムの運用が比較的得意です。今までの経験を活かしてチームに貢献したいと考えたときに、システムの信頼性と開発の生産性を共に最適化できるチームをつくりたい、と考えました。そのために取り組みたいことの一つに、チームがオブザーバビリティツールを使いこなせるようにする、というものがあります。

SREやDevOpsに関心があるチームでは、何らかのオブザーバビリティツールを利用していると思いますが、私が所属するチームはNew Relicを使っています。New Relicは高機能なツールですが、所属チームではまだ一部の機能しか使えておらず、活用しているとは言えない状況です。また、普段からNew Relicを見ているメンバーは数名に限られています。

私は、信頼性と生産性を共に最適化できるチームにおいて、システムの状態はメンバー全員の関心事であるべきだと考えているので、チームの全員にNew Relicの画面を見てほしいと思っています。そこで、チームでNew Relicのダッシュボードを見て、アプリケーションのレイテンシースループット、エラー率といったパフォーマンス指標の傾向を知ることでシステムの状態を把握する定点観測の時間を作ろうとしています。いずれは、SREのサービスレベルの考え方をチームのシステム運用に導入することを見据えての活動です。

前置きが長くなりましたが、ダッシュボードを見てシステムの状態を把握しようと思うときに便利な機能が、New RelicのChange Trackingです。ここからは、Change Trackingの便利な点と、この機能の導入手順を紹介します。手軽に導入できるので、New Relicのユーザーにはぜひ試してほしい機能です。

Change Trackingの便利な点

まず、Change Trackingの便利な点を一言で述べると、アプリケーションの変更(アプリケーションのデプロイイベント)をグラフ上に表示することで、変更によるパフォーマンスへの影響があったかどうか分かりやすくなる、というものです。

あとで導入手順を紹介する際にキャプチャを貼りますが、New Relicのブログの説明を読んでいただくと、この機能について理解し易いと思います。リンク先の画像を見るとResponse time(ms)とThroughputのグラフの上に縦方向の点線が入っていますが、この時点でシステムに変更が加えられたことを意味しています。もし、ある点線を境にグラフに変化が生じた場合、その点線が指している変更と関連性があることがすぐに分かります。

とてもシンプルな機能ですが、これだけでグラフから読み取れる情報量が増えます。グラフを集めたダッシュボードを眺めていると、パフォーマンスは安定していることもあれば、悪くなったり良くなったりすることもあるでしょう。特に悪化した場合は原因を調査する必要がありますが、単にグラフがそこにあるだけでは、ある時点から悪くなった、ということしか読み取れません。そこから先は、アプリケーションのソースコードが置かれたリポジトリや、ログ・メトリクスなどが保存された場所などを見て回って、パフォーマンス悪化要因となるイベントがないか探すことになります。ところが、グラフ上にアプリケーションの変更も一緒に記録されていると、New Relicの画面からすぐに関連のあるイベントを特定することができます。

New Relicにアプリケーションの変更を記録するときはアプリケーションのバージョンの情報を送る必要があります。一般的にはコミットハッシュやタグがバージョン情報として使用されます。こちらのNew Relicのドキュメントに載っている1つ目のキャプチャのように、点線にマウスオーバーするとアプリケーションのバージョンが表示されるため、パフォーマンスを変化させた可能性がある変更がすぐに分かります。Change Trackingを使用することで、パフォーマンス悪化の要因を特定して修復するまでにかかる時間(MTTR)を短縮できる可能性があります。

Change Trackingの導入手順

基本的にはこちらのNew Relicのドキュメントに記載の手順に従えば、難なく導入できると思います。特にJenkinsもしくはGitHub Actionsを使用している場合は、New Relic社が提供しているpluginやactionを追加してパラメータを渡すだけで使うことができます。

本記事では、NerdGraph(GraphQL)APIを使用するユーザーに参考になる手順として、Bitbucket pipelinesからChange Trackingを使う方法を紹介したいと思います。紹介する手順は、いくつかの環境変数を除けばCIツールに依存しない方法です。

さて、NerdGraph APIにデータを送信するためにHTTPクライアントを準備する必要がありますが、多くの環境で追加インストールが不要であると思われるcurlコマンドを使います。New RelicのAPIエンドポイントにJSONをPOSTする必要があるので、まずはcurlコマンドを組み立てていきます。

ここからはcurlコマンドを組み立てる手順です。まず、New Relicにログインして、画面左のアイコン群から虫眼鏡を選んで、検索窓に「Apps」と入力して選択してください。Your appsと表示されているアプリの中に「NerdGraph API Explorer」があるので、選択して開いてください。私はこのアプリにスターをつけているので、Favorite appsに移動しています。

NerdGraph API Explorerを探す

するとGraphQLのクエリを組み立てる画面が開くので、mutationの一覧から「changeTracking」を探して「changeTrackingCreateDeployment」にチェックを入れてください。

changeTrackingCreateDeploymentにチェックを入れる

続いて、以下のキャプチャと同じようにチェックを入れてください。オプション属性ですが、本記事ではdeepLinkにもチェックを入れています。必須属性はentityGuidversionです。指定可能な属性についてはこちらのNew Relicのドキュメントを参照してください。キャプチャではtestと入力していますが、実際のentityGuidはNew Relicにおいてアプリケーションを特定するために振られた一意のID,versionはコミットハッシュ、タグ等が入ります。

mutationに属性を追加する

画面左上の「tools」を押して、「Copy as CURL」を押すとクリップボードに以下のようなコマンドがコピーされます。

curlコマンドを生成する

curl https://api.newrelic.com/graphql \
  -H 'Content-Type: application/json' \
  -H 'API-Key: your USER key' \
  --data-binary '{"query":"mutation {\n  changeTrackingCreateDeployment(\n    deployment: {entityGuid: \"test\", version: \"test\", deepLink: \"test\"}\n  ) {\n    entityGuid\n    version\n    deepLink\n  }\n}", "variables":""}'

API-Keyヘッダーの値、entityGuidversionに適切な値が入力された状態でコマンドを実行すると、entityGuidに対応するNew Relic APM, Browserのアプリケーションのグラフ上に点線が引かれたはずです。このようにNerdGraph API Explorercurlコマンドを生成すると楽ですから、使ってみてください。

ところで、Bitbucket pipelinesのようなCIツールでこのcurlコマンドを実行するときは、各属性値はパラメータにしておかないと使い物になりません。Bitbucket pipelinesで使いやすいようにコマンドを以下のように修正します。(ついでにシェルスクリプトファイルにします)

#!/usr/bin/env bash
set -euo pipefail
curl -X POST https://api.newrelic.com/graphql \
     -H "Content-Type: application/json" \
     -H "API-Key: $NEW_RELIC_USER_KEY" \
     --data-binary '{"query":"mutation {\n changeTrackingCreateDeployment(deployment: {version: \"'"$BITBUCKET_COMMIT"'\", entityGuid: \"'"$NEW_RELIC_ENTITY_GUID"'\", deepLink: \"'"$BITBUCKET_GIT_HTTP_ORIGIN"/commits/"$BITBUCKET_COMMIT"'\"}) {\n deploymentId\n entityGuid\n deepLink\n }\n}"}'

BITBUCKET_のprefixを持つ変数は、Bitbucket pipelines固有の変数です。使用可能な変数はBitbucketのこちらのドキュメントを参照してください。他のツール使う場合は、適当に書き替えてください。

$NEW_RELIC_USER_KEYはBitbucket pipelinesのリポジトリ変数やデプロイ変数に、保護された変数としてセットしておきます。機密情報なので扱いに注意してください。

$BITBUCKET_COMMITはCI(パイプライン)を起動したブランチのコミットハッシュが入ります。

$NEW_RELIC_ENTITY_GUIDも、$NEW_RELIC_USER_KEYと同様にします。

$BITBUCKET_GIT_HTTP_ORIGINには、リポジトリのURLが入ります。 $BITBUCKET_GIT_HTTP_ORIGIN+commits+$BITBUCKET_COMMITを連結することで、コミットに含まれる内容が記されたURLを作成することができます。URLを動的に生成してdeepLinkの値に格納することで、New Relicの画面からリポジトリに移動しやすくします。

変数展開についても補足しておきます。シングルクォート内ではすべての文字が特別な意味を持たなくなるため、"$BITBUCKET_COMMIT"と書くと変数展開が行われません。そのため、本記事では環境変数をシングルクォートで囲うことで変数展開されるようにしています。上記のJSON程度の長さであれば、個人的にはこの方法で構わないと感じますが、より複雑な構造のJSONをPOSTするときは他の書き方にしないと、可読性が悪くなると思います*1

もう一つ、tipsを書いておきます。curlコマンドを、CIの定義ファイル内の別々の箇所に繰り返し書くのは筋が悪いです。コマンドを修正するときに、修正漏れしてしまう懸念があります。本記事では、処理を共通化するためにシェルスクリプトファイルに書いておき、各環境のCIを実行するときにsourceコマンドで呼び出すようにします。例えばBitbucket pipelinesでは以下のように記述できます。

- step:
    name: Send deployment event to NewRelic ChangeTracking
    deployment: develop
    script:
      - source ./scripts/newrelic_change_tracking.sh
- step:
    name: Send deployment event to NewRelic ChangeTracking
    deployment: production
    script:
      - source ./scripts/newrelic_change_tracking.sh

deployment: envの記述により、事前定義されたデプロイ変数が読み込まれます。デプロイ変数は、CIを実行するときに環境固有のパラメータを参照したいケース等で便利です*2。本記事の文脈では、$NEW_RELIC_USER_KEY$NEW_RELIC_ENTITY_GUIDは環境固有の値なので、これらをデプロイ変数としてセットしておくと、処理そのものはシェルスクリプトファイルに書いて共通化しながら、スクリプトの実行時に環境固有の値を参照することができます。

以上を設定してBitbucket pipelinesでCIを実行すると、以下のようにNew Relicに変更が記録されます。

NewRelic APMの画面

おわりに

いかがでしょうか。Change Trackingが便利であることを伝えきるには情報が不足しているかもしれませんが、簡単に導入できることは伝わったと思いますので、試していただけたら幸いです。

最後に、個人的な決意表明ですが、所属チームにSREやDevOpsの文化・考え方を浸透させるために、New Relicを活用してきたいと思います。