🐙

Google Analytics Data API + Cloud Functionsでブログの閲覧レポートを通知する

2023/04/17に公開

先日作成した個人ブログの閲覧数が知りたかったのでGoogleAnalyticsのアクティブユーザー数をCloud Functionsを使用し月末にslack通知するようにしたのでその備忘録です。Cloud FunctionsはGoで作成しています。Google AnalyticsとSlackはそれぞれGoのSDKを使用しています。

ブログ作成した話はこちら

https://zenn.dev/jy8752/articles/0b842e7f380fb8

対象読者

  • 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のプロジェクトを作成してまずはユーザー数を取得していきます。ほぼほぼ以下の記事を参考させていただきました。

https://qiita.com/RikuRicky/items/f048d6f334fced826718

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のクライアントレポジトリを見てもいいかもです。

https://github.com/googleapis/google-api-go-client

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 取得する期間を設定します。todayNdaysAgoといったように文字列で指定します。期間は複数指定可能ですが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

以下の記事を参考にさせていただきました

https://zenn.dev/kou_pg_0131/articles/go-slack-go-usage

	// 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.NewSectionBlockslack.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にデプロイします。

関数の全文はこちら
notify.go
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に記載してください。環境変数ファイルは最低限指定してください。

.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に通知する方法でした。

今回作った全ての成果物はこちら

https://github.com/JY8752/my-blog/tree/main/gcp

GitHubで編集を提案

Discussion