CloudTrail 証跡から Quick Sight ダッシュボード・分析の利用状況を可視化する

CloudTrail 証跡から Quick Sight ダッシュボード・分析の利用状況を可視化する

参照されていない Quick Sight ダッシュボードを洗い出すため、既存の CloudTrail 証跡からダッシュボード・分析の閲覧履歴ログを Athena で集計する仕組みを構築しました。
2026.08.05

コーヒーが好きな emi です。

Quick Sight(旧 QuickSight)でダッシュボードを配布していると、だんだん数が増えていって「これ誰か見てるんだっけ」というダッシュボードが混ざってきます。作った側は気づきにくいので、参照回数を実際に数えて、使われていないものを機械的に洗い出せる仕組みを作りました。

Quick Sight のダッシュボード参照ログは CloudTrail 証跡に記録されています。今回はそれを Athena で取得し、日次で自動更新されるダッシュボード一覧と JOIN し、Quick Sight で可視化するためのデータを準備するところまでを構築しました。

検証環境

項目 内容
リージョン ap-northeast-1(QuickSightの分析が一部 us-east-1 にもあったため、Lambda・Athena側は両リージョンを見る構成にしています)
Lambda ランタイム Python 3.14(x86_64 / メモリ128MB / タイムアウト60秒)
参照ログの集計対象期間 直近90日分

全体構成

AWS アーキテクチャイメージです。

quicksight-dashboard-usage-visualization-cloudtrail-athena_10

順番はこんな感じです。
quicksight-dashboard-usage-visualization-cloudtrail-athena_11

ログは既存の CloudTrail 証跡を利用します。

0. CloudTrail 証跡の管理イベント設定を確認する

既存の CloudTrail 証跡が欲しいイベントを記録しているか最初に確認します。

CloudTrail の管理イベントは、証跡の「API アクティビティ」設定で以下 3 種類から選べます。

  1. 読み取り (参照系。GetDashboardGetAnalysis など)
  2. 書き込み (変更を伴う書き込み系。CreateDashboardDeleteDashboard など)
  3. すべて

今回は「すべて」もしくは「読み取り」が設定されているか確認します。

あわせて証跡がマルチリージョンになっているかも確認します。Quick Sight のアセットはリージョンごとに存在するため、証跡が単一リージョンのままだと別リージョンのイベントを取りこぼします。

quicksight-dashboard-usage-visualization-cloudtrail-athena_2

なお、管理イベントは 1 証跡目までは無料です。(データイベントは別途課金)

https://aws.amazon.com/jp/cloudtrail/pricing/
https://dev.classmethod.jp/articles/aws-cloudtrail-watch-out-for-2nd-trail-costs/

CloudTrail のイベント履歴画面では Quick Sight ユーザーが UUID 表示になる

Athena を構築する前に CloudTrail の「イベント履歴」画面で GetDashboard イベントを覗いたところ、IAM の権限を持つユーザーはユーザー名がそのまま表示される一方、IAM 権限を持たない Quick Sight 側でプロビジョニングされたユーザー(メールアドレスで招待したユーザー)は、610f4ac4-... のような UUID (Quick Sight 内部の PrincipalId) が表示され、誰が見たのか一見分かりません。

quicksight-dashboard-usage-visualization-cloudtrail-athena_3

最初はこれを見て、AWS の公式ドキュメント

CloudTrail は、Amazon Quick Sight によってプロビジョニングされたunknownかのようにユーザーを表示します。このように表示されるのは、これらのユーザーが既知の IAM ID のタイプではないためです。
CloudTrail を使用した Amazon Quick Sight でのインシデント対応、ログ記録、モニタリング - Amazon Quick

という記述の通りだと思い、ユーザーの特定には何らかの形でユーザー名と PrincipalId を紐づけないといけないのだと考えていました。

ところが CloudTrail の生ログが溜まっている S3 バケットに対して Athena でクエリし同じイベントの JSON を直接見てみると、ユーザー名が分かりました(クエリの詳細は後述します)。イベント履歴画面で UUID (PrincipalId) が表示されていたのはコンソール側の表示処理の都合であって、ログの実体には名前が残っていたということが分かりました。

1. Athena の器を作る(ワークグループとデータベース)

クエリを書き始める前に、専用のワークグループとデータベースを作成しました。どちらも default のままでも進められますが、後々の運用や他の検証用リソースとの混在を避けるため、今回はあらかじめ分離しておく方針としました。

ワークグループを分けるモチベーション

Athena のワークグループは、クエリ結果の出力先・スキャン量の上限・クエリ履歴などをまとめて管理する単位です。default のままでも動きますが、目的ごとに分けておくと以下の点で扱いやすくなります。

  • クエリ結果が専用の S3 バケットに書き出されるため、他の用途の結果ファイルと混在しない
  • 「スキャンされるデーやの上限」を設定しておくと、スキャン量の上限に達した際クエリが強制キャンセルされ、コスト対策になる
    • 「ワークグループデータの使用状況アラート - オプション」を設定すると、指定期間内にワークグループ全体で 設定したしきい値を超えた場合にアラートを出せる。これは通知のみ
      上限に達してもクエリは止まりません。 内部的に CloudWatch アラームが作られ、SNS で通知が飛ぶだけです。
  • ワークグループごとにクエリ履歴が分かれるため、他の検証用クエリが混ざらない
