Skip to content
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

Amp analytics 3p vendors #9661

Closed
wants to merge 52 commits into from
Closed

Amp analytics 3p vendors #9661

wants to merge 52 commits into from

Conversation

jonkeller
Copy link
Contributor

Enables amp-analytics tag to use third-party vendors. These vendors' code will be run in a non-AMP iframe.

One-pager: https://docs.google.com/document/d/1mCW7HbQBWQONTWA2q_rKNJyTEdqGOyhX-x-ces4JHWc/edit#

@@ -148,6 +148,10 @@ exports.rules = [
{
filesMatching: '{src,extensions}/**/*.js',
mustNotDependOn: '3p/**/*.js',
whitelist: [
'extensions/amp-analytics/0.1/transport.js->' +
'3p/iframe-messaging-client.js',
Copy link
Contributor Author

@jonkeller jonkeller Jun 1, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This exception is added because the amp-analytics tag needs to send messages to the third-party iframe. It covers the import of the file that contains sendMessage().

@@ -317,6 +317,7 @@ var forbiddenTerms = {
'3p/ampcontext.js',
'3p/ampcontext-integration.js',
'dist.3p/current/integration.js', // includes previous
'extensions/amp-analytics/0.1/transport.js',
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This exception is added because the amp-analytics tag needs to send messages to the third-party iframe. It covers the actual calls to the sendMessage() method.

@@ -0,0 +1,188 @@
/**
Copy link
Contributor Author

@jonkeller jonkeller Jun 1, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Upon approval of its content, this file will be placed at a public/static URL. In so doing, we'll include a small set of other AMP files, which will resolve (or enable to be resolved) the TODOs on lines 17, 44 and 139.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, pending separate code review

const frame = createElementWithAttributes(ampDoc, 'iframe', {
sandbox: 'allow-scripts',
name: JSON.stringify({
'scriptSrc': '/examples/analytics-3p-remote-frame-helper.js',
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As mentioned in the comments of /examples/analytics-3p-remote-frame-helper.js, upon approval that frame will be placed at a public/static URL. Once that is done, the value on line 249 must change to that URL.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be an absolute path

*/
processCrossDomainIframeResponse_(msg) {
// Used in ../../../src/anchor-click-interceptor.js
this.win.document.mostRecentCrossDomainIframeResponse = msg;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

^^ Reviewers: extra attention requested on this line. Is there a better place to store this? The logic is that, when there are multiple ads with amp-analytics tags on the page, then if the user clicks on ad A, we want to use the most recent response from ad A. But if they click on ad B, we want to use the most recent response from ad B. So I've stored the response as an attribute on the ad's document (not the containg AMP publisher page). Then anchorClickInterceptor pulls it from the event target's document.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should be able to do this with an enclosed object as a field which maps the creative to its last message, no?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True, but enclosed in what? AnchorClickInterceptor knows what "A" tag has been clicked, and can get from there to the enclosing document in O(1) time. But if I store it in the amp-analytics tag then we'd have to go walk the DOM tree to find it at the time of the click.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Note that it's enclosed in the anchor click listener not amp-analytics, to avoid anchor click listener having a dependency on amp-analytics which lives in extensions.

* @param {string} request
* @param {!Object<string, string>} transportOptions
*/
export function sendRequest(win, request, transportOptions) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not deleted, just moved inside the Transport class below, because needs to maintain extraData as non-static.

if (!frameData.isReady) {
timerFor(window).delay(() => {
this.sendExtraData(frameUrl, extraData);
}, 10);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The issue here is that the sequence could go:

  1. Ad A amp-analytics tag calls processCrossDomainIframe() (line 164).
  2. That calls createCrossDomainIframe_(), which sets up the frame load promise (line 254)
  3. Ad B amp-analytics tag calls processCrossDomainIframe() too, before the load promise from Remove usages of for-of #2 has fired. So there is no point in doing sendMessage() (line 232) yet since the recipient isn't listening yet.
    I'd like to explore chaining the loadPromise somehow but personally I see this as an optimization that is not necessary for phase 1. Please let me know if there is disagreement here.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Couple of things:

  1. After consideration, I do not want to use load as an indicator that the slot is ready. Instead assume library within the frame will send a message indicating it is ready to which you will response
  2. Given Add commit hooks #1, you should have sendExtraData (or send any post messages) store pending messages in a queue and send them upon receiving the ready message (you should consider grouping into a single message to ensure no potential race conditions though I assume post message receiver would always get them in the order they were sent)
  3. The ready signal should trigger the queue to be flushed for all creatives waiting on the frame

Waiting for an arbitrary 10 ms seems odd...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, cool. Am already doing the queue idea for event messages, but not for extraData. Will not be difficult to switch to using the queue for all. Will also not be difficult to switch from load to a proactive send.

* limitations under the License.
*/

const RTV_VERSION = '1234'; // TODO Replace with import from AMP, or just remove
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe you can just put $internalRuntimeVersion$ and the compiler will macro it in for you

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done (removed entire line)

class AmpAnalyticsRemoteFrameManager {
constructor() {
/**
* @type {Function<!String>}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

type def should be: {!function(string)} but I'm curious why you bother with an empty function? Would seem better to allow these to be null by default and then only bother calling them when they are not

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, can change.
(I did it that way because "if (listener_) { listener_(); }" or "listener_ && listener_()" seemed uglier than what's here IMO)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

* @param {!String} message The message that was received.
*/
receiveMessageFromCreative(message) {
if (message && message.data) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I prefer returns in order to remove indents. Something like:

if (!message || !message.data) {
return;
}
if (!deserialized) {
return;
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, here and elsewhere

*/
setSentinel_(sentinel) {
if (this.sentinel_ && sentinel != this.sentinel_) {
console.warn("Attempting to set sentinel to " + sentinel + " when it" +
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dev().warn and use single quotes for strings

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will fix (in concert with bringing in the AMP imports - as-is, dev() is not defined yet)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done but temporarily added function dev() { return console; } at the bottom of the page, which can stay until I do the AMP imports.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done completely now

console.warn("Attempting to set sentinel to " + sentinel + " when it" +
" was already set to " + this.sentinel_);
}
this.sentinel_ = sentinel;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this safe? It implies that you'll honor the first sentinel you receive? Would be preferable to set the sentinel at the time you create the analytics instance as presumably you're passing it to the 3p frame

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The sentinel is created in transport.js, in createCrossDomainIframe():
iframeMessagingClient.setSentinel(Transport.createUniqueId_());
So each frame (since we may have multiple vendors) gets a unique sentinel, which associated to the frame URL in a map that is static to the Transport class. And then this is indeed passed to the 3p frame (this file is running in the 3p frame).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed - now set sentinel on frame's name attr at creation time instead.

Transport.incrementFrameUsageCount_(frameUrl);
this.sendExtraData(frameUrl, transportOptions['extraData']);
} else {
const frame = this.createCrossDomainIframe_(ampDoc, frameUrl,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider creating getOrCreateCrossDomainFrame which combines this logic in an easily testable location?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Called it beginUsingCrossDomainIframe_() since get...() implies a return value and none was needed. Name pairs with existing doneUsing...()

// Regardless of whether we just created it, or are re-using an existing
// one, wire up the response callback
if (processResponse) {
const imc = Transport.crossDomainIframes_[frameUrl].iframeMessagingClient;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

give this variable a better name

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I also dislike the current name. The line is precisely 80 chars, hence choosing something with length=3. Will switch to better name and just bite the bullet and carriage-return on "."

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

* Sends extra data (from the amp-analytics config object) to the
* cross-domain iframe.
* @param {!string} frameUrl The URL of the frame to send the data to
* @param {?string=} extraData The data to send to the frame
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is ? needed (would you expect null?) and variable name should start with opt_

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, here and elsewhere.

if (!frameData.isReady) {
timerFor(window).delay(() => {
this.sendExtraData(frameUrl, extraData);
}, 10);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Couple of things:

  1. After consideration, I do not want to use load as an indicator that the slot is ready. Instead assume library within the frame will send a message indicating it is ready to which you will response
  2. Given Add commit hooks #1, you should have sendExtraData (or send any post messages) store pending messages in a queue and send them upon receiving the ready message (you should consider grouping into a single message to ensure no potential race conditions though I assume post message receiver would always get them in the order they were sent)
  3. The ready signal should trigger the queue to be flushed for all creatives waiting on the frame

Waiting for an arbitrary 10 ms seems odd...

const frame = createElementWithAttributes(ampDoc, 'iframe', {
sandbox: 'allow-scripts',
name: JSON.stringify({
'scriptSrc': '/examples/analytics-3p-remote-frame-helper.js',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be an absolute path

gulpfile.js Outdated
@@ -228,6 +228,18 @@ function compile(watch, shouldMinify, opt_preventRemoveAndMakeDir,
includePolyfills: false,
}),

compileJs('./3p/', 'ampanalytics-lib.js',
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO: This file was changed by another PR which will require resolving

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Copy link
Contributor

@keithwrightbos keithwrightbos left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't complete the review but some initial proposed changes to the API and flow

class AmpAnalyticsRemoteFrameManager {
constructor() {
/**
* @type {function<!string>}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

{?function}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

...I think you may be reviewing an old version of the code. This region of this file currently looks like:
class AmpAnalytics3pRemoteFrameHelper {
constructor(win) {
/**
* @type {!Array<function(string)>}
* @Private
*/
this.eventsListeners_ = [];

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

...in fact, this file was renamed to /3p/ampanalytics-lib.js so you're definitely looking at old stuff.

this.listener_ = null;

/**
* @type {Function<!String>}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

{?function}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Outdated


/**
* Holds a mapping between sender ID and extra data
* @type {Map}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why map as opposed to just object? note that @Private can set type and in this case it should not be nullable: @Private {!Map} Also can you type the contents of the map (e.g. !Map<string, string>)?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Outdated

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

...and actually I just changed all my instances of Map back to Object, because now that my "gulp check-types" is working correctly again, it complains about those:
ERROR - Cannot do '[]' access on a struct


/**
* Registers a callback function to be called when AMP Analytics events occur
* @param {!Function} listener A function that takes an array of event
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

types should match field types. Any reason this couldn't be part of the constructor? If a frame is loading the library, I think we can assume it wants to listen to any inbound messages.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be happy to make this change. But, you previously preferred the frame to contain this:
window.onNewAmpAnalyticsInstance = ampAnalytics => {
ampAnalytics.registerAmpAnalytics3pEventsListener(events => {
...which means that ampAnalytics must exist in order to be passed to the frame code. Is this no longer preferable?

* passing your function which will receive the analytics events.
*/
window.onNewAmpAnalyticsInstance = ampAnalytics => {
ampAnalytics.registerAmpAnalyticsEventListener(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking a better name would be onAmpAnalyticsEvents

const frameData = Transport.crossDomainIframes_[frameUrl];
const iframeMessagingClient = frameData.iframeMessagingClient;
iframeMessagingClient.registerCallback(
'ampAnalytics3pReady', () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally the enum declaring these message types would be in a common location shared between AMP analytics and the library to ensure they remain consistent

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree.

}
iframeMessagingClient.registerCallback(
'ampAnalytics3pResponse', response => {
if (!response || !response.ampAnalytics3pResponse) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be dev().assert(response && response.ampAnalytics3pResponse) as I would assume you expect them to be present?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

if (!response || !response.ampAnalytics3pResponse) {
return;
}
opt_processResponse(this.type_, response.ampAnalytics3pResponse);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider removing opt_processResponse existence check and instead put it here such that if you receive a message you did not expect, you do dev().warn('unexpected response...') so that developers can get feedback as to why integration is not working

Copy link
Contributor Author

@jonkeller jonkeller Jun 6, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

if (window.onNewAmpAnalyticsInstance) {
window.onNewAmpAnalyticsInstance(remoteFrameHelper_);
} else {
dev().error(TAG_, 'Vendor page must implement onNewAmpAnalyticsInstance.');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would add ' prior to loading library script.' I would also advocate that we wait to create the remoteFrameHelper instance until we get a message indicating a new amp analytics instance has been created as we don't need it until then. The flow is:

  • create xdomain frame for provider passing sentinel and lib source path in name
  • xdomain frame executes by loading script which send out 'ready' post message
  • AMP analytics responds to ready post message by sending 'new instance' message (with any additional state)
  • xdomain frame receives 'new instance' message (which I believe you're referring to as "extraData"?) and calls window.onNewAmpAnalyticsInstance

For any additional ad slots that use the same vendor, window.onNewAmpAnalyticsInstance would be called again and should use the latest value (e.g. a vendor could re-assign onNewAmpAnalyticsInstance between slot instances which is ugly but not impossible).

We need to move away from using an extraData listener and instead you should call onNewAmpAnalyticsInstance whenever you receive a "new slot" message. This will ensure the vendor can properly enclose a slot's state in a single call to onNewAmpAnalyticsInstance. Does that make sense?

});
});
// The following is optional:
ampAnalytics.registerAmpAnalytics3pExtraDataListener(extraDataMsgs => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As mentioned, we should remove the registerAmpAnalytics3pExtraDataListener and instead you should either pass the initial state in onNewAmpAnalyticsInstance (e.g. (ampAnalytics, startUpState)) or make it a field of the ampAnalytics instance such that it has 1 field (startUpState) and 2 functions (onAmpAnalytics3pEvents & sendMessageToCreative)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about I just pass it in the frame's name attribute?

externs: ['ads/ads.extern.js',],
include3pDirectories: true,
includePolyfills: false,
}),
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The type checking issues I was having yesterday on unchanged files are fixed. I had duplicated the "closureCompile(['./3p/ampcontext-lib.js'], './dist'," block beginning on line 653 and changed "ampcontext" to "ampanalytics". It also made "gulp check-types" not report any typing errors in my actual code. Removing that fixed the compile issue, and allowed me to see my own typing errors again. I am currently investigating whether that code is necessary, and if so, whether its re-inclusion now that my typing errors are resolved will be better behaved.

Copy link
Contributor Author

@jonkeller jonkeller Jun 7, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, putting it back in hoses things again. The block of code is:
closureCompile(['./3p/ampanalytics-lib.js'], './dist', 'ampanalytics-check-types.js', { externs: ['ads/ads.extern.js'], include3pDirectories: true, includePolyfills: true, checkTypes: true, }),
The errors are stuff like this:
src/services.js:225: ERROR - Bad type annotation. Unknown type module$src$service$template_impl.Templates return /** @type {!./service/template-impl.Templates} */ ( ^
(the caret points to the dot when not using a variable-width font...)

/** @private {string} */
this.sentinel_ = null;

/** @private {Object<string, AmpAnalytics3pCreativeMessageRouter>} */
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

!Object<string, !AmpAnalytics3pCreativeMessageRouter>

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

this.creativeMessageRouters_ = [];

/**
* Simple requestIdleCallback polyfill
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

native requestIdleCallback when available, setTimeout based polyfill otherwise

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

let newId;
do {
newId = String(Math.random()).substr(2);
} while (Transport.usedIds_[newId]);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let newId;
while (Transport.usedIds[(newId = String(Math.random()).substr(2))]);
Transport.usedIds_[newId] = true;
return newId;

Why do you substring? Possibly unending while loops make me nervous as they could cause the browser to hang. Just to be uber safe, let's bound this to a max # of iterations (say 20).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Substring: because without it the string will be like "0.xxxxxxxx" instead of "xxxxxxxx" and that looked ugly.
I agree we don't want it to hang. And likely the universe will end before it chooses and already-chosen number 20x in a row. But there is still the tiny chance that it will therefore return a number that is not unique. Are we okay with that miniscule risk?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm hoping we move to incrementing number but assuming we don't, I would argue the probability of an inadvertent, neverending loop is higher than 20x random not creating unique ID and as mentioned, you could append the type (e.g. moat) to ensure its truly unique.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair enough. Moved to incrementing number.

/** @private {!Window} */
this.win_ = win;

/** @private {string} */
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add a comment that the sentinel is required by iframe messaging client and you use it to uniquely identify the frame as part of message routing? FWIW - you could have used the type given it must be unique, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment done.
Not sure what you mean about the type...a frame can send multiple responses. If the order goes:

  1. creative 1 creates xframe
  2. creative 2 wants to use xframe
  3. xframe renders
    ...then xframe will send ready messages to both creatives in order to release their queues

*/
this.requestIdleCallback =
this.win_.requestIdleCallback.bind(this.win_) ||
(cb => setTimeout(cb, 1));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any reason not use timeout delay of 0? I believe most browsers enforce a minimum of higher anyway (5 by my recollection)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed.

iframeMessagingClient.registerCallback(
MessageTypes.ampAnalytics3pReady, () => {
iframeMessagingClient.setHostWindow(frameData.frame.contentWindow);
Transport.setIsReady_(frameUrl);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You have reference to frameData so might as well pass to isReady_ directly instead of imposing a lookup. In general, you should only ever need to lookup frameData once here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

* @param {string=} opt_extraData The data to send to the frame
* @private
*/
beginUsingCrossDomainIframe_(ampDoc, frameUrl, opt_extraData) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have this return frameData so that you do not need to look it up again within processCrossDomainIframe.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

if (frameData.sendTimer) {
return; // Timer is already about to fire, no need to set a new one
}
frameData.sendTimer = Transport.timer_.delay(() => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The negative with this approach is that a new message will ALWAYS need to wait the throttle time even if there has been no messaging traffic. The intent here is to not send them to often so I suggest moving to an interval based solution which you clear when destroying the frameData (note that you currently do not actually cancel the timer, at least not that I could find nor do you attempt to flush any existing messages)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So you mean, send the first one immediately, and then set an invariant "I will not send again for at least 100ms", and if subsequent ones come in during that 100ms queue them until that time has elapsed? I like this.
(Currently implementation destroys the timer about 4 lines down from here, upon it firing.)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My point regarding destruction is that its possible you set the delay, unlayoutCallback executes clearing a bunch on state, and then the timeout runs leading to errors. To be safe, you should ensure that unlayoutCallback cancels the timer.

removeElement(iframe);
}, 5000);
};
user().assert(
dev().assert(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why did you downgrade to dev?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Didn't touch the sendRequestUsingIframe() method at all. Note that it is comparing line 141 to line 426, so I suspect that's just the diff tool trying to gain an efficiency when it shouldn't. Want me to change it to dev(), though?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could it be an merge issue? I believe it should match whatever is in master.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Huh. It is user() in master...guess it was a merge issue after all. Fixed.

*/

/** @private @const {Object<string,string>} */
export const MessageTypes = {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should not be plural and consider a name more specific to your usage case

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

*/
this.iframeMessagingClient_ = new IframeMessagingClient(win);
try {
const frameNameData = JSON.parse(this.win_.name);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you move this parse up in the constructor, you can make sentinel a const and pass it directly to the frame. I'm curious why we don't allow passing sentinel in the constructor.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

this.iframeMessagingClient_ = new IframeMessagingClient(win);
try {
const frameNameData = JSON.parse(this.win_.name);
if (frameNameData.sentinel) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

currently if there is valid JSON in the name but no sentinel, the catch block does not execute

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

*/
receiveMessage(message) {
if (message[AMP_ANALYTICS_3P_MESSAGE_TYPE.NEW]) {
this.extraData_ = message[AMP_ANALYTICS_3P_MESSAGE_TYPE.NEW];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally onNewAmpAnalyticsInstance is called here but I believe you're working on that.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct. Done but not committed/pushed yet.

* in the queue.
*/
sendQueuedMessagesToListeners() {
if (this.receivedMessagesQueue_.length == 0 ||
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI, You don't need the == 0, can instead do !this.receivedMessagesQueue_

Copy link
Contributor Author

@jonkeller jonkeller Jun 8, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have switched to (!this.receivedMessagesQueue.length) but what you said is not correct:

var foo = [];
if (foo) { console.log('truthy'); } else { console.log('falsey'); }
truthy

this.eventListeners_.length == 0) {
return;
}
this.eventListeners_.forEach(listener => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this.eventListeners_.forEach(listener => listener(this. receivedMessagesQueue_));

We should consider wrapping each listener call with try/catch as otherwise the first throwing would not only cause the rest not to execute, it would cause the queue not to be cleared.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moot in this particular location due to refactor, but done in new location.

@@ -33,7 +33,7 @@ export class IframeMessagingClient {
this.win_ = win;
/** @private {?string} */
this.rtvVersion_ = getMode().rtvVersion || null;
/** @private {!Window} */
/** @private {!(Window|Element)} */
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a type specific to frames?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tried this originally, but unfortunately there is not.
`
3p/iframe-messaging-client.js:131: ERROR - Bad type annotation. Unknown type Frame

  • @param {!(Window|Frame)} win The window to communicate with. This may
    `

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

try HTMLIFrameElement

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. (Where did you find that, so I don't have to bug you next time I need to type something as a checkbox or whatever?)

'Received empty events envelope');
// Iterate through the array of events, dispatching each to the
// listener for the appropriate creative
envelope[AMP_ANALYTICS_3P_MESSAGE_TYPE.EVENTS].forEach(event => {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need to loop through them here, because they may have come from different creatives

initLogConstructor();
// TODO(alanorozco): Refactor src/error.reportError so it does not contain big
// transitive dependencies and can be included here.
setReportError(() => {});
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re: your suggestion over lunch, lines 23-25 are from ampcontext, and fix the issue of assert() complaining that ampdoc does not exist.

/** @type {Array<(
* ../../../src/3p-analytics-common.ampAnalytics3pEvent|
* ../../../src/3p-analytics-common.ampAnalytics3pNewCreative
* )>} */
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I find this preferable to a linter exception to the 80-char line, but if you feel otherwise I am happy to change it.

@@ -0,0 +1,164 @@
/**
Copy link
Contributor Author

@jonkeller jonkeller Jun 8, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Refactored the message queueing/sending stuff from transport.js to here.

const typedMessageObject =
/** @type {../../../src/3p-analytics-common.ampAnalytics3pEvent} */
(messageObject);
return typedMessageObject;
Copy link
Contributor Author

@jonkeller jonkeller Jun 8, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did it this way because if you do:

return
  /** @type {../../../src/3p-analytics-common.ampAnalytics3pEvent} */
  (messageObject);

...then it interprets the "return" as a complete statement with no semicolon, and the subsequent two lines are unreachable code.

includePolyfills: true,
checkTypes: true,
}),
*/
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Uncomment, or delete?

* limitations under the License.
*/

// TODO(jonkeller): Extern this
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's already externed, just a vestigial comment. Removed comment already...will commit/push with next set of changes, if any.

* Receives messages bound for this cross-domain iframe, from all creatives
*/
class AmpAnalytics3pMessageRouter {
/** @param {!Window} win */
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: add line here and remove first, empty line in constructor

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

// The sentinel is required by iframe-messaging-client, and is used to
// uniquely identify the frame as part of message routing
/** @const {string} */
this.sentinel_ = frameNameData.sentinel;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would move this outside of the try/catch and change to this.sentinel_ = frameNameData && frameNameData.sentinel;

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

const frameNameData = JSON.parse(this.win_.name);
if (!frameNameData.sentinel) {
const ex = {message: 'JSON parsed, but did not include sentinel.'};
throw ex;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be throw new Error('JSON parsed...');

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

return;
}

/** @private {!Object<string, !AmpAnalytics3pCreativeMessageRouter>} */
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add some documentation explaining the purpose of this object and the value of its keys.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

this.iframeMessagingClient_.setSentinel(this.sentinel_);
this.iframeMessagingClient_.registerCallback(
AMP_ANALYTICS_3P_MESSAGE_TYPE.CREATIVES,
envelope => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

envelope is an odd variable name IMO. What exactly is the purpose of this object? What are its values?

Copy link
Contributor Author

@jonkeller jonkeller Jun 10, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I meant it in a similar sense to its use in https://docs.oracle.com/cd/E19509-01/820-4408/gguuw/index.html or https://www.tutorialspoint.com/soap/soap_message_structure.htm but somewhat less formal - it's just what I always use to name something whose sole purpose is to contain other thing(s) during transit (since that's the purpose of a real-world envelope). Happy to change if you prefer.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed

@@ -278,6 +298,17 @@ export class AmpAnalytics extends AMP.BaseElement {
}

/**
* Receives any response that may be sent from the cross-domain iframe
* @param {!string} type The type parameter of the cross-domain iframe
* @param {Object} response The response message from the iframe
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can the response be typed to a known def?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, but to be honest I don't like it. The iframe creates an arbitrary object, which is passed to sendMessageToCreative(), which then wraps that in a AmpAnalytics3pResponse. I liked the symmetry of the amp-analytics tag receiving exactly what the iframe created, with Transport doing the unwrapping. Now that AmpAnalytics.processCrossDomainIframeResponse_() is receiving an AmpAnalytics3pResponse, that symmetry is broken. (We could restore it by requiring the iframe to create the AmpAnalytics3pResponse directly, but I am less in favor of that.)

@@ -748,8 +796,8 @@ export class AmpAnalytics extends AMP.BaseElement {
* Merges two objects. If the value is array or plain object, the values are
* merged otherwise the value is overwritten.
*
* @param {Object|Array} from Object or array to merge from
* @param {Object|Array} to Object or Array to merge into
* @param {(Object|Array)} from Object or array to merge from
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why add parens here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed.
(The "why" is that at Taymon's suggestion, I tried this when I was experiencing all those type-checking errors last week in files that I didn't touch. They didn't fix it, and I later found something else which did, but did not remove these parens.)

@@ -791,8 +839,8 @@ export class AmpAnalytics extends AMP.BaseElement {
}

/**
* @param {!Object<string, Object<string, string|Array<string>>>} source1
* @param {!Object<string, Object<string, string|Array<string>>>} source2
* @param {!Object<string, Object<string, (string|Array<string>)>>} source1
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ditto

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed

* contain the queued messages (see 3p-analytics-common)
*/
constructor(iframeMessagingClient, envelopeType) {
/** @type {Array<(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

!Array

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

if (this.timerId_) {
return; // Timer is already about to fire, no need to set a new one
}
this.timerId_ = CrossDomainIframeMessageQueue.timer_.delay(() => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This still suffers from the same problem where message after long inactivity will result in it being unnecessarily delayed. I recommend moving to a setInterval approach which does nothing if the queue is empty. Seems simpler to me and gives the correct behavior of throttling.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that my method unnecessarily delays the first message after a long delay.
But I think that your proposed method is also (albeit less) wasteful, in that you're going to have an interval go off frequently when I bet most of the time there will be no work for it to do.
What about an approach where the first message gets sent immediately, and then we say that no more messages will be sent for the next x milliseconds (we'll queue them and send them once that expires). Something like this pseudocode:

if (door is unlocked) {
  send the message NOW
  lock the door
  setTimeout({ unlock the door; flush the queue; }, 100ms);
} else {
  enqueue the message
}

Copy link
Contributor

@keithwrightbos keithwrightbos left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking good, needs tests and I tend to agree that the abstract queue arrangement seems a bit over engineered.

if (!frameNameData.sentinel) {
throw new Error('JSON parsed, but did not include sentinel.');
}
// The sentinel is required by iframe-messaging-client, and is used to
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be moved to just above the this.sentinel_ line

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oops. Done.

let frameNameData = null;
try {
frameNameData = JSON.parse(this.win_.name);
if (!frameNameData.sentinel) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would structure this slightly differently:

let frameNameData = null;
try { frameNameData = JSON.parse(this.win_.name); } catch (e) {}
this.sentinel_ = user().assertString(frameNameData && frameNameData.sentinel, 'Invalid/missing sentinel on iframe name attribute', this.win_.name);
if (!this.sentinel_) { return; }

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. (Note that assertString() only takes 2 args. So changed your last comma to a + )

this.iframeMessagingClient_.registerCallback(
AMP_ANALYTICS_3P_MESSAGE_TYPE.CREATIVE,
messageContainer => {
dev().assert(messageContainer.data,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider checking empty array case: dev().assert(messageContainer.data && messageContainer.data.length)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done (Object.entries(messageContainer.data).length since it's an object not an array)

Object.entries(messageContainer.data).forEach(entry => {
const creativeId = entry[0];
const extraData = entry[1];
if (this.creativeMessageRouters_.hasOwnProperty(creativeId)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can collapse to: dev().assert(!this.creativeMessageRouters_[creativeId])

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

dev().warn(TAG_, 'Duplicate new creative message for ' + creativeId);
}
this.creativeMessageRouters_[creativeId] =
this.creativeMessageRouters_[creativeId] ||
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need to do || case as this.creativeMessageRouters_[creativeId] should not exist

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

this.throttledFlushQueue_ = throttle(this.win_, () => {
this.flushQueue_();
}, MESSAGE_THROTTLE_TIME_);

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: remove extra line

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

* @private
*/
flushQueue_() {
if (this.isReady_ && Object.keys(this.store_).length > 0) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

greater than 0 is not necessary

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

* iframe
*/
enqueue(senderId, opt_data) {
if (this.store_.hasOwnProperty(senderId)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should never happen, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct. Will change to assert() instead of warn().

dev().warn(TAG_, 'Replacing existing extra data for: ' + senderId);
}
this.store_[senderId] = opt_data || '';

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: remove extra line

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

* @param {string=} opt_data The data to be enqueued and then sent to the
* iframe
*/
enqueue(senderId, opt_data) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like both implement unqueue so this should be part of your abstract class

Copy link
Contributor Author

@jonkeller jonkeller Jun 14, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intentional. They implement it differently, since for new creatives each entry in .creativeToPendingMessages_ is <string, string> since you can only have one extraData per creative, whereas for events it's <string, Array < string >> since one creative can have any number of events. I originally had just the parent class with no subclasses, and this is actually what made me split it into the current class structure.

Copy link
Contributor

@keithwrightbos keithwrightbos left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking good. Just needs unit test coverage and try to remove ResponseMap.

// The sentinel is required by iframe-messaging-client, and is used to
// uniquely identify the frame as part of message routing
/** @const {string} */
this.sentinel_ = dev().assertString(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry you can further reduce code here by using src/json#tryParseJson:

this.sentinel_ = dev().assertString(tryParseJson(this.win_.name, {}).sentinel, 'Invalid/missing sentinel on iframe name attribute' + this.win_.name);
if (!this.sentinel_) {
return;
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool find! Done.

AMP_ANALYTICS_3P_MESSAGE_TYPE.CREATIVE,
messageContainer => {
dev().assert(
messageContainer.data && Object.entries(messageContainer.data).length,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For all I know the JS compiler optimizes this but currently you're converting into an array twice so instead do:
let entries;
dev().assert(messageContainer.data && (entries = Object.entries(messageContainer.data)).length, ...);
entries.forEach(entry =>

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

this.iframeMessagingClient_ = iframeMessagingClient;

/**
* @private {!string}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: make all of these one line to match format of iframeMessageClient_ doc

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

this.extraData_ = opt_extraData;

/**
* private {?function(!Array<!AmpAnalytics3pEvent>)=}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing @

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that it didn't like the = at the end, so did not include that

/** @type {!Object<string,!string|!Array<string>>} */
this.creativeToPendingMessages_ = {};

this.throttledFlushQueue_ = throttle(this.win_, () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needs doc

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

}
opt_processResponse(
this.type_,
/** @type {!../../../src/3p-analytics-common.AmpAnalytics3pResponse} */ (response)); // eslint-disable-line max-len
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you just put (response)) on next line so you can remove the disable line length?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, because the /** */ itself exceeds the limit

static doneUsingCrossDomainIframe(ampDoc, transportOptions) {
const frameUrl = transportOptions['iframe'];
const frameData = Transport.getFrameData_(frameUrl);
if (!frameData) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would this ever occur?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suppose not. Removed.

setStyles(frame,
{width: 0, height: 0, display: 'none',
position: 'absolute', top: 0, left: 0});
/** @const {FrameData} */
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe you want this to be: const frameData = /** @type {!FrameData} */ ({....});

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any reason why you switched to @type instead of @const?

}

/** @private @const {!Object<string,boolean>} */
Transport.usedIds_ = {};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is no longer used

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed. I wonder if we could make the linter catch this...

@@ -60,6 +60,20 @@ window.AMP_CONFIG.errorReportingUrl;

window.AMP_CONTEXT_DATA;

// AMP-Analytics Cross-domain iframes
/** @enum {string} */
const AMP_ANALYTICS_3P_MESSAGE_TYPE = {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since you already include this in a common util class, I don't believe you need it in externs as the compiler will have enforced type check

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed.

Copy link
Contributor

@keithwrightbos keithwrightbos left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some initial thoughts. Need to keep going through it. Looks like we're still missing unit test coverage and removing ResponseMap in favor of a closure based solution

dev().assert(!this.creativeMessageRouters_[creativeId],
'Duplicate new creative message for ' + creativeId);
this.creativeMessageRouters_[creativeId] =
new AmpAnalytics3pCreativeMessageRouter(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this indentation correct? I would have expected "new" to be indented further.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

this.iframeMessagingClient_.registerCallback(
AMP_ANALYTICS_3P_MESSAGE_TYPE.EVENT,
messageContainer => {
Object.entries(messageContainer.data).forEach(entry => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add dev().assert(isArray(messageContainer.data) && messageContainer.data.length); // isArray comes from src/types.js

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, in the style of lines 70-74 since messageContainer.data is an object, not an array.

const messages = entry[1];
dev().assert(messages && messages.length,
'Received empty events list for' + creativeId);
dev().assert(this.creativeMessageRouters_[creativeId],
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note that asserts throw so should we consider wrapping the contents in a try/catch to ensure one invalid data item doesn't cause all to be lost?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

this.eventListener_ = null;

if (this.win_.onNewAmpAnalyticsInstance) {
this.win_.onNewAmpAnalyticsInstance(this);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How do we handle if this throws?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a dev().error().

@@ -100,6 +100,17 @@ export class IframeMessagingClient {
*/
setupEventListener_() {
listen(this.win_, 'message', event => {
try {
if (!this.hostWindow_.postMessage && this.hostWindow_.contentWindow) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can assume all AMP browsers will have postMessage.

Copy link
Contributor Author

@jonkeller jonkeller Jun 20, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's not what I'm trying to do here. I'm trying to determine whether this.hostWindow_ is a window or a frame. If it's a frame, it won't have .postMessage(), it'll have .contentWindow.postMessage(). But I don't want to go straight for the .contentWindow.postMessage() check, because if it is a cross-domain window then that may throw.
I've split it into a helper method, and added comments, to make this clearer.

// contentWindow is now non-null.
this.hostWindow_ = this.hostWindow_.contentWindow;
}
} catch (e) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would occur if window is xdomained? Do we want to warn in that case? When would it happen?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't happen, given that I'm being careful in the "if" condition above (which is now extracted to another method, but works the same). Removed.

*/
constructor(win, iframeMessagingClient, messageType) {
/** @private {boolean} */
this.isReady_ = false;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: I like to put parameter based fields first.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

* ../../../src/3p-analytics-common.AmpAnalytics3pEvent}
* @abstract
*/
buildMessage_() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I usual make the default content an error: throw new Error('Not yet implemented?!');

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Heh, I had originally thought of doing this, but https://github.com/google/closure-compiler/wiki/%40abstract-classes-and-methods says it's not allowed (second bullet point under "Rules" section)
Although, it only generates a warning, not an error:
WARNING - Misplaced @abstract annotation. function with a non-empty body cannot be abstract
Ideally we should change the type checking to allow an abstract method to contain a throw.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PS Tried with @Suppress {misplacedTypeAnnotation} but that doesn't squelch the warning. There does not seem to be a reference to misplacedAbstractAnnotation in https://github.com/google/closure-compiler/blob/master/src/com/google/javascript/jscomp/DiagnosticGroups.java

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Surprisingly, Travis will fail if it gets a warning. So, taking this throw back out :(
https://travis-ci.org/ampproject/amphtml/jobs/245496442

Copy link
Contributor Author

@jonkeller jonkeller Jun 22, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have filed a feature request w/ the Closure compiler team for this.

type: this.messageType_,
data: this.creativeToPendingMessages_,
});
return message;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: you can put the object constructor right after return, save message variable creation

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In general yes, but then you're left with two bad choices. Choice 1:

return
    /** @type {../../../src/3p-analytics-common.AmpAnalytics3pNewCreative} */
    ({
        type: this.messageType_,
        data: this.creativeToPendingMessages_,
    });

In this case, the JS interpreter in its infinite wisdom thinks that "return" is a complete statement with no semicolon, so you're returning undefined, and what comes after that is unreachable code >:(

Choice 2:

return /** @type {../../../src/3p-analytics-common.AmpAnalytics3pNewCreative} */ ({
        type: this.messageType_,
        data: this.creativeToPendingMessages_,
      });

Putting the opening paren (and therefore necessarily the casting comment) on a single line will do what we want semantically, but now we'd have to add a comment to tell the linter to ignore the line length violation.
I did it the way I did it for these reasons, though I agree it's stupid that I had to do so.

@rsimha
Copy link
Contributor

rsimha commented Jun 21, 2017

The Percy failure was due to #10022, which has now been fixed.

@jonkeller jonkeller requested a review from lannka June 22, 2017 13:31
… typedefs. Fixes error in which asserts cause error due to no ampdoc
…sage format, but new message format resulted in this being over-engineered
Copy link
Contributor

@keithwrightbos keithwrightbos left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see test coverage for transport but not any of the new classes you created. Are those in progress?

this.creativeMessageRouters_[creativeId]
.sendMessagesToListener(messages);
} catch (e) {
dev().error(TAG_, 'Failed to send message to listener: ' +
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using the term "send" is a bit misleading here as one may expect it to mean you postMessaged. Perhaps "Failed to execute listener"...?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re: your above comment which GitHub UI won't let me comment on, I forgot to "git add" the file to test the new Queue classes. Will add that together with addressing your comments here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And re: the comment I'm actually responding to here, done. Changed to "Failed to pass message to event listener"

});
});
this.iframeMessagingClient_.sendMessage(
AMP_ANALYTICS_3P_MESSAGE_TYPE.READY);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this fit on one line?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

81 characters, unless I rename stuff.

try {
this.win_.onNewAmpAnalyticsInstance(this);
} catch (e) {
dev().error(TAG_, 'Exception thrown by onNewAmpAnalyticsInstance in' +
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider moving onNewAmpAnalyticsInstance check prior to creating AmpAnalytics3pCreativeMessageRouter as AmpAnalytics3pMessageRouter essentially expects it to work properly. In other words, it would call router's sendMessagesToListener function even though client is not actually listening

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

* @param {!Array<AmpAnalytics3pEvent>} messages The message that was received
*/
sendMessagesToListener(messages) {
if (!messages.length || !this.eventListener_) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider adding a dev().warn indicating messages are being lost and registerAmpAnalytics3pEventsListener should be called.

We could also try and recover from previous invalid/missing onNewAmpAnalyticsInstance here

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given the fix to your previous comment, it is no longer possible to reach here without implementing onNewAmpAnalyticsInstance, as the enclosing AmpAnalytics3pMessageRouter will now not have been created.
However, this could still happen if they have done the first line here but not the second:

      window.onNewAmpAnalyticsInstance = ampAnalytics => {
        ampAnalytics.registerAmpAnalytics3pEventsListener(events => {

Unfortunately we can't go and create that for them, so I just made the dev().warn message explicitly say "Attempted to send messages when no listener configured. Be sure to first call registerAmpAnalytics3pEventsListener()"


// Load the script specified in the iframe’s name attribute:
script = document.createElement('script');
script.src = JSON.parse(window.name).scriptSrc;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I fear this opens an XSS exploit for 3p analytic vendors. They shouldn't just trust the script src is to a valid AMP url. Instead we should either provide only the path to the file and hard code the domain or the vendor should have code verifying the domain. See how remote HTML loads the context script: https://github.com/ampproject/amphtml/blob/master/3p/remote.html#L9

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This sounds like a good idea.
In Transport.createCrossDomainIframe, it sets the URL like this:

    const scriptSrc = getMode().localDev
      ? '/dist.3p/current/ampanalytics-lib.js'
      : `${urls.thirdParty}/$internalRuntimeVersion$/ampanalytics-v0.js`;

I just ran it and forced the non-local path, and that gave me:

https://3p.ampproject.net/$internalRuntimeVersion$/ampanalytics-v0.js

So, sure, I could definitely just send everything after "https://3p.ampproject.net", and hard-code that part on the client side.
Though this makes me also wonder if I'm missing something necessary for the substitution of the internal runtime version?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm...though we'll also have to know on that side whether we're local, since I won't want to prepend the "https://3p.ampproject.net" in that case.

sandbox.stub(Transport, 'sendRequestUsingImage');
function setupStubs(crossDomainIframeRetval, imageRetval,
beaconRetval, xhrRetval) {
sandbox.stub(transport, 'sendRequestUsingCrossDomainIframe_').returns(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

similarly consider making the function public but with @VisibleForTesting

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, for all uses of private methods/members

@@ -48,69 +57,193 @@ describe('amp-analytics.transport', () => {
'sendRequestUsingImage call count').to.equal(expectedImageCalls);
}

function expectAllUnique(numArray) {
if (!numArray) {
return true;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why return true instead of just return?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed


it('must create xframe before sending message to it', () => {
expect(() => {
transport.sendRequest(window, 'https://example.com/test', {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test referencing window makes me nervous and implies your test may not be hermetic...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I need to use a window object, to hang iframes on. Also the existing tests need one, to do stuff like win.navigator.sendBeacon(request, '');
But I agree it would be best to somehow reset it, or use some type of "test window" object that lives in the sinon sandbox. Do you know a way to do this?

sandbox.spy(transport, 'createCrossDomainIframe_');
transport.processCrossDomainIframe(window, config);

assert(transport.useExistingOrCreateCrossDomainIframe_.calledOnce);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why aren't these expect (e.g. expect(transport.useExistingOrCreateCrossDomainIframe_).to.be.calledOnce; )

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm. I thought they were equivalent, but just tested with assert(false) and expect(false).to.be.true and I see now that the latter has a more informative message.
Converted these to look like:
expect(transport.createCrossDomainIframe.calledOnce).to.be.true;

@@ -59,6 +60,14 @@ function maybeExpandUrlParams(ampdoc, e) {
'CLICK_Y': () => {
return e.pageY;
},
'3PANALYTICS': (frameType, key) => {
const responses = ResponseMap.get(ampdoc, frameType,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do you need responseMap? Idea was to use closure to keep track of data where message listener would update ampDoc.getAnchorClickListenerBinding() value.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried that, but it didn't seem like it was going to work. Though maybe I am missing something - I would be happy to pair on it if you'd like.
As it stands now, ResponseMap is now just a convenience wrapper around ampdoc's binding. I didn't want to spread the implementation details across both anchor-click-interceptor and amp-analytics, especially if they should ever change.

@@ -101,6 +101,15 @@ export class IframeMessagingClient {
*/
setupEventListener_() {
listen(this.win_, 'message', event => {
if (this.hostWindowIsActuallyAnIframe_()) {
// this.hostWindow_ can now be set to an iframe, after it has been
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why hostWindow can be an iframe?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a chicken-and-egg problem. I've created this iframe, and I want to know when it is ready. So I want to say "I am willing to receive messages from this iframe's contentWindow". But the iframe's content window is not defined until it has finished rendering. So basically, if I can only use the window object, then I can't know it's ready unless I can receive messages from it, but I can't receive messages from it until I know it's ready.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should you wait for a handshake initiated from the iframe window?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh, wait, you wanted to make the iframe client a "host". which should not be a case after you switched to SubscriptionApi.

'Cross-domain frame parameters missing ${this.type_}');
const frameData = this.useExistingOrCreateCrossDomainIframe_(
win, frameUrl, transportOptions['extraData']);
const iframeMessagingClient = frameData.iframeMessagingClient;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should you use SubscriptionApi at host side?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keith, what do you think? It does seem like this would be better on the AMP side. But AFAICT the 3p side would have to revert to window.addEventListener("message", ...) which loses a lot of what we gain from iframeMessagingClient.

/**
* Receives messages bound for this cross-domain iframe, from all creatives
*/
class AmpAnalytics3pMessageRouter {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

to accelerate this PR, I suggest you split off the code into a 2nd PR. I can see clear boundary between the 2:
PR1: runtime side code
PR2: adding a library for 3p to use.

As long as there're clear API defined, you can easily separate the 2

@jonkeller
Copy link
Contributor Author

At lannka's request, I have split this PR into two:
#10184
#10185
Therefore #9661 is now closed.

@jonkeller jonkeller closed this Jun 28, 2017
@@ -152,6 +152,10 @@ exports.rules = [
{
filesMatching: '{src,extensions}/**/*.js',
mustNotDependOn: '3p/**/*.js',
whitelist: [
'extensions/amp-analytics/0.1/transport.js->' +
'3p/iframe-messaging-client.js',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

don't do this

@jonkeller jonkeller deleted the amp_analytics_3p_vendors branch July 24, 2017 15:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

5 participants