Skip to content

Commit e74a1b4

Browse files
committed
Implement AMP-side click measurement.
Introduces a redirect-avoidance mechanism that enables referrers to track a link invocation without an intermediate redirect by passing a URL to AMP via a fragment param. This change is experiment guarded. Also introduces a small new binary for use on the referrer side (e.g. inside of an ad creative) that helps with constructing the respectives URLs and preconnects to AMP. Primary implementation of ampproject#2934
1 parent 6f970a9 commit e74a1b4

File tree

12 files changed

+652
-4
lines changed

12 files changed

+652
-4
lines changed

ads/alp/handler.js

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
/**
2+
* Copyright 2016 The AMP HTML Authors. All Rights Reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS-IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import {
18+
addParamToUrl,
19+
parseQueryString,
20+
} from '../../src/url';
21+
import {closest} from '../../src/dom';
22+
23+
24+
25+
/**
26+
* Install a click listener that transforms navigation to the AMP cache
27+
* to a form that directly navigates to the doc and transmits the original
28+
* URL as a click logging info passed via a fragment param.
29+
* Expects to find a URL starting with "https://cdn.ampproject.org/c/"
30+
* to be available via a param call "adurl" (or defined by the
31+
* `data-url-param-name` attribute on the a tag.
32+
* @param {!Window} win
33+
*/
34+
export function installAlpClickHandler(win) {
35+
win.document.documentElement.addEventListener('click', handleClick);
36+
// Start loading destination doc when finger is down.
37+
// Needs experiment whether this is a good idea.
38+
win.document.documentElement.addEventListener('touchstart', warmupDynamic);
39+
}
40+
41+
/**
42+
* Filter click event and then transform URL for direct AMP navigation
43+
* with impression logging.
44+
* @param {!MouseEvent} e
45+
* @visibleForTesting
46+
*/
47+
export function handleClick(e) {
48+
if (e.defaultPrevented) {
49+
return;
50+
}
51+
// Only handle simple clicks with the left mouse button/touch and without
52+
// modifier keys.
53+
if (e.buttons != 0 && e.buttons != 1) {
54+
return;
55+
}
56+
if (e.ctrlKey || e.altKey || e.shiftKey || e.metaKey) {
57+
return;
58+
}
59+
60+
const link = getLinkInfo(e);
61+
if (!link || !link.eventualUrl) {
62+
return;
63+
}
64+
65+
// Tag the original href with &amp=1 and make it a fragment param with
66+
// name click.
67+
const fragment = 'click=' + encodeURIComponent(
68+
addParamToUrl(link.a.href, 'amp', '1'));
69+
let destination = link.eventualUrl;
70+
if (link.eventualUrl.indexOf('#') == -1) {
71+
destination += '#' + fragment;
72+
} else {
73+
destination += '&' + fragment;
74+
}
75+
const win = link.a.ownerDocument.defaultView;
76+
const ancestors = win.location.ancestorOrigins;
77+
if (ancestors && ancestors[ancestors.length - 1] == 'http://localhost:8000') {
78+
destination = destination.replace('https://cdn.ampproject.org/c/',
79+
'http://localhost:8000/max/');
80+
}
81+
82+
e.preventDefault();
83+
navigateTo(win, link.a, destination);
84+
}
85+
86+
/**
87+
* For an event, see if there is an anchor tag in the target
88+
* ancestor chain and if yes, check whether we can figure
89+
* out an AMP target URL.
90+
* @param {!Event} e
91+
* @return {{
92+
* eventualUrl: (string|undefined),
93+
* a: !Element
94+
* }|undefined} A URL on the AMP Cache.
95+
*/
96+
function getLinkInfo(e) {
97+
const a = closest(e.target, element => {
98+
return element.tagName == 'A' && element.href;
99+
});
100+
if (!a) {
101+
return;
102+
}
103+
return {
104+
eventualUrl: getEventualUrl(a),
105+
a,
106+
};
107+
}
108+
109+
/**
110+
* Given an anchor tag, figure out whether this goes to an AMP destination
111+
* via a redirect.
112+
* @param {!Element} a An anchor tag.
113+
* @return {string|undefined} A URL on the AMP Cache.
114+
*/
115+
function getEventualUrl(a) {
116+
const urlParamName = a.getAttribute('data-url-param-name') || 'adurl';
117+
const eventualUrl = parseQueryString(a.search)[urlParamName];
118+
if (!eventualUrl) {
119+
return;
120+
}
121+
if (!eventualUrl.indexOf('https://cdn.ampproject.org/c/') == 0) {
122+
return;
123+
}
124+
return eventualUrl;
125+
}
126+
127+
/**
128+
* Navigate to the given URL. Infers the target from the given anchor
129+
* tag.
130+
* @param {!Window} win
131+
* @param {!Element} a Anchor element
132+
* @param {string} url
133+
*/
134+
function navigateTo(win, a, url) {
135+
const target = (a.target || '_top').toLowerCase();
136+
win.open(url, target);
137+
}
138+
139+
/**
140+
* Establishes a connection to the AMP Cache and makes sure
141+
* the AMP JS is cached.
142+
* @param {!Window} win
143+
*/
144+
export function warmupStatic(win) {
145+
// Preconnect using an image, because that works on all browsers.
146+
// The image has a 1 minute cache time to avoid duplicate
147+
// preconnects.
148+
new win.Image().src = 'https://cdn.ampproject.org/preconnect.gif';
149+
// Preload the primary AMP JS that is render blocking.
150+
const linkRel = /*OK*/document.createElement('link');
151+
linkRel.rel = 'preload';
152+
linkRel.setAttribute('as', 'script');
153+
linkRel.href =
154+
'https://cdn.ampproject.org/rtv/01$internalRuntimeVersion$/v0.js';
155+
getHeadOrFallback(win.document).appendChild(linkRel);
156+
}
157+
158+
/**
159+
* For events (such as touch events) that point to an eligible URL, preload
160+
* that URL.
161+
* @param {!Event} e
162+
* @visibleForTesting
163+
*/
164+
export function warmupDynamic(e) {
165+
const link = getLinkInfo(e);
166+
if (!link || !link.eventualUrl) {
167+
return;
168+
}
169+
const linkRel = /*OK*/document.createElement('link');
170+
linkRel.rel = 'preload';
171+
linkRel.setAttribute('as', 'document');
172+
linkRel.href = link.eventualUrl;
173+
getHeadOrFallback(e.target.ownerDocument).appendChild(linkRel);
174+
}
175+
176+
/**
177+
* Return <head> if present or just the document element.
178+
* @param {!Document} doc
179+
* @return {!Element}
180+
*/
181+
function getHeadOrFallback(doc) {
182+
return doc.head || doc.documentElement;
183+
}

