NTTドコモR&Dの技術ブログです。

Google Analytics 4をStreamlitに導入してグロースできる環境を実現してみた

自己紹介

NTTドコモ データプラットフォーム部(以下DP部)藤平です。
システムやアプリケーションはリリースしたら終わりではなく、利用者の声や利用状況に合わせたUIの改善など日々成長させていく必要があります。
DP部が提供するデータアプリケーションプラットフォームではStreamlitで約100アプリを提供していますが、各々のアプリを成長させるために利用状況を分析できる環境が必要でした。
そこで、ドコモで10年以上Google Analytics/Google Tag Managerに携わっていただいている、協力会社の大畑さんに実装をお願いしました。
以下、大畑さんに本取り組みの内容をご紹介していただきます。

背景

DP部ではStreamlitを用いたデータ分析用のアプリケーションプラットフォームを作成しています。
多種多様なアプリ開発者が多種多様なアプリを実装していますが、アプリの改修を進めるにあたり次のような情報が不足していました。

  • 利用者がどのように利用しているか?詳細がわからない
  • アプリを利用する上でのボトルネックとなっている箇所がわからない

このような問題を解決するため、Google Analytics 4(以下GA4)を導入しました。

なぜGA4が必要か?

サーバ等のログから各アプリの利用者を確認することは可能です。
ただし、利用者がアプリ内でどのような操作をしたか?等の細かい情報の把握は困難となります。
アプリ内の操作をGA4にイベント送信することで、利用者がどのような操作を行っていたか簡単に把握することが可能となります。

Google Tag Managerの導入

GA4に情報を送信するために、Google Tag Manager(以下GTM)を用いることにしました。
StreamlitではGTMを公式で対応していないため、コードスニペットを手動で設定する必要がありますので、 Streamlitの初期表示で使われるindex.html<head>にコードスニペットを追加します。

コードスニペットの取得

GTMの管理画面から取得します。 StremlitはJavaScriptの使用が前提なので、<noscript>の部分は不要です。

index.htmlの書き換え

~省略~
    <title>Streamlit</title>

    <!-- initialize window.prerenderReady to false and then set to true in React app when app is ready for indexing -->
    <script>
      window.prerenderReady = false
    </script>
    <!-- Google Tag Manager -->
    ここにコードスニペットを追加する
    <!-- End Google Tag Manager -->
  </head>
  <body>
    <noscript>You need to enable JavaScript to run this app.</noscript>
    <div id="root"></div>
    <script type="module" src="/src/index.tsx"></script>
  </body>
</html>

pip install -U streamlit等で、バージョンアップするとindex.htmlが戻ってしまうので、書き換え用のPythonコードを用意しておきます。ついでに、言語設定も日本語に変更しておきましょう。

gtm_container = """<!-- Google Tag Manager -->
    <script>(function (w, d, s, l, i) { w[l] = w[l] || []; w[l].push({ 'gtm.start': new Date().getTime(), event: 'gtm.js' }); var f = d.getElementsByTagName(s)[0], j = d.createElement(s), dl = l != 'dataLayer' ? '&l=' + l : ''; j.async = true; j.src = 'https://www.googletagmanager.com/gtm.js?id=' + i + dl; f.parentNode.insertBefore(j, f); })(window, document, 'script', 'dataLayer', 'GTM-*******');</script>
    <!-- End Google Tag Manager -->"""

with open(index_html_path,'r') as file:
  html = file.read()

html_edit = html.replace('<html lang="en">','<html lang="ja">').replace('</head>',f'{gtm_container}</head>')

これで、GTMを使う準備は完了です!

操作イベントの取得

通常のWebサイトではサイトの目標地点への到達(GA4ではこれを「キーイベント」と言います)までの分岐点にイベントを設定します。
一例として、ECサイトの場合は商品の購入がキーイベントとなります。
また、購入商品にどのように到達したか?を把握するため、レコメンド欄の商品をクリックしたか?新着欄の商品をクリックしたか?などがわかるようイベントを設定します。

今回は多種多様なアプリに対してイベント設定を行う必要があり、当然キーイベントも各アプリで異なるものとなります。
そこで、まずはStreamlitでよくつかわれるコンポーネントの操作をイベント取得できるように設定を進めました。

