Prebid.js 導入による Header Bidding 改善の舞台裏

こんにちは。メディアプロダクト開発部の我妻謙樹(@itiskj)です。 サーバーサイドエンジニアとして、広告配信システムの開発・運用を担当しています。好きな言語は Go と TypeScript です。

以前、"Header Bidding 導入によるネットワーク広告改善の開発事情" というタイトルで、

  • Header Bidding の仕組み
  • 弊社の広告配信のクライアント側の設計
  • Transparent Ad Marketplace(以下、TAM)導入の過程

についてご紹介しました。今回は、TAM に次いで Prebid.js をあわせて導入した際の知見についてご紹介します。

What is Prebid.js?

Prebid.js とは、OSS で開発されている Web 向け Header Bidding ライブラリです。 http://prebid.org/ において開発されているサービスの1つで、他にはアプリ向けの Prebid Mobile、サーバーサイド向けの Prebid Server などが開発されています。

https://github.com/prebid/Prebid.js/

Prebid のサービス群を用いると、以下二種類いずれかの Header Bidding に対応できます。

  • Client-to-Server Header Bidding(以下、C2S)
    • Prebid.js 及び Prebid Server を用いる
  • Server-to-Server Header Bidding(以下、S2S)
    • Prebid Server を自前でホスティングするか、すでに提供されている第三者サーバーを利用する

C2S 及び S2S の Pros/Cons は以下の通りです。

Type Pros Cons
C2S 対応事業者数が多い/トータルでの実装コストが低い クライアントのネットワーク帯域をより消費する
S2S サーバーサイドで制御できる/クライアント側からは Single Request 対応事業者が C2S と比べて限られている/Cookie Sync の技術的課題

今回は、「対応事業者数が多い」こと、及び「実装コストの改修」を考慮して、C2S 方式を導入しました。

Glossary

本文で言及している用語のうち、ドメイン知識のため説明が必要と思われる用語の一覧です。

word description
事業者 Header Bidding で入札リクエストを送っている先の事業者のこと。ここでは基本的に SSP(DSP を接続する場合もある)のことを指す。
スロット 広告が実際に表示される枠のこと。
DFP DoubleClick For Publisher の略。新名称は Google Ad Manager。
APS Amazon Publisher Services の略。TAM などの一連の広告サービスを提供する総称。
TAM Transparent Ad Marketplace の略。APS のサービスの一つで、Header Bidding を提供する。

Development with Prebid.js

Prebid.js を導入する際に、基本的に 公式ドキュメント | Getting Started 及び Publisher API Reference を参考することになります。公式ドキュメントがかなり充実しているので、基本的なユースケースであれば、ほぼ嵌らずに実装できるでしょう。

以下は、Getting Started に紹介されている、最小限の実装例です。ここで使用されている API のうち、主要なものについて紹介します。

なお、Google Publisher Tag(以下、GPT)との併合を前提としています。

<html>

    <head>
        <link rel="icon" type="image/png" href="/favicon.png">
        <script async src="//www.googletagservices.com/tag/js/gpt.js"></script>
        <script async src="//acdn.adnxs.com/prebid/not-for-prod/1/prebid.js"></script>
        <script>
            var sizes = [
                [300, 250]
            ];
            var PREBID_TIMEOUT = 1000;
            var FAILSAFE_TIMEOUT = 3000;

            var adUnits = [{
                code: '/19968336/header-bid-tag-1',
                mediaTypes: {
                    banner: {
                        sizes: sizes
                    }
                },
                bids: [{
                    bidder: 'appnexus',
                    params: {
                        placementId: 13144370
                    }
                }]
            }];

            // ======== DO NOT EDIT BELOW THIS LINE =========== //
            var googletag = googletag || {};
            googletag.cmd = googletag.cmd || [];
            googletag.cmd.push(function() {
                googletag.pubads().disableInitialLoad();
            });

            var pbjs = pbjs || {};
            pbjs.que = pbjs.que || [];

            pbjs.que.push(function() {
                pbjs.addAdUnits(adUnits);
                pbjs.requestBids({
                    bidsBackHandler: initAdserver,
                    timeout: PREBID_TIMEOUT
                });
            });

            function initAdserver() {
                if (pbjs.initAdserverSet) return;
                pbjs.initAdserverSet = true;
                googletag.cmd.push(function() {
                    pbjs.setTargetingForGPTAsync && pbjs.setTargetingForGPTAsync();
                    googletag.pubads().refresh();
                });
            }

            // in case PBJS doesn't load
            setTimeout(function() {
                initAdserver();
            }, FAILSAFE_TIMEOUT);

            googletag.cmd.push(function() {
                googletag.defineSlot('/19968336/header-bid-tag-1', sizes, 'div-1')
                   .addService(googletag.pubads());
                googletag.pubads().enableSingleRequest();
                googletag.enableServices();
            });

        </script>

    </head>

    <body>
        <h2>Basic Prebid.js Example</h2>
        <h5>Div-1</h5>
        <div id='div-1'>
            <script type='text/javascript'>
                googletag.cmd.push(function() {
                    googletag.display('div-1');
                });

            </script>
        </div>
    </body>

