Bug 1522832 - Fine-grained visibility on impression stats a=lizzard
authorEd Lee <edilee@mozilla.com>
Tue, 19 Feb 2019 22:16:15 +0200
changeset 516035 64706783dc06674e49ddbac40532440d0c5a5e81
parent 516034 bc8e82a065d9424501eecb43f6e7616157018e7d
child 516036 77f97b1778eb750349a74882e08b4787a47e1809
push id1953
push userffxbld-merge
push dateMon, 11 Mar 2019 12:10:20 +0000
treeherdermozilla-release@9c35dcbaa899 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewerslizzard
bugs1522832
milestone66.0
Bug 1522832 - Fine-grained visibility on impression stats a=lizzard Reviewers: andreio Reviewed By: andreio Bug #: 1522832 Differential Revision: https://phabricator.services.mozilla.com/D20050
browser/components/newtab/common/Actions.jsm
browser/components/newtab/content-src/components/DiscoveryStreamBase/DiscoveryStreamBase.jsx
browser/components/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid.jsx
browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/DSCard.jsx
browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/_DSCard.scss
browser/components/newtab/content-src/components/DiscoveryStreamComponents/Hero/Hero.jsx
browser/components/newtab/content-src/components/DiscoveryStreamComponents/Hero/_Hero.scss
browser/components/newtab/content-src/components/DiscoveryStreamComponents/List/List.jsx
browser/components/newtab/content-src/components/DiscoveryStreamComponents/List/_List.scss
browser/components/newtab/content-src/components/DiscoveryStreamComponents/SpocIntersectionObserver/SpocIntersectionObserver.jsx
browser/components/newtab/content-src/components/DiscoveryStreamImpressionStats/ImpressionStats.jsx
browser/components/newtab/content-src/components/DiscoveryStreamImpressionStats/_ImpressionStats.scss
browser/components/newtab/content-src/styles/_activity-stream.scss
browser/components/newtab/css/activity-stream-linux.css
browser/components/newtab/css/activity-stream-mac.css
browser/components/newtab/css/activity-stream-windows.css
browser/components/newtab/data/content/activity-stream.bundle.js
browser/components/newtab/lib/TelemetryFeed.jsm
browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/ImpressionStats.test.jsx
browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/List.test.jsx
browser/components/newtab/test/unit/lib/TelemetryFeed.test.js
--- a/browser/components/newtab/common/Actions.jsm
+++ b/browser/components/newtab/common/Actions.jsm
@@ -38,16 +38,17 @@ for (const type of [
   "DELETE_FROM_POCKET",
   "DELETE_HISTORY_URL",
   "DIALOG_CANCEL",
   "DIALOG_OPEN",
   "DISCOVERY_STREAM_CONFIG_CHANGE",
   "DISCOVERY_STREAM_CONFIG_SETUP",
   "DISCOVERY_STREAM_CONFIG_SET_VALUE",
   "DISCOVERY_STREAM_FEEDS_UPDATE",
+  "DISCOVERY_STREAM_IMPRESSION_STATS",
   "DISCOVERY_STREAM_LAYOUT_RESET",
   "DISCOVERY_STREAM_LAYOUT_UPDATE",
   "DISCOVERY_STREAM_SPOCS_ENDPOINT",
   "DISCOVERY_STREAM_SPOCS_UPDATE",
   "DISCOVERY_STREAM_SPOC_IMPRESSION",
   "DOWNLOAD_CHANGED",
   "FAKE_FOCUS_SEARCH",
   "FILL_SEARCH_TERM",
@@ -328,16 +329,31 @@ function PerfEvent(data, importContext =
 function ImpressionStats(data, importContext = globalImportContext) {
   const action = {
     type: actionTypes.TELEMETRY_IMPRESSION_STATS,
     data,
   };
   return importContext === UI_CODE ? AlsoToMain(action) : action;
 }
 
+/**
+ * DiscoveryStreamImpressionStats - A telemetry ping indicating an impression stats in Discovery Stream.
+ *
+ * @param  {object} data Fields to include in the ping
+ * @param  {int} importContext (For testing) Override the import context for testing.
+ * #return {object} An action. For UI code, a AlsoToMain action.
+ */
+function DiscoveryStreamImpressionStats(data, importContext = globalImportContext) {
+  const action = {
+    type: actionTypes.DISCOVERY_STREAM_IMPRESSION_STATS,
+    data,
+  };
+  return importContext === UI_CODE ? AlsoToMain(action) : action;
+}
+
 function SetPref(name, value, importContext = globalImportContext) {
   const action = {type: actionTypes.SET_PREF, data: {name, value}};
   return importContext === UI_CODE ? AlsoToMain(action) : action;
 }
 
 function WebExtEvent(type, data, importContext = globalImportContext) {
   if (!data || !data.source) {
     throw new Error("WebExtEvent actions should include a property \"source\", the id of the webextension that should receive the event.");
@@ -358,16 +374,17 @@ this.actionCreators = {
   ImpressionStats,
   AlsoToOneContent,
   OnlyToOneContent,
   AlsoToMain,
   OnlyToMain,
   AlsoToPreloaded,
   SetPref,
   WebExtEvent,
+  DiscoveryStreamImpressionStats,
 };
 
 // These are helpers to test for certain kinds of actions
 this.actionUtils = {
   isSendToMain(action) {
     if (!action.meta) {
       return false;
     }
--- a/browser/components/newtab/content-src/components/DiscoveryStreamBase/DiscoveryStreamBase.jsx
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamBase/DiscoveryStreamBase.jsx
@@ -1,31 +1,20 @@
 import {CardGrid} from "content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid";
 import {connect} from "react-redux";
 import {DSMessage} from "content-src/components/DiscoveryStreamComponents/DSMessage/DSMessage";
 import {Hero} from "content-src/components/DiscoveryStreamComponents/Hero/Hero";
 import {HorizontalRule} from "content-src/components/DiscoveryStreamComponents/HorizontalRule/HorizontalRule";
-import {ImpressionStats} from "content-src/components/DiscoveryStreamImpressionStats/ImpressionStats";
 import {List} from "content-src/components/DiscoveryStreamComponents/List/List";
 import {Navigation} from "content-src/components/DiscoveryStreamComponents/Navigation/Navigation";
 import React from "react";
 import {SectionTitle} from "content-src/components/DiscoveryStreamComponents/SectionTitle/SectionTitle";
 import {selectLayoutRender} from "content-src/lib/selectLayoutRender";
 import {TopSites} from "content-src/components/DiscoveryStreamComponents/TopSites/TopSites";
 
-// According to the Pocket API endpoint specs, `component.properties.items` is a required property with following values:
-//   - List 1-12 items
-//   - Hero 1-5 items
-//   - CardGrid 1-16 items
-// To enforce that, we define various maximium items for individual components as an extra check.
-// Note that these values are subject to the future changes of the specs.
-const MAX_ROWS_HERO = 5;
-const MAX_ROWS_LIST = 12;
-const MAX_ROWS_CARDGRID = 16;
-
 const ALLOWED_CSS_URL_PREFIXES = ["chrome://", "resource://", "https://img-getpocket.cdn.mozilla.net/"];
 const DUMMY_CSS_SELECTOR = "DUMMY#CSS.SELECTOR";
 
 /**
  * Validate a CSS declaration. The values are assumed to be normalized by CSSOM.
  */
 export function isAllowedCSS(property, value) {
   // Bug 1454823: INTERNAL properties, e.g., -moz-context-properties, are
@@ -42,35 +31,16 @@ export function isAllowedCSS(property, v
 }
 
 export class _DiscoveryStreamBase extends React.PureComponent {
   constructor(props) {
     super(props);
     this.onStyleMount = this.onStyleMount.bind(this);
   }
 
-  /**
-   * Extracts the recommendation rows from component for the impression ping.
-   * If `component.data.recommendations` is unset, returns an empty array.
-   *
-   * The row size is determined by the following rules:
-   *   - Use `component.properties.items` from the endpoint if it's specified
-   *   - Otherwise, use the length of recommendation array
-   *   - The row size is capped by the argument `limit`, which could be one of
-   *     [`MAX_ROW_HERO`, `MAX_ROWS_LIST`, `MAX_ROWS_CARDGRID`]
-   */
-  extractRows(component, limit) {
-    if (component.data && component.data.recommendations) {
-      const items = Math.min(limit, component.properties.items || component.data.recommendations.length);
-      return component.data.recommendations.slice(0, items);
-    }
-
-    return [];
-  }
-
   onStyleMount(style) {
     // Unmounting style gets rid of old styles, so nothing else to do
     if (!style) {
       return;
     }
 
     const {sheet} = style;
     const styles = JSON.parse(style.dataset.styles);
@@ -110,18 +80,16 @@ export class _DiscoveryStreamBase extend
             console.error(`Bad CSS selector ${selectors}`); // eslint-disable-line no-console
           }
         });
       });
     });
   }
 
   renderComponent(component, embedWidth) {
-    let rows;
-
     switch (component.type) {
       case "TopSites":
         return (<TopSites header={component.header} />);
       case "Message":
         return (
           <DSMessage
             title={component.header && component.header.title}
             subtitle={component.header && component.header.subtitle}
@@ -137,60 +105,51 @@ export class _DiscoveryStreamBase extend
       case "Navigation":
         return (
           <Navigation
             links={component.properties.links}
             alignment={component.properties.alignment}
             header={component.header} />
         );
       case "CardGrid":
-        rows = this.extractRows(component, MAX_ROWS_CARDGRID);
         return (
-          <ImpressionStats rows={rows} dispatch={this.props.dispatch} source={component.type}>
-            <CardGrid
-              title={component.header && component.header.title}
-              data={component.data}
-              feed={component.feed}
-              border={component.properties.border}
-              type={component.type}
-              dispatch={this.props.dispatch}
-              items={component.properties.items} />
-          </ImpressionStats>
+          <CardGrid
+            title={component.header && component.header.title}
+            data={component.data}
+            feed={component.feed}
+            border={component.properties.border}
+            type={component.type}
+            dispatch={this.props.dispatch}
+            items={component.properties.items} />
         );
       case "Hero":
-        rows = this.extractRows(component, MAX_ROWS_HERO);
         return (
-          <ImpressionStats rows={rows} dispatch={this.props.dispatch} source={component.type}>
-            <Hero
-              subComponentType={embedWidth >= 9 ? `cards` : `list`}
-              feed={component.feed}
-              title={component.header && component.header.title}
-              data={component.data}
-              border={component.properties.border}
-              type={component.type}
-              dispatch={this.props.dispatch}
-              items={component.properties.items} />
-          </ImpressionStats>
+          <Hero
+            subComponentType={embedWidth >= 9 ? `cards` : `list`}
+            feed={component.feed}
+            title={component.header && component.header.title}
+            data={component.data}
+            border={component.properties.border}
+            type={component.type}
+            dispatch={this.props.dispatch}
+            items={component.properties.items} />
         );
       case "HorizontalRule":
         return (<HorizontalRule />);
       case "List":
-        rows = this.extractRows(component, MAX_ROWS_LIST);
         return (
-          <ImpressionStats rows={rows} dispatch={this.props.dispatch} source={component.type}>
-            <List
-              data={component.data}
-              fullWidth={component.properties.full_width}
-              hasBorders={component.properties.border === "border"}
-              hasImages={component.properties.has_images}
-              hasNumbers={component.properties.has_numbers}
-              items={component.properties.items}
-              type={component.type}
-              header={component.header} />
-          </ImpressionStats>
+          <List
+            data={component.data}
+            fullWidth={component.properties.full_width}
+            hasBorders={component.properties.border === "border"}
+            hasImages={component.properties.has_images}
+            hasNumbers={component.properties.has_numbers}
+            items={component.properties.items}
+            type={component.type}
+            header={component.header} />
         );
       default:
         return (<div>{component.type}</div>);
     }
   }
 
   renderStyles(styles) {
     // Use json string as both the key and styles to render so React knows when
--- a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid.jsx
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid.jsx
@@ -9,18 +9,18 @@ export class CardGrid extends React.Pure
     if (!data) {
       return (
         <div />
       );
     }
 
     let cards = data.recommendations.slice(0, this.props.items).map((rec, index) => (
       <DSCard
+        key={`dscard-${index}`}
         campaignId={rec.campaign_id}
-        key={`dscard-${index}`}
         image_src={rec.image_src}
         title={rec.title}
         excerpt={rec.excerpt}
         url={rec.url}
         id={rec.id}
         index={index}
         type={this.props.type}
         context={rec.context}
--- a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/DSCard.jsx
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/DSCard.jsx
@@ -1,12 +1,12 @@
 import {actionCreators as ac} from "common/Actions.jsm";
+import {ImpressionStats} from "../../DiscoveryStreamImpressionStats/ImpressionStats";
 import React from "react";
 import {SafeAnchor} from "../SafeAnchor/SafeAnchor";
-import {SpocIntersectionObserver} from "content-src/components/DiscoveryStreamComponents/SpocIntersectionObserver/SpocIntersectionObserver";
 
 export class DSCard extends React.PureComponent {
   constructor(props) {
     super(props);
 
     this.onLinkClick = this.onLinkClick.bind(this);
   }
 
@@ -24,32 +24,35 @@ export class DSCard extends React.PureCo
         tiles: [{id: this.props.id, pos: this.props.index}],
       }));
     }
   }
 
   render() {
     return (
       <SafeAnchor url={this.props.url} className="ds-card" onLinkClick={this.onLinkClick}>
-        <SpocIntersectionObserver campaignId={this.props.campaignId} dispatch={this.props.dispatch}>
-          <div className="img-wrapper">
-            <div className="img" style={{backgroundImage: `url(${this.props.image_src}`}} />
+        <div className="img-wrapper">
+          <div className="img" style={{backgroundImage: `url(${this.props.image_src}`}} />
+        </div>
+        <div className="meta">
+          <div className="info-wrap">
+            <header className="title">{this.props.title}</header>
+            {this.props.excerpt && <p className="excerpt">{this.props.excerpt}</p>}
           </div>
-          <div className="meta">
-            <div className="info-wrap">
-              <header className="title">{this.props.title}</header>
-              {this.props.excerpt && <p className="excerpt">{this.props.excerpt}</p>}
-            </div>
-            <p>
-              {this.props.context && (
-                <span>
-                  <span className="context">{this.props.context}</span>
-                  <br />
-                </span>
-              )}
-              <span className="source">{this.props.source}</span>
-            </p>
-          </div>
-        </SpocIntersectionObserver>
+          <p>
+            {this.props.context && (
+              <span>
+                <span className="context">{this.props.context}</span>
+                <br />
+              </span>
+            )}
+            <span className="source">{this.props.source}</span>
+          </p>
+        </div>
+        <ImpressionStats
+          campaignId={this.props.campaignId}
+          rows={[{id: this.props.id}]}
+          dispatch={this.props.dispatch}
+          source={this.props.type} />
       </SafeAnchor>
     );
   }
 }
--- a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/_DSCard.scss
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/_DSCard.scss
@@ -2,16 +2,17 @@
 $header-font-size: 17;
 $header-line-height: 24;
 $excerpt-font-size: 14;
 $excerpt-line-height: 20;
 
 .ds-card {
   display: flex;
   flex-direction: column;
+  position: relative;
 
   &:hover {
     header {
       color: $blue-60;
     }
   }
 
   &:active {
--- a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Hero/Hero.jsx
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Hero/Hero.jsx
@@ -1,10 +1,11 @@
 import {actionCreators as ac} from "common/Actions.jsm";
 import {DSCard} from "../DSCard/DSCard.jsx";
+import {ImpressionStats} from "../../DiscoveryStreamImpressionStats/ImpressionStats";
 import {List} from "../List/List.jsx";
 import React from "react";
 import {SafeAnchor} from "../SafeAnchor/SafeAnchor";
 
 export class Hero extends React.PureComponent {
   constructor(props) {
     super(props);
     this.onLinkClick = this.onLinkClick.bind(this);
@@ -37,17 +38,16 @@ export class Hero extends React.PureComp
     }
 
     let [heroRec, ...otherRecs] = data.recommendations.slice(0, this.props.items);
     this.heroRec = heroRec;
 
     // Note that `{index + 1}` is necessary below for telemetry since we treat heroRec as index 0.
     let cards = otherRecs.map((rec, index) => (
       <DSCard
-        campaignId={rec.campaign_id}
         key={`dscard-${index}`}
         image_src={rec.image_src}
         title={rec.title}
         url={rec.url}
         id={rec.id}
         index={index + 1}
         type={this.props.type}
         dispatch={this.props.dispatch}
@@ -79,16 +79,21 @@ export class Hero extends React.PureComp
                 <p className="excerpt">{heroRec.excerpt}</p>
               </div>
               {heroRec.context ? (
                 <p className="context">{heroRec.context}</p>
               ) : (
                 <p className="source">{heroRec.domain}</p>
               )}
             </div>
+            <ImpressionStats
+              campaignId={heroRec.campaignId}
+              rows={[{id: heroRec.id}]}
+              dispatch={this.props.dispatch}
+              source={this.props.type} />
           </SafeAnchor>
           <div className={`${this.props.subComponentType}`}>
             { this.props.subComponentType === `cards` ? cards : list }
           </div>
         </div>
       </div>
     );
   }
--- a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Hero/_Hero.scss
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Hero/_Hero.scss
@@ -1,12 +1,14 @@
 $card-header-in-hero-font-size: 14;
 $card-header-in-hero-line-height: 20;
 
 .ds-hero {
+  position: relative;
+
   .img {
     @include image-as-background;
   }
 
   header {
     font-weight: 600;
   }
 
@@ -87,16 +89,17 @@
     .meta {
       display: block;
       flex-direction: column;
       justify-content: space-between;
 
       header {
         @include limit-visibile-lines(4, 28, 22);
         color: $grey-90;
+        margin-bottom: 8px;
       }
 
       .context {
         color: $teal-70;
       }
 
       .source {
         font-size: 13px;
@@ -182,17 +185,16 @@
 
       .meta {
         flex-grow: 1;
         display: flex;
         padding: 0 24px 0 0;
 
         header {
           @include limit-visibile-lines(3, 28, 22);
-          margin: 0 0 8px;
         }
 
         .source {
           margin-bottom: 0;
         }
       }
     }
 
--- a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/List/List.jsx
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/List/List.jsx
@@ -1,13 +1,13 @@
 import {actionCreators as ac} from "common/Actions.jsm";
 import {connect} from "react-redux";
+import {ImpressionStats} from "../../DiscoveryStreamImpressionStats/ImpressionStats";
 import React from "react";
 import {SafeAnchor} from "../SafeAnchor/SafeAnchor";
-import {SpocIntersectionObserver} from "content-src/components/DiscoveryStreamComponents/SpocIntersectionObserver/SpocIntersectionObserver";
 
 /**
  * @note exported for testing only
  */
 export class ListItem extends React.PureComponent {
   // TODO performance: get feeds to send appropriately sized images rather
   // than waiting longer and scaling down on client?
   constructor(props) {
@@ -29,34 +29,37 @@ export class ListItem extends React.Pure
         tiles: [{id: this.props.id, pos: this.props.index}],
       }));
     }
   }
 
   render() {
     return (
       <li className="ds-list-item">
-        <SpocIntersectionObserver campaignId={this.props.campaignId} dispatch={this.props.dispatch}>
-          <SafeAnchor url={this.props.url} className="ds-list-item-link" onLinkClick={this.onLinkClick}>
-            <div className="ds-list-item-text">
-              <div className="ds-list-item-title">{this.props.title}</div>
-              {this.props.excerpt && <div className="ds-list-item-excerpt">{this.props.excerpt}</div>}
-              <p>
-                {this.props.context && (
-                  <span>
-                    <span className="ds-list-item-context">{this.props.context}</span>
-                    <br />
-                  </span>
-                )}
-                <span className="ds-list-item-info">{this.props.domain}</span>
-              </p>
-            </div>
-            <div className="ds-list-image" style={{backgroundImage: `url(${this.props.image_src})`}} />
-          </SafeAnchor>
-        </SpocIntersectionObserver>
+        <SafeAnchor url={this.props.url} className="ds-list-item-link" onLinkClick={this.onLinkClick}>
+          <div className="ds-list-item-text">
+            <div className="ds-list-item-title">{this.props.title}</div>
+            {this.props.excerpt && <div className="ds-list-item-excerpt">{this.props.excerpt}</div>}
+            <p>
+              {this.props.context && (
+                <span>
+                  <span className="ds-list-item-context">{this.props.context}</span>
+                  <br />
+                </span>
+              )}
+              <span className="ds-list-item-info">{this.props.domain}</span>
+            </p>
+          </div>
+          <div className="ds-list-image" style={{backgroundImage: `url(${this.props.image_src})`}} />
+          <ImpressionStats
+            campaignId={this.props.campaignId}
+            rows={[{id: this.props.id}]}
+            dispatch={this.props.dispatch}
+            source={this.props.type} />
+        </SafeAnchor>
       </li>
     );
   }
 }
 
 /**
  * @note exported for testing only
  */
@@ -64,28 +67,28 @@ export function _List(props) {
   const feed = props.data;
   if (!feed || !feed.recommendations) {
     return null;
   }
   const recs = feed.recommendations;
   let recMarkup = recs.slice(props.recStartingPoint,
                              props.recStartingPoint + props.items).map((rec, index) => (
     <ListItem key={`ds-list-item-${index}`}
+      dispatch={props.dispatch}
       campaignId={rec.campaign_id}
-      dispatch={props.dispatch}
       domain={rec.domain}
       excerpt={rec.excerpt}
       id={rec.id}
       image_src={rec.image_src}
       index={index}
       title={rec.title}
       context={rec.context}
       type={props.type}
-      url={rec.url} />)
-  );
+      url={rec.url} />
+  ));
   const listStyles = [
     "ds-list",
     props.fullWidth ? "ds-list-full-width" : "",
     props.hasBorders ? "ds-list-borders" : "",
     props.hasImages ? "ds-list-images" : "",
     props.hasNumbers ? "ds-list-numbers" : "",
   ];
   return (
--- a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/List/_List.scss
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/List/_List.scss
@@ -16,16 +16,17 @@
   }
 }
 
 @mixin set-item-sizes($font-size, $line-height, $image-size) {
   .ds-list-item {
     // XXX see if we really want absolute units, maybe hoist somewhere central?
     font-size: $font-size * 1px;
     line-height: $line-height * 1px;
+    position: relative;
   }
 
   .ds-list-item-title {
     @include limit-visibile-lines(3, $line-height, $font-size);
   }
 
   .ds-list-image {
     min-width: $image-size;
deleted file mode 100644
--- a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/SpocIntersectionObserver/SpocIntersectionObserver.jsx
+++ /dev/null
@@ -1,72 +0,0 @@
-import {actionCreators as ac, actionTypes as at} from "common/Actions.jsm";
-import React from "react";
-const VISIBLE = "visible";
-const VISIBILITY_CHANGE_EVENT = "visibilitychange";
-const INTERSECTION_RATIO = 0.5;
-
-export class SpocIntersectionObserver extends React.PureComponent {
-  constructor(props) {
-    super(props);
-
-    this.spocElementRef = this.spocElementRef.bind(this);
-  }
-
-  componentDidMount() {
-    if (this.props.document.visibilityState === VISIBLE) {
-      this.setupIntersectionObserver();
-    } else {
-      this._onVisibilityChange = () => {
-        if (this.props.document.visibilityState === VISIBLE) {
-          this.setupIntersectionObserver();
-          this.props.document.removeEventListener(VISIBILITY_CHANGE_EVENT, this._onVisibilityChange);
-        }
-      };
-      this.props.document.addEventListener(VISIBILITY_CHANGE_EVENT, this._onVisibilityChange);
-    }
-  }
-
-  componentWillUnmount() {
-    if (this._onVisibilityChange) {
-      this.props.document.removeEventListener(VISIBILITY_CHANGE_EVENT, this._onVisibilityChange);
-    }
-    if (this._intersectionObserver) {
-      this._intersectionObserver.unobserve(this.spocElement);
-    }
-  }
-
-  setupIntersectionObserver() {
-    const options = {threshold: INTERSECTION_RATIO};
-    this._intersectionObserver = new IntersectionObserver(entries => {
-      for (let entry of entries) {
-        if (entry.isIntersecting && entry.intersectionRatio >= INTERSECTION_RATIO) {
-          this.dispatchSpocImpression();
-          break;
-        }
-      }
-    }, options);
-    this._intersectionObserver.observe(this.spocElement);
-  }
-
-  dispatchSpocImpression() {
-    if (this.props.campaignId) {
-      this.props.dispatch(ac.OnlyToMain({type: at.DISCOVERY_STREAM_SPOC_IMPRESSION, data: {campaignId: this.props.campaignId}}));
-    }
-    this._intersectionObserver.unobserve(this.spocElement);
-  }
-
-  spocElementRef(element) {
-    this.spocElement = element;
-  }
-
-  render() {
-    return (
-      <div ref={this.spocElementRef}>
-        {this.props.children}
-      </div>
-    );
-  }
-}
-
-SpocIntersectionObserver.defaultProps = {
-  document: global.document,
-};
--- a/browser/components/newtab/content-src/components/DiscoveryStreamImpressionStats/ImpressionStats.jsx
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamImpressionStats/ImpressionStats.jsx
@@ -1,14 +1,36 @@
-import {actionCreators as ac} from "common/Actions.jsm";
+import {actionCreators as ac, actionTypes as at} from "common/Actions.jsm";
 import React from "react";
 
 const VISIBLE = "visible";
 const VISIBILITY_CHANGE_EVENT = "visibilitychange";
 
+// Per analytical requirement, we set the minimal intersection ratio to
+// 0.5, and an impression is identified when the wrapped item has at least
+// 50% visibility.
+//
+// This constant is exported for unit test
+export const INTERSECTION_RATIO = 0.5;
+
+/**
+ * Impression wrapper for Discovery Stream related React components.
+ *
+ * It makses use of the Intersection Observer API to detect the visibility,
+ * and relies on page visibility to ensure the impression is reported
+ * only when the component is visible on the page.
+ *
+ * Note:
+ *   * This wrapper could be used either at the individual card level,
+ *     or by the card container components
+ *   * Each impression will be sent only once as soon as the desired
+ *     visibility is detected
+ *   * Batching is not yet implemented, hence it might send multiple
+ *     impression pings separately
+ */
 export class ImpressionStats extends React.PureComponent {
   // This checks if the given cards are the same as those in the last impression ping.
   // If so, it should not send the same impression ping again.
   _needsImpressionStats(cards) {
     if (!this.impressionCardGuids || (this.impressionCardGuids.length !== cards.length)) {
       return true;
     }
 
@@ -20,74 +42,108 @@ export class ImpressionStats extends Rea
 
     return false;
   }
 
   _dispatchImpressionStats() {
     const {props} = this;
     const cards = props.rows;
 
+    if (this.props.campaignId) {
+      this.props.dispatch(ac.OnlyToMain({type: at.DISCOVERY_STREAM_SPOC_IMPRESSION, data: {campaignId: this.props.campaignId}}));
+    }
+
     if (this._needsImpressionStats(cards)) {
-      props.dispatch(ac.ImpressionStats({
+      props.dispatch(ac.DiscoveryStreamImpressionStats({
         source: props.source.toUpperCase(),
         tiles: cards.map(link => ({id: link.id})),
       }));
       this.impressionCardGuids = cards.map(link => link.id);
     }
   }
 
-  // This sends an event when a user sees a set of new content. If content
-  // changes while the page is hidden (i.e. preloaded or on a hidden tab),
-  // only send the event if the page becomes visible again.
-  sendImpressionStatsOrAddListener() {
+  setImpressionObserverOrAddListener() {
     const {props} = this;
 
     if (!props.dispatch) {
       return;
     }
 
     if (props.document.visibilityState === VISIBLE) {
-      this._dispatchImpressionStats();
+      this.setImpressionObserver();
     } else {
       // We should only ever send the latest impression stats ping, so remove any
       // older listeners.
       if (this._onVisibilityChange) {
         props.document.removeEventListener(VISIBILITY_CHANGE_EVENT, this._onVisibilityChange);
       }
 
       this._onVisibilityChange = () => {
         if (props.document.visibilityState === VISIBLE) {
-          this._dispatchImpressionStats();
+          this.setImpressionObserver();
           props.document.removeEventListener(VISIBILITY_CHANGE_EVENT, this._onVisibilityChange);
         }
       };
       props.document.addEventListener(VISIBILITY_CHANGE_EVENT, this._onVisibilityChange);
     }
   }
 
+  /**
+   * Set an impression observer for the wrapped component. It makes use of
+   * the Intersection Observer API to detect if the wrapped component is
+   * visible with a desired ratio, and only sends impression if that's the case.
+   *
+   * See more details about Intersection Observer API at:
+   * https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API
+   */
+  setImpressionObserver() {
+    const {props} = this;
+
+    if (!props.rows.length) {
+      return;
+    }
+
+    this._handleIntersect = entries => {
+      if (entries.some(entry => entry.isIntersecting && entry.intersectionRatio >= INTERSECTION_RATIO)) {
+        this._dispatchImpressionStats();
+        this.impressionObserver.unobserve(this.refs.impression);
+      }
+    };
+
+    const options = {threshold: INTERSECTION_RATIO};
+    this.impressionObserver = new props.IntersectionObserver(this._handleIntersect, options);
+    this.impressionObserver.observe(this.refs.impression);
+  }
+
   componentDidMount() {
     if (this.props.rows.length) {
-      this.sendImpressionStatsOrAddListener();
+      this.setImpressionObserverOrAddListener();
     }
   }
 
   componentDidUpdate(prevProps) {
     if (this.props.rows.length && this.props.rows !== prevProps.rows) {
-      this.sendImpressionStatsOrAddListener();
+      this.setImpressionObserverOrAddListener();
     }
   }
 
   componentWillUnmount() {
+    if (this._handleIntersect && this.impressionObserver) {
+      this.impressionObserver.unobserve(this.refs.impression);
+    }
     if (this._onVisibilityChange) {
       this.props.document.removeEventListener(VISIBILITY_CHANGE_EVENT, this._onVisibilityChange);
     }
   }
 
   render() {
-    return this.props.children;
+    return (<div ref={"impression"} className="impression-observer">
+      {this.props.children}
+    </div>);
   }
 }
 
 ImpressionStats.defaultProps = {
+  IntersectionObserver: global.IntersectionObserver,
   document: global.document,
   rows: [],
   source: "",
 };
new file mode 100644
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamImpressionStats/_ImpressionStats.scss
@@ -0,0 +1,6 @@
+.impression-observer {
+  position: absolute;
+  width: 100%;
+  height: 100%;
+  pointer-events: none;
+}
--- a/browser/components/newtab/content-src/styles/_activity-stream.scss
+++ b/browser/components/newtab/content-src/styles/_activity-stream.scss
@@ -151,16 +151,17 @@ input {
 @import '../components/DiscoveryStreamComponents/Hero/Hero';
 @import '../components/DiscoveryStreamComponents/HorizontalRule/HorizontalRule';
 @import '../components/DiscoveryStreamComponents/List/List';
 @import '../components/DiscoveryStreamComponents/Navigation/Navigation';
 @import '../components/DiscoveryStreamComponents/SectionTitle/SectionTitle';
 @import '../components/DiscoveryStreamComponents/TopSites/TopSites';
 @import '../components/DiscoveryStreamComponents/DSCard/DSCard';
 @import '../components/DiscoveryStreamComponents/DSMessage/DSMessage';
+@import '../components/DiscoveryStreamImpressionStats/ImpressionStats';
 
 // AS Router
 @import '../asrouter/components/Button/Button';
 @import '../asrouter/components/SnippetBase/SnippetBase';
 @import '../asrouter/components/ModalOverlay/ModalOverlay';
 @import '../asrouter/templates/ReturnToAMO/ReturnToAMO';
 @import '../asrouter/templates/SimpleSnippet/SimpleSnippet';
 @import '../asrouter/templates/SubmitFormSnippet/SubmitFormSnippet';
--- a/browser/components/newtab/css/activity-stream-linux.css
+++ b/browser/components/newtab/css/activity-stream-linux.css
@@ -1886,225 +1886,218 @@ main {
     .ds-column-10 .ds-card-grid.ds-card-grid-divisible-by-4 .title,
     .ds-column-11 .ds-card-grid.ds-card-grid-divisible-by-4 .title,
     .ds-column-12 .ds-card-grid.ds-card-grid-divisible-by-4 .title {
       font-size: 14px;
       line-height: 20px;
       max-height: 4.28571em;
       overflow: hidden; }
 
-.ds-hero .img {
-  background-color: var(--newtab-card-placeholder-color);
-  background-position: center;
-  background-repeat: no-repeat;
-  background-size: cover;
-  border-radius: 4px;
-  box-shadow: inset 0 0 0 0.5px rgba(0, 0, 0, 0.15); }
-
-.ds-hero header {
-  font-weight: 600; }
-
-.ds-hero p {
-  line-height: 1.538;
-  margin: 8px 0; }
-
-.ds-hero .excerpt {
-  font-size: 14px;
-  line-height: 20px;
-  max-height: 4.28571em;
-  overflow: hidden;
-  margin: 4px 0 8px; }
-
-.ds-hero .ds-list {
-  border-top: 0;
-  padding-top: 0; }
-
-.ds-hero .ds-card {
-  border: 0;
-  padding-bottom: 20px; }
-  .ds-hero .ds-card p {
-    margin-top: 4px; }
-  .ds-hero .ds-card:hover {
+.ds-hero {
+  position: relative; }
+  .ds-hero .img {
+    background-color: var(--newtab-card-placeholder-color);
+    background-position: center;
+    background-repeat: no-repeat;
+    background-size: cover;
+    border-radius: 4px;
+    box-shadow: inset 0 0 0 0.5px rgba(0, 0, 0, 0.15); }
+  .ds-hero header {
+    font-weight: 600; }
+  .ds-hero p {
+    line-height: 1.538;
+    margin: 8px 0; }
+  .ds-hero .excerpt {
+    font-size: 14px;
+    line-height: 20px;
+    max-height: 4.28571em;
+    overflow: hidden;
+    margin: 4px 0 8px; }
+  .ds-hero .ds-list {
+    border-top: 0;
+    padding-top: 0; }
+  .ds-hero .ds-card {
     border: 0;
-    box-shadow: none;
-    border-radius: 0; }
-  .ds-hero .ds-card .meta {
-    padding: 0; }
-  .ds-hero .ds-card .img-wrapper {
+    padding-bottom: 20px; }
+    .ds-hero .ds-card p {
+      margin-top: 4px; }
+    .ds-hero .ds-card:hover {
+      border: 0;
+      box-shadow: none;
+      border-radius: 0; }
+    .ds-hero .ds-card .meta {
+      padding: 0; }
+    .ds-hero .ds-card .img-wrapper {
+      margin: 0 0 12px; }
+  .ds-hero .img-wrapper {
     margin: 0 0 12px; }
-
-.ds-hero .img-wrapper {
-  margin: 0 0 12px; }
-
-.ds-hero .wrapper {
-  color: #737373;
-  display: block;
-  margin: 12px 0 16px;
-  padding: 16px 0;
-  border-top: 1px solid var(--newtab-border-secondary-color);
-  border-bottom: 1px solid var(--newtab-border-secondary-color); }
-  .ds-hero-no-border .wrapper {
-    border-top: 0;
-    border-bottom: 0;
-    padding: 0 0 8px; }
-  .ds-hero .wrapper:hover .meta header {
-    color: #0060DF; }
-  .ds-hero .wrapper:active .meta header {
-    color: #003EAA; }
-  .ds-hero .wrapper .img-wrapper {
-    width: 100%; }
-  .ds-hero .wrapper .img {
-    height: 0;
-    padding-top: 50%; }
-  .ds-hero .wrapper .meta {
+  .ds-hero .wrapper {
+    color: #737373;
     display: block;
-    flex-direction: column;
-    justify-content: space-between; }
-    .ds-hero .wrapper .meta header {
-      font-size: 22px;
-      line-height: 28px;
-      max-height: 5.09091em;
-      overflow: hidden;
-      color: #0C0C0D; }
-    .ds-hero .wrapper .meta .context {
-      color: #008EA4; }
-    .ds-hero .wrapper .meta .source {
-      font-size: 13px;
-      color: #005A71;
-      margin-bottom: 0;
-      overflow-x: hidden;
-      text-overflow: ellipsis; }
-
-.ds-column-5 .ds-hero .wrapper,
-.ds-column-6 .ds-hero .wrapper,
-.ds-column-7 .ds-hero .wrapper,
-.ds-column-8 .ds-hero .wrapper {
-  display: grid;
-  grid-template-columns: repeat(2, 1fr);
-  grid-column-gap: 24px; }
-  .ds-column-5 .ds-hero .wrapper .img-wrapper,
-  .ds-column-6 .ds-hero .wrapper .img-wrapper,
-  .ds-column-7 .ds-hero .wrapper .img-wrapper,
-  .ds-column-8 .ds-hero .wrapper .img-wrapper {
-    margin: 0;
-    grid-column: 2;
-    grid-row: 1; }
-  .ds-column-5 .ds-hero .wrapper .meta,
-  .ds-column-6 .ds-hero .wrapper .meta,
-  .ds-column-7 .ds-hero .wrapper .meta,
-  .ds-column-8 .ds-hero .wrapper .meta {
-    grid-column: 1;
-    grid-row: 1;
-    display: flex; }
-  .ds-column-5 .ds-hero .wrapper .img,
-  .ds-column-6 .ds-hero .wrapper .img,
-  .ds-column-7 .ds-hero .wrapper .img,
-  .ds-column-8 .ds-hero .wrapper .img {
-    height: 0;
-    padding-top: 100%; }
-
-.ds-column-5 .ds-hero .cards,
-.ds-column-6 .ds-hero .cards,
-.ds-column-7 .ds-hero .cards,
-.ds-column-8 .ds-hero .cards {
-  display: grid;
-  grid-template-columns: repeat(2, 1fr);
-  grid-column-gap: 24px; }
-
-.ds-column-9 .ds-hero,
-.ds-column-10 .ds-hero,
-.ds-column-11 .ds-hero,
-.ds-column-12 .ds-hero {
-  display: grid;
-  grid-template-columns: repeat(2, 1fr);
-  grid-column-gap: 24px; }
-  .ds-column-9 .ds-hero.ds-hero-border,
-  .ds-column-10 .ds-hero.ds-hero-border,
-  .ds-column-11 .ds-hero.ds-hero-border,
-  .ds-column-12 .ds-hero.ds-hero-border {
+    margin: 12px 0 16px;
+    padding: 16px 0;
     border-top: 1px solid var(--newtab-border-secondary-color);
-    padding: 20px 0; }
-    .ds-column-9 .ds-hero.ds-hero-border .ds-card:nth-child(-n+2),
-    .ds-column-10 .ds-hero.ds-hero-border .ds-card:nth-child(-n+2),
-    .ds-column-11 .ds-hero.ds-hero-border .ds-card:nth-child(-n+2),
-    .ds-column-12 .ds-hero.ds-hero-border .ds-card:nth-child(-n+2) {
-      border-bottom: 1px solid var(--newtab-border-secondary-color);
-      margin-bottom: 20px; }
-  .ds-column-9 .ds-hero .wrapper,
-  .ds-column-10 .ds-hero .wrapper,
-  .ds-column-11 .ds-hero .wrapper,
-  .ds-column-12 .ds-hero .wrapper {
-    border-top: 0;
-    border-bottom: 0;
-    margin: 0;
-    padding: 0 0 20px;
-    display: flex;
-    flex-direction: column; }
-    .ds-column-9 .ds-hero .wrapper .img-wrapper,
-    .ds-column-10 .ds-hero .wrapper .img-wrapper,
-    .ds-column-11 .ds-hero .wrapper .img-wrapper,
-    .ds-column-12 .ds-hero .wrapper .img-wrapper {
-      margin: 0; }
-    .ds-column-9 .ds-hero .wrapper .img,
-    .ds-column-10 .ds-hero .wrapper .img,
-    .ds-column-11 .ds-hero .wrapper .img,
-    .ds-column-12 .ds-hero .wrapper .img {
-      margin-bottom: 16px;
+    border-bottom: 1px solid var(--newtab-border-secondary-color); }
+    .ds-hero-no-border .wrapper {
+      border-top: 0;
+      border-bottom: 0;
+      padding: 0 0 8px; }
+    .ds-hero .wrapper:hover .meta header {
+      color: #0060DF; }
+    .ds-hero .wrapper:active .meta header {
+      color: #003EAA; }
+    .ds-hero .wrapper .img-wrapper {
+      width: 100%; }
+    .ds-hero .wrapper .img {
       height: 0;
       padding-top: 50%; }
-    .ds-column-9 .ds-hero .wrapper .meta,
-    .ds-column-10 .ds-hero .wrapper .meta,
-    .ds-column-11 .ds-hero .wrapper .meta,
-    .ds-column-12 .ds-hero .wrapper .meta {
-      flex-grow: 1;
-      display: flex;
-      padding: 0 24px 0 0; }
-      .ds-column-9 .ds-hero .wrapper .meta header,
-      .ds-column-10 .ds-hero .wrapper .meta header,
-      .ds-column-11 .ds-hero .wrapper .meta header,
-      .ds-column-12 .ds-hero .wrapper .meta header {
+    .ds-hero .wrapper .meta {
+      display: block;
+      flex-direction: column;
+      justify-content: space-between; }
+      .ds-hero .wrapper .meta header {
         font-size: 22px;
         line-height: 28px;
-        max-height: 3.81818em;
+        max-height: 5.09091em;
         overflow: hidden;
-        margin: 0 0 8px; }
-      .ds-column-9 .ds-hero .wrapper .meta .source,
-      .ds-column-10 .ds-hero .wrapper .meta .source,
-      .ds-column-11 .ds-hero .wrapper .meta .source,
-      .ds-column-12 .ds-hero .wrapper .meta .source {
-        margin-bottom: 0; }
-  .ds-column-9 .ds-hero .cards,
-  .ds-column-10 .ds-hero .cards,
-  .ds-column-11 .ds-hero .cards,
-  .ds-column-12 .ds-hero .cards {
+        color: #0C0C0D;
+        margin-bottom: 8px; }
+      .ds-hero .wrapper .meta .context {
+        color: #008EA4; }
+      .ds-hero .wrapper .meta .source {
+        font-size: 13px;
+        color: #005A71;
+        margin-bottom: 0;
+        overflow-x: hidden;
+        text-overflow: ellipsis; }
+  .ds-column-5 .ds-hero .wrapper,
+  .ds-column-6 .ds-hero .wrapper,
+  .ds-column-7 .ds-hero .wrapper,
+  .ds-column-8 .ds-hero .wrapper {
     display: grid;
     grid-template-columns: repeat(2, 1fr);
     grid-column-gap: 24px; }
-    .ds-column-9 .ds-hero .cards .title,
-    .ds-column-10 .ds-hero .cards .title,
-    .ds-column-11 .ds-hero .cards .title,
-    .ds-column-12 .ds-hero .cards .title {
-      font-size: 14px;
-      line-height: 20px;
-      max-height: 4.28571em;
-      overflow: hidden; }
+    .ds-column-5 .ds-hero .wrapper .img-wrapper,
+    .ds-column-6 .ds-hero .wrapper .img-wrapper,
+    .ds-column-7 .ds-hero .wrapper .img-wrapper,
+    .ds-column-8 .ds-hero .wrapper .img-wrapper {
+      margin: 0;
+      grid-column: 2;
+      grid-row: 1; }
+    .ds-column-5 .ds-hero .wrapper .meta,
+    .ds-column-6 .ds-hero .wrapper .meta,
+    .ds-column-7 .ds-hero .wrapper .meta,
+    .ds-column-8 .ds-hero .wrapper .meta {
+      grid-column: 1;
+      grid-row: 1;
+      display: flex; }
+    .ds-column-5 .ds-hero .wrapper .img,
+    .ds-column-6 .ds-hero .wrapper .img,
+    .ds-column-7 .ds-hero .wrapper .img,
+    .ds-column-8 .ds-hero .wrapper .img {
+      height: 0;
+      padding-top: 100%; }
+  .ds-column-5 .ds-hero .cards,
+  .ds-column-6 .ds-hero .cards,
+  .ds-column-7 .ds-hero .cards,
+  .ds-column-8 .ds-hero .cards {
+    display: grid;
+    grid-template-columns: repeat(2, 1fr);
+    grid-column-gap: 24px; }
+  .ds-column-9 .ds-hero,
+  .ds-column-10 .ds-hero,
+  .ds-column-11 .ds-hero,
+  .ds-column-12 .ds-hero {
+    display: grid;
+    grid-template-columns: repeat(2, 1fr);
+    grid-column-gap: 24px; }
+    .ds-column-9 .ds-hero.ds-hero-border,
+    .ds-column-10 .ds-hero.ds-hero-border,
+    .ds-column-11 .ds-hero.ds-hero-border,
+    .ds-column-12 .ds-hero.ds-hero-border {
+      border-top: 1px solid var(--newtab-border-secondary-color);
+      padding: 20px 0; }
+      .ds-column-9 .ds-hero.ds-hero-border .ds-card:nth-child(-n+2),
+      .ds-column-10 .ds-hero.ds-hero-border .ds-card:nth-child(-n+2),
+      .ds-column-11 .ds-hero.ds-hero-border .ds-card:nth-child(-n+2),
+      .ds-column-12 .ds-hero.ds-hero-border .ds-card:nth-child(-n+2) {
+        border-bottom: 1px solid var(--newtab-border-secondary-color);
+        margin-bottom: 20px; }
+    .ds-column-9 .ds-hero .wrapper,
+    .ds-column-10 .ds-hero .wrapper,
+    .ds-column-11 .ds-hero .wrapper,
+    .ds-column-12 .ds-hero .wrapper {
+      border-top: 0;
+      border-bottom: 0;
+      margin: 0;
+      padding: 0 0 20px;
+      display: flex;
+      flex-direction: column; }
+      .ds-column-9 .ds-hero .wrapper .img-wrapper,
+      .ds-column-10 .ds-hero .wrapper .img-wrapper,
+      .ds-column-11 .ds-hero .wrapper .img-wrapper,
+      .ds-column-12 .ds-hero .wrapper .img-wrapper {
+        margin: 0; }
+      .ds-column-9 .ds-hero .wrapper .img,
+      .ds-column-10 .ds-hero .wrapper .img,
+      .ds-column-11 .ds-hero .wrapper .img,
+      .ds-column-12 .ds-hero .wrapper .img {
+        margin-bottom: 16px;
+        height: 0;
+        padding-top: 50%; }
+      .ds-column-9 .ds-hero .wrapper .meta,
+      .ds-column-10 .ds-hero .wrapper .meta,
+      .ds-column-11 .ds-hero .wrapper .meta,
+      .ds-column-12 .ds-hero .wrapper .meta {
+        flex-grow: 1;
+        display: flex;
+        padding: 0 24px 0 0; }
+        .ds-column-9 .ds-hero .wrapper .meta header,
+        .ds-column-10 .ds-hero .wrapper .meta header,
+        .ds-column-11 .ds-hero .wrapper .meta header,
+        .ds-column-12 .ds-hero .wrapper .meta header {
+          font-size: 22px;
+          line-height: 28px;
+          max-height: 3.81818em;
+          overflow: hidden; }
+        .ds-column-9 .ds-hero .wrapper .meta .source,
+        .ds-column-10 .ds-hero .wrapper .meta .source,
+        .ds-column-11 .ds-hero .wrapper .meta .source,
+        .ds-column-12 .ds-hero .wrapper .meta .source {
+          margin-bottom: 0; }
+    .ds-column-9 .ds-hero .cards,
+    .ds-column-10 .ds-hero .cards,
+    .ds-column-11 .ds-hero .cards,
+    .ds-column-12 .ds-hero .cards {
+      display: grid;
+      grid-template-columns: repeat(2, 1fr);
+      grid-column-gap: 24px; }
+      .ds-column-9 .ds-hero .cards .title,
+      .ds-column-10 .ds-hero .cards .title,
+      .ds-column-11 .ds-hero .cards .title,
+      .ds-column-12 .ds-hero .cards .title {
+        font-size: 14px;
+        line-height: 20px;
+        max-height: 4.28571em;
+        overflow: hidden; }
 
 .ds-hr {
   border: 0;
   height: 0;
   border-top: 1px solid var(--newtab-border-secondary-color); }
 
 .ds-list {
   display: grid;
   grid-row-gap: 24px;
   grid-column-gap: 24px;
   padding-inline-start: 0; }
   .ds-list:not(.ds-list-full-width) .ds-list-item {
     font-size: 14px;
-    line-height: 20px; }
+    line-height: 20px;
+    position: relative; }
   .ds-list:not(.ds-list-full-width) .ds-list-item-title {
     font-size: 14px;
     line-height: 20px;
     max-height: 4.28571em;
     overflow: hidden; }
   .ds-list:not(.ds-list-full-width) .ds-list-image {
     min-width: 72px;
     width: 72px; }
@@ -2172,17 +2165,18 @@ main {
   .ds-column-11 .ds-list-borders:not(.ds-list-full-width) .ds-list-item:not(:nth-last-child(-n+3)),
   .ds-column-12 .ds-list-borders:not(.ds-list-full-width) .ds-list-item:not(:nth-last-child(-n+3)) {
     border-bottom: 1px solid var(--newtab-border-secondary-color);
     margin-bottom: -1px;
     padding-bottom: 16px; }
 
 .ds-list-full-width .ds-list-item {
   font-size: 17px;
-  line-height: 24px; }
+  line-height: 24px;
+  position: relative; }
 
 .ds-list-full-width .ds-list-item-title {
   font-size: 17px;
   line-height: 24px;
   max-height: 4.23529em;
   overflow: hidden; }
 
 .ds-list-full-width .ds-list-image {
@@ -2356,17 +2350,18 @@ main {
   .ds-column-1 .ds-top-sites .top-site-inner .title,
   .ds-column-2 .ds-top-sites .top-site-inner .title,
   .ds-column-3 .ds-top-sites .top-site-inner .title,
   .ds-column-4 .ds-top-sites .top-site-inner .title {
     width: var(--rightPanelIconWidth); }
 
 .ds-card {
   display: flex;
-  flex-direction: column; }
+  flex-direction: column;
+  position: relative; }
   .ds-card:hover header {
     color: #0060DF; }
   .ds-card:active header {
     color: #003EAA; }
   .ds-card .img-wrapper {
     width: 100%; }
   .ds-card .img {
     background-color: var(--newtab-card-placeholder-color);
@@ -2430,16 +2425,22 @@ main {
       font-weight: 600;
       padding-right: 12px; }
     .ds-message .title .link {
       line-height: 20px;
       font-size: 13px; }
       .ds-message .title .link:hover, .ds-message .title .link:focus {
         text-decoration: underline; }
 
+.impression-observer {
+  position: absolute;
+  width: 100%;
+  height: 100%;
+  pointer-events: none; }
+
 .ASRouterButton {
   font-weight: 600;
   font-size: 14px;
   white-space: nowrap;
   border-radius: 2px;
   border: 0;
   font-family: inherit;
   padding: 8px 15px;
--- a/browser/components/newtab/css/activity-stream-mac.css
+++ b/browser/components/newtab/css/activity-stream-mac.css
@@ -1889,225 +1889,218 @@ main {
     .ds-column-10 .ds-card-grid.ds-card-grid-divisible-by-4 .title,
     .ds-column-11 .ds-card-grid.ds-card-grid-divisible-by-4 .title,
     .ds-column-12 .ds-card-grid.ds-card-grid-divisible-by-4 .title {
       font-size: 14px;
       line-height: 20px;
       max-height: 4.28571em;
       overflow: hidden; }
 
-.ds-hero .img {
-  background-color: var(--newtab-card-placeholder-color);
-  background-position: center;
-  background-repeat: no-repeat;
-  background-size: cover;
-  border-radius: 4px;
-  box-shadow: inset 0 0 0 0.5px rgba(0, 0, 0, 0.15); }
-
-.ds-hero header {
-  font-weight: 600; }
-
-.ds-hero p {
-  line-height: 1.538;
-  margin: 8px 0; }
-
-.ds-hero .excerpt {
-  font-size: 14px;
-  line-height: 20px;
-  max-height: 4.28571em;
-  overflow: hidden;
-  margin: 4px 0 8px; }
-
-.ds-hero .ds-list {
-  border-top: 0;
-  padding-top: 0; }
-
-.ds-hero .ds-card {
-  border: 0;
-  padding-bottom: 20px; }
-  .ds-hero .ds-card p {
-    margin-top: 4px; }
-  .ds-hero .ds-card:hover {
+.ds-hero {
+  position: relative; }
+  .ds-hero .img {
+    background-color: var(--newtab-card-placeholder-color);
+    background-position: center;
+    background-repeat: no-repeat;
+    background-size: cover;
+    border-radius: 4px;
+    box-shadow: inset 0 0 0 0.5px rgba(0, 0, 0, 0.15); }
+  .ds-hero header {
+    font-weight: 600; }
+  .ds-hero p {
+    line-height: 1.538;
+    margin: 8px 0; }
+  .ds-hero .excerpt {
+    font-size: 14px;
+    line-height: 20px;
+    max-height: 4.28571em;
+    overflow: hidden;
+    margin: 4px 0 8px; }
+  .ds-hero .ds-list {
+    border-top: 0;
+    padding-top: 0; }
+  .ds-hero .ds-card {
     border: 0;
-    box-shadow: none;
-    border-radius: 0; }
-  .ds-hero .ds-card .meta {
-    padding: 0; }
-  .ds-hero .ds-card .img-wrapper {
+    padding-bottom: 20px; }
+    .ds-hero .ds-card p {
+      margin-top: 4px; }
+    .ds-hero .ds-card:hover {
+      border: 0;
+      box-shadow: none;
+      border-radius: 0; }
+    .ds-hero .ds-card .meta {
+      padding: 0; }
+    .ds-hero .ds-card .img-wrapper {
+      margin: 0 0 12px; }
+  .ds-hero .img-wrapper {
     margin: 0 0 12px; }
-
-.ds-hero .img-wrapper {
-  margin: 0 0 12px; }
-
-.ds-hero .wrapper {
-  color: #737373;
-  display: block;
-  margin: 12px 0 16px;
-  padding: 16px 0;
-  border-top: 1px solid var(--newtab-border-secondary-color);
-  border-bottom: 1px solid var(--newtab-border-secondary-color); }
-  .ds-hero-no-border .wrapper {
-    border-top: 0;
-    border-bottom: 0;
-    padding: 0 0 8px; }
-  .ds-hero .wrapper:hover .meta header {
-    color: #0060DF; }
-  .ds-hero .wrapper:active .meta header {
-    color: #003EAA; }
-  .ds-hero .wrapper .img-wrapper {
-    width: 100%; }
-  .ds-hero .wrapper .img {
-    height: 0;
-    padding-top: 50%; }
-  .ds-hero .wrapper .meta {
+  .ds-hero .wrapper {
+    color: #737373;
     display: block;
-    flex-direction: column;
-    justify-content: space-between; }
-    .ds-hero .wrapper .meta header {
-      font-size: 22px;
-      line-height: 28px;
-      max-height: 5.09091em;
-      overflow: hidden;
-      color: #0C0C0D; }
-    .ds-hero .wrapper .meta .context {
-      color: #008EA4; }
-    .ds-hero .wrapper .meta .source {
-      font-size: 13px;
-      color: #005A71;
-      margin-bottom: 0;
-      overflow-x: hidden;
-      text-overflow: ellipsis; }
-
-.ds-column-5 .ds-hero .wrapper,
-.ds-column-6 .ds-hero .wrapper,
-.ds-column-7 .ds-hero .wrapper,
-.ds-column-8 .ds-hero .wrapper {
-  display: grid;
-  grid-template-columns: repeat(2, 1fr);
-  grid-column-gap: 24px; }
-  .ds-column-5 .ds-hero .wrapper .img-wrapper,
-  .ds-column-6 .ds-hero .wrapper .img-wrapper,
-  .ds-column-7 .ds-hero .wrapper .img-wrapper,
-  .ds-column-8 .ds-hero .wrapper .img-wrapper {
-    margin: 0;
-    grid-column: 2;
-    grid-row: 1; }
-  .ds-column-5 .ds-hero .wrapper .meta,
-  .ds-column-6 .ds-hero .wrapper .meta,
-  .ds-column-7 .ds-hero .wrapper .meta,
-  .ds-column-8 .ds-hero .wrapper .meta {
-    grid-column: 1;
-    grid-row: 1;
-    display: flex; }
-  .ds-column-5 .ds-hero .wrapper .img,
-  .ds-column-6 .ds-hero .wrapper .img,
-  .ds-column-7 .ds-hero .wrapper .img,
-  .ds-column-8 .ds-hero .wrapper .img {
-    height: 0;
-    padding-top: 100%; }
-
-.ds-column-5 .ds-hero .cards,
-.ds-column-6 .ds-hero .cards,
-.ds-column-7 .ds-hero .cards,
-.ds-column-8 .ds-hero .cards {
-  display: grid;
-  grid-template-columns: repeat(2, 1fr);
-  grid-column-gap: 24px; }
-
-.ds-column-9 .ds-hero,
-.ds-column-10 .ds-hero,
-.ds-column-11 .ds-hero,
-.ds-column-12 .ds-hero {
-  display: grid;
-  grid-template-columns: repeat(2, 1fr);
-  grid-column-gap: 24px; }
-  .ds-column-9 .ds-hero.ds-hero-border,
-  .ds-column-10 .ds-hero.ds-hero-border,
-  .ds-column-11 .ds-hero.ds-hero-border,
-  .ds-column-12 .ds-hero.ds-hero-border {
+    margin: 12px 0 16px;
+    padding: 16px 0;
     border-top: 1px solid var(--newtab-border-secondary-color);
-    padding: 20px 0; }
-    .ds-column-9 .ds-hero.ds-hero-border .ds-card:nth-child(-n+2),
-    .ds-column-10 .ds-hero.ds-hero-border .ds-card:nth-child(-n+2),
-    .ds-column-11 .ds-hero.ds-hero-border .ds-card:nth-child(-n+2),
-    .ds-column-12 .ds-hero.ds-hero-border .ds-card:nth-child(-n+2) {
-      border-bottom: 1px solid var(--newtab-border-secondary-color);
-      margin-bottom: 20px; }
-  .ds-column-9 .ds-hero .wrapper,
-  .ds-column-10 .ds-hero .wrapper,
-  .ds-column-11 .ds-hero .wrapper,
-  .ds-column-12 .ds-hero .wrapper {
-    border-top: 0;
-    border-bottom: 0;
-    margin: 0;
-    padding: 0 0 20px;
-    display: flex;
-    flex-direction: column; }
-    .ds-column-9 .ds-hero .wrapper .img-wrapper,
-    .ds-column-10 .ds-hero .wrapper .img-wrapper,
-    .ds-column-11 .ds-hero .wrapper .img-wrapper,
-    .ds-column-12 .ds-hero .wrapper .img-wrapper {
-      margin: 0; }
-    .ds-column-9 .ds-hero .wrapper .img,
-    .ds-column-10 .ds-hero .wrapper .img,
-    .ds-column-11 .ds-hero .wrapper .img,
-    .ds-column-12 .ds-hero .wrapper .img {
-      margin-bottom: 16px;
+    border-bottom: 1px solid var(--newtab-border-secondary-color); }
+    .ds-hero-no-border .wrapper {
+      border-top: 0;
+      border-bottom: 0;
+      padding: 0 0 8px; }
+    .ds-hero .wrapper:hover .meta header {
+      color: #0060DF; }
+    .ds-hero .wrapper:active .meta header {
+      color: #003EAA; }
+    .ds-hero .wrapper .img-wrapper {
+      width: 100%; }
+    .ds-hero .wrapper .img {
       height: 0;
       padding-top: 50%; }
-    .ds-column-9 .ds-hero .wrapper .meta,
-    .ds-column-10 .ds-hero .wrapper .meta,
-    .ds-column-11 .ds-hero .wrapper .meta,
-    .ds-column-12 .ds-hero .wrapper .meta {
-      flex-grow: 1;
-      display: flex;
-      padding: 0 24px 0 0; }
-      .ds-column-9 .ds-hero .wrapper .meta header,
-      .ds-column-10 .ds-hero .wrapper .meta header,
-      .ds-column-11 .ds-hero .wrapper .meta header,
-      .ds-column-12 .ds-hero .wrapper .meta header {
+    .ds-hero .wrapper .meta {
+      display: block;
+      flex-direction: column;
+      justify-content: space-between; }
+      .ds-hero .wrapper .meta header {
         font-size: 22px;
         line-height: 28px;
-        max-height: 3.81818em;
+        max-height: 5.09091em;
         overflow: hidden;
-        margin: 0 0 8px; }
-      .ds-column-9 .ds-hero .wrapper .meta .source,
-      .ds-column-10 .ds-hero .wrapper .meta .source,
-      .ds-column-11 .ds-hero .wrapper .meta .source,
-      .ds-column-12 .ds-hero .wrapper .meta .source {
-        margin-bottom: 0; }
-  .ds-column-9 .ds-hero .cards,
-  .ds-column-10 .ds-hero .cards,
-  .ds-column-11 .ds-hero .cards,
-  .ds-column-12 .ds-hero .cards {
+        color: #0C0C0D;
+        margin-bottom: 8px; }
+      .ds-hero .wrapper .meta .context {
+        color: #008EA4; }
+      .ds-hero .wrapper .meta .source {
+        font-size: 13px;
+        color: #005A71;
+        margin-bottom: 0;
+        overflow-x: hidden;
+        text-overflow: ellipsis; }
+  .ds-column-5 .ds-hero .wrapper,
+  .ds-column-6 .ds-hero .wrapper,
+  .ds-column-7 .ds-hero .wrapper,
+  .ds-column-8 .ds-hero .wrapper {
     display: grid;
     grid-template-columns: repeat(2, 1fr);
     grid-column-gap: 24px; }
-    .ds-column-9 .ds-hero .cards .title,
-    .ds-column-10 .ds-hero .cards .title,
-    .ds-column-11 .ds-hero .cards .title,
-    .ds-column-12 .ds-hero .cards .title {
-      font-size: 14px;
-      line-height: 20px;
-      max-height: 4.28571em;
-      overflow: hidden; }
+    .ds-column-5 .ds-hero .wrapper .img-wrapper,
+    .ds-column-6 .ds-hero .wrapper .img-wrapper,
+    .ds-column-7 .ds-hero .wrapper .img-wrapper,
+    .ds-column-8 .ds-hero .wrapper .img-wrapper {
+      margin: 0;
+      grid-column: 2;
+      grid-row: 1; }
+    .ds-column-5 .ds-hero .wrapper .meta,
+    .ds-column-6 .ds-hero .wrapper .meta,
+    .ds-column-7 .ds-hero .wrapper .meta,
+    .ds-column-8 .ds-hero .wrapper .meta {
+      grid-column: 1;
+      grid-row: 1;
+      display: flex; }
+    .ds-column-5 .ds-hero .wrapper .img,
+    .ds-column-6 .ds-hero .wrapper .img,
+    .ds-column-7 .ds-hero .wrapper .img,
+    .ds-column-8 .ds-hero .wrapper .img {
+      height: 0;
+      padding-top: 100%; }
+  .ds-column-5 .ds-hero .cards,
+  .ds-column-6 .ds-hero .cards,
+  .ds-column-7 .ds-hero .cards,
+  .ds-column-8 .ds-hero .cards {
+    display: grid;
+    grid-template-columns: repeat(2, 1fr);
+    grid-column-gap: 24px; }
+  .ds-column-9 .ds-hero,
+  .ds-column-10 .ds-hero,
+  .ds-column-11 .ds-hero,
+  .ds-column-12 .ds-hero {
+    display: grid;
+    grid-template-columns: repeat(2, 1fr);
+    grid-column-gap: 24px; }
+    .ds-column-9 .ds-hero.ds-hero-border,
+    .ds-column-10 .ds-hero.ds-hero-border,
+    .ds-column-11 .ds-hero.ds-hero-border,
+    .ds-column-12 .ds-hero.ds-hero-border {
+      border-top: 1px solid var(--newtab-border-secondary-color);
+      padding: 20px 0; }
+      .ds-column-9 .ds-hero.ds-hero-border .ds-card:nth-child(-n+2),
+      .ds-column-10 .ds-hero.ds-hero-border .ds-card:nth-child(-n+2),
+      .ds-column-11 .ds-hero.ds-hero-border .ds-card:nth-child(-n+2),
+      .ds-column-12 .ds-hero.ds-hero-border .ds-card:nth-child(-n+2) {
+        border-bottom: 1px solid var(--newtab-border-secondary-color);
+        margin-bottom: 20px; }
+    .ds-column-9 .ds-hero .wrapper,
+    .ds-column-10 .ds-hero .wrapper,
+    .ds-column-11 .ds-hero .wrapper,
+    .ds-column-12 .ds-hero .wrapper {
+      border-top: 0;
+      border-bottom: 0;
+      margin: 0;
+      padding: 0 0 20px;
+      display: flex;
+      flex-direction: column; }
+      .ds-column-9 .ds-hero .wrapper .img-wrapper,
+      .ds-column-10 .ds-hero .wrapper .img-wrapper,
+      .ds-column-11 .ds-hero .wrapper .img-wrapper,
+      .ds-column-12 .ds-hero .wrapper .img-wrapper {
+        margin: 0; }
+      .ds-column-9 .ds-hero .wrapper .img,
+      .ds-column-10 .ds-hero .wrapper .img,
+      .ds-column-11 .ds-hero .wrapper .img,
+      .ds-column-12 .ds-hero .wrapper .img {
+        margin-bottom: 16px;
+        height: 0;
+        padding-top: 50%; }
+      .ds-column-9 .ds-hero .wrapper .meta,
+      .ds-column-10 .ds-hero .wrapper .meta,
+      .ds-column-11 .ds-hero .wrapper .meta,
+      .ds-column-12 .ds-hero .wrapper .meta {
+        flex-grow: 1;
+        display: flex;
+        padding: 0 24px 0 0; }
+        .ds-column-9 .ds-hero .wrapper .meta header,
+        .ds-column-10 .ds-hero .wrapper .meta header,
+        .ds-column-11 .ds-hero .wrapper .meta header,
+        .ds-column-12 .ds-hero .wrapper .meta header {
+          font-size: 22px;
+          line-height: 28px;
+          max-height: 3.81818em;
+          overflow: hidden; }
+        .ds-column-9 .ds-hero .wrapper .meta .source,
+        .ds-column-10 .ds-hero .wrapper .meta .source,
+        .ds-column-11 .ds-hero .wrapper .meta .source,
+        .ds-column-12 .ds-hero .wrapper .meta .source {
+          margin-bottom: 0; }
+    .ds-column-9 .ds-hero .cards,
+    .ds-column-10 .ds-hero .cards,
+    .ds-column-11 .ds-hero .cards,
+    .ds-column-12 .ds-hero .cards {
+      display: grid;
+      grid-template-columns: repeat(2, 1fr);
+      grid-column-gap: 24px; }
+      .ds-column-9 .ds-hero .cards .title,
+      .ds-column-10 .ds-hero .cards .title,
+      .ds-column-11 .ds-hero .cards .title,
+      .ds-column-12 .ds-hero .cards .title {
+        font-size: 14px;
+        line-height: 20px;
+        max-height: 4.28571em;
+        overflow: hidden; }
 
 .ds-hr {
   border: 0;
   height: 0;
   border-top: 1px solid var(--newtab-border-secondary-color); }
 
 .ds-list {
   display: grid;
   grid-row-gap: 24px;
   grid-column-gap: 24px;
   padding-inline-start: 0; }
   .ds-list:not(.ds-list-full-width) .ds-list-item {
     font-size: 14px;
-    line-height: 20px; }
+    line-height: 20px;
+    position: relative; }
   .ds-list:not(.ds-list-full-width) .ds-list-item-title {
     font-size: 14px;
     line-height: 20px;
     max-height: 4.28571em;
     overflow: hidden; }
   .ds-list:not(.ds-list-full-width) .ds-list-image {
     min-width: 72px;
     width: 72px; }
@@ -2175,17 +2168,18 @@ main {
   .ds-column-11 .ds-list-borders:not(.ds-list-full-width) .ds-list-item:not(:nth-last-child(-n+3)),
   .ds-column-12 .ds-list-borders:not(.ds-list-full-width) .ds-list-item:not(:nth-last-child(-n+3)) {
     border-bottom: 1px solid var(--newtab-border-secondary-color);
     margin-bottom: -1px;
     padding-bottom: 16px; }
 
 .ds-list-full-width .ds-list-item {
   font-size: 17px;
-  line-height: 24px; }
+  line-height: 24px;
+  position: relative; }
 
 .ds-list-full-width .ds-list-item-title {
   font-size: 17px;
   line-height: 24px;
   max-height: 4.23529em;
   overflow: hidden; }
 
 .ds-list-full-width .ds-list-image {
@@ -2359,17 +2353,18 @@ main {
   .ds-column-1 .ds-top-sites .top-site-inner .title,
   .ds-column-2 .ds-top-sites .top-site-inner .title,
   .ds-column-3 .ds-top-sites .top-site-inner .title,
   .ds-column-4 .ds-top-sites .top-site-inner .title {
     width: var(--rightPanelIconWidth); }
 
 .ds-card {
   display: flex;
-  flex-direction: column; }
+  flex-direction: column;
+  position: relative; }
   .ds-card:hover header {
     color: #0060DF; }
   .ds-card:active header {
     color: #003EAA; }
   .ds-card .img-wrapper {
     width: 100%; }
   .ds-card .img {
     background-color: var(--newtab-card-placeholder-color);
@@ -2433,16 +2428,22 @@ main {
       font-weight: 600;
       padding-right: 12px; }
     .ds-message .title .link {
       line-height: 20px;
       font-size: 13px; }
       .ds-message .title .link:hover, .ds-message .title .link:focus {
         text-decoration: underline; }
 
+.impression-observer {
+  position: absolute;
+  width: 100%;
+  height: 100%;
+  pointer-events: none; }
+
 .ASRouterButton {
   font-weight: 600;
   font-size: 14px;
   white-space: nowrap;
   border-radius: 2px;
   border: 0;
   font-family: inherit;
   padding: 8px 15px;
--- a/browser/components/newtab/css/activity-stream-windows.css
+++ b/browser/components/newtab/css/activity-stream-windows.css
@@ -1886,225 +1886,218 @@ main {
     .ds-column-10 .ds-card-grid.ds-card-grid-divisible-by-4 .title,
     .ds-column-11 .ds-card-grid.ds-card-grid-divisible-by-4 .title,
     .ds-column-12 .ds-card-grid.ds-card-grid-divisible-by-4 .title {
       font-size: 14px;
       line-height: 20px;
       max-height: 4.28571em;
       overflow: hidden; }
 
-.ds-hero .img {
-  background-color: var(--newtab-card-placeholder-color);
-  background-position: center;
-  background-repeat: no-repeat;
-  background-size: cover;
-  border-radius: 4px;
-  box-shadow: inset 0 0 0 0.5px rgba(0, 0, 0, 0.15); }
-
-.ds-hero header {
-  font-weight: 600; }
-
-.ds-hero p {
-  line-height: 1.538;
-  margin: 8px 0; }
-
-.ds-hero .excerpt {
-  font-size: 14px;
-  line-height: 20px;
-  max-height: 4.28571em;
-  overflow: hidden;
-  margin: 4px 0 8px; }
-
-.ds-hero .ds-list {
-  border-top: 0;
-  padding-top: 0; }
-
-.ds-hero .ds-card {
-  border: 0;
-  padding-bottom: 20px; }
-  .ds-hero .ds-card p {
-    margin-top: 4px; }
-  .ds-hero .ds-card:hover {
+.ds-hero {
+  position: relative; }
+  .ds-hero .img {
+    background-color: var(--newtab-card-placeholder-color);
+    background-position: center;
+    background-repeat: no-repeat;
+    background-size: cover;
+    border-radius: 4px;
+    box-shadow: inset 0 0 0 0.5px rgba(0, 0, 0, 0.15); }
+  .ds-hero header {
+    font-weight: 600; }
+  .ds-hero p {
+    line-height: 1.538;
+    margin: 8px 0; }
+  .ds-hero .excerpt {
+    font-size: 14px;
+    line-height: 20px;
+    max-height: 4.28571em;
+    overflow: hidden;
+    margin: 4px 0 8px; }
+  .ds-hero .ds-list {
+    border-top: 0;
+    padding-top: 0; }
+  .ds-hero .ds-card {
     border: 0;
-    box-shadow: none;
-    border-radius: 0; }
-  .ds-hero .ds-card .meta {
-    padding: 0; }
-  .ds-hero .ds-card .img-wrapper {
+    padding-bottom: 20px; }
+    .ds-hero .ds-card p {
+      margin-top: 4px; }
+    .ds-hero .ds-card:hover {
+      border: 0;
+      box-shadow: none;
+      border-radius: 0; }
+    .ds-hero .ds-card .meta {
+      padding: 0; }
+    .ds-hero .ds-card .img-wrapper {
+      margin: 0 0 12px; }
+  .ds-hero .img-wrapper {
     margin: 0 0 12px; }
-
-.ds-hero .img-wrapper {
-  margin: 0 0 12px; }
-
-.ds-hero .wrapper {
-  color: #737373;
-  display: block;
-  margin: 12px 0 16px;
-  padding: 16px 0;
-  border-top: 1px solid var(--newtab-border-secondary-color);
-  border-bottom: 1px solid var(--newtab-border-secondary-color); }
-  .ds-hero-no-border .wrapper {
-    border-top: 0;
-    border-bottom: 0;
-    padding: 0 0 8px; }
-  .ds-hero .wrapper:hover .meta header {
-    color: #0060DF; }
-  .ds-hero .wrapper:active .meta header {
-    color: #003EAA; }
-  .ds-hero .wrapper .img-wrapper {
-    width: 100%; }
-  .ds-hero .wrapper .img {
-    height: 0;
-    padding-top: 50%; }
-  .ds-hero .wrapper .meta {
+  .ds-hero .wrapper {
+    color: #737373;
     display: block;
-    flex-direction: column;
-    justify-content: space-between; }
-    .ds-hero .wrapper .meta header {
-      font-size: 22px;
-      line-height: 28px;
-      max-height: 5.09091em;
-      overflow: hidden;
-      color: #0C0C0D; }
-    .ds-hero .wrapper .meta .context {
-      color: #008EA4; }
-    .ds-hero .wrapper .meta .source {
-      font-size: 13px;
-      color: #005A71;
-      margin-bottom: 0;
-      overflow-x: hidden;
-      text-overflow: ellipsis; }
-
-.ds-column-5 .ds-hero .wrapper,
-.ds-column-6 .ds-hero .wrapper,
-.ds-column-7 .ds-hero .wrapper,
-.ds-column-8 .ds-hero .wrapper {
-  display: grid;
-  grid-template-columns: repeat(2, 1fr);
-  grid-column-gap: 24px; }
-  .ds-column-5 .ds-hero .wrapper .img-wrapper,
-  .ds-column-6 .ds-hero .wrapper .img-wrapper,
-  .ds-column-7 .ds-hero .wrapper .img-wrapper,
-  .ds-column-8 .ds-hero .wrapper .img-wrapper {
-    margin: 0;
-    grid-column: 2;
-    grid-row: 1; }
-  .ds-column-5 .ds-hero .wrapper .meta,
-  .ds-column-6 .ds-hero .wrapper .meta,
-  .ds-column-7 .ds-hero .wrapper .meta,
-  .ds-column-8 .ds-hero .wrapper .meta {
-    grid-column: 1;
-    grid-row: 1;
-    display: flex; }
-  .ds-column-5 .ds-hero .wrapper .img,
-  .ds-column-6 .ds-hero .wrapper .img,
-  .ds-column-7 .ds-hero .wrapper .img,
-  .ds-column-8 .ds-hero .wrapper .img {
-    height: 0;
-    padding-top: 100%; }
-
-.ds-column-5 .ds-hero .cards,
-.ds-column-6 .ds-hero .cards,
-.ds-column-7 .ds-hero .cards,
-.ds-column-8 .ds-hero .cards {
-  display: grid;
-  grid-template-columns: repeat(2, 1fr);
-  grid-column-gap: 24px; }
-
-.ds-column-9 .ds-hero,
-.ds-column-10 .ds-hero,
-.ds-column-11 .ds-hero,
-.ds-column-12 .ds-hero {
-  display: grid;
-  grid-template-columns: repeat(2, 1fr);
-  grid-column-gap: 24px; }
-  .ds-column-9 .ds-hero.ds-hero-border,
-  .ds-column-10 .ds-hero.ds-hero-border,
-  .ds-column-11 .ds-hero.ds-hero-border,
-  .ds-column-12 .ds-hero.ds-hero-border {
+    margin: 12px 0 16px;
+    padding: 16px 0;
     border-top: 1px solid var(--newtab-border-secondary-color);
-    padding: 20px 0; }
-    .ds-column-9 .ds-hero.ds-hero-border .ds-card:nth-child(-n+2),
-    .ds-column-10 .ds-hero.ds-hero-border .ds-card:nth-child(-n+2),
-    .ds-column-11 .ds-hero.ds-hero-border .ds-card:nth-child(-n+2),
-    .ds-column-12 .ds-hero.ds-hero-border .ds-card:nth-child(-n+2) {
-      border-bottom: 1px solid var(--newtab-border-secondary-color);
-      margin-bottom: 20px; }
-  .ds-column-9 .ds-hero .wrapper,
-  .ds-column-10 .ds-hero .wrapper,
-  .ds-column-11 .ds-hero .wrapper,
-  .ds-column-12 .ds-hero .wrapper {
-    border-top: 0;
-    border-bottom: 0;
-    margin: 0;
-    padding: 0 0 20px;
-    display: flex;
-    flex-direction: column; }
-    .ds-column-9 .ds-hero .wrapper .img-wrapper,
-    .ds-column-10 .ds-hero .wrapper .img-wrapper,
-    .ds-column-11 .ds-hero .wrapper .img-wrapper,
-    .ds-column-12 .ds-hero .wrapper .img-wrapper {
-      margin: 0; }
-    .ds-column-9 .ds-hero .wrapper .img,
-    .ds-column-10 .ds-hero .wrapper .img,
-    .ds-column-11 .ds-hero .wrapper .img,
-    .ds-column-12 .ds-hero .wrapper .img {
-      margin-bottom: 16px;
+    border-bottom: 1px solid var(--newtab-border-secondary-color); }
+    .ds-hero-no-border .wrapper {
+      border-top: 0;
+      border-bottom: 0;
+      padding: 0 0 8px; }
+    .ds-hero .wrapper:hover .meta header {
+      color: #0060DF; }
+    .ds-hero .wrapper:active .meta header {
+      color: #003EAA; }
+    .ds-hero .wrapper .img-wrapper {
+      width: 100%; }
+    .ds-hero .wrapper .img {
       height: 0;
       padding-top: 50%; }
-    .ds-column-9 .ds-hero .wrapper .meta,
-    .ds-column-10 .ds-hero .wrapper .meta,
-    .ds-column-11 .ds-hero .wrapper .meta,
-    .ds-column-12 .ds-hero .wrapper .meta {
-      flex-grow: 1;
-      display: flex;
-      padding: 0 24px 0 0; }
-      .ds-column-9 .ds-hero .wrapper .meta header,
-      .ds-column-10 .ds-hero .wrapper .meta header,
-      .ds-column-11 .ds-hero .wrapper .meta header,
-      .ds-column-12 .ds-hero .wrapper .meta header {
+    .ds-hero .wrapper .meta {
+      display: block;
+      flex-direction: column;
+      justify-content: space-between; }
+      .ds-hero .wrapper .meta header {
         font-size: 22px;
         line-height: 28px;
-        max-height: 3.81818em;
+        max-height: 5.09091em;
         overflow: hidden;
-        margin: 0 0 8px; }
-      .ds-column-9 .ds-hero .wrapper .meta .source,
-      .ds-column-10 .ds-hero .wrapper .meta .source,
-      .ds-column-11 .ds-hero .wrapper .meta .source,
-      .ds-column-12 .ds-hero .wrapper .meta .source {
-        margin-bottom: 0; }
-  .ds-column-9 .ds-hero .cards,
-  .ds-column-10 .ds-hero .cards,
-  .ds-column-11 .ds-hero .cards,
-  .ds-column-12 .ds-hero .cards {
+        color: #0C0C0D;
+        margin-bottom: 8px; }
+      .ds-hero .wrapper .meta .context {
+        color: #008EA4; }
+      .ds-hero .wrapper .meta .source {
+        font-size: 13px;
+        color: #005A71;
+        margin-bottom: 0;
+        overflow-x: hidden;
+        text-overflow: ellipsis; }
+  .ds-column-5 .ds-hero .wrapper,
+  .ds-column-6 .ds-hero .wrapper,
+  .ds-column-7 .ds-hero .wrapper,
+  .ds-column-8 .ds-hero .wrapper {
     display: grid;
     grid-template-columns: repeat(2, 1fr);
     grid-column-gap: 24px; }
-    .ds-column-9 .ds-hero .cards .title,
-    .ds-column-10 .ds-hero .cards .title,
-    .ds-column-11 .ds-hero .cards .title,
-    .ds-column-12 .ds-hero .cards .title {
-      font-size: 14px;
-      line-height: 20px;
-      max-height: 4.28571em;
-      overflow: hidden; }
+    .ds-column-5 .ds-hero .wrapper .img-wrapper,
+    .ds-column-6 .ds-hero .wrapper .img-wrapper,
+    .ds-column-7 .ds-hero .wrapper .img-wrapper,
+    .ds-column-8 .ds-hero .wrapper .img-wrapper {
+      margin: 0;
+      grid-column: 2;
+      grid-row: 1; }
+    .ds-column-5 .ds-hero .wrapper .meta,
+    .ds-column-6 .ds-hero .wrapper .meta,
+    .ds-column-7 .ds-hero .wrapper .meta,
+    .ds-column-8 .ds-hero .wrapper .meta {
+      grid-column: 1;
+      grid-row: 1;
+      display: flex; }
+    .ds-column-5 .ds-hero .wrapper .img,
+    .ds-column-6 .ds-hero .wrapper .img,
+    .ds-column-7 .ds-hero .wrapper .img,
+    .ds-column-8 .ds-hero .wrapper .img {
+      height: 0;
+      padding-top: 100%; }
+  .ds-column-5 .ds-hero .cards,
+  .ds-column-6 .ds-hero .cards,
+  .ds-column-7 .ds-hero .cards,
+  .ds-column-8 .ds-hero .cards {
+    display: grid;
+    grid-template-columns: repeat(2, 1fr);
+    grid-column-gap: 24px; }
+  .ds-column-9 .ds-hero,
+  .ds-column-10 .ds-hero,
+  .ds-column-11 .ds-hero,
+  .ds-column-12 .ds-hero {
+    display: grid;
+    grid-template-columns: repeat(2, 1fr);
+    grid-column-gap: 24px; }
+    .ds-column-9 .ds-hero.ds-hero-border,
+    .ds-column-10 .ds-hero.ds-hero-border,
+    .ds-column-11 .ds-hero.ds-hero-border,
+    .ds-column-12 .ds-hero.ds-hero-border {
+      border-top: 1px solid var(--newtab-border-secondary-color);
+      padding: 20px 0; }
+      .ds-column-9 .ds-hero.ds-hero-border .ds-card:nth-child(-n+2),
+      .ds-column-10 .ds-hero.ds-hero-border .ds-card:nth-child(-n+2),
+      .ds-column-11 .ds-hero.ds-hero-border .ds-card:nth-child(-n+2),
+      .ds-column-12 .ds-hero.ds-hero-border .ds-card:nth-child(-n+2) {
+        border-bottom: 1px solid var(--newtab-border-secondary-color);
+        margin-bottom: 20px; }
+    .ds-column-9 .ds-hero .wrapper,
+    .ds-column-10 .ds-hero .wrapper,
+    .ds-column-11 .ds-hero .wrapper,
+    .ds-column-12 .ds-hero .wrapper {
+      border-top: 0;
+      border-bottom: 0;
+      margin: 0;
+      padding: 0 0 20px;
+      display: flex;
+      flex-direction: column; }
+      .ds-column-9 .ds-hero .wrapper .img-wrapper,
+      .ds-column-10 .ds-hero .wrapper .img-wrapper,
+      .ds-column-11 .ds-hero .wrapper .img-wrapper,
+      .ds-column-12 .ds-hero .wrapper .img-wrapper {
+        margin: 0; }
+      .ds-column-9 .ds-hero .wrapper .img,
+      .ds-column-10 .ds-hero .wrapper .img,
+      .ds-column-11 .ds-hero .wrapper .img,
+      .ds-column-12 .ds-hero .wrapper .img {
+        margin-bottom: 16px;
+        height: 0;
+        padding-top: 50%; }
+      .ds-column-9 .ds-hero .wrapper .meta,
+      .ds-column-10 .ds-hero .wrapper .meta,
+      .ds-column-11 .ds-hero .wrapper .meta,
+      .ds-column-12 .ds-hero .wrapper .meta {
+        flex-grow: 1;
+        display: flex;
+        padding: 0 24px 0 0; }
+        .ds-column-9 .ds-hero .wrapper .meta header,
+        .ds-column-10 .ds-hero .wrapper .meta header,
+        .ds-column-11 .ds-hero .wrapper .meta header,
+        .ds-column-12 .ds-hero .wrapper .meta header {
+          font-size: 22px;
+          line-height: 28px;
+          max-height: 3.81818em;
+          overflow: hidden; }
+        .ds-column-9 .ds-hero .wrapper .meta .source,
+        .ds-column-10 .ds-hero .wrapper .meta .source,
+        .ds-column-11 .ds-hero .wrapper .meta .source,
+        .ds-column-12 .ds-hero .wrapper .meta .source {
+          margin-bottom: 0; }
+    .ds-column-9 .ds-hero .cards,
+    .ds-column-10 .ds-hero .cards,
+    .ds-column-11 .ds-hero .cards,
+    .ds-column-12 .ds-hero .cards {
+      display: grid;
+      grid-template-columns: repeat(2, 1fr);
+      grid-column-gap: 24px; }
+      .ds-column-9 .ds-hero .cards .title,
+      .ds-column-10 .ds-hero .cards .title,
+      .ds-column-11 .ds-hero .cards .title,
+      .ds-column-12 .ds-hero .cards .title {
+        font-size: 14px;
+        line-height: 20px;
+        max-height: 4.28571em;
+        overflow: hidden; }
 
 .ds-hr {
   border: 0;
   height: 0;
   border-top: 1px solid var(--newtab-border-secondary-color); }
 
 .ds-list {
   display: grid;
   grid-row-gap: 24px;
   grid-column-gap: 24px;
   padding-inline-start: 0; }
   .ds-list:not(.ds-list-full-width) .ds-list-item {
     font-size: 14px;
-    line-height: 20px; }
+    line-height: 20px;
+    position: relative; }
   .ds-list:not(.ds-list-full-width) .ds-list-item-title {
     font-size: 14px;
     line-height: 20px;
     max-height: 4.28571em;
     overflow: hidden; }
   .ds-list:not(.ds-list-full-width) .ds-list-image {
     min-width: 72px;
     width: 72px; }
@@ -2172,17 +2165,18 @@ main {
   .ds-column-11 .ds-list-borders:not(.ds-list-full-width) .ds-list-item:not(:nth-last-child(-n+3)),
   .ds-column-12 .ds-list-borders:not(.ds-list-full-width) .ds-list-item:not(:nth-last-child(-n+3)) {
     border-bottom: 1px solid var(--newtab-border-secondary-color);
     margin-bottom: -1px;
     padding-bottom: 16px; }
 
 .ds-list-full-width .ds-list-item {
   font-size: 17px;
-  line-height: 24px; }
+  line-height: 24px;
+  position: relative; }
 
 .ds-list-full-width .ds-list-item-title {
   font-size: 17px;
   line-height: 24px;
   max-height: 4.23529em;
   overflow: hidden; }
 
 .ds-list-full-width .ds-list-image {
@@ -2356,17 +2350,18 @@ main {
   .ds-column-1 .ds-top-sites .top-site-inner .title,
   .ds-column-2 .ds-top-sites .top-site-inner .title,
   .ds-column-3 .ds-top-sites .top-site-inner .title,
   .ds-column-4 .ds-top-sites .top-site-inner .title {
     width: var(--rightPanelIconWidth); }
 
 .ds-card {
   display: flex;
-  flex-direction: column; }
+  flex-direction: column;
+  position: relative; }
   .ds-card:hover header {
     color: #0060DF; }
   .ds-card:active header {
     color: #003EAA; }
   .ds-card .img-wrapper {
     width: 100%; }
   .ds-card .img {
     background-color: var(--newtab-card-placeholder-color);
@@ -2430,16 +2425,22 @@ main {
       font-weight: 600;
       padding-right: 12px; }
     .ds-message .title .link {
       line-height: 20px;
       font-size: 13px; }
       .ds-message .title .link:hover, .ds-message .title .link:focus {
         text-decoration: underline; }
 
+.impression-observer {
+  position: absolute;
+  width: 100%;
+  height: 100%;
+  pointer-events: none; }
+
 .ASRouterButton {
   font-weight: 600;
   font-size: 14px;
   white-space: nowrap;
   border-radius: 2px;
   border: 0;
   font-family: inherit;
   padding: 8px 15px;
--- a/browser/components/newtab/data/content/activity-stream.bundle.js
+++ b/browser/components/newtab/data/content/activity-stream.bundle.js
@@ -89,26 +89,26 @@
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* WEBPACK VAR INJECTION */(function(global) {/* harmony import */ var common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(2);
 /* harmony import */ var content_src_lib_snippets__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(3);
 /* harmony import */ var content_src_asrouter_asrouter_content__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(4);
 /* harmony import */ var content_src_components_Base_Base__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(25);
-/* harmony import */ var content_src_lib_detect_user_session_start__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(53);
-/* harmony import */ var content_src_lib_asroutercontent__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(54);
+/* harmony import */ var content_src_lib_detect_user_session_start__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(52);
+/* harmony import */ var content_src_lib_asroutercontent__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(53);
 /* harmony import */ var content_src_lib_init_store__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(6);
 /* harmony import */ var react_redux__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(24);
 /* harmony import */ var react_redux__WEBPACK_IMPORTED_MODULE_7___default = /*#__PURE__*/__webpack_require__.n(react_redux__WEBPACK_IMPORTED_MODULE_7__);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_8__ = __webpack_require__(10);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_8___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_8__);
 /* harmony import */ var react_dom__WEBPACK_IMPORTED_MODULE_9__ = __webpack_require__(14);
 /* harmony import */ var react_dom__WEBPACK_IMPORTED_MODULE_9___default = /*#__PURE__*/__webpack_require__.n(react_dom__WEBPACK_IMPORTED_MODULE_9__);
-/* harmony import */ var common_Reducers_jsm__WEBPACK_IMPORTED_MODULE_10__ = __webpack_require__(59);
+/* harmony import */ var common_Reducers_jsm__WEBPACK_IMPORTED_MODULE_10__ = __webpack_require__(58);
 
 
 
 
 
 
 
 
@@ -206,17 +206,17 @@ const globalImportContext = typeof Windo
 
 // Create an object that avoids accidental differing key/value pairs:
 // {
 //   INIT: "INIT",
 //   UNINIT: "UNINIT"
 // }
 const actionTypes = {};
 
-for (const type of ["ADDONS_INFO_REQUEST", "ADDONS_INFO_RESPONSE", "ARCHIVE_FROM_POCKET", "AS_ROUTER_INITIALIZED", "AS_ROUTER_PREF_CHANGED", "AS_ROUTER_TELEMETRY_USER_EVENT", "BLOCK_URL", "BOOKMARK_URL", "COPY_DOWNLOAD_LINK", "DELETE_BOOKMARK_BY_ID", "DELETE_FROM_POCKET", "DELETE_HISTORY_URL", "DIALOG_CANCEL", "DIALOG_OPEN", "DISCOVERY_STREAM_CONFIG_CHANGE", "DISCOVERY_STREAM_CONFIG_SETUP", "DISCOVERY_STREAM_CONFIG_SET_VALUE", "DISCOVERY_STREAM_FEEDS_UPDATE", "DISCOVERY_STREAM_LAYOUT_RESET", "DISCOVERY_STREAM_LAYOUT_UPDATE", "DISCOVERY_STREAM_SPOCS_ENDPOINT", "DISCOVERY_STREAM_SPOCS_UPDATE", "DISCOVERY_STREAM_SPOC_IMPRESSION", "DOWNLOAD_CHANGED", "FAKE_FOCUS_SEARCH", "FILL_SEARCH_TERM", "HANDOFF_SEARCH_TO_AWESOMEBAR", "HIDE_SEARCH", "INIT", "MIGRATION_CANCEL", "MIGRATION_COMPLETED", "MIGRATION_START", "NEW_TAB_INIT", "NEW_TAB_INITIAL_STATE", "NEW_TAB_LOAD", "NEW_TAB_REHYDRATED", "NEW_TAB_STATE_REQUEST", "NEW_TAB_UNLOAD", "OPEN_DOWNLOAD_FILE", "OPEN_LINK", "OPEN_NEW_WINDOW", "OPEN_PRIVATE_WINDOW", "OPEN_WEBEXT_SETTINGS", "PAGE_PRERENDERED", "PLACES_BOOKMARK_ADDED", "PLACES_BOOKMARK_REMOVED", "PLACES_HISTORY_CLEARED", "PLACES_LINKS_CHANGED", "PLACES_LINK_BLOCKED", "PLACES_LINK_DELETED", "PLACES_SAVED_TO_POCKET", "POCKET_CTA", "POCKET_LOGGED_IN", "POCKET_WAITING_FOR_SPOC", "PREFS_INITIAL_VALUES", "PREF_CHANGED", "PREVIEW_REQUEST", "PREVIEW_REQUEST_CANCEL", "PREVIEW_RESPONSE", "REMOVE_DOWNLOAD_FILE", "RICH_ICON_MISSING", "SAVE_SESSION_PERF_DATA", "SAVE_TO_POCKET", "SCREENSHOT_UPDATED", "SECTION_DEREGISTER", "SECTION_DISABLE", "SECTION_ENABLE", "SECTION_MOVE", "SECTION_OPTIONS_CHANGED", "SECTION_REGISTER", "SECTION_UPDATE", "SECTION_UPDATE_CARD", "SETTINGS_CLOSE", "SETTINGS_OPEN", "SET_PREF", "SHOW_DOWNLOAD_FILE", "SHOW_FIREFOX_ACCOUNTS", "SHOW_SEARCH", "SKIPPED_SIGNIN", "SNIPPETS_BLOCKLIST_CLEARED", "SNIPPETS_BLOCKLIST_UPDATED", "SNIPPETS_DATA", "SNIPPETS_PREVIEW_MODE", "SNIPPETS_RESET", "SNIPPET_BLOCKED", "SUBMIT_EMAIL", "SYSTEM_TICK", "TELEMETRY_IMPRESSION_STATS", "TELEMETRY_PERFORMANCE_EVENT", "TELEMETRY_UNDESIRED_EVENT", "TELEMETRY_USER_EVENT", "TOP_SITES_CANCEL_EDIT", "TOP_SITES_CLOSE_SEARCH_SHORTCUTS_MODAL", "TOP_SITES_EDIT", "TOP_SITES_INSERT", "TOP_SITES_OPEN_SEARCH_SHORTCUTS_MODAL", "TOP_SITES_PIN", "TOP_SITES_PREFS_UPDATED", "TOP_SITES_UNPIN", "TOP_SITES_UPDATED", "TOTAL_BOOKMARKS_REQUEST", "TOTAL_BOOKMARKS_RESPONSE", "UNINIT", "UPDATE_PINNED_SEARCH_SHORTCUTS", "UPDATE_SEARCH_SHORTCUTS", "UPDATE_SECTION_PREFS", "WEBEXT_CLICK", "WEBEXT_DISMISS"]) {
+for (const type of ["ADDONS_INFO_REQUEST", "ADDONS_INFO_RESPONSE", "ARCHIVE_FROM_POCKET", "AS_ROUTER_INITIALIZED", "AS_ROUTER_PREF_CHANGED", "AS_ROUTER_TELEMETRY_USER_EVENT", "BLOCK_URL", "BOOKMARK_URL", "COPY_DOWNLOAD_LINK", "DELETE_BOOKMARK_BY_ID", "DELETE_FROM_POCKET", "DELETE_HISTORY_URL", "DIALOG_CANCEL", "DIALOG_OPEN", "DISCOVERY_STREAM_CONFIG_CHANGE", "DISCOVERY_STREAM_CONFIG_SETUP", "DISCOVERY_STREAM_CONFIG_SET_VALUE", "DISCOVERY_STREAM_FEEDS_UPDATE", "DISCOVERY_STREAM_IMPRESSION_STATS", "DISCOVERY_STREAM_LAYOUT_RESET", "DISCOVERY_STREAM_LAYOUT_UPDATE", "DISCOVERY_STREAM_SPOCS_ENDPOINT", "DISCOVERY_STREAM_SPOCS_UPDATE", "DISCOVERY_STREAM_SPOC_IMPRESSION", "DOWNLOAD_CHANGED", "FAKE_FOCUS_SEARCH", "FILL_SEARCH_TERM", "HANDOFF_SEARCH_TO_AWESOMEBAR", "HIDE_SEARCH", "INIT", "MIGRATION_CANCEL", "MIGRATION_COMPLETED", "MIGRATION_START", "NEW_TAB_INIT", "NEW_TAB_INITIAL_STATE", "NEW_TAB_LOAD", "NEW_TAB_REHYDRATED", "NEW_TAB_STATE_REQUEST", "NEW_TAB_UNLOAD", "OPEN_DOWNLOAD_FILE", "OPEN_LINK", "OPEN_NEW_WINDOW", "OPEN_PRIVATE_WINDOW", "OPEN_WEBEXT_SETTINGS", "PAGE_PRERENDERED", "PLACES_BOOKMARK_ADDED", "PLACES_BOOKMARK_REMOVED", "PLACES_HISTORY_CLEARED", "PLACES_LINKS_CHANGED", "PLACES_LINK_BLOCKED", "PLACES_LINK_DELETED", "PLACES_SAVED_TO_POCKET", "POCKET_CTA", "POCKET_LOGGED_IN", "POCKET_WAITING_FOR_SPOC", "PREFS_INITIAL_VALUES", "PREF_CHANGED", "PREVIEW_REQUEST", "PREVIEW_REQUEST_CANCEL", "PREVIEW_RESPONSE", "REMOVE_DOWNLOAD_FILE", "RICH_ICON_MISSING", "SAVE_SESSION_PERF_DATA", "SAVE_TO_POCKET", "SCREENSHOT_UPDATED", "SECTION_DEREGISTER", "SECTION_DISABLE", "SECTION_ENABLE", "SECTION_MOVE", "SECTION_OPTIONS_CHANGED", "SECTION_REGISTER", "SECTION_UPDATE", "SECTION_UPDATE_CARD", "SETTINGS_CLOSE", "SETTINGS_OPEN", "SET_PREF", "SHOW_DOWNLOAD_FILE", "SHOW_FIREFOX_ACCOUNTS", "SHOW_SEARCH", "SKIPPED_SIGNIN", "SNIPPETS_BLOCKLIST_CLEARED", "SNIPPETS_BLOCKLIST_UPDATED", "SNIPPETS_DATA", "SNIPPETS_PREVIEW_MODE", "SNIPPETS_RESET", "SNIPPET_BLOCKED", "SUBMIT_EMAIL", "SYSTEM_TICK", "TELEMETRY_IMPRESSION_STATS", "TELEMETRY_PERFORMANCE_EVENT", "TELEMETRY_UNDESIRED_EVENT", "TELEMETRY_USER_EVENT", "TOP_SITES_CANCEL_EDIT", "TOP_SITES_CLOSE_SEARCH_SHORTCUTS_MODAL", "TOP_SITES_EDIT", "TOP_SITES_INSERT", "TOP_SITES_OPEN_SEARCH_SHORTCUTS_MODAL", "TOP_SITES_PIN", "TOP_SITES_PREFS_UPDATED", "TOP_SITES_UNPIN", "TOP_SITES_UPDATED", "TOTAL_BOOKMARKS_REQUEST", "TOTAL_BOOKMARKS_RESPONSE", "UNINIT", "UPDATE_PINNED_SEARCH_SHORTCUTS", "UPDATE_SEARCH_SHORTCUTS", "UPDATE_SECTION_PREFS", "WEBEXT_CLICK", "WEBEXT_DISMISS"]) {
   actionTypes[type] = type;
 }
 
 // These are acceptable actions for AS Router messages to have. They can show up
 // as call-to-action buttons in snippets, onboarding tour, etc.
 const ASRouterActions = {};
 
 for (const type of ["INSTALL_ADDON_FROM_URL", "OPEN_APPLICATIONS_MENU", "OPEN_PRIVATE_BROWSER_WINDOW", "OPEN_URL", "OPEN_ABOUT_PAGE", "OPEN_PREFERENCES_PAGE", "SHOW_FIREFOX_ACCOUNTS"]) {
@@ -399,16 +399,31 @@ function PerfEvent(data, importContext =
 function ImpressionStats(data, importContext = globalImportContext) {
   const action = {
     type: actionTypes.TELEMETRY_IMPRESSION_STATS,
     data
   };
   return importContext === UI_CODE ? AlsoToMain(action) : action;
 }
 
+/**
+ * DiscoveryStreamImpressionStats - A telemetry ping indicating an impression stats in Discovery Stream.
+ *
+ * @param  {object} data Fields to include in the ping
+ * @param  {int} importContext (For testing) Override the import context for testing.
+ * #return {object} An action. For UI code, a AlsoToMain action.
+ */
+function DiscoveryStreamImpressionStats(data, importContext = globalImportContext) {
+  const action = {
+    type: actionTypes.DISCOVERY_STREAM_IMPRESSION_STATS,
+    data
+  };
+  return importContext === UI_CODE ? AlsoToMain(action) : action;
+}
+
 function SetPref(name, value, importContext = globalImportContext) {
   const action = { type: actionTypes.SET_PREF, data: { name, value } };
   return importContext === UI_CODE ? AlsoToMain(action) : action;
 }
 
 function WebExtEvent(type, data, importContext = globalImportContext) {
   if (!data || !data.source) {
     throw new Error("WebExtEvent actions should include a property \"source\", the id of the webextension that should receive the event.");
@@ -425,17 +440,18 @@ var actionCreators = {
   PerfEvent,
   ImpressionStats,
   AlsoToOneContent,
   OnlyToOneContent,
   AlsoToMain,
   OnlyToMain,
   AlsoToPreloaded,
   SetPref,
-  WebExtEvent
+  WebExtEvent,
+  DiscoveryStreamImpressionStats
 };
 
 // These are helpers to test for certain kinds of actions
 
 var actionUtils = {
   isSendToMain(action) {
     if (!action.meta) {
       return false;
@@ -931,24 +947,24 @@ function addSnippetsSubscriber(store) {
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "ASRouterUISurface", function() { return ASRouterUISurface; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "ASRouterContent", function() { return ASRouterContent; });
 /* harmony import */ var react_intl__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(5);
 /* harmony import */ var react_intl__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(react_intl__WEBPACK_IMPORTED_MODULE_0__);
 /* harmony import */ var common_Actions_jsm__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(2);
 /* harmony import */ var content_src_lib_init_store__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(6);
 /* harmony import */ var _rich_text_strings__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(8);
 /* harmony import */ var _components_ImpressionsWrapper_ImpressionsWrapper__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(9);
-/* harmony import */ var fluent_react__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(58);
+/* harmony import */ var fluent_react__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(57);
 /* harmony import */ var _templates_OnboardingMessage_OnboardingMessage__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(12);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(10);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_7___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_7__);
 /* harmony import */ var react_dom__WEBPACK_IMPORTED_MODULE_8__ = __webpack_require__(14);
 /* harmony import */ var react_dom__WEBPACK_IMPORTED_MODULE_8___default = /*#__PURE__*/__webpack_require__.n(react_dom__WEBPACK_IMPORTED_MODULE_8__);
 /* harmony import */ var _templates_ReturnToAMO_ReturnToAMO__WEBPACK_IMPORTED_MODULE_9__ = __webpack_require__(15);
-/* harmony import */ var _templates_template_manifest__WEBPACK_IMPORTED_MODULE_10__ = __webpack_require__(56);
+/* harmony import */ var _templates_template_manifest__WEBPACK_IMPORTED_MODULE_10__ = __webpack_require__(55);
 /* harmony import */ var _templates_StartupOverlay_StartupOverlay__WEBPACK_IMPORTED_MODULE_11__ = __webpack_require__(23);
 var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; };
 
 
 
 
 
 
@@ -1453,17 +1469,17 @@ module.exports = Redux;
 /***/ }),
 /* 8 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "RICH_TEXT_KEYS", function() { return RICH_TEXT_KEYS; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "generateMessages", function() { return generateMessages; });
-/* harmony import */ var fluent__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(57);
+/* harmony import */ var fluent__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(56);
 
 
 /**
  * Properties that allow rich text MUST be added to this list.
  *   key: the localization_id that should be used
  *   value: a property or array of properties on the message.content object
  */
 const RICH_TEXT_CONFIG = {
@@ -1849,17 +1865,17 @@ class ReturnToAMO extends react__WEBPACK
 /***/ }),
 /* 16 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "convertLinks", function() { return convertLinks; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "RichText", function() { return RichText; });
-/* harmony import */ var fluent_react__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(58);
+/* harmony import */ var fluent_react__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(57);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(10);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_1__);
 /* harmony import */ var _rich_text_strings__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(8);
 /* harmony import */ var _template_utils__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(17);
 var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; };
 
 
 
@@ -2215,24 +2231,24 @@ module.exports = ReactRedux;
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "Base", function() { return Base; });
 /* harmony import */ var common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(2);
 /* harmony import */ var react_intl__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(5);
 /* harmony import */ var react_intl__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(react_intl__WEBPACK_IMPORTED_MODULE_1__);
 /* harmony import */ var content_src_components_ASRouterAdmin_ASRouterAdmin__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(26);
 /* harmony import */ var content_src_components_ConfirmDialog_ConfirmDialog__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(28);
 /* harmony import */ var react_redux__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(24);
 /* harmony import */ var react_redux__WEBPACK_IMPORTED_MODULE_4___default = /*#__PURE__*/__webpack_require__.n(react_redux__WEBPACK_IMPORTED_MODULE_4__);
-/* harmony import */ var content_src_components_DiscoveryStreamBase_DiscoveryStreamBase__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(55);
-/* harmony import */ var content_src_components_ErrorBoundary_ErrorBoundary__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(34);
-/* harmony import */ var content_src_components_ManualMigration_ManualMigration__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(45);
-/* harmony import */ var common_PrerenderData_jsm__WEBPACK_IMPORTED_MODULE_8__ = __webpack_require__(46);
+/* harmony import */ var content_src_components_DiscoveryStreamBase_DiscoveryStreamBase__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(54);
+/* harmony import */ var content_src_components_ErrorBoundary_ErrorBoundary__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(33);
+/* harmony import */ var content_src_components_ManualMigration_ManualMigration__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(44);
+/* harmony import */ var common_PrerenderData_jsm__WEBPACK_IMPORTED_MODULE_8__ = __webpack_require__(45);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_9__ = __webpack_require__(10);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_9___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_9__);
-/* harmony import */ var content_src_components_Search_Search__WEBPACK_IMPORTED_MODULE_10__ = __webpack_require__(47);
-/* harmony import */ var content_src_components_Sections_Sections__WEBPACK_IMPORTED_MODULE_11__ = __webpack_require__(49);
+/* harmony import */ var content_src_components_Search_Search__WEBPACK_IMPORTED_MODULE_10__ = __webpack_require__(46);
+/* harmony import */ var content_src_components_Sections_Sections__WEBPACK_IMPORTED_MODULE_11__ = __webpack_require__(48);
 var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; };
 
 
 
 
 
 
 
@@ -3712,110 +3728,49 @@ class _ConfirmDialog extends react__WEBP
 const ConfirmDialog = Object(react_redux__WEBPACK_IMPORTED_MODULE_1__["connect"])(state => state.Dialog)(_ConfirmDialog);
 
 /***/ }),
 /* 29 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
-/* WEBPACK VAR INJECTION */(function(global) {/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "SpocIntersectionObserver", function() { return SpocIntersectionObserver; });
-/* harmony import */ var common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(2);
-/* harmony import */ var react__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(10);
-/* harmony import */ var react__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_1__);
-
-
-const VISIBLE = "visible";
-const VISIBILITY_CHANGE_EVENT = "visibilitychange";
-const INTERSECTION_RATIO = 0.5;
-
-class SpocIntersectionObserver extends react__WEBPACK_IMPORTED_MODULE_1___default.a.PureComponent {
-  constructor(props) {
-    super(props);
-
-    this.spocElementRef = this.spocElementRef.bind(this);
-  }
-
-  componentDidMount() {
-    if (this.props.document.visibilityState === VISIBLE) {
-      this.setupIntersectionObserver();
-    } else {
-      this._onVisibilityChange = () => {
-        if (this.props.document.visibilityState === VISIBLE) {
-          this.setupIntersectionObserver();
-          this.props.document.removeEventListener(VISIBILITY_CHANGE_EVENT, this._onVisibilityChange);
-        }
-      };
-      this.props.document.addEventListener(VISIBILITY_CHANGE_EVENT, this._onVisibilityChange);
-    }
-  }
-
-  componentWillUnmount() {
-    if (this._onVisibilityChange) {
-      this.props.document.removeEventListener(VISIBILITY_CHANGE_EVENT, this._onVisibilityChange);
-    }
-    if (this._intersectionObserver) {
-      this._intersectionObserver.unobserve(this.spocElement);
-    }
-  }
-
-  setupIntersectionObserver() {
-    const options = { threshold: INTERSECTION_RATIO };
-    this._intersectionObserver = new IntersectionObserver(entries => {
-      for (let entry of entries) {
-        if (entry.isIntersecting && entry.intersectionRatio >= INTERSECTION_RATIO) {
-          this.dispatchSpocImpression();
-          break;
-        }
-      }
-    }, options);
-    this._intersectionObserver.observe(this.spocElement);
-  }
-
-  dispatchSpocImpression() {
-    if (this.props.campaignId) {
-      this.props.dispatch(common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__["actionCreators"].OnlyToMain({ type: common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__["actionTypes"].DISCOVERY_STREAM_SPOC_IMPRESSION, data: { campaignId: this.props.campaignId } }));
-    }
-    this._intersectionObserver.unobserve(this.spocElement);
-  }
-
-  spocElementRef(element) {
-    this.spocElement = element;
-  }
-
-  render() {
-    return react__WEBPACK_IMPORTED_MODULE_1___default.a.createElement(
-      "div",
-      { ref: this.spocElementRef },
-      this.props.children
-    );
-  }
-}
-
-SpocIntersectionObserver.defaultProps = {
-  document: global.document
-};
-/* WEBPACK VAR INJECTION */}.call(this, __webpack_require__(1)))
-
-/***/ }),
-/* 30 */
-/***/ (function(module, __webpack_exports__, __webpack_require__) {
-
-"use strict";
-__webpack_require__.r(__webpack_exports__);
-/* WEBPACK VAR INJECTION */(function(global) {/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "ImpressionStats", function() { return ImpressionStats; });
+/* WEBPACK VAR INJECTION */(function(global) {/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "INTERSECTION_RATIO", function() { return INTERSECTION_RATIO; });
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "ImpressionStats", function() { return ImpressionStats; });
 /* harmony import */ var common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(2);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(10);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_1__);
 
 
 
 const VISIBLE = "visible";
 const VISIBILITY_CHANGE_EVENT = "visibilitychange";
 
+// Per analytical requirement, we set the minimal intersection ratio to
+// 0.5, and an impression is identified when the wrapped item has at least
+// 50% visibility.
+//
+// This constant is exported for unit test
+const INTERSECTION_RATIO = 0.5;
+
+/**
+ * Impression wrapper for Discovery Stream related React components.
+ *
+ * It makses use of the Intersection Observer API to detect the visibility,
+ * and relies on page visibility to ensure the impression is reported
+ * only when the component is visible on the page.
+ *
+ * Note:
+ *   * This wrapper could be used either at the individual card level,
+ *     or by the card container components
+ *   * Each impression will be sent only once as soon as the desired
+ *     visibility is detected
+ *   * Batching is not yet implemented, hence it might send multiple
+ *     impression pings separately
+ */
 class ImpressionStats extends react__WEBPACK_IMPORTED_MODULE_1___default.a.PureComponent {
   // This checks if the given cards are the same as those in the last impression ping.
   // If so, it should not send the same impression ping again.
   _needsImpressionStats(cards) {
     if (!this.impressionCardGuids || this.impressionCardGuids.length !== cards.length) {
       return true;
     }
 
@@ -3827,106 +3782,142 @@ class ImpressionStats extends react__WEB
 
     return false;
   }
 
   _dispatchImpressionStats() {
     const { props } = this;
     const cards = props.rows;
 
+    if (this.props.campaignId) {
+      this.props.dispatch(common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__["actionCreators"].OnlyToMain({ type: common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__["actionTypes"].DISCOVERY_STREAM_SPOC_IMPRESSION, data: { campaignId: this.props.campaignId } }));
+    }
+
     if (this._needsImpressionStats(cards)) {
-      props.dispatch(common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__["actionCreators"].ImpressionStats({
+      props.dispatch(common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__["actionCreators"].DiscoveryStreamImpressionStats({
         source: props.source.toUpperCase(),
         tiles: cards.map(link => ({ id: link.id }))
       }));
       this.impressionCardGuids = cards.map(link => link.id);
     }
   }
 
-  // This sends an event when a user sees a set of new content. If content
-  // changes while the page is hidden (i.e. preloaded or on a hidden tab),
-  // only send the event if the page becomes visible again.
-  sendImpressionStatsOrAddListener() {
+  setImpressionObserverOrAddListener() {
     const { props } = this;
 
     if (!props.dispatch) {
       return;
     }
 
     if (props.document.visibilityState === VISIBLE) {
-      this._dispatchImpressionStats();
+      this.setImpressionObserver();
     } else {
       // We should only ever send the latest impression stats ping, so remove any
       // older listeners.
       if (this._onVisibilityChange) {
         props.document.removeEventListener(VISIBILITY_CHANGE_EVENT, this._onVisibilityChange);
       }
 
       this._onVisibilityChange = () => {
         if (props.document.visibilityState === VISIBLE) {
-          this._dispatchImpressionStats();
+          this.setImpressionObserver();
           props.document.removeEventListener(VISIBILITY_CHANGE_EVENT, this._onVisibilityChange);
         }
       };
       props.document.addEventListener(VISIBILITY_CHANGE_EVENT, this._onVisibilityChange);
     }
   }
 
+  /**
+   * Set an impression observer for the wrapped component. It makes use of
+   * the Intersection Observer API to detect if the wrapped component is
+   * visible with a desired ratio, and only sends impression if that's the case.
+   *
+   * See more details about Intersection Observer API at:
+   * https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API
+   */
+  setImpressionObserver() {
+    const { props } = this;
+
+    if (!props.rows.length) {
+      return;
+    }
+
+    this._handleIntersect = entries => {
+      if (entries.some(entry => entry.isIntersecting && entry.intersectionRatio >= INTERSECTION_RATIO)) {
+        this._dispatchImpressionStats();
+        this.impressionObserver.unobserve(this.refs.impression);
+      }
+    };
+
+    const options = { threshold: INTERSECTION_RATIO };
+    this.impressionObserver = new props.IntersectionObserver(this._handleIntersect, options);
+    this.impressionObserver.observe(this.refs.impression);
+  }
+
   componentDidMount() {
     if (this.props.rows.length) {
-      this.sendImpressionStatsOrAddListener();
+      this.setImpressionObserverOrAddListener();
     }
   }
 
   componentDidUpdate(prevProps) {
     if (this.props.rows.length && this.props.rows !== prevProps.rows) {
-      this.sendImpressionStatsOrAddListener();
+      this.setImpressionObserverOrAddListener();
     }
   }
 
   componentWillUnmount() {
+    if (this._handleIntersect && this.impressionObserver) {
+      this.impressionObserver.unobserve(this.refs.impression);
+    }
     if (this._onVisibilityChange) {
       this.props.document.removeEventListener(VISIBILITY_CHANGE_EVENT, this._onVisibilityChange);
     }
   }
 
   render() {
-    return this.props.children;
+    return react__WEBPACK_IMPORTED_MODULE_1___default.a.createElement(
+      "div",
+      { ref: "impression", className: "impression-observer" },
+      this.props.children
+    );
   }
 }
 
 ImpressionStats.defaultProps = {
+  IntersectionObserver: global.IntersectionObserver,
   document: global.document,
   rows: [],
   source: ""
 };
 /* WEBPACK VAR INJECTION */}.call(this, __webpack_require__(1)))
 
 /***/ }),
-/* 31 */
+/* 30 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* WEBPACK VAR INJECTION */(function(global) {/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "_TopSites", function() { return _TopSites; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "TopSites", function() { return TopSites; });
 /* harmony import */ var common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(2);
-/* harmony import */ var _TopSitesConstants__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(32);
-/* harmony import */ var content_src_components_CollapsibleSection_CollapsibleSection__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(33);
-/* harmony import */ var content_src_components_ComponentPerfTimer_ComponentPerfTimer__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(38);
+/* harmony import */ var _TopSitesConstants__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(31);
+/* harmony import */ var content_src_components_CollapsibleSection_CollapsibleSection__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(32);
+/* harmony import */ var content_src_components_ComponentPerfTimer_ComponentPerfTimer__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(37);
 /* harmony import */ var react_redux__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(24);
 /* harmony import */ var react_redux__WEBPACK_IMPORTED_MODULE_4___default = /*#__PURE__*/__webpack_require__.n(react_redux__WEBPACK_IMPORTED_MODULE_4__);
 /* harmony import */ var react_intl__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(5);
 /* harmony import */ var react_intl__WEBPACK_IMPORTED_MODULE_5___default = /*#__PURE__*/__webpack_require__.n(react_intl__WEBPACK_IMPORTED_MODULE_5__);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(10);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_6___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_6__);
-/* harmony import */ var _SearchShortcutsForm__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(40);
-/* harmony import */ var common_Reducers_jsm__WEBPACK_IMPORTED_MODULE_8__ = __webpack_require__(59);
-/* harmony import */ var _TopSiteForm__WEBPACK_IMPORTED_MODULE_9__ = __webpack_require__(61);
-/* harmony import */ var _TopSite__WEBPACK_IMPORTED_MODULE_10__ = __webpack_require__(41);
+/* harmony import */ var _SearchShortcutsForm__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(39);
+/* harmony import */ var common_Reducers_jsm__WEBPACK_IMPORTED_MODULE_8__ = __webpack_require__(58);
+/* harmony import */ var _TopSiteForm__WEBPACK_IMPORTED_MODULE_9__ = __webpack_require__(60);
+/* harmony import */ var _TopSite__WEBPACK_IMPORTED_MODULE_10__ = __webpack_require__(40);
 var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; };
 
 
 
 
 
 
 
@@ -4105,17 +4096,17 @@ class _TopSites extends react__WEBPACK_I
 const TopSites = Object(react_redux__WEBPACK_IMPORTED_MODULE_4__["connect"])(state => ({
   TopSites: state.TopSites,
   Prefs: state.Prefs,
   TopSitesRows: state.Prefs.values.topSitesRows
 }))(Object(react_intl__WEBPACK_IMPORTED_MODULE_5__["injectIntl"])(_TopSites));
 /* WEBPACK VAR INJECTION */}.call(this, __webpack_require__(1)))
 
 /***/ }),
-/* 32 */
+/* 31 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "TOP_SITES_SOURCE", function() { return TOP_SITES_SOURCE; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "TOP_SITES_CONTEXT_MENU_OPTIONS", function() { return TOP_SITES_CONTEXT_MENU_OPTIONS; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "TOP_SITES_SEARCH_SHORTCUTS_CONTEXT_MENU_OPTIONS", function() { return TOP_SITES_SEARCH_SHORTCUTS_CONTEXT_MENU_OPTIONS; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "MIN_RICH_FAVICON_SIZE", function() { return MIN_RICH_FAVICON_SIZE; });
@@ -4125,31 +4116,31 @@ const TOP_SITES_CONTEXT_MENU_OPTIONS = [
 // the special top site for search shortcut experiment can only have the option to unpin (which removes) the topsite
 const TOP_SITES_SEARCH_SHORTCUTS_CONTEXT_MENU_OPTIONS = ["CheckPinTopSite", "Separator", "BlockUrl"];
 // minimum size necessary to show a rich icon instead of a screenshot
 const MIN_RICH_FAVICON_SIZE = 96;
 // minimum size necessary to show any icon in the top left corner with a screenshot
 const MIN_CORNER_FAVICON_SIZE = 16;
 
 /***/ }),
-/* 33 */
+/* 32 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* WEBPACK VAR INJECTION */(function(global) {/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "_CollapsibleSection", function() { return _CollapsibleSection; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "CollapsibleSection", function() { return CollapsibleSection; });
 /* harmony import */ var react_intl__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(5);
 /* harmony import */ var react_intl__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(react_intl__WEBPACK_IMPORTED_MODULE_0__);
 /* harmony import */ var common_Actions_jsm__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(2);
-/* harmony import */ var content_src_components_ErrorBoundary_ErrorBoundary__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(34);
+/* harmony import */ var content_src_components_ErrorBoundary_ErrorBoundary__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(33);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(10);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_3___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_3__);
-/* harmony import */ var content_src_components_SectionMenu_SectionMenu__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(35);
-/* harmony import */ var content_src_lib_section_menu_options__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(37);
+/* harmony import */ var content_src_components_SectionMenu_SectionMenu__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(34);
+/* harmony import */ var content_src_lib_section_menu_options__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(36);
 
 
 
 
 
 
 
 const VISIBLE = "visible";
@@ -4392,17 +4383,17 @@ class _CollapsibleSection extends react_
   },
   Prefs: { values: {} }
 };
 
 const CollapsibleSection = Object(react_intl__WEBPACK_IMPORTED_MODULE_0__["injectIntl"])(_CollapsibleSection);
 /* WEBPACK VAR INJECTION */}.call(this, __webpack_require__(1)))
 
 /***/ }),
-/* 34 */
+/* 33 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "ErrorBoundaryFallback", function() { return ErrorBoundaryFallback; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "ErrorBoundary", function() { return ErrorBoundary; });
 /* harmony import */ var react_intl__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(5);
 /* harmony import */ var react_intl__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(react_intl__WEBPACK_IMPORTED_MODULE_0__);
@@ -4479,30 +4470,30 @@ class ErrorBoundary extends react__WEBPA
 
     return react__WEBPACK_IMPORTED_MODULE_1___default.a.createElement(this.props.FallbackComponent, { className: this.props.className });
   }
 }
 
 ErrorBoundary.defaultProps = { FallbackComponent: ErrorBoundaryFallback };
 
 /***/ }),
-/* 35 */
+/* 34 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "_SectionMenu", function() { return _SectionMenu; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "SectionMenu", function() { return SectionMenu; });
 /* harmony import */ var common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(2);
-/* harmony import */ var content_src_components_ContextMenu_ContextMenu__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(36);
+/* harmony import */ var content_src_components_ContextMenu_ContextMenu__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(35);
 /* harmony import */ var react_intl__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(5);
 /* harmony import */ var react_intl__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(react_intl__WEBPACK_IMPORTED_MODULE_2__);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(10);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_3___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_3__);
-/* harmony import */ var content_src_lib_section_menu_options__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(37);
+/* harmony import */ var content_src_lib_section_menu_options__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(36);
 
 
 
 
 
 
 const DEFAULT_SECTION_MENU_OPTIONS = ["MoveUp", "MoveDown", "Separator", "RemoveSection", "CheckCollapsed", "Separator", "ManageSection"];
 const WEBEXT_SECTION_MENU_OPTIONS = ["MoveUp", "MoveDown", "Separator", "CheckCollapsed", "Separator", "ManageWebExtension"];
@@ -4551,17 +4542,17 @@ class _SectionMenu extends react__WEBPAC
       onUpdate: this.props.onUpdate,
       options: this.getOptions() });
   }
 }
 
 const SectionMenu = Object(react_intl__WEBPACK_IMPORTED_MODULE_2__["injectIntl"])(_SectionMenu);
 
 /***/ }),
-/* 36 */
+/* 35 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* WEBPACK VAR INJECTION */(function(global) {/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "ContextMenu", function() { return ContextMenu; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "ContextMenuItem", function() { return ContextMenuItem; });
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(10);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_0__);
@@ -4650,17 +4641,17 @@ class ContextMenuItem extends react__WEB
         option.label
       )
     );
   }
 }
 /* WEBPACK VAR INJECTION */}.call(this, __webpack_require__(1)))
 
 /***/ }),
-/* 37 */
+/* 36 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "SectionMenuOptions", function() { return SectionMenuOptions; });
 /* harmony import */ var common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(2);
 
 
@@ -4739,24 +4730,24 @@ const SectionMenuOptions = {
       data: { url: section.privacyNoticeURL }
     }),
     userEvent: "MENU_PRIVACY_NOTICE"
   }),
   CheckCollapsed: section => section.collapsed ? SectionMenuOptions.ExpandSection(section) : SectionMenuOptions.CollapseSection(section)
 };
 
 /***/ }),
-/* 38 */
+/* 37 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "ComponentPerfTimer", function() { return ComponentPerfTimer; });
 /* harmony import */ var common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(2);
-/* harmony import */ var common_PerfService_jsm__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(39);
+/* harmony import */ var common_PerfService_jsm__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(38);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(10);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_2__);
 
 
 
 
 // Currently record only a fixed set of sections. This will prevent data
 // from custom sections from showing up or from topstories.
@@ -4913,17 +4904,17 @@ class ComponentPerfTimer extends react__
       this._ensureFirstRenderTsRecorded();
       this._maybeSendBadStateEvent();
     }
     return this.props.children;
   }
 }
 
 /***/ }),
-/* 39 */
+/* 38 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "_PerfService", function() { return _PerfService; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "perfService", function() { return perfService; });
 /* globals Services */
 
@@ -5048,28 +5039,28 @@ function _PerfService(options) {
     let mostRecentEntry = entries[entries.length - 1];
     return this._perf.timeOrigin + mostRecentEntry.startTime;
   }
 };
 
 var perfService = new _PerfService();
 
 /***/ }),
-/* 40 */
+/* 39 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "SearchShortcutsForm", function() { return SearchShortcutsForm; });
 /* harmony import */ var common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(2);
 /* harmony import */ var react_intl__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(5);
 /* harmony import */ var react_intl__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(react_intl__WEBPACK_IMPORTED_MODULE_1__);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(10);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_2__);
-/* harmony import */ var _TopSitesConstants__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(32);
+/* harmony import */ var _TopSitesConstants__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(31);
 
 
 
 
 
 class SelectableSearchShortcut extends react__WEBPACK_IMPORTED_MODULE_2___default.a.PureComponent {
   render() {
     const { shortcut, selected } = this.props;
@@ -5228,35 +5219,35 @@ class SearchShortcutsForm extends react_
           react__WEBPACK_IMPORTED_MODULE_2___default.a.createElement(react_intl__WEBPACK_IMPORTED_MODULE_1__["FormattedMessage"], { id: "topsites_form_save_button" })
         )
       )
     );
   }
 }
 
 /***/ }),
