-
Notifications
You must be signed in to change notification settings - Fork 3.9k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Introduce iframe transport to amp-analytics - 3p side #10596
Changes from 70 commits
9a2c05e
c539c66
2f892c3
e754522
be880d6
8c76838
dbf2960
7e58280
5d46a9d
b6351f2
d15b9c5
fb539c8
f252fe1
ab3b882
8b47fb0
b291ea1
8373e55
e56dbed
83fa626
8b5f533
88d118d
5e4e3f7
558339f
305a84b
421b56e
c1b36b5
e111811
e590d9e
4c17cbe
3ce7c3b
fbdb4a8
f664d31
fcad58a
4c52b48
2920068
d5b2dae
7b12404
28c7b7a
409ac7c
ee822cf
f4f5048
0203122
bff17d9
0d64916
0e54a38
3d7dbd6
4889860
21d272f
f363bf1
284d363
c7898d7
352f1f8
904148d
56cc6a6
2cccfc3
cfa8c33
d8dde0f
98249eb
78098c5
fa9c3f3
ac5bdca
d96b644
0f03774
3ff0ae4
3092770
ac08ff3
8e4a0a3
29e94e5
ab3db5f
a7f05ea
2f9bec5
66d0cc4
545ac7b
c7b6c88
88b6c4c
015c48a
ef8fb8d
e7c0311
6ca88ee
1e8595d
3349b6b
bd9a9eb
39164c5
7b0a03b
d265980
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,264 @@ | ||
/** | ||
* Copyright 2017 The AMP HTML Authors. All Rights Reserved. | ||
* | ||
* Licensed under the Apache License, Version 2.0 (the "License"); | ||
* you may not use this file except in compliance with the License. | ||
* You may obtain a copy of the License at | ||
* | ||
* http://www.apache.org/licenses/LICENSE-2.0 | ||
* | ||
* Unless required by applicable law or agreed to in writing, software | ||
* distributed under the License is distributed on an "AS-IS" BASIS, | ||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
* See the License for the specific language governing permissions and | ||
* limitations under the License. | ||
*/ | ||
|
||
import './polyfills'; | ||
import {tryParseJson} from '../src/json'; | ||
import {dev, user, initLogConstructor, setReportError} from '../src/log'; | ||
import {IFRAME_TRANSPORT_EVENTS_TYPE} from '../src/iframe-transport-common'; | ||
import {getData} from '../src/event-helper'; | ||
|
||
initLogConstructor(); | ||
// TODO(alanorozco): Refactor src/error.reportError so it does not contain big | ||
// transitive dependencies and can be included here. | ||
setReportError(() => {}); | ||
|
||
/** @private @const {string} */ | ||
const TAG_ = 'ampanalytics-lib'; | ||
|
||
/** | ||
* Receives event messages bound for this cross-domain iframe, from all | ||
* creatives | ||
*/ | ||
export class EventRouter { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. IframeTransportClient There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Will do There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done |
||
|
||
/** @param {!Window} win */ | ||
constructor(win) { | ||
/** @private {!Window} */ | ||
this.win_ = win; | ||
|
||
/** @const {string} */ | ||
this.sentinel_ = user().assertString( | ||
tryParseJson(this.win_.name)['sentinel'], | ||
'Invalid/missing sentinel on iframe name attribute' + this.win_.name); | ||
if (!this.sentinel_) { | ||
return; | ||
} | ||
|
||
/** | ||
* Multiple creatives on a page may wish to use the same type of | ||
* amp-analytics tag. This object provides a mapping between the | ||
* IDs which identify which amp-analytics tag a message is to/from, | ||
* with each ID's corresponding CreativeEventRouter, | ||
* which is an object that handles messages to/from a particular creative. | ||
* @private {!Object<string, !CreativeEventRouter>} | ||
*/ | ||
this.creativeEventRouters_ = {}; | ||
|
||
this.win_.addEventListener('message', event => { | ||
const messageContainer = this.extractMessage_(event); | ||
if (this.sentinel_ != messageContainer['sentinel']) { | ||
return; | ||
} | ||
user().assert(messageContainer['type'], | ||
'Received message with missing type in ' + this.win_.location.href); | ||
user().assert(messageContainer['events'], | ||
'Received empty message in ' + this.win_.location.href); | ||
user().assert( | ||
messageContainer['type'] == IFRAME_TRANSPORT_EVENTS_TYPE, | ||
'Received unrecognized message type ' + messageContainer['type'] + | ||
' in ' + this.win_.location.href); | ||
this.processEventsMessage_( | ||
/** | ||
* @type {!Array<../src/iframe-transport-common.IframeTransportEvent>} | ||
*/ | ||
(messageContainer['events'])); | ||
}, false); | ||
|
||
this.subscribeTo(IFRAME_TRANSPORT_EVENTS_TYPE); | ||
} | ||
|
||
/** | ||
* Sends a message to the parent frame, requesting to subscribe to a | ||
* particular message type | ||
* @param messageType The type of message to subscribe to | ||
* @VisibleForTesting | ||
*/ | ||
subscribeTo(messageType) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. should you use IframeMessagingClient? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm a bit confused here. You said to change from IframeMessagingClient to SubscriptionAPI. Do you mean to have one side of the conversation using IframeMessagingClient, and the other side using SubscriptionAPI? I can research whether that will work, but want to make sure I understand you correctly. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. sorry if i didn't make it clear. that's how it works for our 3P ads iframe There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done |
||
this.win_.parent./*OK*/postMessage(/** @type {JsonObject} */ ({ | ||
sentinel: this.sentinel_, | ||
type: messageType, | ||
}), '*'); | ||
} | ||
|
||
/** | ||
* Handle receipt of a message indicating that creative(s) have sent | ||
* event(s) to this frame | ||
* @param {!Array<!../src/iframe-transport-common.IframeTransportEvent>} | ||
* events An array of events | ||
* @private | ||
*/ | ||
processEventsMessage_(events) { | ||
user().assert(events && events.length, | ||
'Received empty events list in ' + this.win_.location.href); | ||
this.win_.onNewAmpAnalyticsInstance = | ||
this.win_.onNewAmpAnalyticsInstance || null; | ||
user().assert(this.win_.onNewAmpAnalyticsInstance, | ||
'Must implement onNewAmpAnalyticsInstance in ' + | ||
this.win_.location.href); | ||
events.forEach(event => { | ||
const transportId = event['transportId']; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. to leverage the typecheck : event.transportid There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good idea - will do. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done |
||
const message = event['message']; | ||
try { | ||
if (!this.creativeEventRouters_[transportId]) { | ||
this.creativeEventRouters_[transportId] = | ||
new CreativeEventRouter( | ||
this.win_, this.sentinel_, transportId); | ||
try { | ||
this.win_.onNewAmpAnalyticsInstance( | ||
this.creativeEventRouters_[transportId]); | ||
} catch (e) { | ||
user().error(TAG_, 'Caught exception in' + | ||
' onNewAmpAnalyticsInstance: ' + e.message); | ||
throw e; | ||
} | ||
} | ||
this.creativeEventRouters_[transportId] | ||
.sendMessageToListener(message); | ||
} catch (e) { | ||
user().error(TAG_, 'Failed to pass message to event listener: ' + | ||
e.message); | ||
} | ||
}); | ||
} | ||
|
||
/** | ||
* Test method to ensure sentinel set correctly | ||
* @returns {string} | ||
* @VisibleForTesting | ||
*/ | ||
getSentinel() { | ||
return this.sentinel_; | ||
} | ||
|
||
/** | ||
* Gets the mapping of creative senderId to | ||
* CreativeEventRouter | ||
* @returns {!Object.<string, !CreativeEventRouter>} | ||
* @VisibleForTesting | ||
*/ | ||
getCreativeEventRouters() { | ||
return this.creativeEventRouters_; | ||
} | ||
|
||
/** | ||
* Gets rid of the mapping to CreativeEventRouter | ||
* @VisibleForTesting | ||
*/ | ||
reset() { | ||
this.creativeEventRouters_ = {}; | ||
} | ||
|
||
/** | ||
* Takes the raw postMessage event, and extracts from it the actual data | ||
* payload | ||
* @param event | ||
* @returns {JsonObject} | ||
* @private | ||
*/ | ||
extractMessage_(event) { | ||
user().assert(event, 'Received null event in ' + this.win_.name); | ||
const data = String(getData(event)); | ||
user().assert(data, 'Received empty event in ' + this.win_.name); | ||
let startIndex; | ||
user().assert((startIndex = data.indexOf('-') + 1) > 0, | ||
'Received truncated events message in ' + this.win_.name); | ||
return tryParseJson(data.substr(startIndex)) || null; | ||
} | ||
} | ||
|
||
if (!window.AMP_TEST) { | ||
try { | ||
new EventRouter(window); | ||
} catch (e) { | ||
user().error(TAG_, 'Failed to construct EventRouter: ' + | ||
e.message); | ||
} | ||
} | ||
|
||
/** | ||
* Receives messages bound for this cross-domain iframe, from a particular | ||
* creative. | ||
*/ | ||
export class CreativeEventRouter { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why is this class necessary? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Currently CreativeEventRouter provides an association between a creative (transport) and the customer-provided listener function, and EventRouter handles communication with the parent frame (for any/all transports). Therefore the function passed to registerCreativeEventListener() is specific to one creative. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. did we normalized all events into one type at host side already? |
||
/** | ||
* @param {!Window} win The enclosing window object | ||
* @param {!string} sentinel The communication sentinel of this iframe | ||
* @param {!string} transportId The ID of the creative to route messages | ||
* to/from | ||
*/ | ||
constructor(win, sentinel, transportId) { | ||
/** @private {!Window} */ | ||
this.win_ = win; | ||
|
||
/** @private {!string} */ | ||
this.sentinel_ = sentinel; | ||
|
||
/** @private {!string} */ | ||
this.transportId_ = transportId; | ||
|
||
/** @private | ||
* {?function(!Array<!string>)} */ | ||
this.eventListener_ = null; | ||
} | ||
|
||
/** | ||
* Registers a callback function to be called when AMP Analytics events occur. | ||
* There may only be one listener. If another function has previously been | ||
* registered as a listener, it will no longer receive events. | ||
* @param {!function(!string)} | ||
* listener A function that takes an event string, and does something with | ||
* it. | ||
*/ | ||
registerCreativeEventListener(listener) { | ||
if (this.eventListener_) { | ||
dev().warn(TAG_, 'Replacing existing eventListener for ' + | ||
this.transportId_); | ||
} | ||
this.eventListener_ = listener; | ||
} | ||
|
||
/** | ||
* Receives message(s) from a creative for the cross-domain iframe | ||
* and passes them to that iframe's listener, if a listener has been | ||
* registered | ||
* @param {!string} message The event message that was received | ||
*/ | ||
sendMessageToListener(message) { | ||
if (!this.eventListener_) { | ||
dev().warn(TAG_, 'Attempted to send message when no listener' + | ||
' configured. Sentinel=' + this.sentinel_ + ', TransportID=' + | ||
this.transportId_ + '. Be sure to' + | ||
' call registerCreativeEventListener() within' + | ||
' onNewAmpAnalyticsInstance()!'); | ||
return; | ||
} | ||
try { | ||
this.eventListener_(message); | ||
} catch (e) { | ||
user().error(TAG_, 'Caught exception executing listener for ' + | ||
this.transportId_ + ': ' + e.message); | ||
} | ||
} | ||
|
||
/** | ||
* @returns {!string} | ||
* @VisibleForTesting | ||
*/ | ||
getTransportId() { | ||
return this.transportId_; | ||
} | ||
} | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,40 @@ | ||
<!DOCTYPE html> | ||
<html lang="en"> | ||
<head> | ||
<meta charset="UTF-8"> | ||
<title>Requests Frame</title> | ||
<script> | ||
/** | ||
* To receive analytics events in a third-party frame, you must | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Comment is outdated |
||
* implement this method, with this signature. Within that method, you | ||
* must create a function which processes the analytics events, and | ||
* pass that function to the registerAmpAnalytics3pEventsListener() method | ||
* of the object which is passed as a parameter to | ||
* onNewAmpAnalyticsInstance(). | ||
* @param ampAnalytics Call registerAmpAnalyticsEventListener() on this, | ||
* passing your function which will receive the analytics events. | ||
*/ | ||
window.onNewAmpAnalyticsInstance = ampAnalytics => { | ||
ampAnalytics.registerCreativeEventListener(event => { | ||
// Now, do something meaningful with the AMP Analytics event | ||
console.log("The page at: " + window.location.href + | ||
" has received an event: " + event + | ||
" from the creative with transport ID: " + | ||
ampAnalytics.getTransportId()); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. so this function processAnalyticsEvent(event) {
// vendor code goes here ...
}
const url = JSON.parse(window.name).scriptSrc;
if (url && url.startsWith('https://3p.ampproject.net/')) {
loadScript(url).then(() => { // see 3p.js
window.context.onAnalyticsEvent(processAnalyticsEvent.bind(null));
});
} There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, it contains the transportId as state. This way, if 2 creatives use the same vendor, we will not confuse events from one creative with events from the other. I fear that your proposal would force the vendor to do the work of keeping events from different creatives separated. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
I don't feel we should do that much for the vendor. The reason we have this client lib with sacrificing of speed is that we don't want to expose the raw postMessage API to them (which is hard to migrate once vendors start to depend on it). So the only job of the client lib is to convert raw message to a js friendly object. I want to keep the API simple and flexible. We simply flush the events to the client, each event has the transport info. And vendors can have their own design about how to passing the creative IDs along with the event. And it's their job how to group them / process them.
I was not talking about import the code here. you can copy-paste the impl here. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ok, I will work to simplify this. If you don't mind, I'll still leave my assert()s though.
Mine:
I'll switch that last line from document.head to document.body, though. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Simplification is done. Did not end up changing from "document.head" to "document.body" since the remote frame has no content and thus no body. |
||
}); | ||
}; | ||
|
||
// Load the script specified in the iframe’s name attribute: | ||
const url = JSON.parse(window.name).scriptSrc; | ||
if (url && url.startsWith('https://3p.ampproject.net/')) { | ||
script = document.createElement('script'); | ||
script.src = url; | ||
document.head.appendChild(script); | ||
// The script will be loaded, and will call onNewAmpAnalyticsInstance() | ||
} else { | ||
console.warn('Received invalid URL - risk of XSS! ' + url); | ||
} | ||
</script> | ||
</head> | ||
<!-- The frame will not be visible, so there is no need for a body tag. --> | ||
</html> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
how about iframe-transport-client.js?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can do, if you think this will be more meaningful to customers. (Note that I am taking a vacation day Tuesday 7/25.)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this name is internal in our repo only, so it's a good idea to keep it consistent with other naming.
we can definitely use a different name for the compiled library (maybe including ampanalytics in the name).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I like this idea. Done.