</html>

pbjs.addAdUnits

事業者ごとの設定項目を、スロットごとに追加します。 実質的には、pbjs.adUnits フィールドに渡された引数を追加しておくだけです。

Source Code: - pbjs.addAdUnits()

この際、事業者ごとの設定項目を設定する必要がありますが、全て以下のドキュメントに記述されています。

入札者ごとの設定項目ドキュメント: http://prebid.org/dev-docs/bidders.html

ただし、以下の注意点があります。

  • 事業者ごとに、パラメータの型が String / Number / Object で差異がある
    • ex. placementId が、文字列のこともあれば数字のこともある
  • ドキュメント上は任意(optional)だが、事業者から「必ず付与してください」と言われる場合がある
  • 一部ドキュメントが古い可能性があり、そのタイミングで事業者に最新のパラメーターを聞く必要がある

pbjs.requestBids

実際に Header Bidding 入札リクエストを行っている、要のメソッドです。

  • 設定された全事業者に対してリクエストを行う準備をする
  • auctionManager クラスを通して、auction を生成する
  • auction.callBids() で実際にリクエストを行い、入札結果レスポンスが返ってきたら、callback を実行する

Source Code: - pbjs.requestBids() - auctionManager.createAuction() - auction.callBids()

pbjs.setTargetingForGPTAsync

Header Bidding 入札結果を、GPT の Key/Value に設定します。したがって、入札が完了した後に呼び出す必要 があります。

Source Code: - pbjs.setTargetingForGPTAsync() - targeting.setTargetingForGPT()

Prebid.js のソースコードを追っていくとわかりますが、入札結果の存在したスロットに対して、gpt.PubAdsService.setTargeting() を呼び出しています。

  /**
   * Sets targeting for DFP
   * @param {Object.<string,Object.<string,string>>} targetingConfig
   */
  targeting.setTargetingForGPT = function(targetingConfig, customSlotMatching) {
    window.googletag.pubads().getSlots().forEach(slot => {
      // ...
      slot.setTargeting(key, value);
    })
  };

Debugging Prebid.js

Prebid.js には、公式で数々のデバッグ方法やベストプラクティスが紹介されています。主に以下のドキュメントに詳しいです。

開発者用デバッグモードやChrome Extension などのツールも揃っており、入札フローが複雑な割には比較的デバッグがしやすい印象です。

主要なものについて紹介します。

Debug Log

pbjs.setConfig API には、Debugging option が提供されています。以下のように Option を渡すと、必要十分なログを出力してくれます。

pbjs.setConfig({ debug: true });

しかし、ブラウザリロード(pbjs の再読込)の度に設定がリセットされてしまうので、ブラウザの Console から打ち込む用途として利用するのでは不便です。

一方、弊社の広告配信サーバーの JavaScript SDK のビルドプロセスでは webpack を利用しており、ビルド環境(production/staging/development)を define-plugin を用いてソースコードに埋め込んでいます。

この仕組を利用し、ステージング及び開発環境では、デフォルトでデバッグログを有効にします。

// index.js
this.pbjs.setConfig({
  debug: process.env.NODE_ENV === "development",
})
// webpack.config.js
plugins: [
  /**
   * DefinePlugin create global constants while compiling.
   *
   * @doc https://webpack.js.org/plugins/define-plugin/
   */
  new webpack.DefinePlugin({
    "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV),
  }),
],

Snippets

Chrome で提供されている Snippets という機能を利用し、本番環境でも手軽に入札リクエストや入札結果の中身をログに出力することができます。

これらのデータは Network タブから各事業者への入札リクエスト・レスポンスを覗くことでも確認できなくはないのですが、視認性が低いため Snippets を利用しています。