データベースを分けるモチベーション

Athena データベースは Glue Data Catalog 上のテーブル管理単位です。ワークグループとは独立していて、同じアカウント・リージョン内であればどのワークグループからでも同じデータベース・テーブルが見えます。専用のデータベースを作っておくと、「このデータベースに入っているテーブルはすべてこの用途のもの」と一目で分かります。

default を使い続けると以下の問題が起きやすいです。

  • 他の用途で作ったテーブルが一覧に並ぶため、どれが何のためのテーブルか分かりにくくなる
  • 関係のないテーブルを DROP したり、別の用途のクエリから参照してしまうミスが起きやすい

専用のワークグループ(wg-quicksight-log)を作り、クエリ結果の出力先として専用の S3 バケット aws-athena-query-results-quicksight-log-<AWS アカウント ID> を作成・設定し、「クエリごとのデータ使用量」の上限を 10GB に設定しました。Athena はスキャン量に応じて $5/TB で課金される従量課金なので、この上限を設けることでクエリのコストが意図せず膨らむのを防ぐガードレールになります。

quicksight-dashboard-usage-visualization-cloudtrail-athena_4

次に Athena で以下クエリを実行し、データベースを作成します。

CREATE DATABASE IF NOT EXISTS cloudtrail_verification
COMMENT 'QuickSightダッシュボード参照ログ可視化用';

2. 既存の CloudTrail ログを Athena で参照できるようにする

器ができたところで、CloudTrail の管理イベントログが溜まっている S3 バケットに対して、Partition Projection 付きの外部テーブルを作成します。Partition Projection については以下記事も参照ください。

https://dev.classmethod.jp/articles/athena-partition-projection-image/

https://docs.aws.amazon.com/ja_jp/athena/latest/ug/partition-projection.html

CloudTrail 管理イベントログをクエリするテーブル作成(Partition Projection 使用)
CREATE EXTERNAL TABLE cloudtrail_pp (
    eventVersion STRING,
    userIdentity STRUCT<
        type: STRING,
        principalId: STRING,
        arn: STRING,
        accountId: STRING,
        invokedBy: STRING,
        accessKeyId: STRING,
        userName: STRING,
        sessionContext: STRUCT<
            attributes: STRUCT<
                mfaAuthenticated: STRING,
                creationDate: STRING>,
            sessionIssuer: STRUCT<
                type: STRING,
                principalId: STRING,
                arn: STRING,
                accountId: STRING,
                username: STRING>,
            ec2RoleDelivery: STRING,
            webIdFederationData: STRUCT<
               federatedProvider: STRING,
               attributes: MAP<STRING,STRING>>>>,
    eventTime STRING,
    eventSource STRING,
    eventName STRING,
    awsRegion STRING,
    sourceIpAddress STRING,
    userAgent STRING,
    errorCode STRING,
    errorMessage STRING,
    requestParameters STRING,
    responseElements STRING,
    additionalEventData STRING,
    requestId STRING,
    eventId STRING,
    resources ARRAY<STRUCT<
        arn: STRING,
        accountId: STRING,
        type: STRING>>,
    eventType STRING,
    apiVersion STRING,
    readOnly STRING,
    recipientAccountId STRING,
    serviceEventDetails STRING,
    sharedEventID STRING,
    vpcEndpointId STRING,
    tlsDetails STRUCT<
        tlsVersion: STRING,
        cipherSuite: STRING,
        clientProvidedHostHeader: STRING>
)
PARTITIONED BY (
    region STRING,
    dt STRING
)
ROW FORMAT SERDE 'org.apache.hive.hcatalog.data.JsonSerDe'
STORED AS INPUTFORMAT 'com.amazon.emr.cloudtrail.CloudTrailInputFormat'
OUTPUTFORMAT 'org.apache.hadoop.hive.ql.io.HiveIgnoreKeyTextOutputFormat'
LOCATION 's3://cm-members-cloudtrail-<AWS アカウント ID>/AWSLogs/<AWS アカウント ID>/CloudTrail/'
TBLPROPERTIES (
    'classification'='cloudtrail',
    'projection.enabled'='true',
    'projection.region.type'='enum',
    'projection.region.values'='ap-northeast-1,us-east-1',
    'projection.dt.type'='date',
    'projection.dt.format'='yyyy/MM/dd',
    'projection.dt.range'='2025/01/01,NOW',
    'projection.dt.interval'='1',
    'projection.dt.interval.unit'='DAYS',
    'storage.location.template'='s3://cm-members-cloudtrail-<AWS アカウント ID>/AWSLogs/<AWS アカウント ID>/CloudTrail/${region}/${dt}'
);

quicksight-dashboard-usage-visualization-cloudtrail-athena_5

CloudTrail 管理イベントログをクエリするテーブル作成(Partition Projection 使用)に関する補足

最初の CREATE EXTERNAL TABLE は、S3 に保存された CloudTrail の JSON ログを Athena から読むためのテーブル定義です。JSON の各項目を Athena の列として対応付けています。userIdentitytlsDetails のように JSON の中に入れ子になったオブジェクトは STRUCT、複数の要素を持つ resourcesARRAY<STRUCT<...>> で表現しています。

