Google Analytics Data API + Cloud Functionsでブログの閲覧レポートを通知する
先日作成した個人ブログの閲覧数が知りたかったのでGoogleAnalyticsのアクティブユーザー数をCloud Functionsを使用し月末にslack通知するようにしたのでその備忘録です。Cloud FunctionsはGoで作成しています。Google AnalyticsとSlackはそれぞれGoのSDKを使用しています。
ブログ作成した話はこちら
対象読者
- Cloud Functionsを使ったことがない方
- Google Analytics Data APIをGoで使用してみたい方
- Cloud FunctionsをGoで作成したい方
- Slack APIをGoで使用して何かしらの通知をしたい方
モチベーション
まだ何も書いていないけど閲覧してくれた方がどのくらいかわからないと書くモチベーションにならないので😓
GCPプロジェクト準備
GCPプロジェクトの作成
GCPプロジェクトを作成します。gcloudでも作成できますが今回はコンソールから作成しました。
gcloud
リソースの作成などはgcloudを使用し手元で作成したかったので作成したプロジェクトを設定します。
gcloud config set project $GCP_PROJECT_ID
デフォルトのプロジェクトとして設定してしまいましたが、たぶんprofileみたいなのを作成できると思うのでデフォルトに設定したくない方は調べてみてください🙇
サービスアカウントの作成
Cloud Functionsに紐づけるサービスアカウントを作成します。Cloud FunctionsがGoogle Analytics Data APIを実行できる権限があればいいと思うのですがowner権限で作成してます。
gcloud projects add-iam-policy-binding $GCP_PROJECT_ID \
--member serviceAccount:"my-blog-service-account@$GCP_PROJECT_ID.iam.gserviceaccount.com" \
--role "roles/owner" \
--no-user-output-enabled
APIの有効化
以下のAPIを有効化しておく必要があるのでまとめて有効化しておきます。
// cloud functions
// Cloud Functionsは裏側でCloud RunやCloud Buildが動くのでそれらも有効化する。
gcloud services enable cloudfunctions.googleapis.com
gcloud services enable run.googleapis.com
gcloud services enable artifactregistry.googleapis.com
gcloud services enable cloudbuild.googleapis.com
// google analytics
gcloud services enable analyticsdata.googleapis.com
// cloudscheduler
gcloud services enable cloudscheduler.googleapis.com
gcloud services enable eventarc.googleapis.com
月間の閲覧ユーザー数を取得する(Google Analytics Data API)
Goのプロジェクトを作成してまずはユーザー数を取得していきます。ほぼほぼ以下の記事を参考させていただきました。
Google Analytics Data APIについて
Google AnalyticsのAPIはGoogle Analytics Data API(G4)とそれ以前まで使われていたUniversal Analytics(G3)があります。G3はサポートが終了する予定なのでこれから使用するならG4の情報を追えば良さそうです。私が作成したブログに埋め込んだGoogle AnalyticsはG4で対応していたようなので今回はGoogle Analytics Data APIを使用します。
Google Analyticsにサービスアカウントを設定する
Google Analyticsのレポートしたいサイトのページを開き、作成したサービスアカウントを設定します。左下にある「管理 > アカウントのアクセス管理 > ユーザーを追加」を選択し、作成したサービスアカウントのメールアドレスとロールを管理者に設定して追加を押してサービスアカウントを追加しておきます。
Goモジュールのインストール
以下のモジュールをインストールします。
go get google.golang.org/api/analyticsdata/v1beta
go get google.golang.org/api/option
詳細知りたい方は以下のGoのクライアントレポジトリを見てもいいかもです。
google.golang.org/api/option
は認証を通すのに使用し、google.golang.org/api/analyticsdata/v1beta
はAPIクライアントとして使用します。
認証
以下のような感じ
// 認証
base64Credential := os.Getenv("GOOGLE_CREDENTIAL")
jsonCredential, err := base64.StdEncoding.DecodeString(base64Credential)
if err != nil {
fmt.Println(err.Error())
return err
}
client, err := ga.NewService(ctx, option.WithCredentialsJSON(jsonCredential))
if err != nil {
fmt.Println(err.Error())
return err
}
サービスアカウントの認証JSONをGCPコンソールからダウンロードできるので、そのJSONファイルをoption.WithCredentialsFile()
に指定する方法もあるのですが、今回Cloud Functionsでソースコードを公開しているので認証ファイルを含めたくなかったので他にいいやり方があるかもしれないですが今回はファイルの中身をbase64でエンコードして環境変数としてそのまま渡しています。そして、base64デコードしてoption.WithCredentialsJSON()
の引数に指定することで認証を通しています。
たぶん、本当はGCPのSecretを使用したほうがいいです。
レスポンスから必要な情報を抽出する
こんな感じ
// Google Analytics APIへのリクエスト作成
runReportRequest := &ga.RunReportRequest{
DateRanges: []*ga.DateRange{
{StartDate: startDate, EndDate: "today"}, // 月間
},
Dimensions: []*ga.Dimension{
{Name: "pageTitle"},
},
Metrics: []*ga.Metric{
{Name: "activeUsers"},
},
Limit: reportCount,
}
// レポート取得
propertyId := os.Getenv("BLOG_PROPERTY_ID")
res, err := client.Properties.RunReport(fmt.Sprintf("properties/%s", propertyId), runReportRequest).Do() // XXXXXXXXX の部分に property id が入る
if err != nil {
fmt.Println(err.Error())
return err
}
if bytes, err := res.MarshalJSON(); err == nil {
fmt.Println(string(bytes))
}
// 取得したレスポンスから必要な情報だけ抽出
monthlyReports := make([]Report, 0, reportCount)
for _, row := range res.Rows {
if len(row.DimensionValues) != 1 {
continue
}
if len(row.MetricValues) != 1 {
continue
}
monthlyReports = append(monthlyReports, Report{row.DimensionValues[0].Value, row.MetricValues[0].Value})
}
ga.RunReportRequest
でリクエスト内容を作成し、client.Properties.RunReport
でAPIリクエストを実行しています。
-
DateRanges 取得する期間を設定します。
today
やNdaysAgo
といったように文字列で指定します。期間は複数指定可能ですが4つまでの指定しかできず、5つ以上指定するとエラーとなってしまいました。(それについて書かれているドキュメントがパッと見つからなかったので間違っていたらすみません。)詳しくはこちら -
Dimensions 取得するデータの属性です。今回はどのページかわかるだけでいいので
pageTitle
だけ指定しています。詳しくはこちら -
Metrics レポートの定量的な測定値を指定できます。今回は
activeUsers
だけ指定しています。詳しくはこちら -
その他 今回は
Limit
だけ指定して取得件数を絞りました。詳しくはこちら
RunReport
の第一引数にはproperties/<property_id>
の形式で文字列で指定します。property_idはGoogle Analyticsの管理画面などで確認してください。
取得はGoの構造体として扱うこともできますし、JSONとして扱うこともできますが今回は構造体から必要な情報だけ抽出しました。
Slackで通知する
今回はSlackが公開しているslack-go
を使用します。
go get -u github.com/slack-go/slack
以下の記事を参考にさせていただきました
// slackへの通知
tkn := os.Getenv("SLACK_TOKEN")
sc := slack.New(tkn)
var sections []slack.Block
// Header Section
headerText := slack.NewTextBlockObject("mrkdwn", fmt.Sprintf("*MyBlog Google Analytics [%d月] 月間レポート*\n", int(now.Month())), false, false)
headerSection := slack.NewSectionBlock(headerText, nil, nil)
sections = append(sections, headerSection)
// 月間レポート
sections = append(sections, slack.NewSectionBlock(slack.NewTextBlockObject("mrkdwn", "*月間*🚀", false, false), nil, nil))
for _, report := range monthlyReports {
txt := slack.NewTextBlockObject("mrkdwn", fmt.Sprintf("*%s*\nアクセス数: %s", report.PageTitle, report.ActiveUsers), false, false)
sections = append(sections, slack.NewSectionBlock(txt, nil, nil))
}
// slack送信
channelId := os.Getenv("SLACK_CHANNEL_ID")
_, _, err = sc.PostMessage(channelId, slack.MsgOptionBlocks(
sections...,
))
まず、SlackのAPIトークンを使用してクライアントの初期化をします。SlackのAPIトークンはSlackのAPIページから作成してください。
slack.NewSectionBlock
にslack.NewTextBlockObject
を指定してブロックを作成します。今回はヘッダー部分と取得したページごとのアクセス数ごとにブロックを作成してsliceに格納しておきます。セクションやブロックについては他にもいろいろあってリッチなデザインの通知もできるので興味があるかたはslack-goのExampleが参考になるかもしれません。
あとはPostMessage
に通知したいslackチャンネルのIDと格納しておいたブロックを渡すことでメッセージを通知することができます。
Pub/Subの作成
作成した関数は毎月月末に通知されるように定期実行します。Cloud Functionsを定期実行するにはGCPのPub/SubとCloud Schedulerを組み合わせることで実現できます。まずPub/Subの作成
gcloud pubsub topics create <トピック名>
Cloud Scheduler
次に作成したPub/Subのトピック名を指定してCloud Schedulerを作成します。
gcloud scheduler jobs create pubsub <スケジュール名> \
--schedule="23 59 28-31 * *" \ // cron設定
--topic=<トピック名> \ // 作成したPub/Subのトピック名
--message-body="{}" \ // 何か渡したいパラメーターなどあれば
--time-zone=Asia/Tokyo \
--location=asia-northeast1
Pub/Subの登録と月末実行の処理
cronの設定ですが月末実行なので28-31日の範囲になってしまい、日にちを指定できないので28-31日で毎日実行し次の日が1日であれば実行するような処理を関数にいれることで月末に通知が飛ぶようにしています。また、今回はPub/Subトリガーによる関数実行なので関数もそれに対応できるよう修正します。
Pub/Subの対応
go get github.com/cloudevents/sdk-go/v2/event
go get github.com/GoogleCloudPlatform/functions-framework-go/functions
func init() {
functions.CloudEvent("BlogNotify", BlogNotify)
}
func BlogNotify(ctx context.Context, e event.Event) error {
月末実行の処理
loc, err := time.LoadLocation("Asia/Tokyo")
if err != nil {
fmt.Println(err.Error())
return err
}
now := time.Now().In(loc)
addOneDay := now.AddDate(0, 0, 1)
// 次の日付が1日でなければ月末ではないので処理を終了する
if addOneDay.Day() != 1 {
fmt.Println("月末ではないので処理をskipします。")
return nil
}
// 実行した日にちで何日前までのレポートを取得するかを決める
var startDate string
switch now.Day() {
case 28:
startDate = "27daysAgo"
case 29:
startDate = "28daysAgo"
case 30:
startDate = "29daysAgo"
case 31:
startDate = "30daysAgo"
default:
startDate = "28daysAgo"
}
Cloud Functionsのデプロイ
ここまででデプロイする関数が完成したのでCloud Functionsにデプロイします。
関数の全文はこちら
package notifyanalytics
import (
"context"
"encoding/base64"
"fmt"
"os"
"time"
"github.com/GoogleCloudPlatform/functions-framework-go/functions"
"github.com/cloudevents/sdk-go/v2/event"
"github.com/slack-go/slack"
ga "google.golang.org/api/analyticsdata/v1beta"
"google.golang.org/api/option"
)
const (
reportCount = 3
)
type Report struct {
PageTitle string `json:"pageTitle"`
ActiveUsers string `json:"activeUsers"`
}
func init() {
functions.CloudEvent("BlogNotify", BlogNotify)
}
func BlogNotify(ctx context.Context, e event.Event) error {
loc, err := time.LoadLocation("Asia/Tokyo")
if err != nil {
fmt.Println(err.Error())
return err
}
now := time.Now().In(loc)
addOneDay := now.AddDate(0, 0, 1)
debugMode := os.Getenv("DEBUG_MODE")
isDebug := debugMode != ""
// 次の日付が1日でなければ月末ではないので処理を終了する
if addOneDay.Day() != 1 && !isDebug {
fmt.Println("月末ではないので処理をskipします。")
return nil
}
// 認証
base64Credential := os.Getenv("GOOGLE_CREDENTIAL")
jsonCredential, err := base64.StdEncoding.DecodeString(base64Credential)
if err != nil {
fmt.Println(err.Error())
return err
}
client, err := ga.NewService(ctx, option.WithCredentialsJSON(jsonCredential))
if err != nil {
fmt.Println(err.Error())
return err
}
var startDate string
switch now.Day() {
case 28:
startDate = "27daysAgo"
case 29:
startDate = "28daysAgo"
case 30:
startDate = "29daysAgo"
case 31:
startDate = "30daysAgo"
default:
startDate = "28daysAgo"
}
fmt.Println("startDate: ", startDate)
// Google Analytics APIへのリクエスト作成
runReportRequest := &ga.RunReportRequest{
DateRanges: []*ga.DateRange{
{StartDate: startDate, EndDate: "today"}, // 月間
},
Dimensions: []*ga.Dimension{
{Name: "pageTitle"},
},
Metrics: []*ga.Metric{
{Name: "activeUsers"},
},
Limit: reportCount,
}
// レポート取得
propertyId := os.Getenv("BLOG_PROPERTY_ID")
res, err := client.Properties.RunReport(fmt.Sprintf("properties/%s", propertyId), runReportRequest).Do() // XXXXXXXXX の部分に property id が入る
if err != nil {
fmt.Println(err.Error())
return err
}
if bytes, err := res.MarshalJSON(); err == nil {
fmt.Println(string(bytes))
}
// 取得したレスポンスから必要な情報だけ抽出
monthlyReports := make([]Report, 0, reportCount)
for _, row := range res.Rows {
if len(row.DimensionValues) != 1 {
continue
}
if len(row.MetricValues) != 1 {
continue
}
monthlyReports = append(monthlyReports, Report{row.DimensionValues[0].Value, row.MetricValues[0].Value})
}
// slackへの通知
tkn := os.Getenv("SLACK_TOKEN")
sc := slack.New(tkn)
var sections []slack.Block
// Header Section
headerText := slack.NewTextBlockObject("mrkdwn", fmt.Sprintf("*MyBlog Google Analytics [%d月] 月間レポート*\n", int(now.Month())), false, false)
headerSection := slack.NewSectionBlock(headerText, nil, nil)
sections = append(sections, headerSection)
// 月間レポート
sections = append(sections, slack.NewSectionBlock(slack.NewTextBlockObject("mrkdwn", "*月間*🚀", false, false), nil, nil))
for _, report := range monthlyReports {
txt := slack.NewTextBlockObject("mrkdwn", fmt.Sprintf("*%s*\nアクセス数: %s", report.PageTitle, report.ActiveUsers), false, false)
sections = append(sections, slack.NewSectionBlock(txt, nil, nil))
}
// slack送信
channelId := os.Getenv("SLACK_CHANNEL_ID")
_, _, err = sc.PostMessage(channelId, slack.MsgOptionBlocks(
sections...,
))
if err != nil {
fmt.Println(err.Error())
return err
}
return nil
}
gcloud functions deploy <function名> \
--entry-point=BlogNotify \
--region=asia-northeast1 \
--runtime=go120 \
--memory=128Mi \
--env-vars-file=.env.yaml \
--allow-unauthenticated \
--gen2 \
--run-service-account="${SERVICE_ACCOUNT_EMAIL}" \
--service-account="${SERVICE_ACCOUNT_EMAIL}" \
--trigger-topic=<Pub/Subのトピック名>
環境変数は.env.yaml
ファイルに記載して渡しています。ファイルは.gitignoreに記載してpushされないようにしてください。また、Cloud Functionsに上げたくないファイルは.gcloudignore
に記載してください。環境変数ファイルは最低限指定してください。
*_test.go
.env
.env.yaml
.envrc
Makefile
デプロイが完了したらCloud Schedulerのコンソール画面を開き強制実行してちゃんと関数が実行されSkack通知されるか確認します。問題なく実行されると以下のようなSlack通知がされます。
ハマったところ
Cloud Functionsのデプロイ時にビルドがこける
Goのモジュール名がちゃんとしたモジュール形式にそってないとビルドでエラーが出る。あんまり公開するようなもの作っていないのでちゃんとモジュール名つけてなかったのでちゃんとつけようと思いました。
ERROR: (gcloud.functions.deploy) OperationError: code=3, message=Build failed with status: FAILURE and message: the module path in the function's go.mod must contain a dot in the first path element before a slash, e.g. example.com/module, found: test. For more details see the logs at https://console.cloud.google.com/cloud-build/builds;region=asia-northeast1/8563c2d5-6004-4ee5-825c-e9c58af32dd6?project=956708463040.
NG test
OK github.com/JY8752/test
まとめ
Cloud Functionsを初めて使ったのですが慣れればかなり簡単に使えそうだなと思いました。最初ビルドは自分でするのかと思ってビルドした実行バイナリをzip化してデプロイするという謎なことしてはまったんですが、裏側でビルドしてくれてるんですね。めちゃくちゃ便利。
以上、Cloud Functionsを定期実行して月間のアクセスレポートをSlackに通知する方法でした。
今回作った全ての成果物はこちら
Discussion