-/* 41 */
+/* 40 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "TopSiteLink", function() { return TopSiteLink; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "TopSite", function() { return TopSite; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "TopSitePlaceholder", function() { return TopSitePlaceholder; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "_TopSiteList", function() { return _TopSiteList; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "TopSiteList", function() { return TopSiteList; });
 /* harmony import */ var common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(2);
 /* harmony import */ var react_intl__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(5);
 /* harmony import */ var react_intl__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(react_intl__WEBPACK_IMPORTED_MODULE_1__);
-/* harmony import */ var _TopSitesConstants__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(32);
-/* harmony import */ var content_src_components_LinkMenu_LinkMenu__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(42);
+/* harmony import */ var _TopSitesConstants__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(31);
+/* harmony import */ var content_src_components_LinkMenu_LinkMenu__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(41);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(10);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_4___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_4__);
-/* harmony import */ var content_src_lib_screenshot_utils__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(44);
-/* harmony import */ var common_Reducers_jsm__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(59);
+/* harmony import */ var content_src_lib_screenshot_utils__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(43);
+/* harmony import */ var common_Reducers_jsm__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(58);
 var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; };
 
 
 
 
 
 
 
@@ -5771,30 +5762,30 @@ class _TopSiteList extends react__WEBPAC
       topSitesUI
     );
   }
 }
 
 const TopSiteList = Object(react_intl__WEBPACK_IMPORTED_MODULE_1__["injectIntl"])(_TopSiteList);
 
 /***/ }),
