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, impl side #10185

Closed
wants to merge 4 commits into from
Closed

Amp analytics 3p vendors, impl side #10185

wants to merge 4 commits into from

Conversation

jonkeller
Copy link
Contributor

Splits PR 9661 in two. This is the impl side. The other side was PRed separately in #10184

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#


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

Choose a reason for hiding this comment

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

Copying conversation from old PR:

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

Me: 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?

expect(
transport.useExistingOrCreateCrossDomainIframe.calledOnce).to.be.true;
expect(transport.createCrossDomainIframe.calledOnce).to.be.true;
expect(Transport.hasCrossDomainIframe(config.iframe)).to.be.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.

@keithwrightbos Changed these from assert() per your comment in old PR

@@ -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 Author

Choose a reason for hiding this comment

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

Copying conversation from old PR:

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

Me:
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.

(We then discussed it offline some. Pairing is a possibility after Keith's travel, but as it's just an implementation barrier this should not derail getting it merged according to desired schedule.)

@@ -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 Author

Choose a reason for hiding this comment

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

Copying conversation from old PR:

@lannka:
why hostWindow can be an iframe?

Me:
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.

const frameData = this.useExistingOrCreateCrossDomainIframe(
win, frameUrl, transportOptions['extraData']);
const iframeMessagingClient = frameData.iframeMessagingClient;
iframeMessagingClient.registerCallback(
Copy link
Contributor Author

@jonkeller jonkeller Jun 29, 2017

Choose a reason for hiding this comment

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

Copying conversation from old PR:

@lannka:
should you use SubscriptionApi at host side?

Me:
@keithwrightbos , 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.

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 thought about this overnight. It seems to me that there is no huge clear advantage to switching the underlying implementation to SubscriptionApi. Vamsee is strongly wanting this in the next canary. I propose that we go with IframeMessagingClient for now, and create a bug to consider switching to SubscriptionApi in the future. But if there is some big reason why we should do this now, please let me know.

Copy link
Contributor

Choose a reason for hiding this comment

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

It's by design that SubscriptionApi handles the host side communication, which is to provide the data. IframeMessagingClient is providing a client stub for calling the API. That's to say, the 2 peers are not parity.

There many aspects here, most importantly, IframeMessagingClient lives in 3p/ code, it's used by those libs, but never AMP runtime (inabox is an exception, but it's a different runtime).

I'm also not convinced to pursue that as a deadline. I don't see we can achieve anything by just getting this PR into next Tuesday's canary.

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 seems that the purpose of SubscriptionAPI is to broadcast a message to all subscribing iframes. That's not quite what needs to be done here - I need to direct each message to one particular iframe. But I can make this work with SubscriptionAPI by making the messageType be actually { messageType, ID of recipient }. Please let me know if you can think of a better way than this.

