Skip to content

Commit 6c37ea2

Browse files
feat: Add a cpp plugin for GAM ad insertion into stream (#285)
This plugin enables configurable Google Ad Manager ad injection into HTML streams. It supports multiple ad positions with flexible placement options (before or after any HTML tag), follows standard GAM GPT implementation, and includes tests. The solution prevents third-party domain blocking by serving ads from the same parent domain as the content. --------- Co-authored-by: Dantebot <dantebot@google.com>
1 parent bd6c373 commit 6c37ea2

4 files changed

Lines changed: 407 additions & 0 deletions

File tree

plugins/samples/ad_insertion/BUILD

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
load("//:plugins.bzl", "proxy_wasm_plugin_cpp", "proxy_wasm_tests")
2+
3+
licenses(["notice"]) # Apache 2
4+
5+
proxy_wasm_plugin_cpp(
6+
name = "plugin_cpp.wasm",
7+
srcs = ["plugin.cc"],
8+
deps = [
9+
"@com_google_absl//absl/strings",
10+
],
11+
)
12+
13+
proxy_wasm_tests(
14+
name = "tests",
15+
config = ":tests.config",
16+
plugins = [
17+
":plugin_cpp.wasm",
18+
],
19+
tests = ":tests.textpb",
20+
)
Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
// Copyright 2026 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
// [START serviceextensions_plugin_ad_insertion]
16+
#include <algorithm>
17+
#include <map>
18+
#include <sstream>
19+
#include <string>
20+
#include <string_view>
21+
#include <vector>
22+
23+
#include "absl/strings/ascii.h"
24+
#include "absl/strings/str_split.h"
25+
#include "proxy_wasm_intrinsics.h"
26+
27+
class MyRootContext : public RootContext {
28+
public:
29+
explicit MyRootContext(uint32_t id, std::string_view root_id)
30+
: RootContext(id, root_id) {}
31+
32+
bool onConfigure(size_t config_len) override {
33+
// 1. Set default configurations for fallback and testing purposes.
34+
gpt_library_url_ = "https://securepubads.g.doubleclick.net/tag/js/gpt.js";
35+
inject_gpt_library_ = true;
36+
ad_configs_ = {
37+
{"header", {"/1234/header_ad", "728x90", "<body>", false}},
38+
{"content", {"/1234/content_ad", "300x250", "<article>", false}},
39+
{"sidebar", {"/1234/sidebar_ad", "160x600", "</article>", true}}
40+
};
41+
42+
// 2. If no configuration is provided, use defaults.
43+
if (config_len == 0) {
44+
LOG_INFO("No configuration provided. Using default ad insertion config.");
45+
return true;
46+
}
47+
48+
// 3. Read the configuration buffer.
49+
auto config_data = getBufferBytes(WasmBufferType::PluginConfiguration, 0, config_len);
50+
if (!config_data || config_data->size() == 0) {
51+
return true;
52+
}
53+
54+
// Clear default ad configs since we are loading custom ones.
55+
ad_configs_.clear();
56+
absl::string_view config_str = config_data->view();
57+
58+
// 4. Parse the CSV-like configuration format.
59+
// Expected format per line (comma-separated):
60+
// gpt_url, <url>
61+
// inject_gpt, <true|false>
62+
// ad, <position>, <gam_slot>, <size>, <insert_before_bool>, <marker>
63+
for (absl::string_view line : absl::StrSplit(config_str, '\n')) {
64+
absl::string_view stripped = absl::StripAsciiWhitespace(line);
65+
// Skip empty lines or comments
66+
if (stripped.empty() || stripped[0] == '#') continue;
67+
68+
std::vector<absl::string_view> parts = absl::StrSplit(stripped, ',');
69+
70+
// Trim whitespace from extracted parts
71+
for (auto& part : parts) {
72+
part = absl::StripAsciiWhitespace(part);
73+
}
74+
75+
if (parts[0] == "gpt_url" && parts.size() >= 2) {
76+
gpt_library_url_ = std::string(parts[1]);
77+
} else if (parts[0] == "inject_gpt" && parts.size() >= 2) {
78+
inject_gpt_library_ = (parts[1] == "true");
79+
} else if (parts[0] == "ad" && parts.size() >= 6) {
80+
std::string position = std::string(parts[1]);
81+
AdConfig config;
82+
config.slot = std::string(parts[2]);
83+
config.size = std::string(parts[3]);
84+
config.insert_before = (parts[4] == "true");
85+
config.marker = std::string(parts[5]);
86+
87+
ad_configs_[position] = config;
88+
} else {
89+
LOG_WARN("Invalid configuration line: " + std::string(stripped));
90+
}
91+
}
92+
93+
LOG_INFO("Ad Insertion plugin configured successfully from custom payload.");
94+
return true;
95+
}
96+
97+
struct AdConfig {
98+
std::string slot; // GAM ad slot path (e.g., "/1234/header_ad")
99+
std::string size; // Ad dimensions (e.g., "728x90")
100+
std::string marker; // HTML tag to insert ads relative to
101+
bool insert_before; // Insert before (true) or after (false) the marker
102+
};
103+
104+
const AdConfig* getAdConfig(std::string_view position) const {
105+
for (const auto& [key, config] : ad_configs_) {
106+
if (key == position) return &config;
107+
}
108+
return nullptr;
109+
}
110+
111+
const std::map<std::string, AdConfig>& getAllAdConfigs() const {
112+
return ad_configs_;
113+
}
114+
115+
const std::string& getGptLibraryUrl() const { return gpt_library_url_; }
116+
bool shouldInjectGpt() const { return inject_gpt_library_; }
117+
118+
private:
119+
std::map<std::string, AdConfig> ad_configs_;
120+
std::string gpt_library_url_;
121+
bool inject_gpt_library_;
122+
};
123+
124+
class MyHttpContext : public Context {
125+
public:
126+
explicit MyHttpContext(uint32_t id, RootContext* root)
127+
: Context(id, root), root_(static_cast<const MyRootContext*>(root)) {}
128+
129+
FilterHeadersStatus onRequestHeaders(uint32_t headers,
130+
bool end_of_stream) override {
131+
// Skip ad insertion for ad requests to avoid infinite loops
132+
auto path = getRequestHeader(":path");
133+
if (path && path->view().find("/ads/") != std::string_view::npos) {
134+
is_ad_request_ = true;
135+
}
136+
return FilterHeadersStatus::Continue;
137+
}
138+
139+
FilterHeadersStatus onResponseHeaders(uint32_t headers,
140+
bool end_of_stream) override {
141+
auto content_type = getResponseHeader("Content-Type");
142+
if (content_type && content_type->view().find("text/html") != std::string_view::npos) {
143+
should_insert_ads_ = true;
144+
removeResponseHeader("Content-Length");
145+
}
146+
return FilterHeadersStatus::Continue;
147+
}
148+
149+
FilterDataStatus onResponseBody(size_t body_size, bool end_of_stream) override {
150+
if (!should_insert_ads_ || is_ad_request_) {
151+
return FilterDataStatus::Continue;
152+
}
153+
154+
// Buffer the body until the end of the stream to ensure we process the complete HTML.
155+
// Processing chunks individually might split HTML tags and break marker matching.
156+
if (!end_of_stream) {
157+
return FilterDataStatus::StopIterationAndBuffer;
158+
}
159+
160+
auto body = getBufferBytes(WasmBufferType::HttpResponseBody, 0, body_size);
161+
if (!body) {
162+
return FilterDataStatus::Continue;
163+
}
164+
165+
std::string body_str = std::string(body->view());
166+
processBodyWithGAM(body_str);
167+
168+
return FilterDataStatus::Continue;
169+
}
170+
171+
private:
172+
bool isGptAlreadyLoaded(std::string_view body) const {
173+
return body.find("googletag") != std::string_view::npos ||
174+
body.find("gpt.js") != std::string_view::npos ||
175+
body.find("doubleclick.net/tag/js/gpt") != std::string_view::npos;
176+
}
177+
178+
void processBodyWithGAM(std::string& body) {
179+
// Vector to store all insertions: (position, content)
180+
std::vector<std::pair<size_t, std::string>> insertions;
181+
182+
// 1. Prepare GPT library injection if needed and not already present
183+
if (root_->shouldInjectGpt() && !isGptAlreadyLoaded(body)) {
184+
prepareGptLibraryInjection(body, insertions);
185+
}
186+
187+
// 2. Prepare all ad insertions in single pass
188+
const auto& ad_configs = root_->getAllAdConfigs();
189+
for (const auto& [position, config] : ad_configs) {
190+
prepareAdInsertion(body, position, config, insertions);
191+
}
192+
193+
// 3. Apply insertions from bottom to top to maintain accurate position values for early insertions.
194+
if (!insertions.empty()) {
195+
applyAllInsertions(body, insertions);
196+
}
197+
198+
setBuffer(WasmBufferType::HttpResponseBody, 0, body.size(), body);
199+
}
200+
201+
void prepareGptLibraryInjection(const std::string& body,
202+
std::vector<std::pair<size_t, std::string>>& insertions) const {
203+
size_t head_pos = body.find("<head>");
204+
if (head_pos != std::string::npos) {
205+
insertions.emplace_back(head_pos + 6,
206+
"\n <script async src=\"" + root_->getGptLibraryUrl() + "\"></script>");
207+
return;
208+
}
209+
210+
size_t body_pos = body.find("<body>");
211+
if (body_pos != std::string::npos) {
212+
insertions.emplace_back(body_pos,
213+
"<script async src=\"" + root_->getGptLibraryUrl() + "\"></script>\n");
214+
}
215+
}
216+
217+
void prepareAdInsertion(const std::string& body, std::string_view position,
218+
const MyRootContext::AdConfig& config,
219+
std::vector<std::pair<size_t, std::string>>& insertions) const {
220+
size_t marker_pos = body.find(config.marker);
221+
if (marker_pos == std::string::npos) return;
222+
223+
size_t insert_pos = config.insert_before ? marker_pos : marker_pos + config.marker.length();
224+
std::string ad_html = generateGAMAdHTML(position, config);
225+
226+
insertions.emplace_back(insert_pos, ad_html);
227+
}
228+
229+
void applyAllInsertions(std::string& body,
230+
std::vector<std::pair<size_t, std::string>>& insertions) const {
231+
std::sort(insertions.begin(), insertions.end(),
232+
[](const auto& a, const auto& b) {
233+
return a.first > b.first;
234+
});
235+
236+
for (const auto& [pos, content] : insertions) {
237+
body.insert(pos, content);
238+
}
239+
}
240+
241+
std::string generateGAMAdHTML(std::string_view position,
242+
const MyRootContext::AdConfig& config) const {
243+
std::ostringstream html;
244+
245+
html << "<div id=\"ad-container-" << position << "\" class=\"ad-unit\">\n"
246+
<< " \n"
247+
<< " <script>\n"
248+
<< " (function() {\n"
249+
<< " // Same-domain GAM integration\n"
250+
<< " var googletag = window.googletag || {};\n"
251+
<< " googletag.cmd = googletag.cmd || [];\n"
252+
<< " googletag.cmd.push(function() {\n"
253+
<< " googletag.defineSlot('" << config.slot << "', \n"
254+
<< " [" << config.size << "], \n"
255+
<< " 'ad-container-" << position << "').addService(googletag.pubads());\n"
256+
<< " googletag.pubads().enableSingleRequest();\n"
257+
<< " googletag.enableServices();\n"
258+
<< " });\n"
259+
<< " })();\n"
260+
<< " </script>\n"
261+
<< " <div id=\"div-gpt-ad-" << position << "\">\n"
262+
<< " <script>\n"
263+
<< " googletag.cmd.push(function() { \n"
264+
<< " googletag.display('div-gpt-ad-" << position << "'); \n"
265+
<< " });\n"
266+
<< " </script>\n"
267+
<< " </div>\n"
268+
<< "</div>";
269+
270+
return html.str();
271+
}
272+
273+
const MyRootContext* root_;
274+
bool should_insert_ads_ = false;
275+
bool is_ad_request_ = false;
276+
};
277+
278+
static RegisterContextFactory register_MyHttpContext(
279+
CONTEXT_FACTORY(MyHttpContext), ROOT_FACTORY(MyRootContext));
280+
// [END serviceextensions_plugin_ad_insertion]
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
gpt_url, https://custom.pubads.g.doubleclick.net/tag/js/gpt.js
2+
inject_gpt, true
3+
ad, custom_header, /9999/custom_header_ad, 970x250, true, <header>
4+
ad, custom_footer, /9999/custom_footer_ad, 728x90, false, <footer>

0 commit comments

Comments
 (0)