-/* 42 */
+/* 41 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "_LinkMenu", function() { return _LinkMenu; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "LinkMenu", function() { return LinkMenu; });
 /* harmony import */ var common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(2);
 /* harmony import */ var react_redux__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(24);
 /* harmony import */ var react_redux__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(react_redux__WEBPACK_IMPORTED_MODULE_1__);
-/* harmony import */ var content_src_components_ContextMenu_ContextMenu__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(36);
+/* harmony import */ var content_src_components_ContextMenu_ContextMenu__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(35);
 /* harmony import */ var react_intl__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(5);
 /* harmony import */ var react_intl__WEBPACK_IMPORTED_MODULE_3___default = /*#__PURE__*/__webpack_require__.n(react_intl__WEBPACK_IMPORTED_MODULE_3__);
-/* harmony import */ var content_src_lib_link_menu_options__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(43);
+/* harmony import */ var content_src_lib_link_menu_options__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(42);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(10);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_5___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_5__);
 
 
 
 
 
 
@@ -5845,17 +5836,17 @@ class _LinkMenu extends react__WEBPACK_I
       options: this.getOptions() });
   }
 }
 
 const getState = state => ({ isPrivateBrowsingEnabled: state.Prefs.values.isPrivateBrowsingEnabled, platform: state.Prefs.values.platform });
 const LinkMenu = Object(react_redux__WEBPACK_IMPORTED_MODULE_1__["connect"])(getState)(Object(react_intl__WEBPACK_IMPORTED_MODULE_3__["injectIntl"])(_LinkMenu));
 
 /***/ }),