ads/alp/install-alp.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/**
2+
* Copyright 2016 The AMP HTML Authors. All Rights Reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS-IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
// Utility file that generates URLs suitable for AMP's impression tracking.
18+
19+
import '../../third_party/babel/custom-babel-helpers';
20+
21+
import {installAlpClickHandler, warmupStatic} from './handler';
22+
23+
installAlpClickHandler(window);
24+
warmupStatic(window);

examples/alp.amp.html

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<!doctype html>
2+
<html >
3+
<head>
4+
<meta charset="utf-8">
5+
<title>ALP examples</title>
6+
<link rel="canonical" href="amps.html">
7+
<meta name="viewport" content="width=device-width,minimum-scale=1,initial-scale=1">
8+
<link href="https://fonts.googleapis.com/css?family=Questrial" rel="stylesheet" type="text/css">
9+
<style amp-boilerplate>body{-webkit-animation:-amp-start 8s steps(1,end) 0s 1 normal both;-moz-animation:-amp-start 8s steps(1,end) 0s 1 normal both;-ms-animation:-amp-start 8s steps(1,end) 0s 1 normal both;animation:-amp-start 8s steps(1,end) 0s 1 normal both}@-webkit-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-moz-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-ms-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-o-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}</style><noscript><style amp-boilerplate>body{-webkit-animation:none;-moz-animation:none;-ms-animation:none;animation:none}</style></noscript>
10+
<script async src="https://cdn.ampproject.org/v0.js"></script>
11+
</head>
12+
<body>
13+
<h2>ALP</h2>
14+
15+
<h3>target=_blank</h3>
16+
<amp-ad
17+
width=300
18+
height=250
19+
type="doubleclick"
20+
data-slot="/35096353/amptesting/landingpagesjs">
21+
</amp-ad>
22+
23+
<h3>target=_top</h3>
24+
<amp-ad
25+
width=300
26+
height=250
27+
type="doubleclick"
28+
data-slot="/35096353/amptesting/landing_sub/_top">
29+
</amp-ad>
30+
</body>
31+
</html>

gulpfile.js

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,8 +156,11 @@ function watch() {
156156
$$.watch('css/**/*.css', function() {
157157
compileCss();
158158
});
159+
buildAlp({
160+
watch: true,
161+
});
159162
buildExtensions({
160-
watch: true
163+
watch: true,
161164
});
162165
buildExamples(true);
163166
compile(true);
@@ -240,6 +243,7 @@ function buildExtensionJs(path, name, version, options) {
240243
function build() {
241244
process.env.NODE_ENV = 'development';
242245
polyfillsForTests();
246+
buildAlp();
243247
buildExtensions();
244248
buildExamples(false);
245249
compile();
@@ -252,6 +256,7 @@ function dist() {
252256
process.env.NODE_ENV = 'production';
253257
cleanupBuildDir();
254258
compile(false, true, true);
259+
buildAlp({minify: true, watch: false, preventRemoveAndMakeDir: true});
255260
buildExtensions({minify: true, preventRemoveAndMakeDir: true});
256261
buildExperiments({minify: true, watch: false, preventRemoveAndMakeDir: true});
257262
buildLoginDone({minify: true, watch: false, preventRemoveAndMakeDir: true});
@@ -279,6 +284,7 @@ function buildExamples(watch) {
279284

280285
// Also update test-example-validation.js
281286
buildExample('ads.amp.html');
287+
buildExample('alp.amp.html');
282288
buildExample('analytics-notification.amp.html');
283289
buildExample('analytics.amp.html');
284290
buildExample('article.amp.html');
@@ -646,6 +652,24 @@ function buildLoginDoneVersion(version, options) {
646652
});
647653
}
648654

655+
/**
656+
* Build ALP JS
657+
*
658+
* @param {!Object} options
659+
*/
660+
function buildAlp(options) {
661+
options = options || {};
662+
console.log('Bundling alp.js');
663+
664+
compileJs('./ads/alp/', 'install-alp.js', './dist/', {
665+
watch: options.watch,
666+
minify: options.minify || argv.minify,
667+
includePolyfills: true,
668+
minifiedName: 'alp.js',
669+
preventRemoveAndMakeDir: options.preventRemoveAndMakeDir,
670+
});
671+
}
672+
649673
/**
650674
* Exits the process if gulp is running with a node version lower than
651675
* the required version. This has to run very early to avoid parse

src/amp.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {stubElements} from './custom-element';
3131
import {adopt} from './runtime';
3232
import {cssText} from '../build/css';
3333
import {maybeValidate} from './validator-integration';
34+
import {maybeTrackImpression} from './impression';
3435

3536
// We must under all circumstances call makeBodyVisible.
3637
// It is much better to have AMP tags not rendered than having
@@ -46,6 +47,7 @@ try {
4647
installCoreServices(window);
4748
// We need the core services (viewer/resources) to start instrumenting
4849
perf.coreServicesAvailable();
50+
maybeTrackImpression(window);
4951
templatesFor(window);
5052

5153
installImg(window);

src/impression.js

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/**
2+
* Copyright 2016 The AMP HTML Authors. All Rights Reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS-IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import {user} from './log';
18+
import {isExperimentOn} from './experiments';
19+
import {viewerFor} from './viewer';
20+
import {xhrFor} from './xhr';
21+
22+
23+
/**
24+
* Emit a HTTP request to a destination defined on the incoming URL.
25+
* Protected by experiment.
26+
* @param {!Window} win
27+
*/
28+
export function maybeTrackImpression(win) {
29+
if (!isExperimentOn(win, 'alp')) {
30+
return;
31+
}
32+
const viewer = viewerFor(win);
33+
const clickUrl = viewer.getParam('click');
34+
if (!clickUrl) {
35+
return;
36+
}
37+
if (clickUrl.indexOf('https://') != 0) {
38+
user.warn('Impression',
39+
'click fragment param should start with https://. Found ',
40+
clickUrl);
41+
return;
42+
}
43+
if (win.location.hash) {
44+
// This is typically done using replaceState inside the viewer.
45+
// If for some reason it failed, get rid of the fragment here to
46+
// avoid duplicate tracking.
47+
win.location.hash = '';
48+
}
49+
viewer.whenFirstVisible().then(() => {
50+
invoke(win, clickUrl);
51+
});
52+
}
53+
54+
function invoke(win, clickUrl) {
55+
xhrFor(win).fetchJson(clickUrl, {
56+
credentials: 'include',
57+
requireAmpResponseSourceOrigin: true,
58+
});
59+
// TODO(@cramforce): Do something with the result.
60+
}

src/service/viewer-impl.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -409,8 +409,9 @@ export class Viewer {
409409
}
410410
});
411411

412-
// Remove hash - no reason to keep it around, but only when embedded.
413-
if (this.isEmbedded_) {
412+
// Remove hash - no reason to keep it around, but only when embedded or we have
413+
// an incoming click tracking string (see impression.js).
414+
if (this.isEmbedded_ || this.params_['click']) {
414415
const newUrl = removeFragment(this.win.location.href);
415416
if (newUrl != this.win.location.href && this.win.history.replaceState) {
416417
// Persist the hash that we removed has location.originalHash.

0 commit comments

Comments
 (0)