操作イベントの設定方法

Streamlitで設定したコンポーネントはclass名「stVerticalBlock」の子要素としてclass名「stElementContainer」で並びます。
その更に子要素を見ると「stButton」など、どのコンポーネントかを示すclass名が設定されます。

<!-- Streamlitでst.buttonとst.radioを入れた場合のHTML構成例 -->
<div class="stVerticalBlock" data-testid="stVerticalBlock">
    <div class="stElementContainer element-container" data-testid="stElementContainer">
        <div class="stButton" data-testid="stButton"> <!-- コンポーネント種を示すclass名 -->
            <!-- st.buttonのHTML要素 -->
        </div>
    </div>
    <div class="stElementContainer element-container" data-testid="stElementContainer">
        <div class="stRadio" data-testid="stRadio"> <!-- コンポーネント種を示すclass名 -->
            <!-- st.radioのHTML要素 -->
        </div>
    </div>
</div>

GTMのクリックイベントトリガーを用い、クリックされた要素の親要素から「stButton」等コンポーネントを示すclass名を確認し、各コンポーネントに合わせたイベントを取得する設定を進めました。
GA4には次のような情報を送信します。

  • 操作されたアプリの名称(URLから判定)
  • 操作されたコンポーネントの名称(stButtonや、stRadio)
  • コンポーネントのlabel値(下記画像の「A,B,Cから選択してください」部分)
  • 選択された値(下記画像の「B」や「C」といった選択された値)
Streamlitのラジオボタンの例

この実装により各コンポーネントがどのように利用されているか?という情報を得ることが可能となりました。 具体的な例をあげると、次のような検討が可能となります。

  • コンポーネントAは使われていないが、コンポーネントBはよく使われるため、コンポーネントBを上に持っていく
  • 選択肢は「C」ばかり選択されているため、「C」をデフォルト値にする

ラジオボタンの操作をGTMでイベント送信する例

実際にGTMでStreamlitの操作をイベント送信する方法を記載します。
ここでは次のPythonコードで作成したラジオボタンの操作をイベント送信します。

st.radio(label='A,B,Cから選択してください',options=['A','B','C'])

GTMからイベント送信する方法は様々ありますが、ここでは「クリック - すべての要素」トリガーを用いてイベントを送信する例となります。
Streamlitのバージョンは「1.40.0」で確認しています。※Streamlitのバージョンにより生成されるHTMLが異なる場合があります。ご注意ください。

操作時に取得される情報の確認

「クリック - すべての要素」トリガーは設定したページ内のすべてのマウスクリック操作を拾ってしまいます。
トリガーの発火条件が緩い場合、意図しない箇所のクリックでイベント送信されてしまうため、発火条件を厳密に設定する必要があります。
そのため、まずはイベント送信したい操作が行われたとき、dataLayer変数にどのような値が格納されるかを確認します。

まずは「クリック - すべての要素」トリガーを作成します。
GTMの左メニューから「トリガー」→「新規」を選択します。
トリガーのタイプにて「クリック」の「すべての要素」を選択し、「保存」を選択します。
ここでは「stRadioEvent」という名称で保存しております。

「クリック - すべての要素」トリガーを作成することで、イベント送信対象の操作が行われた際にどのような情報がdataLayer変数に格納されるかを確認することができます。
GTMのプレビュー機能を使って動作を見てみましょう。
右上の「プレビュー」を選択後、「ウェブサイトのURL」欄に動作を確認するStreamlitのURLを入力し、「リンク」を押下してください。

ブラウザ上でStreamlitアプリが起動します。 ブラウザの開発者ツール(F12キー押下など)を開き、「Console」タブに「dataLayer」と入力して実行することでdatalayer変数の中身を確認できます。
実際にラジオボタンの操作を行った後、もう一度「dataLayer」と入力して実行すると、ラジオボタンの操作で得られたdataLayerの情報を確認できます。

ラジオボタンの操作とdataLayer変数の確認を何度か繰り返すと、クリックする位置によって取得される情報は変わりますが、「INPUT」要素のクリックが必ず検出されることがわかります。
単純に「INPUT」要素のクリックを発火条件にしてしまうと、他の「INPUT」要素でも発火してしまうため、ここではclass名「stRadio」要素の配下にある「INPUT」要素のクリックをイベント発火条件とします。