PARTITIONED BY で指定した regiondt は、JSON の中の項目ではなく、S3 のパスから決まるパーティション列です。TBLPROPERTIES では、CloudTrail 用の SerDe (シリアライズ・デシリアライズ。S3 上のデータ形式と Athena が扱うテーブルの列を変換する仕組み) を使うこと、Partition Projection でリージョンと日付の候補を計算すること、そしてパーティション値から S3 の読み取り先を組み立てることを設定しています。これで、Athena はクエリの条件に合う S3 のプレフィックスだけを読み取れます。

Partition Projection を使う理由は、dt(日付)でクエリの読み取り範囲を絞るためです。今回の CloudTrail ログは、S3 上でリージョンと日付ごとに次のようなパスへ保存されています。

s3://<バケット名>/AWSLogs/<AWSアカウントID>/CloudTrail/${region}/yyyy/mm/dd/

このパスの ${region} は、Partition Projection の region パーティションに指定した値に置き換わります。yyyy/mm/dd の部分は、dt パーティションの日付に対応します。
リージョンの指定は必須ではありません。dt だけを条件に指定した場合は、projection.region.values に設定したすべてのリージョンについて、該当日付の S3 プレフィックスを読み取ります。region も条件に指定すると、読み取り対象をそのリージョンだけに絞れます。

このまま cloudtrail_pp を毎回参照すると、CloudTrail の項目名や JSON の解析処理をクエリごとに書く必要があります。そこで、ダッシュボードと分析の参照ログを共通の列に整える qs_resource_views を作ります。ビューはクエリ結果を保存するのではなく、cloudtrail_pp に対する SELECT 文を名前付きで保存する仕組みです。このビュー自体では、Partition Projection の日付やリージョンを絞り込んでいません。

CREATE OR REPLACE VIEW qs_resource_views AS
SELECT
  region,
  dt,
  eventid AS event_id,
  date_add(
    'hour', 9,
    CAST(from_iso8601_timestamp(eventtime) AS TIMESTAMP)
  ) AS viewed_at_jst,
  CASE eventname
    WHEN 'GetDashboard' THEN 'DASHBOARD'
    ELSE 'ANALYSIS'
  END AS resource_type,
  element_at(
    split(
      COALESCE(
        json_extract_scalar(
          serviceeventdetails,
          '$.eventRequestDetails.dashboardId'
        ),
        json_extract_scalar(
          serviceeventdetails,
          '$.eventRequestDetails.analysisId'
        ),
        json_extract_scalar(
          requestparameters,
          '$.dashboardId'
        ),
        json_extract_scalar(
          requestparameters,
          '$.analysisId'
        )
      ),
      '/'
    ),
    -1
  ) AS resource_id,
  COALESCE(
    json_extract_scalar(
      json_extract(
        serviceeventdetails,
        '$.eventResponseDetails'
      ),
      '$.dashboardDetails.dashboardName'
    ),
    json_extract_scalar(
      json_extract(
        serviceeventdetails,
        '$.eventResponseDetails'
      ),
      '$.analysisDetails.analysisName'
    )
  ) AS logged_name,
  COALESCE(
    -- 1. Quick Sight 招待ユーザー・IAM ユーザーの名前
    NULLIF(useridentity.username, ''),
    -- 2. AssumedRoleのセッション名(principalidの末尾)
    NULLIF(
      element_at(
        split(useridentity.principalid, ':'),
        -1
      ),
      ''
    ),
    -- 3. ロール名(共有ロールだと全員同じになるので2より後)
    NULLIF(
      useridentity
        .sessioncontext
        .sessionissuer
        .username,
      ''
    )
  ) AS viewer,
  useridentity.type AS identity_type
FROM cloudtrail_pp
WHERE eventsource
      = 'quicksight.amazonaws.com'
  AND eventname IN (
        'GetDashboard',
        'GetAnalysis'
      );

qs_resource_views を使うときは、SELECT * FROM qs_resource_views WHERE dt BETWEEN '2026/07/01' AND '2026/07/29' のように dt を条件に指定します。この条件が裏側の cloudtrail_pp に渡り、Partition Projection によって該当日付の S3 プレフィックスだけが読み取られます。dt を指定せずに SELECT * FROM qs_resource_views と実行すると、Partition Projection に設定した日付範囲全体が読み取り対象になってしまい、スキャン量がかさんで Athena の料金が跳ね上がります。

qs_resource_views には region を残しているため、SELECT * FROM qs_resource_views WHERE region = 'ap-northeast-1' AND dt BETWEEN '2026/07/01' AND '2026/07/29' のように、呼び出し側でリージョンを指定できます。リージョンを指定せず dt だけで絞った場合は、projection.region.values に設定した東京とバージニア北部の両リージョンを読み取ります。

後述の qs_resource_views_90dqs_resource_daily_usage では、両リージョンのログを読み取ったうえで、regionresource_typeresource_id を組み合わせてマスタと突き合わせます。これにより、リージョンが異なる同じ ID のリソースを別々に扱います。

viewed_at_jst は、CloudTrail のログに UTC で記録されている eventtime を JST に変換した列です。単純に AT TIME ZONE で変換すると Athena の仕様でうまくいかなかったため、date_add で9時間足す方法を採っています。詳しくは以下を参照ください。