-/* 43 */
+/* 42 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "GetPlatformString", function() { return GetPlatformString; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "LinkMenuOptions", function() { return LinkMenuOptions; });
 /* harmony import */ var common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(2);
 
@@ -6072,17 +6063,17 @@ const LinkMenuOptions = {
   CheckBookmark: site => site.bookmarkGuid ? LinkMenuOptions.RemoveBookmark(site) : LinkMenuOptions.AddBookmark(site),
   CheckPinTopSite: (site, index) => site.isPinned ? LinkMenuOptions.UnpinTopSite(site) : LinkMenuOptions.PinTopSite(site, index),
   CheckSavedToPocket: (site, index) => site.pocket_id ? LinkMenuOptions.DeleteFromPocket(site) : LinkMenuOptions.SaveToPocket(site, index),
   CheckBookmarkOrArchive: site => site.pocket_id ? LinkMenuOptions.ArchiveFromPocket(site) : LinkMenuOptions.CheckBookmark(site),
   OpenInPrivateWindow: (site, index, eventSource, isEnabled) => isEnabled ? _OpenInPrivateWindow(site) : LinkMenuOptions.EmptyItem()
 };
 
 /***/ }),
-/* 44 */
+/* 43 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* WEBPACK VAR INJECTION */(function(global) {/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "ScreenshotUtils", function() { return ScreenshotUtils; });
 /**
  * List of helper functions for screenshot-based images.
  *
@@ -6129,17 +6120,17 @@ const ScreenshotUtils = {
     // This will only handle the remaining three possible outcomes.
     // (i.e. everything except when both image and localImage are present)
     return !remoteImage && !localImage;
   }
 };
 /* WEBPACK VAR INJECTION */}.call(this, __webpack_require__(1)))
 
 /***/ }),