トリガーの設定

class名「stRadio」要素と「INPUT」要素の位置関係は次のようになります。

<div class="stElementContainer element-container" data-testid="stElementContainer">
  <div class="stRadio" data-testid="stRadio">
    <div role="radiogroup" aria-label="A,B,Cから選択してください">
      <label data-baseweb="radio">
        <input name="" tabindex="0" type="radio" value="0" checked="">

トリガーの設定では、次の2点を行います。

  • クリックされた要素が「INPUT」要素
  • クリックされた要素がclass名「stRadio」要素の配下

クリックされた要素のタグ名を取得する変数の作成

クリックされた要素が「INPUT」要素であったかを確認するために、クリックされた要素のタグ名を取得する変数を作成します。
GTMの左メニューから「変数」→ユーザー定義変数の「新規」を選択します。
変数のタイプに「データレイヤーの変数」、データレイヤーの変数名に「gtm.element.tagName」を入力し「保存」します。
ここでは「clickElementTagName」という変数名で保存しています。

クリックされた要素がclass名「stRadio」の配下か確認する変数の作成

クリックされた要素がclass名「stRadio」の配下かを確認する変数を作成します。
GTMの左メニューから「変数」→組み込み変数の「設定」を選択します。
「クリック」欄にある「Click Element」をチェックします。

この「Click Element」変数は クリックトリガーにて選択された要素の情報を持ちます。

つづいてGTMの左メニューから「変数」→ユーザー定義変数の「新規」を選択します。
変数のタイプに「カスタムJavaScript」を選択し、次のコードを入力し「保存」します。
ここでは「componentClassName」という変数名で保存しています。

function(){
  var element = {{Click Element}};
  return element.closest('.stElementContainer').children[0].className;
}

この変数では、クリックされた要素の親要素からclass名「stElementContainer」要素を見つけ、その最初の子要素のclass名を返却します。

トリガーの編集

最初に作成した「stRadioEvent」トリガーの修正を行います。 「このトリガーの発生場所」で「一部のクリック」を選択し、先ほど作成した変数を用いて次のように設定し「保存」します。

  • 変数「clickElementTagName」 等しい 「INPUT」
  • 変数「componentClassName」 等しい 「stRadio」

これでトリガーの設定は完了となります。

タグの設定

タグではGA4に送信する情報を設定します。 ここでは次の情報を設定します。

  • ラジオボタンのlabel情報(「A,B,Cから選択してください」部分)
  • 選択された値(「A or B or C」の情報)

ラジオボタンのlabel情報を取得する変数の作成

HTMLを見ると、ラジオボタンのlabel情報はイベント対象とした「INPUT」要素の親要素で、role属性「radiogroup」要素の「aria-label」に設定されていることがわかります。

<div role="radiogroup" aria-label="A,B,Cから選択してください">
  <label data-baseweb="radio">
    <input name="" tabindex="0" type="radio" value="0" checked="">

GTMの左メニューから「変数」→ユーザー定義変数の「新規」を選択します。
変数のタイプに「カスタムJavaScript」を選択し、次のコードを入力し「保存」します。
ここでは「radiogroupAriaLabel」という変数名で保存しています。

function(){
  var element = {{Click Element}};
  return element.closest('[role="radiogroup"]').ariaLabel;
}

この変数では、クリックされた要素の親要素からrole属性「radiogroup」要素を見つけ、その要素の「aria-label」値を返却します。

選択された値を取得する変数の作成

HTMLを見ると、選択された値の情報は「INPUT」要素の弟要素の子要素に格納されていることがわかります。

    <input name="" tabindex="0" type="radio" value="0" checked="">
    <div>
      <div data-testid="stMarkdownContainer">
        <p>A</p>

GTMの左メニューから「変数」→ユーザー定義変数の「新規」を選択します。
変数のタイプに「カスタムJavaScript」を選択し、次のコードを入力し「保存」します。
ここでは「nextElementInnerText」という変数名で保存しています。

function(){
  var element = {{Click Element}};
  return element.nextElementSibling.innerText;
}

この変数では、クリックされた要素の弟要素の内部テキストを返却します。