viewed_at_jst の時差修正

CloudTrail の eventtime は ISO 8601 の UTC 文字列です(例: 2026-07-29T01:23:45Z)。これを日本時間で表示したいとき、直感的には次のように書きたくなります。

CAST(
  from_iso8601_timestamp(eventtime) AT TIME ZONE 'Asia/Tokyo'
  AS TIMESTAMP
)

from_iso8601_timestamptimestamp with time zone 型を返します。これに AT TIME ZONE 'Asia/Tokyo' を付けると、UTC の 01:23:45 が JST の 10:23:45 に変換されます。ここまでは正しいです。

問題は外側の CAST(... AS TIMESTAMP) です。Athena のセッションタイムゾーンとは、クエリ実行時に Athena が内部的に使うタイムゾーンの設定で、明示的に変更しない限り UTC です。Athena には timestamp with time zone(時刻+タイムゾーン情報)と timestamp(時刻のみ)の2種類の型があり、ビューの列には後者しか使えません。CAST(... AS TIMESTAMP) でタイムゾーン付きの値を「時刻のみ」に変換する際、Athena はセッションタイムゾーン(UTC)の時刻に換算してからタイムゾーン情報を外します。

つまり、以下のようになります。

  1. from_iso8601_timestamp('2026-07-29T01:23:45Z') → UTC 01:23:45(timestamp with time zone)
  2. AT TIME ZONE 'Asia/Tokyo' → JST 10:23:45(timestamp with time zone、表示上は +09:00)
  3. CAST(... AS TIMESTAMP) → Athena のセッションタイムゾーン設定(既定 UTC)を基準に変換されるため、JST 10:23:45+09:00 が「UTC では何時か」と解釈し直され、01:23:45 に戻ってしまう(timestamp without time zone)

せっかく AT TIME ZONE で JST にシフトした 10:23:45 が 01:23:45 に戻ってしまいます。実測でも viewed_at_jst の値が元の eventtime と同じ数字になっていたのはこれが原因でした。

回避策として、タイムゾーン変換に頼らず、UTC の TIMESTAMP に対して単純に 9 時間を加算しています。

date_add('hour', 9, CAST(from_iso8601_timestamp(eventtime) AS TIMESTAMP))
  1. from_iso8601_timestamp('2026-07-29T01:23:45Z') → UTC 01:23:45
  2. CAST(... AS TIMESTAMP) → 01:23:45(タイムゾーン情報を落とす。元々 UTC なのでズレない)
  3. date_add('hour', 9, ...) → 10:23:45

こちらはセッションタイムゾーンの影響を受けません。常に +9 時間で正しい JST になります。

resource_id は、どのダッシュボード・分析が参照されたかを特定するための ID です。list-dashboardslist-analyses で取得できる DashboardId / AnalysisId と同じ値で、後でマスタ側と JOIN して「参照0回のリソース」を洗い出すときの突合キーになります。

resource_id の取り出し方

CloudTrail のログ JSON には、イベントの詳細が記録されるフィールドが2つあります。

  • requestParameters: 通常の AWS API コールではここにリクエスト内容が入る
  • serviceEventDetails: Quick Sight の GetDashboard / GetAnalysis のような「非 API イベント」ではこちらに入る

実際のログを見ると、GetDashboardserviceEventDetails にはこのような値が入っています。

{
  "eventRequestDetails": {
    "dashboardId": "arn:aws:quicksight:ap-northeast-1:<AWSアカウントID>:dashboard/9ab6a64b-61db-43a2-99d6-fba52cd3cc82"
  },
  "eventResponseDetails": {
    "dashboardDetails": {
      "dashboardName": "ごきげんの秘訣"
    }
  }
}

dashboardId の値は ARN 形式で、実際のダッシュボード ID は / の末尾部分 9ab6a64b-... です。split('/', -1) で末尾だけを取り出しています。分析の場合も同じ構造で、$.eventRequestDetails.analysisId に ARN が入ります。

最初は通常の API コールと同じ感覚で requestParameters を見に行って ID が入ってないことに気づきました。Quick Sight の GetDashboard / GetAnalysis は CloudTrail 上「非 API イベント」扱いで、情報のが serviceEventDetails に入る形になっています。

logged_name は CloudTrail ログの serviceEventDetails に記録されていたダッシュボード名または分析名です。GetDashboard イベントでは dashboardDetails.dashboardName (dashboardDetails ブロックの dashboardName) にダッシュボード名が入り、GetAnalysis イベントでは analysisDetails.analysisName に分析名が入ります。
このビューは GetDashboardGetAnalysis を 1 つの SELECT にまとめているため、ダッシュボード名のパス(dashboardDetails.dashboardName)と分析名のパス(analysisDetails.analysisName)の両方を COALESCE に並べています。GetDashboard のイベントなら前者に名前が入り後者は NULL、GetAnalysis なら逆になるので、どちらのイベントでも NULL でないほうが採用されます。

viewer(閲覧者)の COALESCE は、次の 3 つの候補を順に確認します。

  1. useridentity.username: Quick Sight 招待ユーザー(type=Directory)と IAM ユーザーで名前が入る
  2. principalid の末尾(: で split): AssumedRole のセッション名。SAML SSO の場合は UPN が個人を示す
  3. sessionissuer.username(ロール名): 共有ロール構成だと全員同じ値になるため、2段目より後に置いている