-/* 45 */
+/* 44 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "_ManualMigration", function() { return _ManualMigration; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "ManualMigration", function() { return ManualMigration; });
 /* harmony import */ var common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(2);
 /* harmony import */ var react_redux__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(24);
@@ -6204,17 +6195,17 @@ class _ManualMigration extends react__WE
       )
     );
   }
 }
 
 const ManualMigration = Object(react_redux__WEBPACK_IMPORTED_MODULE_1__["connect"])()(_ManualMigration);
 
 /***/ }),
-/* 46 */
+/* 45 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "_PrerenderData", function() { return _PrerenderData; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "PrerenderData", function() { return PrerenderData; });
 class _PrerenderData {
   constructor(options) {
@@ -6327,29 +6318,29 @@ var PrerenderData = new _PrerenderData({
     id: "highlights",
     icon: "highlights",
     order: 2,
     title: { id: "header_highlights" }
   }]
 });
 
 /***/ }),
-/* 47 */
+/* 46 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "_Search", function() { return _Search; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "Search", function() { return Search; });
 /* harmony import */ var common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(2);
 /* harmony import */ var react_intl__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(5);
 /* harmony import */ var react_intl__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(react_intl__WEBPACK_IMPORTED_MODULE_1__);
 /* harmony import */ var react_redux__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(24);
 /* harmony import */ var react_redux__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(react_redux__WEBPACK_IMPORTED_MODULE_2__);
