無料ツールで構築するマーケティング基盤
Chiefmartecの2023年版マーケティングテクノロジー・ランドスケープには11,038のソリューションが掲載されました¹。選択肢が爆発的に増える一方で、導入・解約・移行のたびにデータが分断され、学習コストとSaaS費用が雪だるま式に膨らむ現実は見過ごされがちです。公開情報と一般的なTCO試算を踏まえると、イベント計測からデータ保管、モデリング、可視化、メールや広告へのアクティベーションまで、主要機能の大半はOSSと各社の無償枠を組み合わせるだけで安定運用できるケースは少なくありません。鍵は、ツール中心ではなくデータ中心に設計し、切り替えに強いイベントスキーマ(イベント項目の取り決め)とサーバーサイド計測を最初から据えることです。CTOやエンジニアリーダーに向け、本稿では実装コードと運用設計を一体で提示します。
設計原則と全体アーキテクチャ
最初に決めるべきはツールではなく境界です。ユーザーの同意と識別を司るレイヤー、イベント収集のレイヤー、ストレージと変換のレイヤー、可視化と意思決定のレイヤー、そしてアクティベーションのレイヤーを明確に分割します。レイヤー間の契約はイベントスキーマとデータマートの定義で表現し、どのコンポーネントも等価交換できるように作ると、無償枠の乗り換えが容易になります。
ベンダーロックインを避ける設計として、行動データの主権を自社に置くことを最優先にします。計測はクライアントSDK任せにせず、まず自社のサーバーエンドポイントに集約し、そこでスキーマ検証と匿名化を行ってからストレージに書き込みます。広告プラットフォームやMAへの送信はその後に複製します。これにより同意撤回や削除要求にも一貫して応えられ、計測ロスを抑えられます。
サーバーサイド計測とプライバシーは両立します。ユーザーのIPやUser-Agentはハッシュ化(HMACなど)し、同意の有無に応じて粒度を変えるルールをコードで明示します。Cookieは最小限にし、FPIDやUUIDはサーバー生成に寄せます。ブラウザ発のfetchが失敗しても送信を試みるよう、Beacon API(ページ離脱時も送信しやすいAPI)や再送の仕組みを持たせてドロップ率を抑えます。サーバーサイド計測の有効性は各種技術解説でも整理されており、クライアント依存を減らしながら計測品質とプライバシー配慮を両立させる実践が紹介されています¹⁰。また、IPやUser-Agentなどのメタデータは組み合わせ次第で識別に寄与し得るため、匿名化やハッシュ化といった保護措置は重要です¹¹。
低コストツールの組み合わせで描く基盤
収集はGTMのタグ配信を補助に使いながら、メインは自社エンドポイントに集約します。エッジでの受け口はCloudflare WorkersやVercel Serverlessの無償枠を活用し²³、ストレージはNeonのPostgreSQLやSelf-hostedのClickHouseが有力です。変換はdbt Core(OSS)でモデル化し⁴、Airbyte OSSがあればELT(取り込み後に変換)の拡張性を確保できます⁶。可視化はMetabase OSSやLooker Studioの接続機能で、現場運用は十分回ります⁵。アクティベーションはHubSpotのFreeプランのフォーム・CRM⁸、あるいはBrevoのフリープランによるメール配信⁷を用いれば、初期段階から費用を抑えて施策の反復が可能です。Consentはオープンソースのcookieconsentライブラリで同意UIを提供し、同意トグルをイベントに同伴させます⁹。
この構成の利点は、どの層も小さく始め、必要に応じて同等機能の商用版に置き換えられる点です。例えばストレージはPostgreSQLからBigQueryのSandboxへ、可視化はMetabaseから有償のBIへ移るとしても、dbtで定義されたモデルやイベントスキーマは再利用できます。TCOの観点でも、ライセンス費を抑え、インフラ費は各社の無償枠を活用することで初期コストを抑制し、人件費に集中投下できます。さらに、Airbyte・dbt・データウェアハウス・BIというモダンデータスタックの組み合わせは、小規模から段階的にスケールさせやすい構成としても広く紹介されています⁶。
実装ガイド:計測からアクティベーションまで
フロントエンドのイベント送信(再送とBeacon対応)
// /public/analytics.js
(function () {
const ENDPOINT = 'https://your-domain.vercel.app/api/events';
const RETRIES = 3;
async function send(payload) {
const body = JSON.stringify(payload);
if (navigator.sendBeacon) {
const ok = navigator.sendBeacon(ENDPOINT, body);
if (ok) return true;
}
let attempt = 0;
while (attempt < RETRIES) {
try {
const res = await fetch(ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
keepalive: true,
credentials: 'omit'
});
if (res.ok) return true;
} catch (e) {}
await new Promise(r => setTimeout(r, Math.pow(2, attempt++) * 200));
}
return false;
}
function track(event, props = {}) {
const base = {
event,
occurred_at: new Date().toISOString(),
url: location.href,
referrer: document.referrer || null,
ua: navigator.userAgent,
consent: window.__consent || { ad: false, analytics: true }
};
return send({ ...base, ...props });
}
window.analytics = { track };
})();
このスニペットはBeacon APIを優先し、失敗時は指数バックオフでfetchを再試行します。サーバーで匿名化する方針のため、クライアントでは個人特定情報を扱いません。
サーバーエンドポイント(Vercel Serverless + PostgreSQL/Neon)
// /api/events.js (Vercel, Node.js)
import crypto from 'crypto';
import Ajv from 'ajv';
import addFormats from 'ajv-formats';
import { Pool } from 'pg';
const ajv = new Ajv({ removeAdditional: true });
addFormats(ajv);
const schema = {
type: 'object',
properties: {
event: { type: 'string', minLength: 1 },
occurred_at: { type: 'string', format: 'date-time' },
url: { type: 'string' },
referrer: { type: ['string', 'null'] },
ua: { type: 'string' },
consent: {
type: 'object',
properties: { ad: { type: 'boolean' }, analytics: { type: 'boolean' } },
required: ['analytics'], additionalProperties: false
}
},
required: ['event', 'occurred_at', 'url', 'ua', 'consent'],
additionalProperties: true
};
const validate = ajv.compile(schema);
const pool = new Pool({ connectionString: process.env.PG_URL, ssl: { rejectUnauthorized: false } });
export default async function handler(req, res) {
if (req.method !== 'POST') return res.status(405).end();
try {
const body = req.body || JSON.parse(req.rawBody || '{}');
if (!validate(body)) return res.status(400).json({ error: 'invalid_schema', details: validate.errors });
const ip = (req.headers['x-forwarded-for'] || '').toString().split(',')[0].trim();
const salt = process.env.HASH_SALT || 'rotate_me';
const anon_ip = ip ? crypto.createHmac('sha256', salt).update(ip).digest('hex').slice(0, 16) : null;
const ua = body.ua || (req.headers['user-agent'] || '');
const server_received_at = new Date().toISOString();
const text = `insert into events(event_name, occurred_at, url, referrer, anon_ip, ua, consent, props, server_received_at)
values($1, $2, $3, $4, $5, $6, $7::jsonb, $8::jsonb, $9)`;
const values = [
body.event,
body.occurred_at,
body.url,
body.referrer || null,
anon_ip,
ua,
JSON.stringify(body.consent || {}),
JSON.stringify(body),
server_received_at
];
await pool.query(text, values);
return res.status(204).end();
} catch (e) {
console.error(e);
return res.status(500).json({ error: 'server_error' });
}
}
AJV(JSON Schemaバリデータ)でのスキーマ検証と、HMACによるIP匿名化を行ったうえでJSONBに原文を格納します。VercelのServerless Functionsにはフリープランがあり、初期段階のコスト最適化に適しています³。
PostgreSQLのDDLとインデックス設計
create table if not exists events (
id bigserial primary key,
event_name text not null,
occurred_at timestamptz not null,
server_received_at timestamptz not null default now(),
url text not null,
referrer text,
anon_ip text,
ua text,
consent jsonb not null,
props jsonb not null
);
create index if not exists idx_events_name_time on events(event_name, occurred_at desc);
create index if not exists idx_events_gin on events using gin (props jsonb_path_ops);
クエリの大半はイベント種類と期間で絞り込むため、複合インデックスを付与します。原文propsはJSONBの部分一致で拾えるようにGINインデックス(JSON検索向けのインデックス)を追加します。
dbt Coreでマート化(セッション集計の例)
-- models/mart_sessions.sql
with base as (
select
anon_ip,
(date_trunc('minute', occurred_at) - ((extract(epoch from occurred_at)::int % 1800) || ' seconds')::interval) as session_bucket,
min(occurred_at) as session_start,
max(occurred_at) as session_end,
count(*) as events
from {{ ref('stg_events') }}
group by 1,2
)
select *,
extract(epoch from (session_end - session_start)) / 60.0 as duration_min
from base;
# models/schema.yml
version: 2
models:
- name: mart_sessions
description: セッション単位の集計
columns:
- name: anon_ip
tests: [not_null]
- name: session_start
tests: [not_null]
ステージングモデルでキー正規化や無効イベント除外を済ませ、マートでは意思決定に必要な粒度に落とします。dbt CoreはOSSとして十分に運用可能で、将来Cloudへ移行してもモデル資産は共通です⁴。
セグメント抽出とメール配信(Brevoのフリープランを利用)
// scripts/send_campaign.js
import { Pool } from 'pg';
import fetch from 'node-fetch';
const pool = new Pool({ connectionString: process.env.PG_URL, ssl: { rejectUnauthorized: false } });
async function main() {
const { rows } = await pool.query(`
select distinct props - 'ua' - 'consent' as p
from events
where event_name = 'signup_completed' and occurred_at > now() - interval '7 days'
`);
const recipients = rows
.map(r => r.p.email)
.filter(Boolean)
.map(e => ({ email: e }));
if (recipients.length === 0) return;
const res = await fetch('https://api.brevo.com/v3/smtp/email', {
method: 'POST',
headers: { 'api-key': process.env.BREVO_API_KEY, 'content-type': 'application/json' },
body: JSON.stringify({
sender: { email: 'noreply@yourdomain.com', name: 'Your Product' },
to: recipients.slice(0, 300),
subject: 'ようこそ。次のステップをご案内します',
htmlContent: '<p>ご登録ありがとうございます。…</p>'
})
});
if (!res.ok) throw new Error(await res.text());
}
main().catch(e => { console.error(e); process.exit(1); });
Brevoのフリープランには1日300通の送信枠があります⁷。週次のオンボーディングなど限定的な用途ならコストをかけずに開始できます。配信停止や同意状態はテーブル側で必ずフィルタします。
負荷試験シナリオ(k6)と目安
// k6 run k6.js
import http from 'k6/http';
import { sleep } from 'k6';
export const options = {
vus: 50,
duration: '1m'
};
export default function () {
const payload = JSON.stringify({
event: 'page_view', occurred_at: new Date().toISOString(), url: 'https://example.com', ua: 'k6'
});
http.post('https://your-domain.vercel.app/api/events', payload, { headers: { 'Content-Type': 'application/json' } });
sleep(0.1);
}
VercelのServerlessとNeonの構成で簡易検証を行うと、低遅延で安定動作しやすいことが多い一方、トラフィック量やリージョン、同時接続数に依存します。大量計測が見込まれる場合は、受信をキュー(例:一時的にファイル/R2やメッセージキュー)に積んでバッチ挿入へ寄せるとピークを吸収できます。エッジ側の受け口としてはCloudflare Workersの無償枠活用も有効です²。
運用とガバナンス:スキーマ・テスト・可観測性
イベント設計はJSON SchemaをGitでバージョン管理し、Pull Requestでレビューする体制にします。スキーマに対応するdbtのテストを用意すれば、計測やETLの変更が下流に与える影響を事前に検出できます。CIはGitHub Actionsの無償枠で十分に回せます。ステージング環境ではダミーデータでE2Eを通し、プロダクションではイベント受信のレイテンシ、DBの挿入失敗率、可視化クエリのp95といった指標をダッシュボード化します。OSSのGrafanaやMetabaseでメトリクスの可視化は可能です⁵。ログはVercelやCloudflareの標準ロギングに集約し、失敗イベントはリトライ・DLQに退避して後続の再処理を自動化します。広告やメールといった外部アクティベーションは、同意状態をファーストクラスの列として保持し、運用者が誤って送信対象に含めない仕組みを先にコード化します。SLO(サービス目標値)は、レイヤーごとに定義して運用します。
# .github/workflows/validate.yml
name: validate
on: [pull_request]
jobs:
schema:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm i -g ajv-cli
- run: ajv validate -s schema/event.json -d samples/**/*.json --errors=text
コストを抑えて始めるからこそ、人手の属人化を避ける自動テストとドキュメントを先に整えます。スキーマの履歴はそのままデータ辞書になり、ツール乗り換えの際も翻訳コストを最小化できます。成果指標は、イベントの完了率、モデリングの日次完了率、可視化の更新遅延、アクティベーションの到達率といった、レイヤーごとのSLOで管理します。
まとめ:ゼロから始め、交換可能に育てる
選択肢は数多くあるものの、足りないのはツールではなく設計でした。イベントスキーマを契約として固定し、サーバーサイド計測でデータ主権を確保し、dbtで意図をコード化し、無償枠で回る範囲から始める。この順序なら、現場の計測・分析・施策は今日からでも動かせます。まずは重要な3つのイベントに絞り、上のサンプルをそのままデプロイして、翌日のダッシュボード更新が滞りなく終わるかを確かめてください。うまく回り始めたら、セグメントの精度やアクティベーションの自動化を段階的に増やしていけば良いのです。コストを抑えつつも、意思決定の速度は確実に上げられます。あなたの組織では、どのレイヤーから始めますか。
参考文献
- Chiefmartec. 2023 Marketing Technology Landscape Supergraphic: 11,038 solutions.
- Cloudflare Developers. Workers pricing (Free plan limits).
- Vercel Docs. Serverless Functions Pricing.
- dbt Labs. Licensing dbt (dbt Core is open source).
- Metabase. Open source analytics.
- Airbyte Blog. The modern open data stack: four core tools.
- Brevo Help Center. FAQs: Are there any sending limits (emails and SMS)?
- HubSpot. Free CRM software.
- Osano/cookieconsent. GitHub repository.
- Miralytics. Server-side tracking overview.
- arXiv. 1912.05861v1.