0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

メールキャンペーンで短縮リンクをA/Bテストし、クリック率を向上させる方法

0
Posted at

image_949aec07.cleaned.jpg

メールキャンペーンで件名だけをA/Bテストしていませんか。リンクの文言やボタン配置、遷移先を変えても、短縮URLのクリック計測が雑だと、差がテスト結果なのかノイズなのか判断できません。

この記事では、短縮リンクをクリック計測の入口として使いながら、配信リストのバリアント割り当て、リダイレクト、クリックログ、集計SQLまでを実装する方法を扱います。日本語の開発チームが、ESPや既存の短縮URLサービスと組み合わせやすい形を想定しています。

メール内のリンクA/Bテストが難しい理由

メールはWebページと違い、JavaScriptでクリックイベントを送れません。計測の基本は、リンク先への遷移、つまりリダイレクトログになります。

さらに、セキュリティ製品やメールクライアントがリンクを事前に開くことがあり、クリックログが人為的なクリックと一致しない場合があります。短縮URLは見た目を短くするだけでなく、クリックを一度自前のエンドポイントで受けてから遷移先へ送る設計にすると、バリアント別の計測がしやすくなります。

ただし、短縮URLを挟むと、リンクの信頼性や表示に影響する場合があります。初回接触のキャンペーンで短縮URLのドメイン自体を大きく変えるテストは、クリック率以外の要因が混ざりやすいため、まずはリンク文言や配置など別の要素をテストする方が安全です。

先に決めること

実装前に、テストの目的と評価方法を決めます。

  • 主指標はユニーククリック率にする。計算式は「ユニーククリック数 ÷ 到達数」。配信数ではなく、バウンスを除いた到達数を基準にする。
  • 変更する要素は1つにする。リンク文言、ボタンとテキストリンク、遷移先、配置のうち、どれを変えるのかを固定する。
  • バリアントはまずA/Bの2つから始める。A/B/Cにする場合は、各バリアントに十分な配信数が割り当たるか確認する。
  • テスト期間と停止条件を先に決める。途中で結果を見て早く止めると、偶然の差を有意差と誤認しやすい。
  • 受信者ごとにどちらのバリアントを見せるかを固定する。同じ受信者に別バリアントを混ぜると、比較が崩れる。

実装パターンは2つある

メール内の短縮リンクA/Bテストには、大きく分けて2つの実装パターンがあります。

自前リダイレクトで制御する

自前のドメインにリダイレクト用エンドポイントを用意し、バリアント別のリンクIDから遷移先へ302リダイレクトします。クリックログを自分のDBに保存できるため、配信リストとの突合がしやすいです。

短縮リンクサービスを使う

短縮リンクの発行・管理にLinkBitを使う場合は、バリアントごとに別の短縮URLを用意し、どのURLがどの条件に対応するかを配信リストと一緒に管理します。たとえば LinkBit のような短縮URLをA/Bそれぞれに発行し、メールHTMLにはバリアントに対応するURLを差し込む形です。

どちらの場合も、配信リストの受信者IDとバリアントを保存し、クリックログと突合できるようにしておくことが重要です。短縮URLサービス側のクリック数だけを見ると、配信対象の偏りや重複クリックを確認しにくくなります。

データモデル

最小構成のテーブル例です。配信リスト、リンク定義、クリックログを分けます。

CREATE TABLE campaign_recipients (
  campaign_id text NOT NULL,
  recipient_id text NOT NULL,
  variant text NOT NULL,
  recipient_token text NOT NULL,
  delivered_at timestamptz,
  PRIMARY KEY (campaign_id, recipient_id)
);

CREATE TABLE campaign_links (
  link_id text PRIMARY KEY,
  campaign_id text NOT NULL,
  variant text NOT NULL,
  destination_url text NOT NULL
);