イベント送信するタグの作成

トリガー、および、送信する情報を取得する変数の作成が完了しましたので、実際にイベントを送信するタグを作成します。
GTMの左メニューから「タグ」→「新規」を選択します。
下記のような設定を行い「保存」します。

  • タグ名:任意のタグ名称を設定 ※ここでは「stRadioEvent」と設定しています。
  • タグの種類:Googleアナリティクス→Googleアナリティクス:GA4イベント
  • 測定ID:送信先となるGA4のWebストリームの測定ID
  • イベント名:GA4で表示されるイベント名となります。任意の値を設定 ※ここでは「stRadioEvent」と設定しています。
  • イベントパラメータ:先ほど作成した変数を設定します。ここでは次のように設定します。
    • パラメータ名「eventLabel」:値 「{{radiogroupAriaLabel}}」
    • パラメータ名「selectValue」:値 「{{nextElementInnerText}}」
      ※パラメータ名はGA4で表示されるカスタムパラメータの名称となります。任意の値を設定してください
  • トリガー:「stRadioEvent」

動作確認

タグの設定が完了しましたので、もう一度GTMのプレビュー機能を使って動作を見てみましょう。
ラジオボタンの操作を行った際のクリックイベントを検知して、配信されたタグに「stRadioEvent」が表示されます。

配信されたタグの中身を確認すると、イベントパラメータとしてラジオボタンのlabel情報と、選択した値が送信されていることを確認できます。
※下記画像は「B」を選択した場合の送信情報となります。

GTMのプレビュー機能での動作確認が終わりましたら、GTMを公開することでイベント取得されるようになります。

キーイベントの検討

操作イベントの設定により、コンポーネントの操作を把握することは可能となりました。
しかし、操作イベントのみでGA4からアプリのボトルネックを読み取るなど高度な分析を行うにはGA4の知識が必要であり、アプリ開発者が習得するにはコストが高いものとなっていました。
そこで、各アプリの目的地点となるキーイベントを設定し、ボトルネックなどを簡単に読み取れるよう検討を始めました。

最初に取得済みの操作イベントからアプリ個別にキーイベントを設定する方法を試みました。
この方法には大きな問題があります。
一例として「実行する」というボタンをキーイベントとした場合、アプリの改修により「実行」というボタンに変わってしまうとキーイベントが取れなくなってしまいます。
また、キーイベントを取り続けるために自由なアプリの開発を制限してしまっては本末転倒となってしまいます。

そこで、アプリ開発者自身にアプリ内に目印を埋め込んでもらい、GTMでキーイベントを取得する方法を模索しました。

アプリ開発者自身にキーイベントを設定していただく方法

アプリ開発者側ではキーイベントとなるコンポーネントの直後に、class名「gtm_keyevent」のHTML要素をst.htmlで埋め込んでもらいます。

# Streamlitの実装例
if st.button(label = 'キーイベントのボタン'):
    # ボタン押下時の処理
# ↓対象とするボタンの次のコンポーネントとして「st.html」を設置
st.html('<span class=gtm_keyevent></span>')

GTMでは操作されたコンポーネントの兄弟要素にclass名「gtm_keyevent」が設定されていた場合、GA4にキーイベントを送信します。

<!-- 生成されるHTML例 -->
<div data-testid="stVerticalBlock">
  <!-- キーイベントとなるst.buttonの要素 -->
  <div class="stElementContainer element-container" data-testid="stElementContainer">
    <div class="stButton" data-testid="stButton">
      <button kind="secondary" data-testid="baseButton-secondary">
        <div data-testid="stMarkdownContainer">
          <p>キーイベントのボタン</p>
        </div>
      </button>
    </div>
  </div>
  <!-- ↑のst.buttonの操作検知時、弟要素にclass名「gtm_keyevent」が存在した場合キーイベントを送信 -->
  <div class="stElementContainer element-container" data-testid="stElementContainer">
    <div class="stHtml" data-testid="stHtml">
      <span class="gtm_keyevent"></span>
    </div>
  </div>

