staging層を作る ― ローデータに触れるのはここだけ
stagingはローデータに最初に触れ、正規化する層です。1ソース1ステージング、命名規約、型とタイムゾーンの統一という規律を置き、GA4 BigQuery Exportのネスト展開や広告各媒体のchannel正規化を架空のD2C企業SOLNAで具体化します。「ローデータに触れるのはstagingだけ」という原則が、下流の変更耐性を生みます。
by Shin
バラバラのローデータを、どこで・どんな規律で揃えればいいのか。答えはstaging層に集約する、です。1ソース1ステージング、命名と型を統一し、ローデータに触れるのはここだけにする。この記事では、stagingが負うべき責務と命名規約を整理し、GA4 BigQuery Exportの展開と広告各媒体の channel 正規化を、過去の受託案件をもとに設計した架空のD2CブランドSOLNAを題材に具体化します。
棲み分けを先に宣言します。計測設計の連載(なぜ計測は静かに壊れるのか ― 計測設計という土台)が扱うのは「データが生まれる前」(収集・同意・識別・送信)の設計で、本連載は「入った後」、つまりローデータをマートへ整える変換です。前回(第1回)で source / ref と staging → intermediate → marts の3層という骨格を引きました。今回はその一番下、生に最初に触れるstaging層を作ります。
stagingの責務(正規化と窓口の一本化)
stagingの役割は、変換ではなく正規化です。ローデータの「方言」を、連載共通の標準語に直す。具体的には、列名を規約に寄せる(rename)、型とタイムゾーンを確定させる(recast)、空文字をNULLに倒すといった軽いクリーニング——ここまでです。結合も集計もビジネスロジックも、stagingには入れません。それらは intermediate(第4回)と marts(第5回)の仕事です。
ここはdbt Labsの構造ガイドが原典で、Kimballのディメンショナルモデリングとは別系統の作法です。Kimballが「どうモデリングするか」を担うのに対し、層分けと命名は構造ガイドが担います。その作法でのstagingの定義は明快で、ソースごとに1モデル、1行=1ソースレコードの粒度を保ち、原子的な整形だけをする層、です。FivetranのdbtパッケージのstagingモデルもこのDNAに従っていて、媒体ごとのrename/recastに徹し、結合は中間層へ送っています。
窓口を一本化する意味は、下流が「生スキーマの方言」を二度と見なくて済むことにあります。生がどんな名前・型・ネストで来ようと、その吸収はstaging一箇所に閉じる。これが後述する変更耐性の源です。
1ソース1ステージングと命名規約
規約はシンプルに `stg_<source>__<entity>`。ソース名とエンティティ名をダブルアンダースコアで区切ります。SOLNAのローデータを割り当てると、おおよそ次の対応になります。
| 生ソース | stagingモデル | 主な整形 |
|---|---|---|
| GA4 BigQuery Export | stg_ga4__events | event_params のネスト展開・型確定 |
| Shopify | stg_shopify__orders / stg_shopify__customers | 列名・通貨・タイムスタンプの統一 |
| Meta広告 | stg_meta_ads__spend | channel 正規化・数値cast |
| Google広告 | stg_google_ads__spend | channel 正規化・数値cast |
| CRM | stg_crm__messages | 配信チャネル・フラグの正規化 |
揃えるのは四つです。命名(snake_case で連載共通のことばに寄せる)、型(媒体が文字列で寄こす数値は明示cast)、タイムゾーン(UTCで保持するかJSTに倒すかを決め打ちし、混在させない)、NULL方針(空文字や '(not set)' をNULLに統一)。この四つを各stagingで機械的に揃えるだけで、下流の事故が大きく減ります。
実案件(匿名・数値はレンジ)の肌感を一つ。広告側の channel ラベルには同じMetaを指す表記が4〜6通り並存していました(facebook / fb / Facebook Ads など)。さらにGA4の page_location を抽出するクエリが、分析者ごとに3通りの実装で散らばっていた。staging以前は、この方言の吸収を全員がその場しのぎに書き直していたわけです。
GA4 BigQuery Exportを展開する
GA4 BQ Exportの厄介さは、event_params がキー・バリューの配列(REPEATED RECORD)で来る点にあります。1イベントあたりおおむね20〜30キー。このネストを毎回その場でUNNESTしていると、抽出ロジックが人ごとにぶれます。stagingで一度だけ展開し、必要なキーを列に引き上げてしまうのが定石です。
-- models/staging/ga4/stg_ga4__events.sql
with source as (
select * from {{ source('ga4', 'events') }}
-- 増分の窓・パーティション指定は第6回(incremental)で扱う
),
flattened as (
select
-- 識別子:元の名前を連載共通のことばへ寄せる
user_pseudo_id as ga_client_id,
nullif(user_id, '') as customer_id, -- 空文字はNULLへ
-- イベント基本属性:型とタイムゾーンをここで確定させる
event_name,
timestamp_micros(event_timestamp) as event_at, -- UTCで保持
parse_date('%Y%m%d', event_date) as event_date,
-- event_params から必要キーだけを列へ引き上げる(展開はここ一箇所)
(select value.int_value
from unnest(event_params)
where key = 'ga_session_id') as ga_session_id,
(select value.string_value
from unnest(event_params)
where key = 'page_location') as page_location,
(select value.int_value
from unnest(event_params)
where key = 'engagement_time_msec') as engagement_time_msec,
-- 流入元:後段の channel 正規化と接合の素材
collected_traffic_source.gclid as gclid,
traffic_source.source as utm_source,
traffic_source.medium as utm_medium
from source
)
select * from flattenedここで効いている線引きが二つあります。一つは識別子の寄せ方で、user_pseudo_id を ga_client_id、user_id を customer_id という連載共通のキー名に直しておく。これで下流は媒体由来の名前を意識せずに済みます。もう一つが粒度で、このモデルはイベント粒度のままにしてあります。SOLNAの sessions マートが持つ session_start・landing_page・pageviews・engaged といったセッション粒度の列は、ここでは作りません。イベントをセッションに巻き上げる集計は intermediate / marts の仕事だからです。「stagingは集計しない」という規律を、GA4のような展開したくなるソースでこそ守る、というのが要点です。
広告各媒体の channel 正規化
広告は媒体ごとに別スキーマ・別カラム名・別ラベルで降ってきます。1ソース1ステージングの原則どおり、Meta・Googleそれぞれに1つずつstagingを置き、各モデルの中で媒体固有の表記を共通の channel 値へ寄せます。下流(真のCAC・MMM)が前提にするのは、媒体を横断して比較できる channel 粒度です。その素地をここで作ります。
-- models/staging/meta_ads/stg_meta_ads__spend.sql
with source as (
select * from {{ source('meta_ads', 'ads_insights') }}
),
renamed as (
select
date(date_start) as date_day,
'meta' as channel, -- 媒体固有 → 連載共通の channel
campaign_name as campaign,
-- 媒体側の地域表記の揺れを共通コードへ寄せる(吸収はここ一箇所だけ)
case
when lower(region) in ('tokyo', '東京', 'jp-13') then 'tokyo'
when lower(region) in ('osaka', '大阪', 'jp-27') then 'osaka'
else 'other'
end as region,
cast(spend as numeric) as spend, -- 文字列で来る媒体があるため明示cast
cast(impressions as int64) as impressions,
cast(clicks as int64) as clicks
from source
)
select * from renamedGoogle広告側も stg_google_ads__spend として同じ形(channel = 'google')に揃えます。注意したいのは、SOLNAの ad_spend マートが持つ date_day × channel × campaign × region の粒度に各媒体をUNIONして一本化する処理は、ここではやらないことです。複数ソースの統合は intermediate の責務(第4回)で、stagingはあくまで各媒体を共通の形に整えるところで止めます。各stagingで channel を確定し、UNIONは後段、という分担が「1ソース1モデル」を崩さないコツです。
接合の話も棲み分けておきます。gclid や customer_id を保持するのはstagingの仕事ですが、sessions と customers を突き合わせて接合率を計算し、閾値で監視する——ここは計測設計の連載が設計した指標を、本連載が marts(第5回)に保持し、テスト(第3回)で恒常的に見張る側の仕事です。計測設計の連載が設計、本連載が保持と監視。
sources宣言 ― ローデータに触れる唯一の場所
`{{ source() }}` を使ってよいのはstagingだけ、というのが「ローデータに触れるのはここだけ」の実装上の意味です。ローデータがどのプロジェクト・データセットに、どんな名前で存在するかは、_sources.yml 一箇所に集約します。
# models/staging/_sources.yml
version: 2
sources:
- name: ga4
database: solna-analytics # ローデータの居場所を知るのはここだけ
schema: analytics_123456789
tables:
- name: events
identifier: events_* # 日付シャーディングをワイルドカードで束ねる
- name: meta_ads
schema: meta_ads_raw
tables:
- name: ads_insightsこのYAMLが、生スキーマと本プロジェクトの唯一の接点になります。テストや freshness(鮮度監視)の宣言もここに足せますが、それは品質の回(次回)と運用の回(第7回)の主題なので、今回は宣言だけに留めます。
「ローデータに触れるのはstagingだけ」が生む変更耐性
この規律の見返りは、変更耐性です。媒体やGA4やShopifyが列名を変えても、影響は対応する stg_* の1ファイルに局所化されます。下流のモデルは共通のことばしか見ていないので、staging側でマッピングを直せば無風で済む。
実案件(匿名)で、広告媒体側が列を1つリネームしたことがありました。stagingを通していた経路は修正1ファイル・下流0で収束し、stagingを介さず生を直接叩いていたその場しのぎのクエリ群は10数本の手直しが発生しました。これが第0回で言った「秘伝のタレSQL」の正体です。生スキーマに直結したクエリは、上流の変更が全箇所へ波及します。
ただし誇張はしません。stagingが吸収できるのは「場所の局所化」であって、「意味のズレ」ではありません。変更が列の追加・リネームなら staging一箇所で吸収できますが、channel の定義そのものが変わる(媒体側が分類基準を変える)ような意味の変化は、staging一箇所を直しても下流の解釈は変わります。構造で防げるのは波及範囲であって、定義の変質ではない——この限界は、テスト(次回)で監視する領域に引き継ぎます。
章末チェックリスト
- stagingの責務(rename・recast・軽いクリーニング)と、やらないこと(join・集計・ビジネスロジック)を区別できる
-
`stg_<source>__<entity>`の命名規約を持ち、1ソース1ステージングになっている - 型・タイムゾーン・NULL方針を各stagingで揃えている
- GA4の
event_paramsをstagingで一度だけ展開し、イベント粒度を保っている(集計を持ち込んでいない) - 媒体固有のラベルを共通の
channelへ寄せ、UNIONは後段に回している -
`{{ source() }}`を使うのがstagingだけになっており、ローデータの居場所が_sources.ymlに集約されている
次回はテストとデータ品質です。stagingで整えた列に not_null・unique・relationships・accepted_values で制約を宣言し、壊れたデータを下流に流さない仕組みと、計測設計の連載で設計した接合率の常時監視を作ります。
「そもそもデータ基盤に投資すべきか」という決裁者の判断は、対になるデータ基盤に投資すべきかへ。整えた土台の上で「何を測り、何を動かすか」を考える段階は、記述・予測編へ送り出します。