CREATE TABLE link_clicks (
  id bigserial PRIMARY KEY,
  campaign_id text NOT NULL,
  recipient_id text NOT NULL,
  variant text NOT NULL,
  link_id text NOT NULL,
  clicked_at timestamptz NOT NULL DEFAULT now(),
  user_agent text,
  UNIQUE (campaign_id, recipient_id, link_id)
);

delivered_atはESPの配信結果やバウンス除外の結果を受けて更新します。link_clicksのUNIQUE制約は、同じ受信者・同じリンクの最初のクリックだけを保存するためのものです。全クリックを見たい場合は、別のログテーブルを用意します。

バリアント割り当てAPI

Expressとpgを使った最小例です。受信者IDとキャンペーンIDから決定論的にバリアントを割り当てます。同じ受信者には、同じキャンペーンで常に同じバリアントが返ります。

import crypto from 'node:crypto';
import express from 'express';
import { Pool } from 'pg';

const app = express();
app.use(express.json());
const pool = new Pool();

const VARIANTS = ['A', 'B'];

function assignVariant(campaignId, recipientId) {
  const digest = crypto
    .createHash('sha256')
    .update(`${campaignId}:${recipientId}`)
    .digest();
  const index = digest.readUInt32BE(0) % VARIANTS.length;
  return VARIANTS[index];
}

function signRecipient(campaignId, recipientId, secret) {
  return crypto
    .createHmac('sha256', secret)
    .update(`${campaignId}:${recipientId}`)
    .digest('base64url');
}

app.post('/api/campaigns/:campaignId/assign', async (req, res) => {
  const { campaignId } = req.params;
  const { recipients } = req.body;
  const client = await pool.connect();

  try {
    await client.query('BEGIN');
    for (const r of recipients) {
      const variant = assignVariant(campaignId, r.id);
      const token = signRecipient(campaignId, r.id, process.env.LINK_SECRET);
      await client.query(
        `INSERT INTO campaign_recipients
         (campaign_id, recipient_id, variant, recipient_token)
         VALUES ($1, $2, $3, $4)
         ON CONFLICT (campaign_id, recipient_id) DO NOTHING`,
        [campaignId, r.id, variant, token]
      );
    }
    await client.query('COMMIT');
    res.json({ ok: true });
  } catch (e) {
    await client.query('ROLLBACK');
    throw e;
  } finally {
    client.release();
  }
});

本番では1件ずつINSERTするより、バルクINSERTやCSV取り込みに置き換えた方が安定します。また、LINK_SECRETは環境変数などで管理し、コードに直書きしないでください。

リダイレクトとクリックログ

短縮リンクのクリックを受け、署名を検証してから遷移先へリダイレクトします。クエリで渡されたURLをそのままリダイレクトせず、DBに保存したdestination_urlだけを使うのがポイントです。

app.get('/r/:linkId', async (req, res) => {
  const { linkId } = req.params;
  const { rid, token } = req.query;

  if (typeof rid !== 'string' || typeof token !== 'string') {
    return res.status(400).send('bad request');
  }

  const { rows } = await pool.query(
    `SELECT campaign_id, variant, destination_url
     FROM campaign_links
     WHERE link_id = $1`,
    [linkId]
  );
  if (rows.length === 0) return res.status(404).send('not found');

  const link = rows[0];
  const expected = signRecipient(link.campaign_id, rid, process.env.LINK_SECRET);
  const a = Buffer.from(token);
  const b = Buffer.from(expected);

  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    return res.status(403).send('forbidden');
  }

  await pool.query(
    `INSERT INTO link_clicks
     (campaign_id, recipient_id, variant, link_id, user_agent)
     VALUES ($1, $2, $3, $4, $5)
     ON CONFLICT (campaign_id, recipient_id, link_id) DO NOTHING`,
    [link.campaign_id, rid, link.variant, linkId, req.get('user-agent') ?? '']
  );

  res.redirect(302, link.destination_url);
});

署名付きトークンを使うと、受信者IDを推測しただけの偽クリックをある程度防げます。ただし、メールセキュリティ製品によるリンクの事前アクセスや、ボットによるアクセスを完全に排除できるわけではありません。主指標をユニーククリックにし、配信直後の異常なアクセスを確認するなどの運用上の対策も合わせて行います。