例えば、以下は Tips for Troubleshooting で紹介されている Snippets です。スロットごとに、全事業者に対する入札の結果、最終的に win した入札を表示してくれます。

var bids = pbjs.getHighestCpmBids();
var output = [];
for (var i = 0; i < bids.length; i++) {
    var b = bids[i];
    output.push({
        'adunit': b.adUnitCode, 'adId': b.adId, 'bidder': b.bidder,
        'time': b.timeToRespond, 'cpm': b.cpm
    });
}
if (output.length) {
    if (console.table) {
        console.table(output);
    } else {
        for (var j = 0; j < output.length; j++) {
            console.log(output[j]);
        }
    }
} else {
    console.warn('No prebid winners');
}

Chrome Extension

Prebid.js の公式ツールとして、Headerbid Expert という Chrome Extension が公開されています。

こちらのツールは、エンジニアだけでなくディレクターやプロジェクトマネージャーなども、気軽に自社の Header Bidding 入札結果を確認できるツールです。このツールを使うことで、事業者ごとの選別や、各社ごとのタイムアウト設定の見直し、インプレッション損失のリスクの洗い出しなどに利用できます。

f:id:itiskj:20190212120755j:plain
headerbid expert screenshhot

分析結果の見方については、Prebid.js Optimal Header Bidding Setup というドキュメントページに詳しく紹介されています。ある特定の事業者のタイムアウトに引っ張られて機会損失をしているパターン、何らかの設定ミスで DFP へのリクエストが遅れて機会損失をしているパターンなどが紹介されています。

Prebid.js Modules

Prebid.js は Module Architecture を導入しており、各事業者ごとのアダプターや通貨関連の共通処理をまとめたモジュールなどが提供されています。そして、ファイルサイズを可能な限り最小限に抑えるため、自社が必要なモジュールのみを Prebid.js Download ページからダウンロードしたものを利用することが基本です。

Source Code: - src/modules/*.js

今回は、そのうちでも特に主要なモジュールについて紹介します。

Currency Module

Prebid.js を開発していると、入札結果の金額に関して、例えば以下のような要件が必ずと言っていいほど発生するはずです。

  • 事業者ごとに、net/gross が違うが、入札結果からオークションする前に net/gross の単位を統一したい
  • 入札金額の粒度をより細かくして、機会損失を最小限に抑えたい
  • JPY/USD などの通貨設定が事業者ごとに違うが、DFP にリクエストする前に通貨単位を統一する必要がある
  • 通貨単位を統一する場合、為替を考慮する必要がある

その場合は、公式で提供されている Currency Module を使うことになります。Currency Module を導入すると、pbjs.setConfig() に以下の設定項目を渡すことができるようになります。

Source Code: - currency.js

例えば、以下は設定例です。

this.pbjs.setConfig({
    /**
     * set up custom CPM buckets to optimize the bidding requests.
     */
    priceGranularity: "high",
    /**
     * setting for the conversion of multiple bidder currencies into a single currency
     * http://prebid.org/dev-docs/modules/currency.html#currency-config-options
     */
    currency: {
        adServerCurrency: 'JPY',
        conversionRateFile: 'https://currency.prebid.org/latest.json',
        bidderCurrencyDefault: {
            bidderA: 'JPY',
            bidderB: 'USD',
        },
        defaultRates: {
            USD: {
                JPY: 110,
            }
        },
    },
});

conversionRateFile には、為替レートを変換する時に参考にする為替レートが格納されたファイルの URL を設定することができます。独自で更新したい場合は、こちらを自社の S3 Bucket などを見るようにしておいて、別途ファイルを更新するような仕組みを導入すればよいでしょう。デフォルトでは、jsDelivr と呼ばれる Open Source CDN に配置されてあるファイルを見に行くようになっています。

// https://github.com/prebid/Prebid.js/blob/master/modules/currency.js#L8
const DEFAULT_CURRENCY_RATE_URL = 'https://cdn.jsdelivr.net/gh/prebid/currency-file@1/latest.json?date=$$TODAY$$';

もちろん、毎回 Network を通じてファイルを取得しているわけではなく、Currency Module 内部でオンメモリに為替レートをキャッシュしています。