ビューのプレビューは以下のようになります。
quicksight-dashboard-usage-visualization-cloudtrail-athena_6

3. ダッシュボード・分析の一覧マスタを Lambda + EventBridge で日次自動生成し S3 バケットに格納する

CloudTrail のログには「開かれたダッシュボード」しか出てきません。一度も開かれていないダッシュボードは 1 行もログに出ないので、「使われていないダッシュボード」を挙げるには存在するダッシュボード・分析の全件リストが別途必要になります。この全件リストを日次で生成する Lambda 関数を作成し、EventBridge で定時実行させます。

3-1. ダッシュボードや分析のマスタを格納する S3 バケット作成

まず Lambda の出力先となる S3 バケットを作成します。
バケット名は任意で、バージョニングは有効にしています。Lambda は毎日同じキーに上書きするため、バージョニングを有効にしておけば、万が一 Lambda のバグで中身が壊れても前日のバージョンに戻せます。あわせてライフサイクルルールで非現行バージョンを30日後に削除する設定にし、旧バージョンの蓄積によるストレージコスト増を抑えています。

quicksight-dashboard-usage-visualization-cloudtrail-athena_7

出力先のパスは以下のようにします。

s3://<S3 バケット名>/qs-master/resources/resources.json

qs-master/resources というプレフィックスをここで作成しておく形ですね。

quicksight-dashboard-usage-visualization-cloudtrail-athena_8

resources.json がマスタ本体で、中身はダッシュボードと分析の全件リスト(ID、名前、リージョンなど)です。Lambda は毎日この同じキーに上書きするので、ファイルは常に最新の状態になります。

Athena の外部テーブルは LOCATION に指定したプレフィックス配下の全ファイルを読むため、qs-master/resources/ にはこのファイルだけを置く前提です。日付別にファイルを分けると全日付分が積み上がって読まれてしまうので、1ファイル上書き + バージョニングで履歴を保持する形にしています。

Lambda 作成

Lambda は list-dashboardslist-analyses で取得したダッシュボード・分析の一覧を、JSON Lines 形式で S3 に書き出すようにします。

Lambda 関数は以下の設定で作成しました。記載のない項目はデフォルトのままです。

項目 設定値
関数名 quicksight-dashboard-master-sync-daily
ランタイム Python 3.14
アーキテクチャ x86_64
メモリ 128MB
タイムアウト 60秒
コードファイル名 lambda_qs_master.py
ハンドラ lambda_qs_master.lambda_handler

コードファイル名とハンドラは対応させる必要があります。今回はコードファイルを lambda_qs_master.py としたため、ハンドラには lambda_qs_master.lambda_handler を指定します。コンソールで最初から用意されている lambda_function.py にコードを貼る場合は、ハンドラも lambda_function.lambda_handler にします。

環境変数は以下のように設定します。

キー
ACCOUNT_ID <AWSアカウントID>
MASTER_BUCKET <作成したマスタバケット名>
QS_REGIONS ap-northeast-1,us-east-1
RESOURCE_KEY qs-master/resources/resources.json

MASTER_BUCKETRESOURCE_KEY はマスタの出力先を決める値です。QS_REGIONS に指定した各リージョンで list-dashboardslist-analyses を実行します。検証環境では us-east-1 にもリソースがあったため、東京とバージニア北部の 2 リージョンを指定しています。

lambda_qs_master.py
import json
import os
from datetime import datetime, timezone

import boto3

ACCOUNT_ID = os.environ["ACCOUNT_ID"]
BUCKET = os.environ["MASTER_BUCKET"]
REGIONS = [
    r.strip()
    for r in os.environ.get(
        "QS_REGIONS", "ap-northeast-1,us-east-1"
    ).split(",")
    if r.strip()
]
RESOURCE_KEY = os.environ.get(
    "RESOURCE_KEY", "qs-master/resources/resources.json"
)

s3 = boto3.client("s3")

def _iso(value):
    """datetime を ISO8601(UTC) 文字列にする。None はそのまま返す。"""
    if value is None:
        return None
    return value.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")

def _list(client, api, result_key, **extra):
    """NextToken を手で回す。QuickSightのAPIはpaginator未提供のものがある。"""
    fn = getattr(client, api)
    items = []
    token = None
    while True:
        params = {"AwsAccountId": ACCOUNT_ID, "MaxResults": 100, **extra}
        if token:
            params["NextToken"] = token
        resp = fn(**params)
        items.extend(resp.get(result_key, []))
        token = resp.get("NextToken")
        if not token:
            return items

def _put_jsonl(key, records):
    lines = [json.dumps(r, ensure_ascii=False) for r in records]
    # 0件のときに空行だけのファイルを置くと JsonSerDe がエラーになる
    body = ("\n".join(lines) + "\n") if lines else ""
    s3.put_object(
        Bucket=BUCKET,
        Key=key,
        Body=body.encode("utf-8"),
        ContentType="application/x-ndjson",
    )