-/* harmony import */ var content_src_lib_constants__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(48);
+/* harmony import */ var content_src_lib_constants__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(47);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(10);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_4___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_4__);
 /* globals ContentSearchUIController */
 
 
 
 
 
@@ -6521,49 +6512,49 @@ class _Search extends react__WEBPACK_IMP
       )
     );
   }
 }
 
 const Search = Object(react_redux__WEBPACK_IMPORTED_MODULE_2__["connect"])()(Object(react_intl__WEBPACK_IMPORTED_MODULE_1__["injectIntl"])(_Search));
 
 /***/ }),
-/* 48 */
+/* 47 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* WEBPACK VAR INJECTION */(function(global) {/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "IS_NEWTAB", function() { return IS_NEWTAB; });
 const IS_NEWTAB = global.document && global.document.documentURI === "about:newtab";
 /* WEBPACK VAR INJECTION */}.call(this, __webpack_require__(1)))
 
 /***/ }),
-/* 49 */
+/* 48 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* WEBPACK VAR INJECTION */(function(global) {/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "Section", function() { return Section; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "SectionIntl", function() { return SectionIntl; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "_Sections", function() { return _Sections; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "Sections", function() { return Sections; });
 /* harmony import */ var common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(2);
-/* harmony import */ var content_src_components_Card_Card__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(60);
+/* harmony import */ var content_src_components_Card_Card__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(59);
 /* harmony import */ var react_intl__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(5);
 /* harmony import */ var react_intl__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(react_intl__WEBPACK_IMPORTED_MODULE_2__);
-/* harmony import */ var content_src_components_CollapsibleSection_CollapsibleSection__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(33);
-/* harmony import */ var content_src_components_ComponentPerfTimer_ComponentPerfTimer__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(38);
+/* harmony import */ var content_src_components_CollapsibleSection_CollapsibleSection__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(32);
+/* harmony import */ var content_src_components_ComponentPerfTimer_ComponentPerfTimer__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(37);
 /* harmony import */ var react_redux__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(24);
 /* harmony import */ var react_redux__WEBPACK_IMPORTED_MODULE_5___default = /*#__PURE__*/__webpack_require__.n(react_redux__WEBPACK_IMPORTED_MODULE_5__);
-/* harmony import */ var content_src_components_MoreRecommendations_MoreRecommendations__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(50);
-/* harmony import */ var content_src_components_PocketLoggedInCta_PocketLoggedInCta__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(51);
+/* harmony import */ var content_src_components_MoreRecommendations_MoreRecommendations__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(49);
+/* harmony import */ var content_src_components_PocketLoggedInCta_PocketLoggedInCta__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(50);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_8__ = __webpack_require__(10);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_8___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_8__);
-/* harmony import */ var content_src_components_Topics_Topics__WEBPACK_IMPORTED_MODULE_9__ = __webpack_require__(52);
-/* harmony import */ var content_src_components_TopSites_TopSites__WEBPACK_IMPORTED_MODULE_10__ = __webpack_require__(31);
+/* harmony import */ var content_src_components_Topics_Topics__WEBPACK_IMPORTED_MODULE_9__ = __webpack_require__(51);
+/* harmony import */ var content_src_components_TopSites_TopSites__WEBPACK_IMPORTED_MODULE_10__ = __webpack_require__(30);
 var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; };
 
 
 
 
 
 
 
@@ -6877,17 +6868,17 @@ class _Sections extends react__WEBPACK_I
     );
   }
 }
 
 const Sections = Object(react_redux__WEBPACK_IMPORTED_MODULE_5__["connect"])(state => ({ Sections: state.Sections, Prefs: state.Prefs }))(_Sections);
 /* WEBPACK VAR INJECTION */}.call(this, __webpack_require__(1)))
 
 /***/ }),
-/* 50 */
+/* 49 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "MoreRecommendations", function() { return MoreRecommendations; });
 /* harmony import */ var react_intl__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(5);
 /* harmony import */ var react_intl__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(react_intl__WEBPACK_IMPORTED_MODULE_0__);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(10);
@@ -6905,17 +6896,17 @@ class MoreRecommendations extends react_
         react__WEBPACK_IMPORTED_MODULE_1___default.a.createElement(react_intl__WEBPACK_IMPORTED_MODULE_0__["FormattedMessage"], { id: "pocket_more_reccommendations" })
       );
     }
     return null;
   }
 }
 
 /***/ }),
-/* 51 */
+/* 50 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "_PocketLoggedInCta", function() { return _PocketLoggedInCta; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "PocketLoggedInCta", function() { return PocketLoggedInCta; });
 /* harmony import */ var react_redux__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(24);
 /* harmony import */ var react_redux__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(react_redux__WEBPACK_IMPORTED_MODULE_0__);
@@ -6949,17 +6940,17 @@ class _PocketLoggedInCta extends react__
       )
     );
   }
 }
 
 const PocketLoggedInCta = Object(react_redux__WEBPACK_IMPORTED_MODULE_0__["connect"])(state => ({ Pocket: state.Pocket }))(_PocketLoggedInCta);
 
 /***/ }),
-/* 52 */
+/* 51 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "Topic", function() { return Topic; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "Topics", function() { return Topics; });
 /* harmony import */ var react_intl__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(5);
 /* harmony import */ var react_intl__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(react_intl__WEBPACK_IMPORTED_MODULE_0__);
@@ -6999,24 +6990,24 @@ class Topics extends react__WEBPACK_IMPO
         null,
         topics && topics.map(t => react__WEBPACK_IMPORTED_MODULE_1___default.a.createElement(Topic, { key: t.name, url: t.url, name: t.name }))
       )
     );
   }
 }
 
 /***/ }),
-/* 53 */
+/* 52 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* WEBPACK VAR INJECTION */(function(global) {/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "DetectUserSessionStart", function() { return DetectUserSessionStart; });
 /* harmony import */ var common_Actions_jsm__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(2);
-/* harmony import */ var common_PerfService_jsm__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(39);
+/* harmony import */ var common_PerfService_jsm__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(38);
 
 
 
 const VISIBLE = "visible";
 const VISIBILITY_CHANGE_EVENT = "visibilitychange";
 
 class DetectUserSessionStart {
   constructor(store, options = {}) {
@@ -7074,17 +7065,17 @@ class DetectUserSessionStart {
       this._sendEvent();
       this.document.removeEventListener(VISIBILITY_CHANGE_EVENT, this._onVisibilityChange);
     }
   }
 }
 /* WEBPACK VAR INJECTION */}.call(this, __webpack_require__(1)))
 
 /***/ }),
-/* 54 */
+/* 53 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "enableASRouterContent", function() { return enableASRouterContent; });
 function enableASRouterContent(store, asrouterContent) {
   // Enable asrouter content
   store.subscribe(() => {
@@ -7097,24 +7088,27 @@ function enableASRouterContent(store, as
       asrouterContent.init(store);
     }
   });
   // Return this for testing purposes
   return { asrouterContent };
 }
 
 /***/ }),
-/* 55 */
+/* 54 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 
 // EXTERNAL MODULE: ./common/Actions.jsm
 var Actions = __webpack_require__(2);
 
+// EXTERNAL MODULE: ./content-src/components/DiscoveryStreamImpressionStats/ImpressionStats.jsx
+var ImpressionStats = __webpack_require__(29);
+
 // EXTERNAL MODULE: external "React"
 var external_React_ = __webpack_require__(10);
 var external_React_default = /*#__PURE__*/__webpack_require__.n(external_React_);
 
 // CONCATENATED MODULE: ./content-src/components/DiscoveryStreamComponents/SafeAnchor/SafeAnchor.jsx
 
 
 class SafeAnchor_SafeAnchor extends external_React_default.a.PureComponent {
@@ -7138,19 +7132,16 @@ class SafeAnchor_SafeAnchor extends exte
     const { url, className, onLinkClick } = this.props;
     return external_React_default.a.createElement(
       "a",
       { href: this.safeURI(url), className: className, onClick: onLinkClick },
       this.props.children
     );
   }
 }