メールHTMLへの差し込み

メールテンプレートには、バリアントに対応するリンクIDと受信者トークンを差し込みます。テンプレート構文は利用するESPに合わせてください。

<a href='https://example.com/r/{{variant_link_id}}?rid={{recipient_id}}&token={{recipient_token}}'>詳細を見る</a>

外部の短縮URLサービスを使う場合も、A用とB用で別のURLを用意し、テンプレート変数で切り替えると管理しやすくなります。リンク文言をテストする場合でも、計測用の短縮URLはバリアントごとに分けておくと、後からログを突合できます。

集計SQL

到達数とユニーククリック数をバリアント別に集計します。link_clicksにUNIQUE制約があり、1受信者1リンクの最初のクリックだけを保存している場合は、クリック側の重複排除は不要です。複数リンクを同じバリアントに含める場合は、受信者単位で重複を除きます。

WITH delivered AS (
  SELECT campaign_id, recipient_id, variant
  FROM campaign_recipients
  WHERE campaign_id = 'spring_2025'
    AND delivered_at IS NOT NULL
),
first_clicks AS (
  SELECT campaign_id, recipient_id, variant
  FROM link_clicks
  WHERE campaign_id = 'spring_2025'
  GROUP BY campaign_id, recipient_id, variant
)
SELECT
  d.variant,
  COUNT(*) AS delivered,
  COUNT(c.recipient_id) AS unique_clickers,
  ROUND(COUNT(c.recipient_id)::numeric / COUNT(*) * 100, 2) AS ctr_pct
FROM delivered d
LEFT JOIN first_clicks c
  ON c.campaign_id = d.campaign_id
 AND c.recipient_id = d.recipient_id
 AND c.variant = d.variant
GROUP BY d.variant
ORDER BY d.variant;

delivered_atが未設定の場合は、配信数とバウンス数から到達数を別途計算します。テストの主指標をユニーククリック率にするなら、分母と分子の定義を配信前に関係者と共有しておくことが重要です。

統計的に判断する

CTRの差が小さいほど、判断に必要な配信数は大きくなります。数%の差を検出したいのに数百件の配信だけで判断すると、偶然のばらつきを実力差と誤認しやすくなります。

実務では、次の点を確認します。

  • 事前に必要なサンプル数を決める。検出力計算ツールや統計ライブラリを使い、想定CTRと検出したい差から概算する。
  • 途中で有意差が出たからといって止めない。停止条件を事前に決める。
  • p値だけでなく信頼区間も見る。差の方向と不確実性を確認する。
  • 統計的な差と実務上の差を分けて考える。CTRが上がってもコンバージョンが下がるなら、主指標の設計を見直す。

よくある失敗

  • 短縮URLのドメインだけを変えて、リンク文言や遷移先も同時に変えてしまう。何が効いたのか分からなくなる。
  • 全クリック数とユニーククリック数を混同する。同じ人が複数回クリックすると、CTRが実態より高く見える。
  • ボットやメールクライアントの事前アクセスを除外せず、配信直後のクリックをそのまま数える。
  • 配信数と到達数を混同する。バウンスが多いリストではCTRが歪む。
  • 短縮URLを別キャンペーンで再利用し、過去のクリックログと混ざる。
  • リンクのクリック率だけを見て、遷移後のコンバージョンを確認しない。

まとめ

メール内の短縮リンクA/Bテストは、リンクを2つ用意するだけでは正確に判断できません。配信前に受信者IDとバリアントを固定し、署名付きトークン付きのリダイレクトURLを使い、ユニーククリックをDBに保存するところまで設計すると、結果を再現しやすくなります。

まずは変更要素を1つに絞り、主指標をユニーククリック率に決め、十分な配信数を確保してから判断してください。短縮リンクはクリック計測の入口であり、テスト設計と集計定義がそのまま結果の信頼性につながります。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?