def lambda_handler(event, context):
    snapshot_date = datetime.now(timezone.utc).strftime("%Y-%m-%d")
    resources = []

    for region in REGIONS:
        qs = boto3.client("quicksight", region_name=region)

        for d in _list(qs, "list_dashboards", "DashboardSummaryList"):
            resources.append(
                {
                    "resource_type": "DASHBOARD",
                    "resource_id": d["DashboardId"],
                    "resource_name": d.get("Name"),
                    "status": None,
                    "region": region,
                    "created_time": _iso(d.get("CreatedTime")),
                    "last_updated_time": _iso(d.get("LastUpdatedTime")),
                    "last_published_time": _iso(d.get("LastPublishedTime")),
                    "snapshot_date": snapshot_date,
                }
            )

        # list-analyses はゴミ箱(30日保持)の分析も Status=DELETED で返す。
        # 除外はせずカラムで持ち、Athena側のビューで絞る。
        for a in _list(qs, "list_analyses", "AnalysisSummaryList"):
            resources.append(
                {
                    "resource_type": "ANALYSIS",
                    "resource_id": a["AnalysisId"],
                    "resource_name": a.get("Name"),
                    "status": a.get("Status"),
                    "region": region,
                    "created_time": _iso(a.get("CreatedTime")),
                    "last_updated_time": _iso(a.get("LastUpdatedTime")),
                    "last_published_time": None,
                    "snapshot_date": snapshot_date,
                }
            )

    _put_jsonl(RESOURCE_KEY, resources)

    counts = {}
    for r in resources:
        key = f"{r['region']}/{r['resource_type']}"
        counts[key] = counts.get(key, 0) + 1

    return {"counts": counts, "snapshot_date": snapshot_date}

コードをデプロイしたら、空の JSON {} をテストイベントとして実行します。成功すると、リージョンとリソース種別ごとの取得件数、およびスナップショットの日付が返ります。

{
  "counts": {
    "ap-northeast-1/DASHBOARD": 21,
    "ap-northeast-1/ANALYSIS": 91,
    "us-east-1/DASHBOARD": 1,
    "us-east-1/ANALYSIS": 4
  },
  "snapshot_date": "2026-07-30"
}

件数は環境によって異なります。あわせて、s3://<マスタバケット>/qs-master/resources/resources.json が作成されたことを確認します。

quicksight-dashboard-usage-visualization-cloudtrail-athena_9

出力は CSV ではなく JSON Lines にしています。
CSV と OpenCSVSerde の組み合わせで試したところ、ダッシュボード名や分析名にカンマや改行が含まれると列の読み取りが崩れてしまいました。JSON Lines なら、Lambda が各レコードを 1 行の JSON オブジェクトとして書き出す際に引用符や改行などを JSON の規則に従ってエスケープしてくれるので、列を正しく読み取れます。

また、Quick Sight のアセットはリージョンごとに存在するので、ap-northeast-1 だけを見ていると、他のリージョンにリソースがある場合に取りこぼします。検証環境では Quick 自体は東京で有効化していましたが、一部のリソースがバージニア北部に存在していました。このため Lambda は複数リージョンをループする作りにしています。