-// EXTERNAL MODULE: ./content-src/components/DiscoveryStreamComponents/SpocIntersectionObserver/SpocIntersectionObserver.jsx
-var SpocIntersectionObserver = __webpack_require__(29);
-
 // CONCATENATED MODULE: ./content-src/components/DiscoveryStreamComponents/DSCard/DSCard.jsx
 
 
 
 
 
 class DSCard_DSCard extends external_React_default.a.PureComponent {
   constructor(props) {
@@ -7175,61 +7166,62 @@ class DSCard_DSCard extends external_Rea
     }
   }
 
   render() {
     return external_React_default.a.createElement(
       SafeAnchor_SafeAnchor,
       { url: this.props.url, className: "ds-card", onLinkClick: this.onLinkClick },
       external_React_default.a.createElement(
-        SpocIntersectionObserver["SpocIntersectionObserver"],
-        { campaignId: this.props.campaignId, dispatch: this.props.dispatch },
-        external_React_default.a.createElement(
-          "div",
-          { className: "img-wrapper" },
-          external_React_default.a.createElement("div", { className: "img", style: { backgroundImage: `url(${this.props.image_src}` } })
-        ),
+        "div",
+        { className: "img-wrapper" },
+        external_React_default.a.createElement("div", { className: "img", style: { backgroundImage: `url(${this.props.image_src}` } })
+      ),
+      external_React_default.a.createElement(
+        "div",
+        { className: "meta" },
         external_React_default.a.createElement(
           "div",
-          { className: "meta" },
+          { className: "info-wrap" },
           external_React_default.a.createElement(
-            "div",
-            { className: "info-wrap" },
+            "header",
+            { className: "title" },
+            this.props.title
+          ),
+          this.props.excerpt && external_React_default.a.createElement(
+            "p",
+            { className: "excerpt" },
+            this.props.excerpt
+          )
+        ),
+        external_React_default.a.createElement(
+          "p",
+          null,
+          this.props.context && external_React_default.a.createElement(
+            "span",
+            null,
             external_React_default.a.createElement(
-              "header",
-              { className: "title" },
-              this.props.title
+              "span",
+              { className: "context" },
+              this.props.context
             ),
-            this.props.excerpt && external_React_default.a.createElement(
-              "p",
-              { className: "excerpt" },
-              this.props.excerpt
-            )
+            external_React_default.a.createElement("br", null)
           ),
           external_React_default.a.createElement(
-            "p",
-            null,
-            this.props.context && external_React_default.a.createElement(
-              "span",
-              null,
-              external_React_default.a.createElement(
-                "span",
-                { className: "context" },
-                this.props.context
-              ),
-              external_React_default.a.createElement("br", null)
-            ),
-            external_React_default.a.createElement(
-              "span",
-              { className: "source" },
-              this.props.source
-            )
+            "span",
+            { className: "source" },
+            this.props.source
           )
         )
-      )
+      ),
+      external_React_default.a.createElement(ImpressionStats["ImpressionStats"], {
+        campaignId: this.props.campaignId,
+        rows: [{ id: this.props.id }],
+        dispatch: this.props.dispatch,
+        source: this.props.type })
     );
   }
 }
 // CONCATENATED MODULE: ./content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid.jsx
 
 
 
 class CardGrid_CardGrid extends external_React_default.a.PureComponent {
@@ -7237,18 +7229,18 @@ class CardGrid_CardGrid extends external
     const { data } = this.props;
 
     // Handle a render before feed has been fetched by displaying nothing
     if (!data) {
       return external_React_default.a.createElement("div", null);
     }
 
     let cards = data.recommendations.slice(0, this.props.items).map((rec, index) => external_React_default.a.createElement(DSCard_DSCard, {
+      key: `dscard-${index}`,
       campaignId: rec.campaign_id,
-      key: `dscard-${index}`,
       image_src: rec.image_src,
       title: rec.title,
       excerpt: rec.excerpt,
       url: rec.url,
       id: rec.id,
       index: index,
       type: this.props.type,
       context: rec.context,
@@ -7348,73 +7340,74 @@ class List_ListItem extends external_Rea
     }
   }
 
   render() {
     return external_React_default.a.createElement(
       "li",
       { className: "ds-list-item" },
       external_React_default.a.createElement(
-        SpocIntersectionObserver["SpocIntersectionObserver"],
-        { campaignId: this.props.campaignId, dispatch: this.props.dispatch },
+        SafeAnchor_SafeAnchor,
+        { url: this.props.url, className: "ds-list-item-link", onLinkClick: this.onLinkClick },
         external_React_default.a.createElement(
-          SafeAnchor_SafeAnchor,
-          { url: this.props.url, className: "ds-list-item-link", onLinkClick: this.onLinkClick },
+          "div",
+          { className: "ds-list-item-text" },
           external_React_default.a.createElement(
             "div",
-            { className: "ds-list-item-text" },
-            external_React_default.a.createElement(
-              "div",
-              { className: "ds-list-item-title" },
-              this.props.title
-            ),
-            this.props.excerpt && external_React_default.a.createElement(
-              "div",
-              { className: "ds-list-item-excerpt" },
-              this.props.excerpt
+            { className: "ds-list-item-title" },
+            this.props.title
+          ),
+          this.props.excerpt && external_React_default.a.createElement(
+            "div",
+            { className: "ds-list-item-excerpt" },
+            this.props.excerpt
+          ),
+          external_React_default.a.createElement(
+            "p",
+            null,
+            this.props.context && external_React_default.a.createElement(
+              "span",
+              null,
+              external_React_default.a.createElement(
+                "span",
+                { className: "ds-list-item-context" },
+                this.props.context
+              ),
+              external_React_default.a.createElement("br", null)
             ),
             external_React_default.a.createElement(
-              "p",
-              null,
-              this.props.context && external_React_default.a.createElement(
-                "span",
-                null,
-                external_React_default.a.createElement(
-                  "span",
-                  { className: "ds-list-item-context" },
-                  this.props.context
-                ),
-                external_React_default.a.createElement("br", null)
-              ),
-              external_React_default.a.createElement(
-                "span",
-                { className: "ds-list-item-info" },
-                this.props.domain
-              )
+              "span",
+              { className: "ds-list-item-info" },
+              this.props.domain
             )
-          ),
-          external_React_default.a.createElement("div", { className: "ds-list-image", style: { backgroundImage: `url(${this.props.image_src})` } })
-        )
+          )
+        ),
+        external_React_default.a.createElement("div", { className: "ds-list-image", style: { backgroundImage: `url(${this.props.image_src})` } }),
+        external_React_default.a.createElement(ImpressionStats["ImpressionStats"], {
+          campaignId: this.props.campaignId,
+          rows: [{ id: this.props.id }],
+          dispatch: this.props.dispatch,
+          source: this.props.type })
       )
     );
   }
 }
 
 /**
  * @note exported for testing only
  */
 function _List(props) {
   const feed = props.data;
   if (!feed || !feed.recommendations) {
     return null;
   }
   const recs = feed.recommendations;
   let recMarkup = recs.slice(props.recStartingPoint, props.recStartingPoint + props.items).map((rec, index) => external_React_default.a.createElement(List_ListItem, { key: `ds-list-item-${index}`,
+    dispatch: props.dispatch,
     campaignId: rec.campaign_id,
-    dispatch: props.dispatch,
     domain: rec.domain,
     excerpt: rec.excerpt,
     id: rec.id,
     image_src: rec.image_src,
     index: index,
     title: rec.title,
     context: rec.context,
     type: props.type,
@@ -7448,16 +7441,17 @@ function _List(props) {
 const List = Object(external_ReactRedux_["connect"])(state => ({ DiscoveryStream: state.DiscoveryStream }))(_List);
 // CONCATENATED MODULE: ./content-src/components/DiscoveryStreamComponents/Hero/Hero.jsx
 
 
 
 
 
 
+
 class Hero_Hero extends external_React_default.a.PureComponent {
   constructor(props) {
     super(props);
     this.onLinkClick = this.onLinkClick.bind(this);
   }
 
   onLinkClick(event) {
     if (this.props.dispatch) {
@@ -7483,17 +7477,16 @@ class Hero_Hero extends external_React_d
       return external_React_default.a.createElement("div", null);
     }
 
     let [heroRec, ...otherRecs] = data.recommendations.slice(0, this.props.items);
     this.heroRec = heroRec;
 
     // Note that `{index + 1}` is necessary below for telemetry since we treat heroRec as index 0.
     let cards = otherRecs.map((rec, index) => external_React_default.a.createElement(DSCard_DSCard, {
-      campaignId: rec.campaign_id,
       key: `dscard-${index}`,
       image_src: rec.image_src,
       title: rec.title,
       url: rec.url,
       id: rec.id,
       index: index + 1,
       type: this.props.type,
       dispatch: this.props.dispatch,
@@ -7548,17 +7541,22 @@ class Hero_Hero extends external_React_d
               "p",
               { className: "context" },
               heroRec.context
             ) : external_React_default.a.createElement(
               "p",
               { className: "source" },
               heroRec.domain
             )
-          )
+          ),
+          external_React_default.a.createElement(ImpressionStats["ImpressionStats"], {
+            campaignId: heroRec.campaignId,
+            rows: [{ id: heroRec.id }],
+            dispatch: this.props.dispatch,
+            source: this.props.type })
         ),
         external_React_default.a.createElement(
           "div",
           { className: `${this.props.subComponentType}` },
           this.props.subComponentType === `cards` ? cards : list
         )
       )
     );
@@ -7573,19 +7571,16 @@ Hero_Hero.defaultProps = {
 // CONCATENATED MODULE: ./content-src/components/DiscoveryStreamComponents/HorizontalRule/HorizontalRule.jsx
 
 
 class HorizontalRule_HorizontalRule extends external_React_default.a.PureComponent {
   render() {
     return external_React_default.a.createElement("hr", { className: "ds-hr" });
   }
 }
-// EXTERNAL MODULE: ./content-src/components/DiscoveryStreamImpressionStats/ImpressionStats.jsx
-var ImpressionStats = __webpack_require__(30);
-
 // CONCATENATED MODULE: ./content-src/components/DiscoveryStreamComponents/Navigation/Navigation.jsx
 
 
 
 class Navigation_Topic extends external_React_default.a.PureComponent {
   render() {
     const { url, name } = this.props;
     return external_React_default.a.createElement(
@@ -7816,17 +7811,17 @@ function layoutRender(layout, feeds, spo
         });
       }
 
       return Object.assign({}, component, { data: maybeInjectSpocs(data, component.spocs) });
     })
   }));
 });
 // EXTERNAL MODULE: ./content-src/components/TopSites/TopSites.jsx
-var TopSites = __webpack_require__(31);
+var TopSites = __webpack_require__(30);
 
 // CONCATENATED MODULE: ./content-src/components/DiscoveryStreamComponents/TopSites/TopSites.jsx
 
 
 
 
 class TopSites_TopSites extends external_React_default.a.PureComponent {
   render() {
@@ -7861,27 +7856,16 @@ const TopSites_TopSites_TopSites = Objec
 
 
 
 
 
 
 
 
-
-// According to the Pocket API endpoint specs, `component.properties.items` is a required property with following values:
-//   - List 1-12 items
-//   - Hero 1-5 items
-//   - CardGrid 1-16 items
-// To enforce that, we define various maximium items for individual components as an extra check.
-// Note that these values are subject to the future changes of the specs.
-const MAX_ROWS_HERO = 5;
-const MAX_ROWS_LIST = 12;
-const MAX_ROWS_CARDGRID = 16;
-
 const ALLOWED_CSS_URL_PREFIXES = ["chrome://", "resource://", "https://img-getpocket.cdn.mozilla.net/"];
 const DUMMY_CSS_SELECTOR = "DUMMY#CSS.SELECTOR";
 
 /**
  * Validate a CSS declaration. The values are assumed to be normalized by CSSOM.
  */
 function isAllowedCSS(property, value) {
   // Bug 1454823: INTERNAL properties, e.g., -moz-context-properties, are
@@ -7897,35 +7881,16 @@ function isAllowedCSS(property, value) {
 }
 
 class DiscoveryStreamBase_DiscoveryStreamBase extends external_React_default.a.PureComponent {
   constructor(props) {
     super(props);
     this.onStyleMount = this.onStyleMount.bind(this);
   }
 
-  /**
-   * Extracts the recommendation rows from component for the impression ping.
-   * If `component.data.recommendations` is unset, returns an empty array.
-   *
-   * The row size is determined by the following rules:
-   *   - Use `component.properties.items` from the endpoint if it's specified
-   *   - Otherwise, use the length of recommendation array
-   *   - The row size is capped by the argument `limit`, which could be one of
-   *     [`MAX_ROW_HERO`, `MAX_ROWS_LIST`, `MAX_ROWS_CARDGRID`]
-   */
-  extractRows(component, limit) {
-    if (component.data && component.data.recommendations) {
-      const items = Math.min(limit, component.properties.items || component.data.recommendations.length);
-      return component.data.recommendations.slice(0, items);
-    }
-
-    return [];
-  }
-
   onStyleMount(style) {
     // Unmounting style gets rid of old styles, so nothing else to do
     if (!style) {
       return;
     }
 
     const { sheet } = style;
     const styles = JSON.parse(style.dataset.styles);
@@ -7965,18 +7930,16 @@ class DiscoveryStreamBase_DiscoveryStrea
             console.error(`Bad CSS selector ${selectors}`); // eslint-disable-line no-console
           }
         });
       });
     });
   }
 
   renderComponent(component, embedWidth) {
-    let rows;
-
     switch (component.type) {
       case "TopSites":
         return external_React_default.a.createElement(TopSites_TopSites_TopSites, { header: component.header });
       case "Message":
         return external_React_default.a.createElement(DSMessage_DSMessage, {
           title: component.header && component.header.title,
           subtitle: component.header && component.header.subtitle,
           link_text: component.header && component.header.link_text,
@@ -7986,61 +7949,46 @@ class DiscoveryStreamBase_DiscoveryStrea
         return external_React_default.a.createElement(SectionTitle_SectionTitle, {
           header: component.header });
       case "Navigation":
         return external_React_default.a.createElement(Navigation_Navigation, {
           links: component.properties.links,
           alignment: component.properties.alignment,
           header: component.header });
       case "CardGrid":
-        rows = this.extractRows(component, MAX_ROWS_CARDGRID);
-        return external_React_default.a.createElement(
-          ImpressionStats["ImpressionStats"],
-          { rows: rows, dispatch: this.props.dispatch, source: component.type },
-          external_React_default.a.createElement(CardGrid_CardGrid, {
-            title: component.header && component.header.title,
-            data: component.data,
-            feed: component.feed,
-            border: component.properties.border,
-            type: component.type,
-            dispatch: this.props.dispatch,
-            items: component.properties.items })
-        );
+        return external_React_default.a.createElement(CardGrid_CardGrid, {
+          title: component.header && component.header.title,
+          data: component.data,
+          feed: component.feed,
+          border: component.properties.border,
+          type: component.type,
+          dispatch: this.props.dispatch,
+          items: component.properties.items });
       case "Hero":
-        rows = this.extractRows(component, MAX_ROWS_HERO);
-        return external_React_default.a.createElement(
-          ImpressionStats["ImpressionStats"],
-          { rows: rows, dispatch: this.props.dispatch, source: component.type },
-          external_React_default.a.createElement(Hero_Hero, {
-            subComponentType: embedWidth >= 9 ? `cards` : `list`,
-            feed: component.feed,
-            title: component.header && component.header.title,
-            data: component.data,
-            border: component.properties.border,
-            type: component.type,
-            dispatch: this.props.dispatch,
-            items: component.properties.items })
-        );
+        return external_React_default.a.createElement(Hero_Hero, {
+          subComponentType: embedWidth >= 9 ? `cards` : `list`,
+          feed: component.feed,
+          title: component.header && component.header.title,
+          data: component.data,
+          border: component.properties.border,
+          type: component.type,
+          dispatch: this.props.dispatch,
+          items: component.properties.items });
       case "HorizontalRule":
         return external_React_default.a.createElement(HorizontalRule_HorizontalRule, null);
       case "List":
-        rows = this.extractRows(component, MAX_ROWS_LIST);
-        return external_React_default.a.createElement(
-          ImpressionStats["ImpressionStats"],
-          { rows: rows, dispatch: this.props.dispatch, source: component.type },
-          external_React_default.a.createElement(List, {
-            data: component.data,
-            fullWidth: component.properties.full_width,
-            hasBorders: component.properties.border === "border",
-            hasImages: component.properties.has_images,
-            hasNumbers: component.properties.has_numbers,
-            items: component.properties.items,
-            type: component.type,
-            header: component.header })
-        );
+        return external_React_default.a.createElement(List, {
+          data: component.data,
+          fullWidth: component.properties.full_width,
+          hasBorders: component.properties.border === "border",
+          hasImages: component.properties.has_images,
+          hasNumbers: component.properties.has_numbers,
+          items: component.properties.items,
+          type: component.type,
+          header: component.header });
       default:
         return external_React_default.a.createElement(
           "div",
           null,
           component.type
         );
     }
   }
@@ -8091,17 +8039,17 @@ function transform(state) {
       layoutRender: selectLayoutRender(state)
     })
   };
 }
 
 const DiscoveryStreamBase = Object(external_ReactRedux_["connect"])(transform)(DiscoveryStreamBase_DiscoveryStreamBase);
 
 /***/ }),
-/* 56 */
+/* 55 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 
 // EXTERNAL MODULE: external "React"
 var external_React_ = __webpack_require__(10);
 var external_React_default = /*#__PURE__*/__webpack_require__.n(external_React_);
 
@@ -8933,17 +8881,17 @@ const SnippetsTemplates = {
   simple_snippet: SimpleSnippet_SimpleSnippet,
   newsletter_snippet: NewsletterSnippet,
   fxa_signup_snippet: FXASignupSnippet,
   send_to_device_snippet: SendToDeviceSnippet,
   eoy_snippet: EOYSnippet
 };
 
 /***/ }),
-/* 57 */
+/* 56 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 
 // CONCATENATED MODULE: ./node_modules/fluent/src/parser.js
 /*  eslint no-magic-numbers: [0]  */
 
 const MAX_PLACEABLES = 100;
@@ -11034,30 +10982,30 @@ function ftl(strings) {
 
 
 
 
 
 
 
 /***/ }),
-/* 58 */
+/* 57 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 
 // EXTERNAL MODULE: external "React"
 var external_React_ = __webpack_require__(10);
 
 // EXTERNAL MODULE: external "PropTypes"
 var external_PropTypes_ = __webpack_require__(11);
 var external_PropTypes_default = /*#__PURE__*/__webpack_require__.n(external_PropTypes_);
 
 // EXTERNAL MODULE: ./node_modules/fluent/src/index.js + 8 modules
-var src = __webpack_require__(57);
+var src = __webpack_require__(56);
 
 // CONCATENATED MODULE: ./node_modules/fluent-react/src/localization.js
 
 
 /*
  * `ReactLocalization` handles translation formatting and fallback.
  *
  * The current negotiated fallback chain of languages is stored in the
@@ -11549,17 +11497,17 @@ localized_Localized.propTypes = {
  */
 
 
 
 
 
 
 /***/ }),
-/* 59 */
+/* 58 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 
 // EXTERNAL MODULE: ./common/Actions.jsm
 var Actions = __webpack_require__(2);
 
 // CONCATENATED MODULE: ./common/Dedupe.jsm
@@ -12122,17 +12070,17 @@ var reducers = {
   Dialog,
   Sections,
   Pocket,
   DiscoveryStream,
   Search
 };
 
 /***/ }),
-/* 60 */
+/* 59 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 
 // EXTERNAL MODULE: ./common/Actions.jsm
 var Actions = __webpack_require__(2);
 
 // EXTERNAL MODULE: external "ReactIntl"
@@ -12164,27 +12112,27 @@ const cardContextTypes = {
     intlID: "type_label_downloaded",
     icon: "download"
   }
 };
 // EXTERNAL MODULE: external "ReactRedux"
 var external_ReactRedux_ = __webpack_require__(24);
 
 // EXTERNAL MODULE: ./content-src/lib/link-menu-options.js
-var link_menu_options = __webpack_require__(43);
+var link_menu_options = __webpack_require__(42);
 
 // EXTERNAL MODULE: ./content-src/components/LinkMenu/LinkMenu.jsx
-var LinkMenu = __webpack_require__(42);
+var LinkMenu = __webpack_require__(41);
 
 // EXTERNAL MODULE: external "React"
 var external_React_ = __webpack_require__(10);
 var external_React_default = /*#__PURE__*/__webpack_require__.n(external_React_);
 
 // EXTERNAL MODULE: ./content-src/lib/screenshot-utils.js
-var screenshot_utils = __webpack_require__(44);
+var screenshot_utils = __webpack_require__(43);
 
 // CONCATENATED MODULE: ./content-src/components/Card/Card.jsx
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "_Card", function() { return Card_Card; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "Card", function() { return Card; });
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "PlaceholderCard", function() { return PlaceholderCard; });
 function _asyncToGenerator(fn) { return function () { var gen = fn.apply(this, arguments); return new Promise(function (resolve, reject) { function step(key, arg) { try { var info = gen[key](arg); var value = info.value; } catch (error) { reject(error); return; } if (info.done) { resolve(value); } else { return Promise.resolve(value).then(function (value) { step("next", value); }, function (err) { step("throw", err); }); } } return step("next"); }); }; }
 
 
@@ -12486,33 +12434,33 @@ class Card_Card extends external_React_d
     );
   }
 }
 Card_Card.defaultProps = { link: {} };
 const Card = Object(external_ReactRedux_["connect"])(state => ({ platform: state.Prefs.values.platform }))(Object(external_ReactIntl_["injectIntl"])(Card_Card));
 const PlaceholderCard = props => external_React_default.a.createElement(Card, { placeholder: true, className: props.className });
 
 /***/ }),
-/* 61 */
+/* 60 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 
 // EXTERNAL MODULE: ./common/Actions.jsm
 var Actions = __webpack_require__(2);
 
 // EXTERNAL MODULE: external "ReactIntl"
 var external_ReactIntl_ = __webpack_require__(5);
 
 // EXTERNAL MODULE: external "React"
 var external_React_ = __webpack_require__(10);
 var external_React_default = /*#__PURE__*/__webpack_require__.n(external_React_);
 
 // EXTERNAL MODULE: ./content-src/components/TopSites/TopSitesConstants.js
-var TopSitesConstants = __webpack_require__(32);
+var TopSitesConstants = __webpack_require__(31);
 
 // CONCATENATED MODULE: ./content-src/components/TopSites/TopSiteFormInput.jsx
 
 
 
 class TopSiteFormInput_TopSiteFormInput extends external_React_default.a.PureComponent {
   constructor(props) {
     super(props);
@@ -12580,17 +12528,17 @@ class TopSiteFormInput_TopSiteFormInput 
 }
 
 TopSiteFormInput_TopSiteFormInput.defaultProps = {
   showClearButton: false,
   value: "",
   validationError: false
 };
 // EXTERNAL MODULE: ./content-src/components/TopSites/TopSite.jsx
-var TopSite = __webpack_require__(41);
+var TopSite = __webpack_require__(40);
 
 // CONCATENATED MODULE: ./content-src/components/TopSites/TopSiteForm.jsx
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "TopSiteForm", function() { return TopSiteForm_TopSiteForm; });
 
 
 
 
 