// If it's a window, it will have .postMessage
// We check for that before .contentWindow, since a cross-domain window
// may throw if we try to access anything unsafe
return (!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 don't believe we actually need to test for postMessage as any AMP supported browser would have it.

Copy link
Contributor Author

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 doing here. I'm testing whether this.hostWindow_ is actually a window, or an iframe. If it has .postMessage then it's a window.

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

Choose a reason for hiding this comment

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

might as well use this.count()

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ah, yes. This predated the existence of count(). Fixed.

* @return {number}
* @VisibleForTesting
*/
count() {
Copy link
Contributor

Choose a reason for hiding this comment

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

More specific name please (e.g. queueSize)

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

*/
enqueue(senderId, opt_data) {
dev().assert(!this.creativeToPendingMessages_[senderId],
'Replacing existing extra data for: ' + senderId);
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 the correct error message?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Each creative should only specify extraData once, at init time. If it does so again, this indicates a problem. So I would say that yes, this error message is correct.

* iframe
*/
enqueue(senderId, opt_data) {
dev().assert(!this.creativeToPendingMessages_[senderId],
Copy link
Contributor

Choose a reason for hiding this comment

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

Might as well use this.messageFor(senderId)

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

* @VisibleForTesting
*/
messageFor(senderId) {
return /** @type {string} */ (
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 surprised you need to type this given creativeToPendingMessages_ is typed as having string based keys

Copy link
Contributor Author

@jonkeller jonkeller Jun 29, 2017

Choose a reason for hiding this comment

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

It's typed has having string-based keys, but we are returning the value, which is typed as string|Array<string>. (String for this subclass, Array for the other.) So without the cast:

extensions/amp-analytics/0.1/amp-analytics-3p-message-queue.js:167: ERROR - inconsistent return type
found   : (Array<string>|string)
required: string
    return this.creativeToPendingMessages_[senderId];

* @param {!string} data The data to be enqueued and then sent to the iframe
*/
enqueue(senderId, data) {
if (!this.creativeToPendingMessages_.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.creativeToPendingMessages_[senderId] = creativeToPendingMessages_[senderId] || [];

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks. Leftover from when it was initalized as map() instead of {}. Done.

* @override
* @VisibleForTesting
*/
buildMessage() {
Copy link
Contributor

Choose a reason for hiding this comment

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

buildMessage and messageFor is duplicated across both classes. Any way to save 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.

For buildMessage()...they are building different types of messages: AmpAnalytics3pNewCreativeMessageQueue.buildMessage() builds a AmpAnalytics3pNewCreative message, whereas AmpAnalytics3pEventMessageQueue.buildMessage() builds a AmpAnalytics3pEvent.

Also note that AmpAnalytics3pNewCreativeMessageQueue has messageFor() whereas AmpAnalytics3pEventMessageQueue has messagesFor(), since a creative will only init once but can send any number of events.

I originally had just the superclass and was jumping through hoops to enforce these things, but pivoted and created the subclasses for these reasons.

// The sentinel value will be present when received but the sender doesn't
// need to add it, this is done by iframe-messaging-client.

/**
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Warning: Note that due to splitting the PR, this new file must exist in both. But only lines 1-90 exist in 10184, whereas the full file must exist in 10185. Therefore when we merge, 10184 must be merged first!

const frameData = this.useExistingOrCreateCrossDomainIframe(
win, frameUrl, transportOptions['extraData']);
const iframeMessagingClient = frameData.iframeMessagingClient;
iframeMessagingClient.registerCallback(
Copy link
Contributor

Choose a reason for hiding this comment

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

It's by design that SubscriptionApi handles the host side communication, which is to provide the data. IframeMessagingClient is providing a client stub for calling the API. That's to say, the 2 peers are not parity.

There many aspects here, most importantly, IframeMessagingClient lives in 3p/ code, it's used by those libs, but never AMP runtime (inabox is an exception, but it's a different runtime).

I'm also not convinced to pursue that as a deadline. I don't see we can achieve anything by just getting this PR into next Tuesday's canary.

* @visibleForTesting
* @abstract
*/
class AbstractAmpAnalytics3pMessageQueue {
Copy link
Contributor

Choose a reason for hiding this comment

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

We plan to handle the batching use case in a different level. @zhouyx has started looking into it.

Ideally, pub should be able to do

transport: {
  beacon: true,
  xhrpost: true,
  image: true,
  batchRequestsInMs: 100,
}

For example, we can enable it for the GoogleAnalytics post API, which does support batching.

So can you hold the message queue code for now, and assume throttling will happen in a later phase.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Interesting...are there docs for that which I could look at?

Copy link
Contributor

Choose a reason for hiding this comment

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

not yet. i was over simplifying the spec. there will be challenges of how pub can specify different endpoint for batch request.

nevertheless, we can further scope down this PR by moving batching out.

setStyles(frame,
{width: 0, height: 0, display: 'none',
position: 'absolute', top: 0, left: 0});
const frameSubscribed = () => {}; // Don't care. SubscriptionAPI is
Copy link
Contributor

Choose a reason for hiding this comment

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

you can use this callback to do the first flush(), instead of try to flush on every enqueue.

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, thanks

READY: 'R',
CREATIVE: 'C',
EVENT: 'E',
RESPONSE: 'A',
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 explain a bit about those types?

Copy link
Contributor Author

@jonkeller jonkeller Jul 11, 2017

Choose a reason for hiding this comment

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

The basic messaging flow is:

  1. xframe sends message informing the parent page that it is ready (R). This /could/ be done with the page load callback, but Keith or Justin, I forget which, prefers sending a message instead. This is because sending a message can happen sooner, i.e. doesn't necessarily require every other (unrelated) resource to be loaded first
  2. Creative sends extra config data to xframe (C). Since multiple creatives may use the same xframe, it doesn't make sense to specify this on the URL/name attr/etc. (We could save a few ms for the first creative to use the xframe I suppose, but another method is definitely required for subsequent creatives to say "here is my extra config data")
  3. Creative sends events (E) to xframe
  4. After some amount of time, or after some number of events, xframe may choose to send a response (A, since R was already taken) to the creative.

* of the amp-analytics config object
* @param {function(!string,
* !../../../src/3p-analytics-common.AmpAnalytics3pResponse)=}
* opt_processResponse An optional function to receive any response
Copy link
Contributor

Choose a reason for hiding this comment

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

is it needed? i don't remember seeing it in the original design

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 is essential for one consumer of the API

@jonkeller
Copy link
Contributor Author

Closing this PR, as it has been replaced by a simplified version at #10442

@jonkeller jonkeller closed this Jul 14, 2017
@jonkeller jonkeller deleted the amp_analytics_3p_impl 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.

4 participants