// https://github.com/prebid/Prebid.js/blob/c2734a73fc907dc6c97d7694e3740e19b8749d3c/modules/currency.js#L236-L240
function getCurrencyConversion(fromCurrency, toCurrency = adServerCurrency) {
  var conversionRate = null;
  var rates;
  let cacheKey = `${fromCurrency}->${toCurrency}`;
  if (cacheKey in conversionCache) {
    conversionRate = conversionCache[cacheKey];
    utils.logMessage('Using conversionCache value ' + conversionRate + ' for ' + cacheKey);
  }
  // ...

なお、https://currency.prebid.org/latest.json というファイルが、https://currency.prebid.org にて提供されています。curl --verbose した結果が以下のとおりです。

curl --verbose http://currency.prebid.org/ | xmllint --format -
* TCP_NODELAY set
* Connected to currency.prebid.org (54.230.108.205) port 80 (#0)
> GET / HTTP/1.1
> Host: currency.prebid.org
> User-Agent: curl/7.54.0
> Accept: */*
>
< HTTP/1.1 200 OK
< Content-Type: application/xml
< Transfer-Encoding: chunked
< Connection: keep-alive
< Date: Fri, 08 Feb 2019 04:17:03 GMT
< x-amz-bucket-region: us-east-1
< Server: AmazonS3
< Age: 43
< X-Cache: Hit from cloudfront
< Via: 1.1 31de515e55a654c65e48898e37e29d09.cloudfront.net (CloudFront)
< X-Amz-Cf-Id: XEpWTG_WXRO4w44X9eIrOV2r_sR-i9EyoZpUwhIRkzXwzqr71w1GyQ==
<
{ [881 bytes data]
100   869    0   869    0     0  27899      0 --:--:-- --:--:-- --:--:-- 28032
* Connection #0 to host currency.prebid.org left intact
<?xml version="1.0" encoding="UTF-8"?>
<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
  <Name>currency.prebid.org</Name>
  <Prefix/>
  <Marker/>
  <MaxKeys>1000</MaxKeys>
  <IsTruncated>false</IsTruncated>
  <Contents>
    <Key>latest-test.json</Key>
    <LastModified>2018-10-15T21:38:13.000Z</LastModified>
    <ETag>"513fe5d930ec3c6c6450ffacda79fb09"</ETag>
    <Size>1325</Size>
    <StorageClass>STANDARD</StorageClass>
  </Contents>
  <Contents>
    <Key>latest.json</Key>
    <LastModified>2019-02-07T10:01:03.000Z</LastModified>
    <ETag>"6e751ac4e7ed227fa0eaf54bbd6c973d"</ETag>
    <Size>1331</Size>
    <StorageClass>STANDARD</StorageClass>
  </Contents>
  <Contents>
    <Key>test.json</Key>
    <LastModified>2018-12-05T11:00:47.000Z</LastModified>
    <ETag>"c4a01460ebce1441625d87ff2ea0af64"</ETag>
    <Size>1341</Size>
    <StorageClass>STANDARD</StorageClass>
  </Contents>
</ListBucketResult>

結果から、次のことがわかります。

  • Amazon S3 に格納されている
    • Server: AmazonS3
  • CloudFront で配信されている
    • X-Cache: Hit from cloudfront
  • latest.json / test.json / latest-test.json が提供されている

特に理由がないのであれば、こちらのファイルを使うことで概ね十分だと言えるでしょう。

また、net/gross の変換には、pbjs.bidderSettings | bidCpmAdjustment を用います。

this.pbjs.bidderSettings = {
    bidderA: {
        bidCpmAdjustment : (bidCpm) => bidCpm * 0.85,
    },
    bidderB: {
        bidCpmAdjustment : (bidCpm) => bidCpm * 0.80,
    },
};

Integration with TAM

"Header Bidding 導入によるネットワーク広告改善の開発事情" でお伝えしたとおり、いくつかの事業者についてはすでに TAM 経由で Header Bidding 入札を行っていました。今回は、TAM と並行する形で Prebid.js の導入をする必要がありました。

以下に、全体のデータフローを示しました。par の部分で、TAM および Prebid.js 経由の Header Bidding 入札を並行して行い、両者から結果が返ってきたら DFP にリクエストします。

type description
ads 社内広告配信サーバ
display.js 広告表示用の JavaScript SDK
cookpad_ads-ruby display.js を埋め込むための Rails 用ヘルパーを定義した簡易な gem
apstag TAM の提供する Header Bidding 用ライブラリ
googletag DFP の提供するアドネットワーク用ライブラリ
pbjs Prebid.js 用ライブラリ
SSP SSP 事業者(実際は複数事業者が存在している)

f:id:itiskj:20190212120829j:plain
Sequence Diagram for Prebid.js and TAM migration

DFP にリクエストをする前に、TAM と Prebid.js 両者の入札を完了させておきたかったので、Promise.all でリクエストを行い、待ち合わせる形で実装しました。以下は、本番で利用しているコードの抜粋です(エラーやロギングなど、本質ではない行を削除したもの)。

  requestHeaderBidding(slots) {
    const prebidPromise = this.requestPrebid(slots);
    const apsPromise = this.requestAPS(slots);

    return Promise.all([prebidPromise, apsPromise])
      .then(() => this.headerBiddingFinishCallback())
      .catch(err => Logger.error(err));
  }

  requestPrebid(slots) {
    return new Promise((resolve) => {
      pbjs.que.push(() => {
        pbjs.addAdUnits(this.getPrebidAdUnits);

        pbjs.requestBids({
          bidsBackHandler: (result) => {
            resolve({
              type: "prebid",
              result: result || [],
            });
          },
          timeout: this.prebid_timeout,
        });
      });
    });
  }

  requestAPS(slots) {
    return new Promise((resolve) => {
      apstag.fetchBids(thihs.apstagBidOption, (bids) => {
        resolve({
          type: "aps",
          bids: bids || [],
        });
      });
    });
  }

  headerBiddingFinishCallback() {
    googletag.cmd.push(() => {
      pbjs.setTargetingForGPTAsync();
      apstag.setDisplayBids();

      googletag.pubads().refresh();
    });
  }

Conclusion

アドテク関連のエンジニア目線での事例紹介や技術詳解はあまり事例が少ないため、この場で紹介させていただきました。特に、Prebid.js は、開発自体はドキュメントが丁寧な分嵌りどころは少ないものの、実際の導入フローにおける知見は、日本においてほとんど共有されていません。そこに問題意識を感じたため、この機会に Prebid.js の導入フローを紹介させていただきました。

広告領域は、技術的にチャレンジングな課題も多く、かつ事業の売上貢献に直結することが多い、非常にエキサイティングな領域です。ぜひ、興味を持っていただけたら、Twitter からご連絡ください。

また、メディアプロダクト開発部では、一緒に働いてくれるメンバーを募集しています。少しでも興味を持っていただけたら、以下をご覧ください。

/* */ @import "/css/theme/report/report.css"; /* */ /* */ body{ background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527163350.png'); background-repeat: repeat-x; background-color:transparent; background-attachment: scroll; background-position: left top;} /* */ body{ border-top: 3px solid orange; color: #3c3c3c; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; line-height: 1.8; font-size: 16px; } a { text-decoration: underline; color: #693e1c; } a:hover { color: #80400e; text-decoration: underline; } .entry-title a{ color: rgb(176, 108, 28); cursor: auto; display: inline; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; font-size: 30px; font-weight: bold; height: auto; line-height: 40.5px; text-decoration: underline solid rgb(176, 108, 28); width: auto; line-height: 1.35; } .date a { color: #9b8b6c; font-size: 14px; text-decoration: none; font-weight: normal; } .urllist-title-link { font-size: 14px; } /* Recent Entries */ .recent-entries a{ color: #693e1c; } .recent-entries a:visited { color: #4d2200; text-decoration: none; } .hatena-module-recent-entries li { padding-bottom: 8px; border-bottom-width: 0px; } /*Widget*/ .hatena-module-body li { list-style-type: circle; } .hatena-module-body a{ text-decoration: none; } .hatena-module-body a:hover{ text-decoration: underline; } /* Widget name */ .hatena-module-title, .hatena-module-title a{ color: #b06c1c; margin-top: 20px; margin-bottom: 7px; } /* work frame*/ #container { width: 970px; text-align: center; margin: 0 auto; background: transparent; padding: 0 30px; } #wrapper { float: left; overflow: hidden; width: 660px; } #box2 { width: 240px; float: right; font-size: 14px; word-wrap: break-word; } /*#blog-title-inner{*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-position: left 0px;*/ /*}*/ /*.header-image-only #blog-title-inner {*/ /*background-repeat: no-repeat;*/ /*position: relative;*/ /*height: 200px;*/ /*display: none;*/ /*}*/ /*#blog-title {*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527172848.png');*/ /*background-repeat: no-repeat;*/ /*background-position: left 0px;*/ /*}*/