--- a/browser/components/newtab/lib/TelemetryFeed.jsm
+++ b/browser/components/newtab/lib/TelemetryFeed.jsm
@@ -262,27 +262,50 @@ this.TelemetryFeed = class TelemetryFeed
   endSession(portID) {
     const session = this.sessions.get(portID);
 
     if (!session) {
       // It's possible the tab was never visible – in which case, there was no user session.
       return;
     }
 
+    this.sendDiscoveryStreamImpressions(portID, session);
+
     if (session.perf.visibility_event_rcvd_ts) {
       session.session_duration = Math.round(perfService.absNow() - session.perf.visibility_event_rcvd_ts);
     }
 
     let sessionEndEvent = this.createSessionEndEvent(session);
     this.sendEvent(sessionEndEvent);
     this.sendUTEvent(sessionEndEvent, this.utEvents.sendSessionEndEvent);
     this.sessions.delete(portID);
   }
 
   /**
+   * Send impression pings for Discovery Stream for a given session.
+   *
+   * @note the impression reports are stored in session.impressionSets for different
+   * sources, and will be sent separately accordingly.
+   *
+   * @param {String} port  The session port with which this is associated
+   * @param {Object} session  The session object
+   */
+  sendDiscoveryStreamImpressions(port, session) {
+    const {impressionSets} = session;
+
+    if (!impressionSets) {
+      return;
+    }
+
+    Object.keys(impressionSets).forEach(source => {
+      this.sendEvent(this.createImpressionStats(port, {source, tiles: impressionSets[source]}));
+    });
+  }
+
+  /**
    * handlePagePrerendered - Set the session as prerendered
    *
    * @param  {string} portID the portID of the target session
    */
   handlePagePrerendered(portID) {
     const session = this.sessions.get(portID);
 
     if (!session) {
@@ -327,24 +350,24 @@ this.TelemetryFeed = class TelemetryFeed
       }
     }
     return ping;
   }
 
   /**
    * createImpressionStats - Create a ping for an impression stats
    *
-   * @param  {ob} action The object with data to be included in the ping.
-   *                     For some user interactions.
+   * @param  {string} portID The portID of the open session
+   * @param  {ob} data The data object to be included in the ping.
    * @return {obj}    A telemetry ping
    */
-  createImpressionStats(action) {
+  createImpressionStats(portID, data) {
     return Object.assign(
-      this.createPing(au.getPortIdOfSender(action)),
-      action.data,
+      this.createPing(portID),
+      data,
       {
         action: "activity_stream_impression_stats",
         impression_id: this._impressionId,
         client_id: "n/a",
         session_id: "n/a",
       }
     );
   }
@@ -468,17 +491,17 @@ this.TelemetryFeed = class TelemetryFeed
   sendASRouterEvent(event_object) {
     if (this.telemetryEnabled) {
       this.pingCentreForASRouter.sendPing(event_object,
       {filter: ACTIVITY_STREAM_ID});
     }
   }
 
   handleImpressionStats(action) {
-    this.sendEvent(this.createImpressionStats(action));
+    this.sendEvent(this.createImpressionStats(au.getPortIdOfSender(action), action.data));
   }
 
   handleUserEvent(action) {
     let userEvent = this.createUserEvent(action);
     this.sendEvent(userEvent);
     this.sendUTEvent(userEvent, this.utEvents.sendUserEvent);
   }
 
@@ -566,16 +589,19 @@ this.TelemetryFeed = class TelemetryFeed
         this.handlePagePrerendered(au.getPortIdOfSender(action));
         break;
       case at.SAVE_SESSION_PERF_DATA:
         this.saveSessionPerfData(au.getPortIdOfSender(action), action.data);
         break;
       case at.TELEMETRY_IMPRESSION_STATS:
         this.handleImpressionStats(action);
         break;
+      case at.DISCOVERY_STREAM_IMPRESSION_STATS:
+        this.handleDiscoveryStreamImpressionStats(au.getPortIdOfSender(action), action.data);
+        break;
       case at.TELEMETRY_UNDESIRED_EVENT:
         this.handleUndesiredEvent(action);
         break;
       case at.TELEMETRY_USER_EVENT:
         this.handleUserEvent(action);
         break;
       case at.AS_ROUTER_TELEMETRY_USER_EVENT:
         this.handleASRouterUserEvent(action);
@@ -585,16 +611,43 @@ this.TelemetryFeed = class TelemetryFeed
         break;
       case at.UNINIT:
         this.uninit();
         break;
     }
   }
 
   /**
+   * Handle impression stats actions from Discovery Stream. The data will be
+   * stored into the session.impressionSets object for the given port, so that
+   * it is sent to the server when the session ends.
+   *
+   * @note session.impressionSets will be keyed on `source` of the `data`,
+   * all the data will be appended to an array for the same source.
+   *
+   * @param {String} port  The session port with which this is associated
+   * @param {Object} data  The impression data structured as {source: "SOURCE", tiles: [{id: 123}]}
+   *
+   */
+  handleDiscoveryStreamImpressionStats(port, data) {
+    let session = this.sessions.get(port);
+
+    if (!session) {
+      throw new Error("Session does not exist.");
+    }
+
+    const impressionSets = session.impressionSets || {};
+    const impressions = impressionSets[data.source] || [];
+    // The payload might contain other properties, we only need `id` here.
+    data.tiles.forEach(tile => impressions.push({id: tile.id}));
+    impressionSets[data.source] = impressions;
+    session.impressionSets = impressionSets;
+  }
+
+  /**
    * Take all enumerable members of the data object and merge them into
    * the session.perf object for the given port, so that it is sent to the
    * server when the session ends.  All members of the data object should
    * be valid values of the perf object, as defined in pings.js and the
    * data*.md documentation.
    *
    * @note Any existing keys with the same names already in the
    * session perf object will be overwritten by values passed in here.
--- a/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/ImpressionStats.test.jsx
+++ b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/ImpressionStats.test.jsx
@@ -1,19 +1,40 @@
+"use strict";
+
+import {ImpressionStats, INTERSECTION_RATIO} from "content-src/components/DiscoveryStreamImpressionStats/ImpressionStats";
 import {actionTypes as at} from "common/Actions.jsm";
-import {ImpressionStats} from "content-src/components/DiscoveryStreamImpressionStats/ImpressionStats";
 import React from "react";
 import {shallow} from "enzyme";
 
-const SOURCE = "TEST_SOURCE";
+describe("<ImpressionStats>", () => {
+  const SOURCE = "TEST_SOURCE";
+  const FullIntersectEntries = [{isIntersecting: true, intersectionRatio: INTERSECTION_RATIO}];
+  const ZeroIntersectEntries = [{isIntersecting: false, intersectionRatio: 0}];
+  const PartialIntersectEntries = [{isIntersecting: true, intersectionRatio: INTERSECTION_RATIO / 2}];
 
-describe("<ImpressionStats>", () => {
+  // Build IntersectionObserver class with the arg `entries` for the intersect callback.
+  function buildIntersectionObserver(entries) {
+    return class {
+      constructor(callback) {
+        this.callback = callback;
+      }
+
+      observe() {
+        this.callback(entries);
+      }
+
+      unobserve() {}
+    };
+  }
+
   const DEFAULT_PROPS = {
     rows: [{id: 1}, {id: 2}, {id: 3}],
     source: SOURCE,
+    IntersectionObserver: buildIntersectionObserver(FullIntersectEntries),
     document: {
       visibilityState: "visible",
       addEventListener: sinon.stub(),
       removeEventListener: sinon.stub(),
     },
   };
 
   const InnerEl = () => (<div>Inner Element</div>);
@@ -23,74 +44,86 @@ describe("<ImpressionStats>", () => {
         <InnerEl />
       </ImpressionStats>);
   }
 
   it("should render props.children", () => {
     const wrapper = renderImpressionStats();
     assert.ok(wrapper.contains(<InnerEl />));
   });
-  it("should send impression with the right stats when the page loads", () => {
+  it("should not send impression when the wrapped item is not visbible", () => {
     const dispatch = sinon.spy();
-    const props = {dispatch};
+    const props = {dispatch, IntersectionObserver: buildIntersectionObserver(ZeroIntersectEntries)};
     renderImpressionStats(props);
 
-    assert.calledOnce(dispatch);
-
-    const [action] = dispatch.firstCall.args;
-    assert.equal(action.type, at.TELEMETRY_IMPRESSION_STATS);
-    assert.equal(action.data.source, SOURCE);
-    assert.deepEqual(action.data.tiles, [{id: 1}, {id: 2}, {id: 3}]);
+    assert.notCalled(dispatch);
   });
-  it("should send 1 impression when the page becomes visibile after loading", () => {
-    const props = {
-      document: {
-        visibilityState: "hidden",
-        addEventListener: sinon.spy(),
-        removeEventListener: sinon.spy(),
-      },
-      dispatch: sinon.spy(),
-    };
-
+  it("should not send impression when the wrapped item is visbible but below the ratio", () => {
+    const dispatch = sinon.spy();
+    const props = {dispatch, IntersectionObserver: buildIntersectionObserver(PartialIntersectEntries)};
     renderImpressionStats(props);
 
-    // Was the event listener added?
-    assert.calledWith(props.document.addEventListener, "visibilitychange");
-
-    // Make sure dispatch wasn't called yet
-    assert.notCalled(props.dispatch);
-
-    // Simulate a visibilityChange event
-    const [, listener] = props.document.addEventListener.firstCall.args;
-    props.document.visibilityState = "visible";
-    listener();
-
-    // Did we actually dispatch an event?
-    assert.calledOnce(props.dispatch);
-    assert.equal(props.dispatch.firstCall.args[0].type, at.TELEMETRY_IMPRESSION_STATS);
-
-    // Did we remove the event listener?
-    assert.calledWith(props.document.removeEventListener, "visibilitychange", listener);
+    assert.notCalled(dispatch);
   });
-  it("should remove visibility change listener when wrapper is removed", () => {
+  it("should not send impression when the page is not visible", () => {
+    const dispatch = sinon.spy();
     const props = {
-      dispatch: sinon.spy(),
+      dispatch,
       document: {
         visibilityState: "hidden",
         addEventListener: sinon.spy(),
         removeEventListener: sinon.spy(),
       },
     };
+    renderImpressionStats(props);
 
+    assert.notCalled(dispatch);
+  });
+  it("should send an impression when the page is visible and the wrapped item meets the visibility ratio", () => {
+    const dispatch = sinon.spy();
+    const props = {dispatch, IntersectionObserver: buildIntersectionObserver(FullIntersectEntries)};
+    renderImpressionStats(props);
+
+    assert.calledOnce(dispatch);
+
+    const [action] = dispatch.firstCall.args;
+    assert.equal(action.type, at.DISCOVERY_STREAM_IMPRESSION_STATS);
+    assert.equal(action.data.source, SOURCE);
+    assert.deepEqual(action.data.tiles, [{id: 1}, {id: 2}, {id: 3}]);
+  });
+  it("should send a DISCOVERY_STREAM_SPOC_IMPRESSION when the wrapped item has a campaignId", () => {
+    const dispatch = sinon.spy();
+    const campaignId = "a_campaign_id";
+    const props = {dispatch, campaignId, IntersectionObserver: buildIntersectionObserver(FullIntersectEntries)};
+    renderImpressionStats(props);
+
+    assert.calledTwice(dispatch);
+
+    const [action] = dispatch.firstCall.args;
+    assert.equal(action.type, at.DISCOVERY_STREAM_SPOC_IMPRESSION);
+    assert.deepEqual(action.data, {campaignId});
+  });
+  it("should send an impression when the wrapped item transiting from invisible to visible", () => {
+    const dispatch = sinon.spy();
+    const props = {dispatch, IntersectionObserver: buildIntersectionObserver(ZeroIntersectEntries, false)};
     const wrapper = renderImpressionStats(props);
-    assert.calledWith(props.document.addEventListener, "visibilitychange");
-    const [, listener] = props.document.addEventListener.firstCall.args;
+
+    assert.notCalled(dispatch);
 
-    wrapper.unmount();
-    assert.calledWith(props.document.removeEventListener, "visibilitychange", listener);
+    // Simulating the full intersection change with a row change
+    wrapper.setProps({
+      ...props,
+      ...{rows: [{id: 1}, {id: 2}, {id: 3}]},
+      ...{IntersectionObserver: buildIntersectionObserver(FullIntersectEntries)},
+    });
+
+    assert.calledOnce(dispatch);
+
+    const [action] = dispatch.firstCall.args;
+    assert.deepEqual(action.data.tiles, [{id: 1}, {id: 2}, {id: 3}]);
   });
   it("should send an impression if props are updated and props.rows are different", () => {
     const props = {dispatch: sinon.spy()};
     const wrapper = renderImpressionStats(props);
     props.dispatch.resetHistory();
 
     // New rows
     wrapper.setProps({...DEFAULT_PROPS, ...{rows: [{id: 4}]}});
@@ -101,16 +134,44 @@ describe("<ImpressionStats>", () => {
     const props = {dispatch: sinon.spy()};
     const wrapper = renderImpressionStats(props);
     props.dispatch.resetHistory();
 
     wrapper.setProps(DEFAULT_PROPS);
 
     assert.notCalled(props.dispatch);
   });
+  it("should remove visibility change listener when the wrapper is removed", () => {
+    const props = {
+      dispatch: sinon.spy(),
+      document: {
+        visibilityState: "hidden",
+        addEventListener: sinon.spy(),
+        removeEventListener: sinon.spy(),
+      },
+      IntersectionObserver,
+    };
+
+    const wrapper = renderImpressionStats(props);
+    assert.calledWith(props.document.addEventListener, "visibilitychange");
+    const [, listener] = props.document.addEventListener.firstCall.args;
+
+    wrapper.unmount();
+    assert.calledWith(props.document.removeEventListener, "visibilitychange", listener);
+  });
+  it("should unobserve the intersection observer when the wrapper is removed", () => {
+    const IntersectionObserver = buildIntersectionObserver(ZeroIntersectEntries);
+    const spy = sinon.spy(IntersectionObserver.prototype, "unobserve");
+    const props = {dispatch: sinon.spy(), IntersectionObserver};
+
+    const wrapper = renderImpressionStats(props);
+    wrapper.unmount();
+
+    assert.calledOnce(spy);
+  });
   it("should only send the latest impression on a visibility change", () => {
     const listeners = new Set();
     const props = {
       dispatch: sinon.spy(),
       document: {
         visibilityState: "hidden",
         addEventListener: (ev, cb) => listeners.add(cb),
         removeEventListener: (ev, cb) => listeners.delete(cb),
--- a/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/List.test.jsx
+++ b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/List.test.jsx
@@ -1,15 +1,15 @@
 import {_List as List, ListItem} from "content-src/components/DiscoveryStreamComponents/List/List";
 import {GlobalOverrider} from "test/unit/utils";
 import React from "react";
 import {shallow} from "enzyme";
 
 describe("<List> presentation component", () => {
-  const ValidRecommendations = [{url: 1}, {url: 2}, {campaign_id: 11, context: "test spoc", url: 3}];
+  const ValidRecommendations = [{url: 1}, {url: 2}, {context: "test spoc", url: 3}];
   const ValidListProps = {
     data: {
       recommendations: ValidRecommendations,
     },
     feed: {
       url: "fakeFeedUrl",
     },
     header: {
@@ -60,19 +60,16 @@ describe("<List> presentation component"
 
     const listItemUrls = wrapper.find(ListItem).map(i => i.prop("url"));
     assert.sameOrderedMembers(listItemUrls, [ValidRecommendations[1].url, ValidRecommendations[2].url]);
   });
 
   it("should return expected spoc ListItem", () => {
     const wrapper = shallow(<List {...ValidListProps} items={3} recStartingPoint={0} />);
 
-    const listItemCampaigns = wrapper.find(ListItem).map(i => i.prop("campaignId"));
-    assert.sameOrderedMembers(listItemCampaigns, [undefined, undefined, ValidRecommendations[2].campaign_id]);
-
     const listItemContext = wrapper.find(ListItem).map(i => i.prop("context"));
     assert.sameOrderedMembers(listItemContext, [undefined, undefined, ValidRecommendations[2].context]);
   });
 });
 
 describe("<ListItem> presentation component", () => {
   const ValidListItemProps = {
     url: "FAKE_URL",
--- a/browser/components/newtab/test/unit/lib/TelemetryFeed.test.js
+++ b/browser/components/newtab/test/unit/lib/TelemetryFeed.test.js
@@ -1,10 +1,10 @@
 /* global Services */
-import {actionCreators as ac, actionTypes as at} from "common/Actions.jsm";
+import {actionCreators as ac, actionTypes as at, actionUtils as au} from "common/Actions.jsm";
 import {
   ASRouterEventPing,
   BasePing,
   ImpressionStatsPing,
   PerfPing,
   SessionPing,
   UndesiredPing,
   UserEventPing,
@@ -457,44 +457,44 @@ describe("TelemetryFeed", () => {
         assert.propertyVal(ping.perf, "load_trigger_type", "unexpected");
       });
     });
   });
   describe("#createImpressionStats", () => {
     it("should create a valid impression stats ping", async () => {
       const tiles = [{id: 10001}, {id: 10002}, {id: 10003}];
       const action = ac.ImpressionStats({source: "POCKET", tiles});
-      const ping = await instance.createImpressionStats(action);
+      const ping = await instance.createImpressionStats(au.getPortIdOfSender(action), action.data);
 
       assert.validate(ping, ImpressionStatsPing);
       assert.propertyVal(ping, "source", "POCKET");
       assert.propertyVal(ping, "tiles", tiles);
     });
     it("should create a valid click ping", async () => {
       const tiles = [{id: 10001, pos: 2}];
       const action = ac.ImpressionStats({source: "POCKET", tiles, click: 0});
-      const ping = await instance.createImpressionStats(action);
+      const ping = await instance.createImpressionStats(au.getPortIdOfSender(action), action.data);
 
       assert.validate(ping, ImpressionStatsPing);
       assert.propertyVal(ping, "click", 0);
       assert.propertyVal(ping, "tiles", tiles);
     });
     it("should create a valid block ping", async () => {
       const tiles = [{id: 10001, pos: 2}];
       const action = ac.ImpressionStats({source: "POCKET", tiles, block: 0});
-      const ping = await instance.createImpressionStats(action);
+      const ping = await instance.createImpressionStats(au.getPortIdOfSender(action), action.data);
 
       assert.validate(ping, ImpressionStatsPing);
       assert.propertyVal(ping, "block", 0);
       assert.propertyVal(ping, "tiles", tiles);
     });
     it("should create a valid pocket ping", async () => {
       const tiles = [{id: 10001, pos: 2}];
       const action = ac.ImpressionStats({source: "POCKET", tiles, pocket: 0});
-      const ping = await instance.createImpressionStats(action);
+      const ping = await instance.createImpressionStats(au.getPortIdOfSender(action), action.data);
 
       assert.validate(ping, ImpressionStatsPing);
       assert.propertyVal(ping, "pocket", 0);
       assert.propertyVal(ping, "tiles", tiles);
     });
   });
   describe("#applyCFRPolicy", () => {
     it("should use client_id and message_id in prerelease", () => {
@@ -914,33 +914,45 @@ describe("TelemetryFeed", () => {
       instance.onAction(action);
 
       assert.calledWith(eventCreator, action);
       assert.calledWith(sendEvent, eventCreator.returnValue);
     });
     it("should send an event on a TELEMETRY_IMPRESSION_STATS action", () => {
       const sendEvent = sandbox.stub(instance, "sendEvent");
       const eventCreator = sandbox.stub(instance, "createImpressionStats");
-      const action = {type: at.TELEMETRY_IMPRESSION_STATS, data: {}};
+      const tiles = [{id: 10001}, {id: 10002}, {id: 10003}];
+      const action = ac.ImpressionStats({source: "POCKET", tiles});
 
       instance.onAction(action);
 
-      assert.calledWith(eventCreator, action);
+      assert.calledWith(eventCreator, au.getPortIdOfSender(action), action.data);
       assert.calledWith(sendEvent, eventCreator.returnValue);
     });
     it("should call .handlePagePrerendered on a PAGE_PRERENDERED action", () => {
       const session = {perf: {}};
       sandbox.stub(instance.sessions, "get").returns(session);
       sandbox.spy(instance, "handlePagePrerendered");
 
       instance.onAction(ac.AlsoToMain({type: at.PAGE_PRERENDERED}));
 
       assert.calledOnce(instance.handlePagePrerendered);
       assert.ok(session.perf.is_prerendered);
     });
+    it("should call .handleDiscoveryStreamImpressionStats on a DISCOVERY_STREAM_IMPRESSION_STATS action", () => {
+      const session = {};
+      sandbox.stub(instance.sessions, "get").returns(session);
+      const data = {source: "foo", tiles: [{id: 1}]};
+      const action = {type: at.DISCOVERY_STREAM_IMPRESSION_STATS, data};
+      sandbox.spy(instance, "handleDiscoveryStreamImpressionStats");
+
+      instance.onAction(ac.AlsoToMain(action, "port123"));
+
+      assert.calledWith(instance.handleDiscoveryStreamImpressionStats, "port123", data);
+    });
   });
   describe("#handlePagePrerendered", () => {
     it("should not throw if there is no session for the given port ID", () => {
       assert.doesNotThrow(() => instance.handlePagePrerendered("doesn't exist"));
     });
     it("should set the session as prerendered on a PAGE_PRERENDERED action", () => {
       const session = {perf: {}};
       sandbox.stub(instance.sessions, "get").returns(session);
@@ -1033,9 +1045,56 @@ describe("TelemetryFeed", () => {
       assert.equal(sendEvent.firstCall.args[0].event, "PAGE_TAKEOVER_DATA");
       assert.deepEqual(sendEvent.firstCall.args[0].value, {
         home_extension_id: ID,
         newtab_extension_id: ID,
       });
       assert.validate(sendEvent.firstCall.args[0], UserEventPing);
     });
   });
+  describe("#sendDiscoveryStreamImpressions", () => {
+    it("should not send impression pings if there is no impression data", () => {
+      const spy = sandbox.spy(instance, "sendEvent");
+      const session = {};
+      instance.sendDiscoveryStreamImpressions("foo", session);
+
+      assert.notCalled(spy);
+    });
+    it("should send impression pings if there is impression data", () => {
+      const spy = sandbox.spy(instance, "sendEvent");
+      const session = {
+        impressionSets: {
+          source_foo: [{id: 1}, {id: 2}],
+          source_bar: [{id: 3}, {id: 4}],
+        },
+      };
+      instance.sendDiscoveryStreamImpressions("foo", session);
+
+      assert.calledTwice(spy);
+    });
+  });
+  describe("#handleDiscoveryStreamImpressionStats", () => {
+    it("should throw for a missing session", () => {
+      assert.throws(() => {
+        instance.handleDiscoveryStreamImpressionStats("a_missing_port", {});
+      }, "Session does not exist.");
+    });
+    it("should store impression to impressionSets", () => {
+      const session = instance.addSession("new_session", "about:newtab");
+      instance.handleDiscoveryStreamImpressionStats("new_session", {source: "foo", tiles: [{id: 1}]});
+
+      assert.equal(Object.keys(session.impressionSets).length, 1);
+      assert.deepEqual(session.impressionSets.foo, [{id: 1}]);
+
+      // Add another ping with the same source
+      instance.handleDiscoveryStreamImpressionStats("new_session", {source: "foo", tiles: [{id: 2}]});
+
+      assert.deepEqual(session.impressionSets.foo, [{id: 1}, {id: 2}]);
+
+      // Add another ping with a different source
+      instance.handleDiscoveryStreamImpressionStats("new_session", {source: "bar", tiles: [{id: 3}]});
+
+      assert.equal(Object.keys(session.impressionSets).length, 2);
+      assert.deepEqual(session.impressionSets.foo, [{id: 1}, {id: 2}]);
+      assert.deepEqual(session.impressionSets.bar, [{id: 3}]);
+    });
+  });
 });