この実装をclass名「gtm_keyevent」に限らず、class名「gtm_stepevent〇〇」(〇〇には数字が入る)でも取得するようにしました。
アプリ開発者はアプリの操作の順番にclass名「gtm_stepevent01」、class名「gtm_stepevent02」と設定し、最後にclass名「gtm_keyevent」を設定します。
この情報は下図のようにLookerStudioで各アプリ別に可視化し、アプリ開発者自身がアプリのボトルネック部分(どこで離脱する利用者が多いか?)を確認できるようにしています。

LookerStudioでステップ&キーイベントを可視化した例

Streamlit1.39.0の登場

このようにステップ&キーイベントという方法を用いてアプリのボトルネックを可視化する方法を実装しましたが、様々な検討/検証を重ね紆余曲折のすえ、この実装に至りました。
その根本として「Streamlitでは特定のコンポーネント対してに自由にidやclass名を設定できない」という制約がありました。

Streamlit1.39.0の登場で状況は一変します。
Streamlit1.39.0からコンポーネントのkey値に設定した値がHTMLのclass名に入るようになりました。

# Streamlitの例
st.button('ステップ1ボタン',key='gtm_stepevent01')
<!-- 作成されるHTML例 -->
<!-- key値に指定した「gtm_stepevent01」がclass名「st-key-gtm_stepevent01」で設定される -->
<div class="stElementContainer element-container st-key-gtm_stepevent01" data-testid="stElementContainer">
    <div class="stButton" data-testid="stButton">
        <button kind="secondary" data-testid="stBaseButton-secondary">
            <div data-testid="stMarkdownContainer">
                <p>ステップ1ボタン</p>
            </div>
        </button>
    </div>
</div>

(今までの苦労は何だったんですか。。。。。。orz)
というわけで、key値を利用した新たなステップ&キーイベントの実装に取り組んでおります!

その他、GTMを導入してよかったこと

GTMを利用してGA4にイベントを送る点にフォーカスして記事を進めてきましたが、他にもGTMを導入することで様々なことが可能となりました。
実際に実装した一例をご紹介いたします。

すべてのアプリで共通した設定を即導入できる点

今回紹介したプラットフォームでは、セキュリティの観点からst.download_button等の利用は禁止しており、基盤チームが作成したカスタムコンポーネントからのみファイルのダウンロードが可能となっています。
そんなさなか、Streamlit1.28.0ではst.dataeditor、st.dataframeのツールバーにて表示しているDataFrameダウンロード機能が実装されます。
主にデータ分析に使用されるプラットフォームであり、多くのアプリでst.dataeditor、st.dataframeが利用されていました。
当初は基盤チームでの対処を検討しておりましたが即日の対応は難しく、GTMに出番が回ってきました。
st.dataeditor等のツールバーが表示された場合、GTMの要素の表示トリガーで検知し、HTMLの中身を書き換えダウンロードを防いでいます。

HTML/CSSを操作できる点

アプリの分析の一貫として、利用者に対しアプリの満足度アンケートを実施しております。

ポップアップアンケートのイメージ

こちらはGTMのカスタムHTMLを用いて画面に表示しており、次のような制御もすべてGTMで行っております。

  • 乱数を振り、表示確立を一定に制御
  • 回答or「×」を押した利用者にはCookieを用いて一定期間再表示されないよう制御
  • アンケートの表示、および利用者の採点などの操作は全てGA4にイベント送信

アンケートの結果はアプリ別に集計し、利用者の満足度を定常的に可視化しています。
この情報はアプリ開発者へのフィードバックとなり、アプリの改修前後での満足度の比較を続けることで利用者全体の満足度上昇につながっています。

一定期間毎の特定アプリと全体の満足度平均を比較したグラフ

上記以外にも次のような点でGTMを活用しております。

  • 公開終了するアプリの終了告知
  • 何か問題が起きた場合にすべてのアプリで表示する注意文言の表示

終わりに

Streamlitのデータ分析用アプリケーションプラットフォームにGA4とGTMを導入することで、アプリの詳細な分析から画面の制御まで様々なことができることを紹介させていただきました。
まだまだできることは沢山あると思いますので今後もよりよい活用方法を模索していきたいと思います。
また、記事内では紹介できませんでしたが、当初は「Streamlit?何それ?」だった私ですが、スペシャルなDP部の仲間達に支えられてここまでやってこれました。
本当にありがとうございます!(引き続きよろしくお願いいたします!!