Lambda 関数に設定する IAM ロールには、IAM ポリシー AWSLambdaBasicExecutionRole と、以下のカスタマー管理の IAM ポリシーをアタッチしています。権限はダッシュボードや分析の一覧取得とマスタ書き込みだけに絞っています。IAM ポリシーの s3:PutObject もこのプレフィックス(arn:aws:s3:::<マスタバケット>/qs-master/*)に絞っています。

quicksight-dashboard-master-sync-daily-role-xxxxxxxxPolicy
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ListQuickSightResources",
      "Effect": "Allow",
      "Action": [
        "quicksight:ListDashboards",
        "quicksight:ListAnalyses"
      ],
      "Resource": [
        "arn:aws:quicksight:*:<アカウントID>:dashboard/*",
        "arn:aws:quicksight:*:<アカウントID>:analysis/*"
      ]
    },
    {
      "Sid": "PutMaster",
      "Effect": "Allow",
      "Action": "s3:PutObject",
      "Resource": "arn:aws:s3:::<マスタバケット>/qs-master/*"
    }
  ]
}

EventBridge 設定

Lambda コンソールの「トリガーを追加」から EventBridge Scheduler を選び、cron(0 17 * * ? *)、タイムゾーン Asia/Tokyo で毎日 17:00 に実行、実行ロールは「このスケジュール用に新しいロールを作成」を選んでこの関数だけに絞っています。今回は検証のために 17:00 にしていますが、この後に設定する Quick Sight の利用状況データセット(qs_resource_daily_usage)の SPICE フル更新より前に、この Lambda によるマスター更新が確実に終わるように時間を調整してください。マスタの更新が後になると、その日の SPICE フル更新に古い一覧が使われてしまいます。

4. Athena で CloudTrail のログとマスタを突き合わせる統合ビューを作る

ここまでで、CloudTrail の参照ログは cloudtrail_ppqs_resource_views から読めるようになりました。

ダッシュボード・分析の全件リストは Lambda が S3 に出力した resources.json から読み出しましょう。
まず、resources.json が置かれている S3 のプレフィックスを qs_resource_master という外部テーブルとして登録します。

S3 のマスタを Athena から読めるようにする

CREATE EXTERNAL TABLE qs_resource_master (
  resource_type       STRING,
  resource_id         STRING,
  resource_name       STRING,
  status              STRING,
  region              STRING,
  created_time        STRING,
  last_updated_time   STRING,
  last_published_time STRING,
  snapshot_date       STRING
)
ROW FORMAT SERDE
  'org.apache.hive.hcatalog.data.JsonSerDe'
LOCATION
  's3://<マスタバケット>/qs-master/resources/'
TBLPROPERTIES ('classification'='json');

resources.json がマスタの実体、qs_resource_master がそのファイルを Athena から読むためのテーブル名です。JsonSerDe が JSON Lines の各オブジェクトをテーブルの各列に対応付けます。LOCATION にはファイル名ではなく、resources.json が置かれている qs-master/resources/ プレフィックスを指定します。

ここから作るビューの関係は次のとおりです。

quicksight-dashboard-usage-visualization-cloudtrail-athena_13

図の qs_resource_views は前のセクションで作成済みです。ここから qs_resource_master と組み合わせ、閲覧者を確認する明細用ビューと、参照 0 回も含む日次集計用ビューを作ります。

閲覧者を確認する明細用ビュー

誰が、いつ、どのダッシュボードや分析を見たか確認するため、直近90日分の明細を返す qs_resource_views_90d を作成します。

誰が、いつ、どのダッシュボードや分析を見たか確認するため、直近90日分の明細を返す qs_resource_views_90d 作成
CREATE OR REPLACE VIEW qs_resource_views_90d AS
SELECT
  v.region,
  v.viewed_at_jst,
  v.resource_type,
  v.resource_id,
  COALESCE(
    m.resource_name,
    v.logged_name
  ) AS resource_name,
  v.logged_name,
  v.viewer,
  v.identity_type
FROM qs_resource_views v
LEFT JOIN qs_resource_master m
  ON  m.region = v.region
  AND m.resource_id = v.resource_id
  AND m.resource_type = v.resource_type
WHERE v.dt >= date_format(
  CAST(
    date_add(
      'hour',
      9,
      CAST(current_timestamp AS TIMESTAMP)
    ) AS DATE
  ) - INTERVAL '90' DAY,
  '%Y/%m/%d'
);

★後ほどテーブル定義を記載します。

CloudTrail 側とマスタ側の regionresource_typeresource_id を JOIN のキーにしています。resource_id は、list-dashboards / list-analyses で取得したマスタ側の ID と同じ値です。リソース名はマスタにある最新名を優先し、削除済みなどの理由でマスタに存在しない場合は、CloudTrail に記録された logged_name を使います。

このビューは CloudTrail のダッシュボード・分析参照ログを起点にしているため、一度も参照されていないリソースは出てきません。閲覧者ごとの利用状況を確認する明細用として使います。

参照 0 回も含める日次集計用ビュー

「使われていないダッシュボード」を見つけるには、参照 0 回のリソースも結果に含める必要があります。qs_resource_daily_usage は、明細用の qs_resource_views_90d とは別に作成します。まず、qs_resource_master にあるすべてのダッシュボード・分析と、クエリ内の sequence 関数で生成した直近 90 日分の日付を組み合わせ、「1 リソース × 1 日 = 1 行」の一覧を作ります。その一覧に、qs_resource_views をリソース別・日別に集計した参照件数を LEFT JOIN します。参照ログが存在しない組み合わせは、参照件数を 0 にします。

CREATE OR REPLACE VIEW qs_resource_daily_usage AS
WITH days AS (
  SELECT
    CAST(d AS DATE) AS view_date
  FROM UNNEST(
    sequence(
      CAST(
        date_add(
          'hour', 9,
          CAST(current_timestamp AS TIMESTAMP)
        ) AS DATE
      ) - INTERVAL '89' DAY,
      CAST(
        date_add(
          'hour', 9,
          CAST(current_timestamp AS TIMESTAMP)
        ) AS DATE
      ),
      INTERVAL '1' DAY
    )
  ) AS t(d)
),
views AS (
  SELECT
    CAST(viewed_at_jst AS DATE) AS view_date,
    region,
    resource_type,
    resource_id,
    COUNT(DISTINCT event_id) AS event_count,
    COUNT(DISTINCT viewer) AS unique_viewer_count
  FROM qs_resource_views
  WHERE dt >= date_format(
    CAST(
      date_add(
        'hour', 9,
        CAST(current_timestamp AS TIMESTAMP)
      ) AS DATE
    ) - INTERVAL '90' DAY,
    '%Y/%m/%d'
  )
  GROUP BY 1, 2, 3, 4
),
master AS (
  SELECT
    resource_type,
    resource_id,
    resource_name,
    region,
    created_time
  FROM qs_resource_master
  WHERE resource_type = 'DASHBOARD'
     OR status NOT IN (
          'DELETED',
          'CREATION_FAILED',
          'CREATION_IN_PROGRESS'
        )
)
SELECT
  m.resource_type,
  m.resource_id,
  m.resource_name,
  m.region,
  m.created_time,
  d.view_date,
  COALESCE(v.event_count, 0) AS event_count,
  COALESCE(v.unique_viewer_count, 0) AS unique_viewer_count
FROM master m
CROSS JOIN days d
LEFT JOIN views v
  ON  v.region = m.region
  AND v.resource_id = m.resource_id
  AND v.resource_type = m.resource_type
  AND v.view_date = d.view_date;

★後ほどテーブル定義を記載します。

master は、削除済みや作成に失敗した分析を除いたリソースの全件リストです。この全件リストと、sequence 関数で生成して days という名前を付けた直近 90 日分の日付一覧を CROSS JOIN し、「1リソース × 1日 = 1行」の組み合わせを先に作ります。実際の参照ログがなかった組み合わせは、最後の COALESCE で参照件数を0にします。

viewsdt 条件を1日広く取っているのは、S3 パーティションの dt が UTC の日付、view_date が JST の日付で、日付の境界が最大1日ずれるためです。実際に表示する90日間は、days と JOIN した時点で確定します。

これにより、Quick Sight では期間フィルタと SUM() だけで参照 0 回のリソースを判定できます。カスタム SQL の期間を利用者が都度書き換える必要はありません。この qs_resource_daily_usage を、後ほど SPICE に取り込むメインのデータセットとして使います。

event_count は、CloudTrail に記録された GetDashboard / GetAnalysis のうち、eventId が異なるイベントの件数です。CloudTrail のイベントレコードでは、eventId は各イベントを一意に識別する値と説明されています。このため、COUNT(DISTINCT event_id) で同じイベントの重複配信を二重に数えないようにしています。

東京リージョンの GetDashboard を 2026 年 7 月 1 日から 7 月 29 日まで確認したところ、全 243 行に対して異なる eventId も 243 件で、同じ eventId の重複はありませんでした。同じ閲覧者、同じダッシュボード、同じ秒に複数行が記録されたケースもありましたが、それぞれ異なる CloudTrail イベントでした。

ただし、複数のイベントが発生した理由までは特定できていません。複数タブ、再クリック、Quick Sight の画面内部の処理など、どの操作によるものかは今回のログだけでは判断できません。そのため、event_count は「人がダッシュボードを開いた回数」ではなく、「CloudTrail に記録された参照イベント数」として扱います。

なお、重複確認の対象は東京リージョンの GetDashboard だけです。GetAnalysis とバージニア北部リージョンは、この確認には含まれていません。

このビューは常に直近 90 日分を計算します。パーティションを使わずに生テーブルをスキャンした場合は 4.28GB でしたが、このビューでは 345MB まで減りました。日次更新した場合のスキャン量は月 10GB 弱、料金は約 0.05 ドルでした。

5. Quick Sight で SPICE 取り込み・可視化する

Quick Sight では用途の異なる 2 つの Athena ビューを、それぞれ別のデータセットとして使います。

参照回数と未使用リソースを確認するデータセット

qs_resource_daily_usage をメインのデータセットとして使います。このビューには、リソースごとの日別の event_countunique_viewer_count が入っています。参照ログがない日も 0 件の行を持つため、期間内に一度も参照されていないダッシュボードや分析を探す用途に向いています。

ただし、閲覧者名は持っていません。unique_viewer_count で閲覧者数は分かりますが、誰が見たかまでは確認できません。

誰がいつ見たかを確認するデータセット

qs_resource_views_90d は、閲覧者を確認するための明細データセットとして使います。viewed_at_jstvieweridentity_type を持っているため、誰が、いつ、どのダッシュボードや分析を参照したか確認できます。

このビューは CloudTrail の参照イベントを起点にしているため、一度も参照されていないリソースは出てきません。未使用リソースの確認には qs_resource_daily_usage、閲覧者ごとの明細確認には qs_resource_views_90d と使い分けます。

Quick Sight の同じ分析には 2 つのデータセットを追加できます。参照回数や未使用リソースを表示するビジュアルでは qs_resource_daily_usage、閲覧者別の表や明細では qs_resource_views_90d を選択します。

あとは Quick Sight で可視化するだけです!
一旦可視化を Generate Analysis で作成してみたのですが、一発では良い感じにならなかったので、また後程プロンプトを調整したりビジュアル自体を調整したりしたいと思います。

quicksight-dashboard-usage-visualization-cloudtrail-athena_12

おわりに

既存の CloudTrail 証跡を使い、日次の Lambda と Athena のビューを足して、Quick Sight ダッシュボード・分析の参照状況を可視化するデータを準備しました。

同じようにQuickSightの利用状況を可視化しようとしている方の参考になれば幸いです。

本記事への質問やご要望については画面下部の「DevelopersIOへのご意見」からお問い合わせいただけます。

参考

https://docs.aws.amazon.com/quick/latest/userguide/incident-response.html

https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudtrail-event-reference-user-identity.html

https://docs.aws.amazon.com/ja_jp/athena/latest/ug/partition-projection.html

https://aws.amazon.com/quick/quicksight/faqs/

https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/lambda-runtimes.html

https://dev.classmethod.jp/articles/how-to-identify-your-quicksight-dashboard-id/

https://dev.classmethod.jp/articles/quicksight-service-role-error-custom-managed-policy-attachment/

https://dev.classmethod.jp/articles/vpc-connections-in-quicksight-are-not-being-removed-easily/

https://repost.aws/ja/knowledge-center/quicksight-permission-errors

この記事をシェアする

関連記事