Bug 1594541 - Add documentation, taskcluster deployment migration, telemetry pipeline migration and bug fixes to New Tab Page r=fluent-reviewers,Mardak
authorScott <scott.downe@gmail.com>
Wed, 06 Nov 2019 20:43:58 +0000
changeset 501036 40a815ecfbe3b248bedafdba52851b774d31efc8
parent 501034 85aa4fa5722bdcb6e0d13f29951772d3c7806e42
child 501037 3fb206538741147ace9598234153588dac5618bd
push id36779
push usercsabou@mozilla.com
push dateThu, 07 Nov 2019 21:53:15 +0000
treeherdermozilla-central@7748cc7e9b63 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersfluent-reviewers, Mardak
bugs1594541
milestone72.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1594541 - Add documentation, taskcluster deployment migration, telemetry pipeline migration and bug fixes to New Tab Page r=fluent-reviewers,Mardak Differential Revision: https://phabricator.services.mozilla.com/D52097
browser/components/newtab/.taskcluster.yml
browser/components/newtab/content-src/asrouter/asrouter-content.jsx
browser/components/newtab/content-src/asrouter/templates/Trailhead/_Trailhead.scss
browser/components/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/_CardGrid.scss
browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/_DSCard.scss
browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSPrivacyModal/DSPrivacyModal.jsx
browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSPrivacyModal/_DSPrivacyModal.scss
browser/components/newtab/content-src/components/DiscoveryStreamComponents/Hero/_Hero.scss
browser/components/newtab/content-src/components/DiscoveryStreamComponents/List/_List.scss
browser/components/newtab/content-src/styles/_mixins.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/docs/v2-system-addon/data_dictionary.md
browser/components/newtab/docs/v2-system-addon/data_events.md
browser/components/newtab/docs/v2-system-addon/preferences.md
browser/components/newtab/lib/ASRouter.jsm
browser/components/newtab/lib/ActivityStream.jsm
browser/components/newtab/lib/DiscoveryStreamFeed.jsm
browser/components/newtab/lib/TelemetryFeed.jsm
browser/components/newtab/lib/ToolbarPanelHub.jsm
browser/components/newtab/locales-src/asrouter.ftl
browser/components/newtab/test/schemas/pings.js
browser/components/newtab/test/unit/asrouter/ASRouter.test.js
browser/components/newtab/test/unit/lib/DiscoveryStreamFeed.test.js
browser/components/newtab/test/unit/lib/TelemetryFeed.test.js
browser/components/newtab/test/unit/lib/ToolbarPanelHub.test.js
browser/modules/PingCentre.jsm
--- a/browser/components/newtab/.taskcluster.yml
+++ b/browser/components/newtab/.taskcluster.yml
@@ -9,18 +9,18 @@ tasks:
         $if: 'tasks_for == "github-push"'
         then: ${event.repository.clone_url}
         else: ${event.pull_request.head.repo.clone_url}
       ref:
         $if: 'tasks_for == "github-push"'
         then: ${event.after}
         else: ${event.pull_request.head.sha}
     in:
-    - provisionerId: aws-provisioner-v1
-      workerType: github-worker
+    - provisionerId: proj-misc
+      workerType: ci
       deadline: ${fromNow('1 day')}
       payload:
         maxRunTime: 7200
         image: piatra/asmochitests
         command:
           - /bin/bash
           - '--login'
           - '-c'
--- a/browser/components/newtab/content-src/asrouter/asrouter-content.jsx
+++ b/browser/components/newtab/content-src/asrouter/asrouter-content.jsx
@@ -185,22 +185,22 @@ export class ASRouterUISurface extends R
     if (this.state.message.provider === "preview") {
       return;
     }
 
     ASRouterUtils.sendMessage({ type: "IMPRESSION", data: this.state.message });
     this.sendUserActionTelemetry({ event: "IMPRESSION", ...extraProps });
   }
 
-  // If link has a `metric` data attribute send it as part of the `value`
+  // If link has a `metric` data attribute send it as part of the `event_context`
   // telemetry field which can have arbitrary values.
   // Used for router messages with links as part of the content.
   sendClick(event) {
     const metric = {
-      value: event.target.dataset.metric,
+      event_context: event.target.dataset.metric,
       // Used for the `source` of the event. Needed to differentiate
       // from other snippet or onboarding events that may occur.
       id: "NEWTAB_FOOTER_BAR_CONTENT",
     };
     const action = {
       type: event.target.dataset.action,
       data: { args: event.target.dataset.args },
     };
--- a/browser/components/newtab/content-src/asrouter/templates/Trailhead/_Trailhead.scss
+++ b/browser/components/newtab/content-src/asrouter/templates/Trailhead/_Trailhead.scss
@@ -327,17 +327,17 @@
   overflow: hidden;
 }
 
 .inline-onboarding {
   &.activity-stream.welcome {
     overflow-y: hidden;
   }
 
-  .modalOverlayInner {
+  .trailhead.modalOverlayInner {
     position: absolute;
   }
 
   .outer-wrapper {
     position: relative;
     display: block;
 
     .prefs-button {
--- a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/_CardGrid.scss
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/_CardGrid.scss
@@ -62,21 +62,21 @@
       grid-template-columns: repeat(3, 1fr);
 
       .title {
         font-size: 17px;
         line-height: 24px;
       }
 
       .excerpt {
-        @include limit-visibile-lines(3, 24, 15);
+        @include limit-visible-lines(3, 24, 15);
       }
     }
 
     &.ds-card-grid-divisible-by-4 .title {
-      @include limit-visibile-lines(3, 20, 15);
+      @include limit-visible-lines(3, 20, 15);
     }
   }
 
   &.empty {
     grid-template-columns: auto;
   }
 }
--- a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/_DSCard.scss
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/_DSCard.scss
@@ -91,23 +91,23 @@
     flex-grow: 1;
 
     .info-wrap {
       flex-grow: 1;
     }
 
     .title {
       // show only 3 lines of copy
-      @include limit-visibile-lines(3, $header-line-height, $header-font-size);
+      @include limit-visible-lines(3, $header-line-height, $header-font-size);
       font-weight: 600;
     }
 
     .excerpt {
       // show only 3 lines of copy
-      @include limit-visibile-lines(
+      @include limit-visible-lines(
         3,
         $excerpt-line-height,
         $excerpt-font-size
       );
     }
 
     .source {
       @include dark-theme-only {
@@ -118,55 +118,56 @@
       margin-bottom: 2px;
       font-size: 13px;
       color: $grey-50;
     }
 
     .cta-button {
       @include dark-theme-only {
         color: $grey-10;
-        background: $grey-90-30;
+        background: $grey-90-70;
       }
+
       width: 100%;
       margin: 12px 0 4px;
       box-shadow: none;
       border-radius: 4px;
       height: 32px;
       font-size: 14px;
       font-weight: 600;
       padding: 5px 8px 7px;
       border: 0;
       color: $grey-90;
       background: $grey-90-10;
 
+      &:focus {
+        @include dark-theme-only {
+          background: $grey-90-70;
+          box-shadow: 0 0 0 2px $grey-80, 0 0 0 5px $blue-50-50;
+        }
+
+        background: $grey-90-10;
+        box-shadow: 0 0 0 2px $white, 0 0 0 5px $blue-50-50;
+      }
+
+      &:hover {
+        @include dark-theme-only {
+          background: $grey-90-50;
+        }
+
+        background: $grey-90-20;
+      }
 
       &:active {
         @include dark-theme-only {
           background: $grey-90-70;
         }
 
         background: $grey-90-30;
       }
-
-      &:hover {
-        @include dark-theme-only {
-          background: $grey-90-50;
-        }
-
-        background: $grey-90-30;
-      }
-
-      &:focus {
-        @include dark-theme-only {
-          background: $grey-90-30;
-          box-shadow: 0 0 0 2px $grey-80, 0 0 0 5px $blue-50-50;
-        }
-
-        box-shadow: 0 0 0 2px $white, 0 0 0 5px $blue-50-50;
-      }
     }
 
     .cta-link {
       @include dark-theme-only {
         color: $blue-40;
         fill: $blue-40;
       }
 
--- a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSPrivacyModal/DSPrivacyModal.jsx
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSPrivacyModal/DSPrivacyModal.jsx
@@ -1,15 +1,14 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this file,
  * You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 import React from "react";
 import { actionCreators as ac } from "common/Actions.jsm";
-import { SafeAnchor } from "../SafeAnchor/SafeAnchor";
 import { ModalOverlayWrapper } from "content-src/asrouter/components/ModalOverlay/ModalOverlay";
 
 export class DSPrivacyModal extends React.PureComponent {
   constructor(props) {
     super(props);
     this.closeModal = this.closeModal.bind(this);
     this.onLinkClick = this.onLinkClick.bind(this);
   }
@@ -34,22 +33,21 @@ export class DSPrivacyModal extends Reac
     return (
       <ModalOverlayWrapper
         onClose={this.closeModal}
         innerClassName="ds-privacy-modal"
       >
         <div className="privacy-notice">
           <h3 data-l10n-id="newtab-privacy-modal-header" />
           <p data-l10n-id="newtab-privacy-modal-paragraph" />
-          <SafeAnchor
-            onLinkClick={this.onLinkClick}
-            url="https://www.mozilla.org/en-US/privacy/firefox/"
-          >
-            <span data-l10n-id="newtab-privacy-modal-link" />
-          </SafeAnchor>
+          <a
+            data-l10n-id="newtab-privacy-modal-link"
+            onClick={this.onLinkClick}
+            href="https://www.mozilla.org/en-US/privacy/firefox/"
+          />
         </div>
         <section className="actions">
           <button
             className="done"
             type="submit"
             onClick={this.closeModal}
             data-l10n-id="newtab-privacy-modal-button-done"
           />
--- a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSPrivacyModal/_DSPrivacyModal.scss
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSPrivacyModal/_DSPrivacyModal.scss
@@ -1,7 +1,11 @@
 .ds-privacy-modal {
+  a:hover {
+    text-decoration: underline;
+  }
+
   .privacy-notice {
     width: 492px;
     padding: 40px 0;
     margin: auto;
   }
 }
--- a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Hero/_Hero.scss
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Hero/_Hero.scss
@@ -9,17 +9,17 @@
   }
 
   p {
     line-height: 1.538;
     margin: 8px 0;
   }
 
   .excerpt {
-    @include limit-visibile-lines(3, 24, 15);
+    @include limit-visible-lines(3, 24, 15);
     @include dark-theme-only {
       color: $grey-10;
     }
 
     color: $grey-90;
     margin: 0 0 10px;
   }
 
@@ -118,17 +118,17 @@
         flex: 1;
       }
 
       header {
         @include dark-theme-only {
           color: $white;
         }
 
-        @include limit-visibile-lines(4, 28, 22);
+        @include limit-visible-lines(4, 28, 22);
         color: $grey-90;
         margin-bottom: 0;
       }
 
       .context,
       .source {
         margin: 0 0 4px;
       }
@@ -228,17 +228,17 @@
       }
 
       .meta {
         flex-grow: 1;
         display: flex;
         padding: 0 24px 0 0;
 
         header {
-          @include limit-visibile-lines(3, 28, 22);
+          @include limit-visible-lines(3, 28, 22);
         }
 
         .source {
           margin-bottom: 0;
         }
       }
     }
 
@@ -265,17 +265,17 @@
           }
         }
 
         .title {
           @include dark-theme-only {
             color: $white;
           }
 
-          @include limit-visibile-lines(3, 20, 14);
+          @include limit-visible-lines(3, 20, 14);
         }
       }
     }
   }
 
   &.empty {
     grid-template-columns: auto;
   }
--- a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/List/_List.scss
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/List/_List.scss
@@ -20,17 +20,17 @@
   .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);
+    @include limit-visible-lines(3, $line-height, $font-size);
   }
 
   .ds-list-image {
     min-width: $image-size;
     width: $image-size;
   }
 }
 
@@ -200,32 +200,32 @@
     mix-blend-mode: normal;
 
     display: flex;
     justify-content: space-between;
     height: 100%;
   }
 
   .ds-list-item-excerpt {
-    @include limit-visibile-lines(2, $item-line-height, $item-font-size);
+    @include limit-visible-lines(2, $item-line-height, $item-font-size);
     @include dark-theme-only {
       color: $grey-10-80;
     }
     color: $grey-50;
     margin: 4px 0 8px;
   }
 
   p {
     font-size: $item-font-size * 1px;
     line-height: $item-line-height * 1px;
     margin: 0;
   }
 
   .ds-list-item-info {
-    @include limit-visibile-lines(1, $item-line-height, $item-font-size);
+    @include limit-visible-lines(1, $item-line-height, $item-font-size);
     @include dark-theme-only {
       color: $grey-40;
     }
 
     color: $grey-50;
     font-size: 13px;
   }
 
--- a/browser/components/newtab/content-src/styles/_mixins.scss
+++ b/browser/components/newtab/content-src/styles/_mixins.scss
@@ -5,17 +5,17 @@
   background-repeat: no-repeat;
   background-size: cover;
   border-radius: 4px;
   box-shadow: inset 0 0 0 0.5px $black-15;
 }
 
 // Note: lineHeight and fontSize should be unitless but can be derived from pixel values
 // Bug 1550624 to clean up / remove this mixin to avoid duplicate styles
-@mixin limit-visibile-lines($line-count, $line-height, $font-size) {
+@mixin limit-visible-lines($line-count, $line-height, $font-size) {
   font-size: $font-size * 1px;
   -webkit-line-clamp: $line-count;
   line-height: $line-height * 1px;
 }
 
 @mixin dark-theme-only {
   [lwt-newtab-brighttext] & {
     @content;
--- a/browser/components/newtab/css/activity-stream-linux.css
+++ b/browser/components/newtab/css/activity-stream-linux.css
@@ -2737,30 +2737,31 @@ main {
       font-size: 14px;
       font-weight: 600;
       padding: 5px 8px 7px;
       border: 0;
       color: #0C0C0D;
       background: rgba(12, 12, 13, 0.1); }
       [lwt-newtab-brighttext] .ds-card .meta .cta-button {
         color: #F9F9FA;
-        background: rgba(12, 12, 13, 0.3); }
+        background: rgba(12, 12, 13, 0.7); }
+      .ds-card .meta .cta-button:focus {
+        background: rgba(12, 12, 13, 0.1);
+        box-shadow: 0 0 0 2px #FFF, 0 0 0 5px rgba(10, 132, 255, 0.5); }
+        [lwt-newtab-brighttext] .ds-card .meta .cta-button:focus {
+          background: rgba(12, 12, 13, 0.7);
+          box-shadow: 0 0 0 2px #2A2A2E, 0 0 0 5px rgba(10, 132, 255, 0.5); }
+      .ds-card .meta .cta-button:hover {
+        background: rgba(12, 12, 13, 0.2); }
+        [lwt-newtab-brighttext] .ds-card .meta .cta-button:hover {
+          background: rgba(12, 12, 13, 0.5); }
       .ds-card .meta .cta-button:active {
         background: rgba(12, 12, 13, 0.3); }
         [lwt-newtab-brighttext] .ds-card .meta .cta-button:active {
           background: rgba(12, 12, 13, 0.7); }
-      .ds-card .meta .cta-button:hover {
-        background: rgba(12, 12, 13, 0.3); }
-        [lwt-newtab-brighttext] .ds-card .meta .cta-button:hover {
-          background: rgba(12, 12, 13, 0.5); }
-      .ds-card .meta .cta-button:focus {
-        box-shadow: 0 0 0 2px #FFF, 0 0 0 5px rgba(10, 132, 255, 0.5); }
-        [lwt-newtab-brighttext] .ds-card .meta .cta-button:focus {
-          background: rgba(12, 12, 13, 0.3);
-          box-shadow: 0 0 0 2px #2A2A2E, 0 0 0 5px rgba(10, 132, 255, 0.5); }
     .ds-card .meta .cta-link {
       font-size: 15px;
       font-weight: 600;
       line-height: 24px;
       height: 24px;
       width: auto;
       background-size: auto;
       background-position: right 1.5px;
@@ -3105,16 +3106,19 @@ main {
     margin: 0 0 0 4px;
     width: 5px;
     height: 8px;
     text-decoration: none;
     display: inline-block; }
     [lwt-newtab-brighttext] .ds-chevron-link::after {
       background-color: #45A1FF; }
 
+.ds-privacy-modal a:hover {
+  text-decoration: underline; }
+
 .ds-privacy-modal .privacy-notice {
   width: 492px;
   padding: 40px 0;
   margin: auto; }
 
 .ASRouterButton {
   font-weight: 600;
   font-size: 14px;
@@ -4239,17 +4243,17 @@ body[lwt-newtab-brighttext] .scene2Icon 
     text-align: center; }
 
 .activity-stream.welcome {
   overflow: hidden; }
 
 .inline-onboarding.activity-stream.welcome {
   overflow-y: hidden; }
 
-.inline-onboarding .modalOverlayInner {
+.inline-onboarding .trailhead.modalOverlayInner {
   position: absolute; }
 
 .inline-onboarding .outer-wrapper {
   position: relative;
   display: block; }
   .inline-onboarding .outer-wrapper .prefs-button button {
     position: absolute; }
 
--- a/browser/components/newtab/css/activity-stream-mac.css
+++ b/browser/components/newtab/css/activity-stream-mac.css
@@ -2740,30 +2740,31 @@ main {
       font-size: 14px;
       font-weight: 600;
       padding: 5px 8px 7px;
       border: 0;
       color: #0C0C0D;
       background: rgba(12, 12, 13, 0.1); }
       [lwt-newtab-brighttext] .ds-card .meta .cta-button {
         color: #F9F9FA;
-        background: rgba(12, 12, 13, 0.3); }
+        background: rgba(12, 12, 13, 0.7); }
+      .ds-card .meta .cta-button:focus {
+        background: rgba(12, 12, 13, 0.1);
+        box-shadow: 0 0 0 2px #FFF, 0 0 0 5px rgba(10, 132, 255, 0.5); }
+        [lwt-newtab-brighttext] .ds-card .meta .cta-button:focus {
+          background: rgba(12, 12, 13, 0.7);
+          box-shadow: 0 0 0 2px #2A2A2E, 0 0 0 5px rgba(10, 132, 255, 0.5); }
+      .ds-card .meta .cta-button:hover {
+        background: rgba(12, 12, 13, 0.2); }
+        [lwt-newtab-brighttext] .ds-card .meta .cta-button:hover {
+          background: rgba(12, 12, 13, 0.5); }
       .ds-card .meta .cta-button:active {
         background: rgba(12, 12, 13, 0.3); }
         [lwt-newtab-brighttext] .ds-card .meta .cta-button:active {
           background: rgba(12, 12, 13, 0.7); }
-      .ds-card .meta .cta-button:hover {
-        background: rgba(12, 12, 13, 0.3); }
-        [lwt-newtab-brighttext] .ds-card .meta .cta-button:hover {
-          background: rgba(12, 12, 13, 0.5); }
-      .ds-card .meta .cta-button:focus {
-        box-shadow: 0 0 0 2px #FFF, 0 0 0 5px rgba(10, 132, 255, 0.5); }
-        [lwt-newtab-brighttext] .ds-card .meta .cta-button:focus {
-          background: rgba(12, 12, 13, 0.3);
-          box-shadow: 0 0 0 2px #2A2A2E, 0 0 0 5px rgba(10, 132, 255, 0.5); }
     .ds-card .meta .cta-link {
       font-size: 15px;
       font-weight: 600;
       line-height: 24px;
       height: 24px;
       width: auto;
       background-size: auto;
       background-position: right 1.5px;
@@ -3108,16 +3109,19 @@ main {
     margin: 0 0 0 4px;
     width: 5px;
     height: 8px;
     text-decoration: none;
     display: inline-block; }
     [lwt-newtab-brighttext] .ds-chevron-link::after {
       background-color: #45A1FF; }
 
+.ds-privacy-modal a:hover {
+  text-decoration: underline; }
+
 .ds-privacy-modal .privacy-notice {
   width: 492px;
   padding: 40px 0;
   margin: auto; }
 
 .ASRouterButton {
   font-weight: 600;
   font-size: 14px;
@@ -4242,17 +4246,17 @@ body[lwt-newtab-brighttext] .scene2Icon 
     text-align: center; }
 
 .activity-stream.welcome {
   overflow: hidden; }
 
 .inline-onboarding.activity-stream.welcome {
   overflow-y: hidden; }
 
-.inline-onboarding .modalOverlayInner {
+.inline-onboarding .trailhead.modalOverlayInner {
   position: absolute; }
 
 .inline-onboarding .outer-wrapper {
   position: relative;
   display: block; }
   .inline-onboarding .outer-wrapper .prefs-button button {
     position: absolute; }
 
--- a/browser/components/newtab/css/activity-stream-windows.css
+++ b/browser/components/newtab/css/activity-stream-windows.css
@@ -2737,30 +2737,31 @@ main {
       font-size: 14px;
       font-weight: 600;
       padding: 5px 8px 7px;
       border: 0;
       color: #0C0C0D;
       background: rgba(12, 12, 13, 0.1); }
       [lwt-newtab-brighttext] .ds-card .meta .cta-button {
         color: #F9F9FA;
-        background: rgba(12, 12, 13, 0.3); }
+        background: rgba(12, 12, 13, 0.7); }
+      .ds-card .meta .cta-button:focus {
+        background: rgba(12, 12, 13, 0.1);
+        box-shadow: 0 0 0 2px #FFF, 0 0 0 5px rgba(10, 132, 255, 0.5); }
+        [lwt-newtab-brighttext] .ds-card .meta .cta-button:focus {
+          background: rgba(12, 12, 13, 0.7);
+          box-shadow: 0 0 0 2px #2A2A2E, 0 0 0 5px rgba(10, 132, 255, 0.5); }
+      .ds-card .meta .cta-button:hover {
+        background: rgba(12, 12, 13, 0.2); }
+        [lwt-newtab-brighttext] .ds-card .meta .cta-button:hover {
+          background: rgba(12, 12, 13, 0.5); }
       .ds-card .meta .cta-button:active {
         background: rgba(12, 12, 13, 0.3); }
         [lwt-newtab-brighttext] .ds-card .meta .cta-button:active {
           background: rgba(12, 12, 13, 0.7); }
-      .ds-card .meta .cta-button:hover {
-        background: rgba(12, 12, 13, 0.3); }
-        [lwt-newtab-brighttext] .ds-card .meta .cta-button:hover {
-          background: rgba(12, 12, 13, 0.5); }
-      .ds-card .meta .cta-button:focus {
-        box-shadow: 0 0 0 2px #FFF, 0 0 0 5px rgba(10, 132, 255, 0.5); }
-        [lwt-newtab-brighttext] .ds-card .meta .cta-button:focus {
-          background: rgba(12, 12, 13, 0.3);
-          box-shadow: 0 0 0 2px #2A2A2E, 0 0 0 5px rgba(10, 132, 255, 0.5); }
     .ds-card .meta .cta-link {
       font-size: 15px;
       font-weight: 600;
       line-height: 24px;
       height: 24px;
       width: auto;
       background-size: auto;
       background-position: right 1.5px;
@@ -3105,16 +3106,19 @@ main {
     margin: 0 0 0 4px;
     width: 5px;
     height: 8px;
     text-decoration: none;
     display: inline-block; }
     [lwt-newtab-brighttext] .ds-chevron-link::after {
       background-color: #45A1FF; }
 
+.ds-privacy-modal a:hover {
+  text-decoration: underline; }
+
 .ds-privacy-modal .privacy-notice {
   width: 492px;
   padding: 40px 0;
   margin: auto; }
 
 .ASRouterButton {
   font-weight: 600;
   font-size: 14px;
@@ -4239,17 +4243,17 @@ body[lwt-newtab-brighttext] .scene2Icon 
     text-align: center; }
 
 .activity-stream.welcome {
   overflow: hidden; }
 
 .inline-onboarding.activity-stream.welcome {
   overflow-y: hidden; }
 
-.inline-onboarding .modalOverlayInner {
+.inline-onboarding .trailhead.modalOverlayInner {
   position: absolute; }
 
 .inline-onboarding .outer-wrapper {
   position: relative;
   display: block; }
   .inline-onboarding .outer-wrapper .prefs-button button {
     position: absolute; }
 
--- a/browser/components/newtab/data/content/activity-stream.bundle.js
+++ b/browser/components/newtab/data/content/activity-stream.bundle.js
@@ -1997,24 +1997,24 @@ class ASRouterUISurface extends react__W
     ASRouterUtils.sendMessage({
       type: "IMPRESSION",
       data: this.state.message
     });
     this.sendUserActionTelemetry({
       event: "IMPRESSION",
       ...extraProps
     });
-  } // If link has a `metric` data attribute send it as part of the `value`
+  } // If link has a `metric` data attribute send it as part of the `event_context`
   // telemetry field which can have arbitrary values.
   // Used for router messages with links as part of the content.
 
 
   sendClick(event) {
     const metric = {
-      value: event.target.dataset.metric,
+      event_context: event.target.dataset.metric,
       // Used for the `source` of the event. Needed to differentiate
       // from other snippet or onboarding events that may occur.
       id: "NEWTAB_FOOTER_BAR_CONTENT"
     };
     const action = {
       type: event.target.dataset.action,
       data: {
         args: event.target.dataset.args
@@ -8951,17 +8951,16 @@ var ModalOverlay = __webpack_require__(2
 
 // CONCATENATED MODULE: ./content-src/components/DiscoveryStreamComponents/DSPrivacyModal/DSPrivacyModal.jsx
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this file,
  * You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 
 
-
 class DSPrivacyModal_DSPrivacyModal extends external_React_default.a.PureComponent {
   constructor(props) {
     super(props);
     this.closeModal = this.closeModal.bind(this);
     this.onLinkClick = this.onLinkClick.bind(this);
   }
 
   onLinkClick(event) {
@@ -8983,22 +8982,21 @@ class DSPrivacyModal_DSPrivacyModal exte
       onClose: this.closeModal,
       innerClassName: "ds-privacy-modal"
     }, external_React_default.a.createElement("div", {
       className: "privacy-notice"
     }, external_React_default.a.createElement("h3", {
       "data-l10n-id": "newtab-privacy-modal-header"
     }), external_React_default.a.createElement("p", {
       "data-l10n-id": "newtab-privacy-modal-paragraph"
-    }), external_React_default.a.createElement(SafeAnchor_SafeAnchor, {
-      onLinkClick: this.onLinkClick,
-      url: "https://www.mozilla.org/en-US/privacy/firefox/"
-    }, external_React_default.a.createElement("span", {
-      "data-l10n-id": "newtab-privacy-modal-link"
-    }))), external_React_default.a.createElement("section", {
+    }), external_React_default.a.createElement("a", {
+      "data-l10n-id": "newtab-privacy-modal-link",
+      onClick: this.onLinkClick,
+      href: "https://www.mozilla.org/en-US/privacy/firefox/"
+    })), external_React_default.a.createElement("section", {
       className: "actions"
     }, external_React_default.a.createElement("button", {
       className: "done",
       type: "submit",
       onClick: this.closeModal,
       "data-l10n-id": "newtab-privacy-modal-button-done"
     })));
   }
--- a/browser/components/newtab/docs/v2-system-addon/data_dictionary.md
+++ b/browser/components/newtab/docs/v2-system-addon/data_dictionary.md
@@ -193,32 +193,34 @@ Schema definitions/validations that can 
   "client_id": "n/a",
   "action": ["snippets_user_event" | "onboarding_user_event"],
   "impression_id": "{005deed0-e3e4-4c02-a041-17405fd703f6}",
   "source": "pocket",
   "addon_version": "20180710100040",
   "locale": "en-US",
   "source": "NEWTAB_FOOTER_BAR",
   "message_id": "some_snippet_id",
-  "event": "IMPRESSION"
+  "event": "IMPRESSION",
+  "event_context": "{\"view\":\"application_menu\"}"
 }
 ```
 
 | KEY | DESCRIPTION | &nbsp; |
 |-----|-------------|:-----:|
 | `action_position` | [Optional] The index of the element in the `source` that was clicked. | :one:
 | `action` | [Required] Either `activity_stream_event`, `activity_stream_session`, or `activity_stream_performance`. | :one:
 | `addon_version` | [Required] Firefox build ID, i.e. `Services.appinfo.appBuildID`. | :one:
 | `client_id` | [Required] An identifier for this client. | :one:
 | `card_type` | [Optional] ("bookmark", "pocket", "trending", "pinned", "search", "spoc") | :one:
 | `search_vendor` | [Optional] the vendor of the search shortcut, one of ("google", "amazon", "wikipedia", "duckduckgo", "bing", etc.). This field only exists when `card_type = "search"` | :one:
 | `date` | [Auto populated by Onyx] The date in YYYY-MM-DD format. | :three:
 | `experiment_id` | [Optional] The unique identifier for a specific experiment. | :one:
 | `event_id` | [Required] An identifier shared by multiple performance pings that describe an entire request flow. | :one:
 | `event` | [Required] The type of event. Any user defined string ("click", "share", "delete", "more_items") | :one:
+| `event_context` | [Optional] A string to record the context of an AS Router event ping. Compound context values will be stringified by JSON.stringify| :one:
 | `highlight_type` | [Optional] Either ["bookmarks", "recommendation", "history"]. | :one:
 | `impression_id` | [Optional] The unique impression identifier for a specific client. | :one:
 | `ip` | [Auto populated by Onyx] The IP address of the client. | :two:
 | `locale` | [Auto populated by Onyx] The browser chrome's language (eg. en-US). | :two:
 | `load_trigger_ts` | [Optional][Server Counter][Server Alert for too many omissions]  DOMHighResTimeStamp of the action perceived by the user to trigger the load of this page. | :one:
 | `load_trigger_type` | [Server Counter][Server Alert for too many omissions] Either ["first_window_opened", "menu_plus_or_keyboard", "unexpected"]. | :one:
 | `metadata_source` | [Optional] The source of which we computed metadata. Either (`MetadataService` or `Local` or `TippyTopProvider`). | :one:
 | `page` | [Required] One of ["about:newtab", "about:home", "about:welcome", "unknown" (which either means not-applicable or is a bug)]. | :one:
--- a/browser/components/newtab/docs/v2-system-addon/data_events.md
+++ b/browser/components/newtab/docs/v2-system-addon/data_events.md
@@ -1077,34 +1077,34 @@ This reports when an error has occurred 
 {
   "client_id": "n/a",
   "action": "asrouter_undesired_event",
   "addon_version": "20180710100040",
   "impression_id": "{005deed0-e3e4-4c02-a041-17405fd703f6}",
   "locale": "en-US",
   "message_id": "some_message_id",
   "event": "TARGETING_EXPRESSION_ERROR",
-  "value": ["MALFORMED_EXPRESSION" | "OTHER_ERROR"]
+  "event_context": ["MALFORMED_EXPRESSION" | "OTHER_ERROR"]
 }
 ```
 
 ### Remote Settings error pings
 
 This reports a failure in the Remote Settings loader to load messages for Activity Stream Router.
 
 ```js
 {
   "action": "asrouter_undesired_event",
   "client_id": "n/a",
   "addon_version": "20180710100040",
   "locale": "en-US",
   "user_prefs": 7,
   "event": ["ASR_RS_NO_MESSAGES" | "ASR_RS_ERROR"],
   // The value is set to the ID of the message provider. For example: remote-cfr, remote-onboarding, etc.
-  "value": "REMOTE_PROVIDER_ID"
+  "event_context": "REMOTE_PROVIDER_ID"
 }
 ```
 
 ## Trailhead experiment enrollment ping
 
 This reports an enrollment ping when a user gets enrolled in a Trailhead experiment. Note that this ping is only collected through the Mozilla Events telemetry pipeline.
 
 ```js
--- a/browser/components/newtab/docs/v2-system-addon/preferences.md
+++ b/browser/components/newtab/docs/v2-system-addon/preferences.md
@@ -71,8 +71,115 @@ prefs.set("foo", true)
 prefs.observe("foo", aCallback);
 
 // This will stop listening to browser.newtabpage.activity-stream.foo
 prefs.ignore("foo", aCallback);
 ```
 
 See [toolkit/modules/Preferences.jsm](https://dxr.mozilla.org/mozilla-central/source/toolkit/modules/Preferences.jsm)
 for more information about what methods are available.
+
+## Discovery Stream Preferences
+
+Preferences specific to the Discovery Stream are nested under the sub-branch `browser.newtabpage.activity-stream.discoverystream` (with the exception of `browser.newtabpage.blocked`).
+
+#### `browser.newtabpage.activity-stream.discoverystream.campaign.blocks`
+
+- Type: `string (JSON)`
+- Default: `{}`
+- Pref Type: AS
+
+Not intended for user configuration, but is programmatically updated. Used for tracking blocked campaign IDs when a user dismisses a SPOC. Keys are campaign IDs. Values don't have a specific meaning.
+
+#### `browser.newtabpage.blocked`
+
+- Type: `string (JSON)`
+- Default: `null`
+- Pref Type: AS
+
+Not intended for user configuration, but is programmatically updated. Used for tracking blocked story IDs when a user dismisses one. Keys are story IDs. Values don't have a specific meaning.
+
+#### `browser.newtabpage.activity-stream.discoverystream.config`
+
+- Type `string (JSON)`
+- Default:
+  ```
+  {
+     "api_key_pref": "extensions.pocket.oAuthConsumerKey",
+     "collapsible": true,
+     "enabled": true,
+     "show_spocs": true,
+     "hardcoded_layout": true,
+     "personalized": false,
+     "layout_endpoint": "https://getpocket.cdn.mozilla.net/v3/newtab/layout?version=1&consumer_key=$apiKey&layout_variant=basic"
+  }
+  ```
+  - `api_key_pref` (string): The name of a variable containing the key for the Pocket API.
+  - `collapsible` (boolean): Controls whether the sections in new tab can be collapsed.
+  - `enabled` (boolean): Controls whether DS is turned on and is programmatically set based on a user's locale. DS enablement is a logical `AND` of this and the value of `browser.newtabpage.activity-stream.discoverystream.enabled`.
+  - `show_spocs` (boolean): Show sponsored content in new tab.
+  - `hardcoded_layout` (boolean): When this is true, a hardcoded layout shipped with Firefox will be used instead of a remotely fetched layout definition.
+  - `personalized` (boolean): When this is `true` personalized content based on browsing history will be displayed.
+  - `layout_endpoint` (string): The URL for a remote layout definition that will be used if `hardcoded_layout` is `false`.
+
+#### `browser.newtabpage.activity-stream.discoverystream.enabled`
+
+- Type: `boolean`
+- Default: `true`
+- Pref Type: Firefox
+
+When this is set to `true` the Discovery Stream experience will show up if `enabled` is also `true` on `browser.newtabpage.activity-stream.discoverystream.config`. Otherwise the old Activity Stream experience will be shown.
+
+#### `browser.newtabpage.activity-stream.discoverystream.endpointSpocsClear`
+
+- Type: `string (URL)`
+- Default: `https://spocs.getpocket.com/user`
+- Pref Type: AS
+
+Endpoint for when a user opts-out of sponsored content to delete the corresponding data from the ad server.
+
+#### `browser.newtabpage.activity-stream.discoverystream.endpoints`
+
+- Type: `string (URLs, CSV)`
+- Default: `https://getpocket.cdn.mozilla.net/,https://spocs.getpocket.com/`
+- Pref Type: AS
+
+A whitelist of endpoints that are allowed to be used by Discovery Stream for remote content (eg: story metadata) and configuration (eg: remote layout definitions for experimentation).
+
+#### `browser.newtabpage.activity-stream.discoverystream.engagementLabelEnabled`
+
+- Type: `boolean`
+- Default: `false`
+- Pref Type: AS
+
+A flag controlling the visibility of engagement labels on cards (eg: "Trending" or "Popular").
+
+#### `browser.newtabpage.activity-stream.discoverystream.hardcoded-basic-layout`
+
+- Type: `boolean`
+- Default: `false`
+- Pref Type: Firefox
+
+If this is `false` the default hardcoded layout is used, and if it's `true` then an alternate hardcoded layout (that currently simulates the older AS experience) is used.
+
+#### `browser.newtabpage.activity-stream.discoverystream.rec.impressions`
+
+- Type: `string (JSON)`
+- Default: `{}`
+- Pref Type: AS
+
+Programmatically generated hash table where the keys are recommendation IDs and the values are timestamps representing the first impression.
+
+#### `browser.newtabpage.activity-stream.discoverystream.spoc.impressions`
+
+- Type: `string`
+- Default: `{}`
+- Pref Type: AS
+
+Programmatically generated hash table where the keys are sponsored content IDs and the values are arrays of timestamps for every impression.
+
+#### `browser.newtabpage.activity-stream.discoverystream.spocs-endpoint`
+
+- Type: `string`
+- Default: `null`
+- Pref Type: Firefox
+
+Override to specify endpoint for SPOCs. Will take precedence over remote and hardcoded layout SPOC endpoints.
--- a/browser/components/newtab/lib/ASRouter.jsm
+++ b/browser/components/newtab/lib/ASRouter.jsm
@@ -345,17 +345,17 @@ const MessageLoaderUtils = {
 
   _handleRemoteSettingsUndesiredEvent(event, providerId, dispatchToAS) {
     if (dispatchToAS) {
       dispatchToAS(
         ac.ASRouterUserEvent({
           action: "asrouter_undesired_event",
           event,
           message_id: "n/a",
-          value: providerId,
+          event_context: providerId,
         })
       );
     }
   },
 
   /**
    * _getMessageLoader - return the right loading function given the provider's type
    *
@@ -958,17 +958,17 @@ class _ASRouter {
   _handleTargetingError(type, error, message) {
     Cu.reportError(error);
     if (this.dispatchToAS) {
       this.dispatchToAS(
         ac.ASRouterUserEvent({
           message_id: message.id,
           action: "asrouter_undesired_event",
           event: "TARGETING_EXPRESSION_ERROR",
-          value: type,
+          event_context: type,
         })
       );
     }
   }
 
   async _hasAddonAttributionData() {
     try {
       const data = (await AttributionCode.getAttrDataAsync()) || {};
--- a/browser/components/newtab/lib/ActivityStream.jsm
+++ b/browser/components/newtab/lib/ActivityStream.jsm
@@ -305,17 +305,17 @@ const PREFS_CONFIG = new Map([
       value: true,
       value_local_dev: false,
     },
   ],
   [
     "telemetry.structuredIngestion.endpoint",
     {
       title: "Structured Ingestion telemetry server endpoint",
-      value: "https://incoming.telemetry.mozilla.org/submit/activity-stream",
+      value: "https://incoming.telemetry.mozilla.org/submit",
     },
   ],
   [
     "telemetry.ping.endpoint",
     {
       title: "Telemetry server endpoint",
       value: "https://tiles.services.mozilla.com/v4/links/activity-stream",
     },
--- a/browser/components/newtab/lib/DiscoveryStreamFeed.jsm
+++ b/browser/components/newtab/lib/DiscoveryStreamFeed.jsm
@@ -387,16 +387,17 @@ this.DiscoveryStreamFeed = class Discove
       ) {
         sendUpdate({
           type: at.DISCOVERY_STREAM_SPOCS_ENDPOINT,
           data: {
             url,
             spocs_per_domain: layoutResp.spocs.spocs_per_domain,
           },
         });
+        this.updatePlacements(sendUpdate, layoutResp.layout);
       }
     }
   }
 
   /**
    * buildFeedPromise - Adds the promise result to newFeeds and
    *                    pushes a promise to newsFeedsPromises.
    * @param {Object} Has both newFeedsPromises (Array) and newFeeds (Object)
--- a/browser/components/newtab/lib/TelemetryFeed.jsm
+++ b/browser/components/newtab/lib/TelemetryFeed.jsm
@@ -54,29 +54,33 @@ ChromeUtils.defineModuleGetter(
   "ExtensionSettingsStore",
   "resource://gre/modules/ExtensionSettingsStore.jsm"
 );
 ChromeUtils.defineModuleGetter(
   this,
   "PrivateBrowsingUtils",
   "resource://gre/modules/PrivateBrowsingUtils.jsm"
 );
+ChromeUtils.defineModuleGetter(
+  this,
+  "ClientID",
+  "resource://gre/modules/ClientID.jsm"
+);
 
 XPCOMUtils.defineLazyServiceGetters(this, {
   gUUIDGenerator: ["@mozilla.org/uuid-generator;1", "nsIUUIDGenerator"],
   aboutNewTabService: [
     "@mozilla.org/browser/aboutnewtab-service;1",
     "nsIAboutNewTabService",
   ],
 });
 
 const ACTIVITY_STREAM_ID = "activity-stream";
 const ACTIVITY_STREAM_ENDPOINT_PREF =
   "browser.newtabpage.activity-stream.telemetry.ping.endpoint";
-const ACTIVITY_STREAM_ROUTER_ID = "activity-stream-router";
 const DOMWINDOW_OPENED_TOPIC = "domwindowopened";
 const DOMWINDOW_UNLOAD_TOPIC = "unload";
 const TAB_PINNED_EVENT = "TabPinned";
 
 // This is a mapping table between the user preferences and its encoding code
 const USER_PREFS_ENCODING = {
   showSearch: 1 << 0,
   "feeds.topsites": 1 << 1,
@@ -89,16 +93,20 @@ const USER_PREFS_ENCODING = {
 };
 
 const PREF_IMPRESSION_ID = "impressionId";
 const TELEMETRY_PREF = "telemetry";
 const EVENTS_TELEMETRY_PREF = "telemetry.ut.events";
 const STRUCTURED_INGESTION_TELEMETRY_PREF = "telemetry.structuredIngestion";
 const STRUCTURED_INGESTION_ENDPOINT_PREF =
   "telemetry.structuredIngestion.endpoint";
+// List of namespaces for the structured ingestion system.
+// They are defined in https://github.com/mozilla-services/mozilla-pipeline-schemas
+const STRUCTURED_INGESTION_NAMESPACE_AS = "activity-stream";
+const STRUCTURED_INGESTION_NAMESPACE_MS = "messaging-system";
 
 this.TelemetryFeed = class TelemetryFeed {
   constructor(options) {
     this.sessions = new Map();
     this._prefs = new Prefs();
     this._impressionId = this.getOrCreateImpressionId();
     this._aboutHomeSeen = false;
     this._classifySite = classifySite;
@@ -117,16 +125,23 @@ this.TelemetryFeed = class TelemetryFeed
   get structuredIngestionTelemetryEnabled() {
     return this._prefs.get(STRUCTURED_INGESTION_TELEMETRY_PREF);
   }
 
   get structuredIngestionEndpointBase() {
     return this._prefs.get(STRUCTURED_INGESTION_ENDPOINT_PREF);
   }
 
+  get telemetryClientId() {
+    Object.defineProperty(this, "telemetryClientId", {
+      value: ClientID.getClientID(),
+    });
+    return this.telemetryClientId;
+  }
+
   init() {
     Services.obs.addObserver(
       this.browserOpenNewtabStart,
       "browser-open-newtab-start"
     );
     // Add pin tab event listeners on future windows
     Services.obs.addObserver(this._addWindowListeners, DOMWINDOW_OPENED_TOPIC);
     // Listen for pin tab events on all open windows
@@ -246,29 +261,16 @@ this.TelemetryFeed = class TelemetryFeed
         topic: ACTIVITY_STREAM_ID,
         overrideEndpointPref: ACTIVITY_STREAM_ENDPOINT_PREF,
       }),
     });
     return this.pingCentre;
   }
 
   /**
-   * Lazily initialize a PingCentre client for Activity Stream Router to send pings.
-   *
-   * Unlike the PingCentre client for Activity Stream, Activity Stream Router
-   * uses a separate client with the standard PingCentre endpoint.
-   */
-  get pingCentreForASRouter() {
-    Object.defineProperty(this, "pingCentreForASRouter", {
-      value: new PingCentre({ topic: ACTIVITY_STREAM_ROUTER_ID }),
-    });
-    return this.pingCentreForASRouter;
-  }
-
-  /**
    * Lazily initialize UTEventReporting to send pings
    */
   get utEvents() {
     Object.defineProperty(this, "utEvents", { value: new UTEventReporting() });
     return this.utEvents;
   }
 
   /**
@@ -414,17 +416,22 @@ this.TelemetryFeed = class TelemetryFeed
     }
 
     Object.keys(impressionSets).forEach(source => {
       const payload = this.createImpressionStats(port, {
         source,
         tiles: impressionSets[source],
       });
       this.sendEvent(payload);
-      this.sendStructuredIngestionEvent(payload, "impression-stats", "1");
+      this.sendStructuredIngestionEvent(
+        payload,
+        STRUCTURED_INGESTION_NAMESPACE_AS,
+        "impression-stats",
+        "1"
+      );
     });
   }
 
   /**
    * Send loaded content pings for Discovery Stream for a given session.
    *
    * @note the loaded content reports are stored in session.loadedContentSets for different
    * sources, and will be sent separately accordingly.
@@ -442,17 +449,22 @@ this.TelemetryFeed = class TelemetryFeed
     Object.keys(loadedContentSets).forEach(source => {
       const tiles = loadedContentSets[source];
       const payload = this.createImpressionStats(port, {
         source,
         tiles,
         loaded: tiles.length,
       });
       this.sendEvent(payload);
-      this.sendStructuredIngestionEvent(payload, "impression-stats", "1");
+      this.sendStructuredIngestionEvent(
+        payload,
+        STRUCTURED_INGESTION_NAMESPACE_AS,
+        "impression-stats",
+        "1"
+      );
     });
   }
 
   /**
    * handleNewTabInit - Handle NEW_TAB_INIT, which creates a new session and sets the a flag
    *                    for session.perf based on whether or not this new tab is preloaded
    *
    * @param  {obj} action the Action object
@@ -505,17 +517,16 @@ this.TelemetryFeed = class TelemetryFeed
       client_id: "n/a",
       session_id: "n/a",
     });
   }
 
   createSpocsFillPing(data) {
     return Object.assign(this.createPing(null), data, {
       impression_id: this._impressionId,
-      client_id: "n/a",
       session_id: "n/a",
     });
   }
 
   createUserEvent(action) {
     return Object.assign(
       this.createPing(au.getPortIdOfSender(action)),
       action.data,
@@ -547,78 +558,89 @@ this.TelemetryFeed = class TelemetryFeed
       perf: session.perf,
     });
   }
 
   /**
    * Create a ping for AS router event. The client_id is set to "n/a" by default,
    * different component can override this by its own telemetry collection policy.
    */
-  createASRouterEvent(action) {
-    const ping = {
-      client_id: "n/a",
+  async createASRouterEvent(action) {
+    let event = {
+      ...action.data,
       addon_version: Services.appinfo.appBuildID,
       locale: Services.locale.appLocaleAsLangTag,
-      impression_id: this._impressionId,
     };
-    const event = Object.assign(ping, action.data);
-    if (event.action === "cfr_user_event") {
-      return this.applyCFRPolicy(event);
-    } else if (event.action === "snippets_user_event") {
-      return this.applySnippetsPolicy(event);
-    } else if (event.action === "onboarding_user_event") {
-      return this.applyOnboardingPolicy(event);
+    if (event.event_context && typeof event.event_context === "object") {
+      event.event_context = JSON.stringify(event.event_context);
+    }
+    switch (event.action) {
+      case "cfr_user_event":
+        event = await this.applyCFRPolicy(event);
+        break;
+      case "snippets_user_event":
+        event = await this.applySnippetsPolicy(event);
+        break;
+      case "onboarding_user_event":
+        event = await this.applyOnboardingPolicy(event);
+        break;
+      case "asrouter_undesired_event":
+        event = this.applyUndesiredEventPolicy(event);
+        break;
+      default:
+        event = { ping: event };
+        break;
     }
     return event;
   }
 
   /**
    * Per Bug 1484035, CFR metrics comply with following policies:
    * 1). In release, it collects impression_id, and treats bucket_id as message_id
    * 2). In prerelease, it collects client_id and message_id
    * 3). In shield experiments conducted in release, it collects client_id and message_id
    */
-  applyCFRPolicy(ping) {
+  async applyCFRPolicy(ping) {
     if (
       UpdateUtils.getUpdateChannel(true) === "release" &&
       !this.isInCFRCohort
     ) {
-      ping.message_id = ping.bucket_id || "n/a";
-      ping.client_id = "n/a";
+      ping.message_id = "n/a";
       ping.impression_id = this._impressionId;
     } else {
-      ping.impression_id = "n/a";
-      // Ping-centre client will fill in the client_id if it's not provided in the ping.
-      delete ping.client_id;
+      ping.client_id = await this.telemetryClientId;
     }
-    // bucket_id is no longer needed
-    delete ping.bucket_id;
-    return ping;
+    delete ping.action;
+    return { ping, pingType: "cfr" };
   }
 
   /**
    * Per Bug 1485069, all the metrics for Snippets in AS router use client_id in
    * all the release channels
    */
-  applySnippetsPolicy(ping) {
-    // Ping-centre client will fill in the client_id if it's not provided in the ping.
-    delete ping.client_id;
-    ping.impression_id = "n/a";
-    return ping;
+  async applySnippetsPolicy(ping) {
+    ping.client_id = await this.telemetryClientId;
+    delete ping.action;
+    return { ping, pingType: "snippets" };
   }
 
   /**
    * Per Bug 1482134, all the metrics for Onboarding in AS router use client_id in
    * all the release channels
    */
-  applyOnboardingPolicy(ping) {
-    // Ping-centre client will fill in the client_id if it's not provided in the ping.
-    delete ping.client_id;
-    ping.impression_id = "n/a";
-    return ping;
+  async applyOnboardingPolicy(ping) {
+    ping.client_id = await this.telemetryClientId;
+    delete ping.action;
+    return { ping, pingType: "onboarding" };
+  }
+
+  applyUndesiredEventPolicy(ping) {
+    ping.impression_id = this._impressionId;
+    delete ping.action;
+    return { ping, pingType: "undesired-events" };
   }
 
   sendEvent(event_object) {
     if (this.telemetryEnabled) {
       this.pingCentre.sendPing(event_object, { filter: ACTIVITY_STREAM_ID });
     }
   }
 
@@ -629,64 +651,71 @@ this.TelemetryFeed = class TelemetryFeed
   }
 
   /**
    * Generates an endpoint for Structured Ingestion telemetry pipeline. Note that
    * Structured Ingestion requires a different endpoint for each ping. See more
    * details about endpoint schema at:
    * https://github.com/mozilla/gcp-ingestion/blob/master/docs/edge.md#postput-request
    *
+   * @param {String} namespace Namespace of the ping, such as "activity-stream" or "messaging-system".
    * @param {String} pingType  Type of the ping, such as "impression-stats".
    * @param {String} version   Endpoint version for this ping type.
    */
-  _generateStructuredIngestionEndpoint(pingType, version) {
+  _generateStructuredIngestionEndpoint(namespace, pingType, version) {
     const uuid = gUUIDGenerator.generateUUID().toString();
     // Structured Ingestion does not support the UUID generated by gUUIDGenerator,
     // because it contains leading and trailing braces. Need to trim them first.
     const docID = uuid.slice(1, -1);
-    const extension = `${pingType}/${version}/${docID}`;
+    const extension = `${namespace}/${pingType}/${version}/${docID}`;
     return `${this.structuredIngestionEndpointBase}/${extension}`;
   }
 
-  sendStructuredIngestionEvent(event_object, pingType, version) {
+  sendStructuredIngestionEvent(eventObject, namespace, pingType, version) {
     if (this.telemetryEnabled && this.structuredIngestionTelemetryEnabled) {
       this.pingCentre.sendStructuredIngestionPing(
-        event_object,
-        this._generateStructuredIngestionEndpoint(pingType, version),
+        eventObject,
+        this._generateStructuredIngestionEndpoint(namespace, pingType, version),
         { filter: ACTIVITY_STREAM_ID }
       );
     }
   }
 
-  sendASRouterEvent(event_object) {
-    if (this.telemetryEnabled) {
-      this.pingCentreForASRouter.sendPing(event_object, {
-        filter: ACTIVITY_STREAM_ID,
-      });
-    }
-  }
-
   handleImpressionStats(action) {
     const payload = this.createImpressionStats(
       au.getPortIdOfSender(action),
       action.data
     );
     this.sendEvent(payload);
-    this.sendStructuredIngestionEvent(payload, "impression-stats", "1");
+    this.sendStructuredIngestionEvent(
+      payload,
+      STRUCTURED_INGESTION_NAMESPACE_AS,
+      "impression-stats",
+      "1"
+    );
   }
 
   handleUserEvent(action) {
     let userEvent = this.createUserEvent(action);
     this.sendEvent(userEvent);
     this.sendUTEvent(userEvent, this.utEvents.sendUserEvent);
   }
 
-  handleASRouterUserEvent(action) {
-    let event = this.createASRouterEvent(action);
-    this.sendASRouterEvent(event);
+  async handleASRouterUserEvent(action) {
+    const { ping, pingType } = await this.createASRouterEvent(action);
+    if (!pingType) {
+      Cu.reportError("Unknown ping type for ASRouter telemetry");
+      return;
+    }
+    this.sendStructuredIngestionEvent(
+      ping,
+      STRUCTURED_INGESTION_NAMESPACE_MS,
+      pingType,
+      "1"
+    );
   }
 
   handleUndesiredEvent(action) {
     this.sendEvent(this.createUndesiredEvent(action));
   }
 
   handleTrailheadEnrollEvent(action) {
     // Unlike `sendUTEvent`, we always send the event if AS's telemetry is enabled
@@ -899,17 +928,22 @@ this.TelemetryFeed = class TelemetryFeed
    *          reason: "n/a",
    *          full_recalc: 1
    *        }
    *      ]
    *    }
    */
   handleDiscoveryStreamSpocsFill(data) {
     const payload = this.createSpocsFillPing(data);
-    this.sendStructuredIngestionEvent(payload, "spoc-fills", "1");
+    this.sendStructuredIngestionEvent(
+      payload,
+      STRUCTURED_INGESTION_NAMESPACE_AS,
+      "spoc-fills",
+      "1"
+    );
   }
 
   /**
    * 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.
@@ -971,19 +1005,16 @@ this.TelemetryFeed = class TelemetryFeed
 
     // Only uninit if the getter has initialized it
     if (Object.prototype.hasOwnProperty.call(this, "pingCentre")) {
       this.pingCentre.uninit();
     }
     if (Object.prototype.hasOwnProperty.call(this, "utEvents")) {
       this.utEvents.uninit();
     }
-    if (Object.prototype.hasOwnProperty.call(this, "pingCentreForASRouter")) {
-      this.pingCentreForASRouter.uninit();
-    }
 
     // TODO: Send any unfinished sessions
   }
 };
 
 const EXPORTED_SYMBOLS = [
   "TelemetryFeed",
   "USER_PREFS_ENCODING",
--- a/browser/components/newtab/lib/ToolbarPanelHub.jsm
+++ b/browser/components/newtab/lib/ToolbarPanelHub.jsm
@@ -472,17 +472,17 @@ class _ToolbarPanelHub {
       !PrivateBrowsingUtils.isBrowserPrivate(
         win.ownerGlobal.gBrowser.selectedBrowser
       )
     ) {
       this._sendTelemetry({
         message_id: message.id,
         bucket_id: message.id,
         event,
-        value: options.value,
+        event_context: options.value,
       });
     }
   }
 
   /**
    * Inserts a message into the Protections Panel. The message is visible once
    * and afterwards set in a collapsed state. It can be shown again using the
    * info button in the panel header.
--- a/browser/components/newtab/locales-src/asrouter.ftl
+++ b/browser/components/newtab/locales-src/asrouter.ftl
@@ -31,16 +31,25 @@ cfr-doorhanger-extension-learn-more-link
 # This string is used on a new line below the add-on name
 # Variables:
 #   $name (String) - Add-on author name
 cfr-doorhanger-extension-author = by { $name }
 
 # This is a notification displayed in the address bar.
 # When clicked it opens a panel with a message for the user.
 cfr-doorhanger-extension-notification = Recommendation
+cfr-doorhanger-extension-notification2 = Recommendation
+  .tooltiptext = Extension recommendation
+  .a11y-announcement = Extension recommendation available
+
+# This is a notification displayed in the address bar.
+# When clicked it opens a panel with a message for the user.
+cfr-doorhanger-feature-notification = Recommendation
+  .tooltiptext = Feature recommendation
+  .a11y-announcement = Feature recommendation available
 
 ## Add-on statistics
 ## These strings are used to display the total number of
 ## users and rating for an add-on. They are shown next to each other.
 
 # Variables:
 #   $total (Number) - The rating of the add-on from 1 to 5
 cfr-doorhanger-extension-rating =
--- a/browser/components/newtab/test/schemas/pings.js
+++ b/browser/components/newtab/test/schemas/pings.js
@@ -199,17 +199,16 @@ export const SpocsFillEntrySchema = Joi.
   full_recalc: Joi.number()
     .integer()
     .required(),
 });
 
 export const SpocsFillPing = Joi.object().keys(
   Object.assign({}, baseKeys, {
     impression_id: Joi.string().required(),
-    client_id: Joi.valid("n/a").required(),
     session_id: Joi.valid("n/a").required(),
     spoc_fills: Joi.array()
       .items(SpocsFillEntrySchema)
       .required(),
   })
 );
 
 export const PerfPing = Joi.object().keys(
@@ -292,26 +291,26 @@ export const SessionPing = Joi.object().
 
         // The boolean to signify whether the page is preloaded or not.
         is_preloaded: Joi.bool().required(),
       })
       .required(),
   })
 );
 
-export const ASRouterEventPing = Joi.object().keys({
-  client_id: Joi.string().required(),
-  action: Joi.string().required(),
-  impression_id: Joi.string().required(),
-  source: Joi.string().required(),
-  addon_version: Joi.string().required(),
-  locale: Joi.string().required(),
-  message_id: Joi.string().required(),
-  event: Joi.string().required(),
-});
+export const ASRouterEventPing = Joi.object()
+  .keys({
+    addon_version: Joi.string().required(),
+    locale: Joi.string().required(),
+    message_id: Joi.string().required(),
+    event: Joi.string().required(),
+    client_id: Joi.string(),
+    impression_id: Joi.string(),
+  })
+  .or("client_id", "impression_id");
 
 export const UTSessionPing = Joi.array().items(
   Joi.string()
     .required()
     .valid("activity_stream"),
   Joi.string()
     .required()
     .valid("end"),
--- a/browser/components/newtab/test/unit/asrouter/ASRouter.test.js
+++ b/browser/components/newtab/test/unit/asrouter/ASRouter.test.js
@@ -745,33 +745,33 @@ describe("ASRouter", () => {
       sandbox
         .stub(MessageLoaderUtils, "_getRemoteSettingsMessages")
         .rejects("fake error");
       await createRouterAndInit();
       assert.calledWith(Router.dispatchToAS, {
         data: {
           action: "asrouter_undesired_event",
           event: "ASR_RS_ERROR",
-          value: "remotey-settingsy",
+          event_context: "remotey-settingsy",
           message_id: "n/a",
         },
         meta: { from: "ActivityStream:Content", to: "ActivityStream:Main" },
         type: "AS_ROUTER_TELEMETRY_USER_EVENT",
       });
     });
     it("should dispatch undesired event if RemoteSettings returns no messages", async () => {
       sandbox
         .stub(MessageLoaderUtils, "_getRemoteSettingsMessages")
         .resolves([]);
       await createRouterAndInit();
       assert.calledWith(Router.dispatchToAS, {
         data: {
           action: "asrouter_undesired_event",
           event: "ASR_RS_NO_MESSAGES",
-          value: "remotey-settingsy",
+          event_context: "remotey-settingsy",
           message_id: "n/a",
         },
         meta: { from: "ActivityStream:Content", to: "ActivityStream:Main" },
         type: "AS_ROUTER_TELEMETRY_USER_EVENT",
       });
     });
     it("should download the attachment if RemoteSettings returns some messages", async () => {
       sandbox
@@ -809,17 +809,17 @@ describe("ASRouter", () => {
         bucket: "cfr",
       };
       await createRouterAndInit([provider]);
 
       assert.calledWith(Router.dispatchToAS, {
         data: {
           action: "asrouter_undesired_event",
           event: "ASR_RS_NO_MESSAGES",
-          value: "ms-language-packs",
+          event_context: "ms-language-packs",
           message_id: "n/a",
         },
         meta: { from: "ActivityStream:Content", to: "ActivityStream:Main" },
         type: "AS_ROUTER_TELEMETRY_USER_EVENT",
       });
     });
   });
 
--- a/browser/components/newtab/test/unit/lib/DiscoveryStreamFeed.test.js
+++ b/browser/components/newtab/test/unit/lib/DiscoveryStreamFeed.test.js
@@ -406,16 +406,23 @@ describe("DiscoveryStreamFeed", () => {
       feed.updatePlacements(feed.store.dispatch, fakeLayout);
 
       assert.calledOnce(feed.store.dispatch);
       assert.calledWith(feed.store.dispatch, {
         type: "DISCOVERY_STREAM_SPOCS_PLACEMENTS",
         data: { placements: [{ name: "first" }, { name: "second" }] },
       });
     });
+    it("should fire update placements from loadLayout", async () => {
+      sandbox.spy(feed, "updatePlacements");
+
+      await feed.loadLayout(feed.store.dispatch);
+
+      assert.calledOnce(feed.updatePlacements);
+    });
   });
 
   describe("#placementsForEach", () => {
     it("should forEach through placements", () => {
       const fakeComponents = {
         components: [
           { placement: { name: "first" } },
           { placement: { name: "second" } },
--- a/browser/components/newtab/test/unit/lib/TelemetryFeed.test.js
+++ b/browser/components/newtab/test/unit/lib/TelemetryFeed.test.js
@@ -18,16 +18,17 @@ import { FakePrefs, GlobalOverrider } fr
 import { ASRouterPreferences } from "lib/ASRouterPreferences.jsm";
 import injector from "inject!lib/TelemetryFeed.jsm";
 
 const FAKE_UUID = "{foo-123-foo}";
 const FAKE_ROUTER_MESSAGE_PROVIDER = [{ id: "cfr", enabled: true }];
 const FAKE_ROUTER_MESSAGE_PROVIDER_COHORT = [
   { id: "cfr", enabled: true, cohort: "cohort_group" },
 ];
+const FAKE_TELEMETRY_ID = "foo123";
 
 describe("TelemetryFeed", () => {
   let globals;
   let sandbox;
   let expectedUserPrefs;
   let browser = {
     getAttribute() {
       return "true";
@@ -96,34 +97,34 @@ describe("TelemetryFeed", () => {
     globals.set("aboutNewTabService", {
       overridden: false,
       newTabURL: "",
     });
     globals.set("HomePage", fakeHomePage);
     globals.set("ExtensionSettingsStore", fakeExtensionSettingsStore);
     globals.set("PingCentre", PingCentre);
     globals.set("UTEventReporting", UTEventReporting);
+    globals.set("ClientID", {
+      getClientID: sandbox.spy(async () => FAKE_TELEMETRY_ID),
+    });
     sandbox
       .stub(ASRouterPreferences, "providers")
       .get(() => FAKE_ROUTER_MESSAGE_PROVIDER);
     instance = new TelemetryFeed();
   });
   afterEach(() => {
     clock.restore();
     globals.restore();
     FakePrefs.prototype.prefs = {};
     ASRouterPreferences.uninit();
   });
   describe("#init", () => {
     it("should add .pingCentre, a PingCentre instance", () => {
       assert.instanceOf(instance.pingCentre, PingCentre);
     });
-    it("should add .pingCentreForASRouter, a PingCentre instance", () => {
-      assert.instanceOf(instance.pingCentreForASRouter, PingCentre);
-    });
     it("should add .utEvents, a UTEventReporting instance", () => {
       assert.instanceOf(instance.utEvents, UTEventReporting);
     });
     it("should make this.browserOpenNewtabStart() observe browser-open-newtab-start", () => {
       sandbox.spy(Services.obs, "addObserver");
 
       instance.init();
 
@@ -768,175 +769,203 @@ describe("TelemetryFeed", () => {
       const action = ac.DiscoveryStreamSpocsFill({ spoc_fills: spocFills });
       const ping = await instance.createSpocsFillPing(action.data);
 
       assert.validate(ping, SpocsFillPing);
       assert.propertyVal(ping, "spoc_fills", spocFills);
     });
   });
   describe("#applyCFRPolicy", () => {
-    it("should use client_id and message_id in prerelease", () => {
+    it("should use client_id and message_id in prerelease", async () => {
       globals.set("UpdateUtils", {
         getUpdateChannel() {
           return "nightly";
         },
       });
       const data = {
         action: "cfr_user_event",
-        source: "CFR",
         event: "IMPRESSION",
-        client_id: "some_client_id",
-        impression_id: "some_impression_id",
         message_id: "cfr_message_01",
         bucket_id: "cfr_bucket_01",
       };
-      const ping = instance.applyCFRPolicy(data);
+      const { ping, pingType } = await instance.applyCFRPolicy(data);
 
-      assert.isUndefined(ping.client_id);
-      assert.propertyVal(ping, "impression_id", "n/a");
+      assert.equal(pingType, "cfr");
+      assert.isUndefined(ping.impression_id);
+      assert.propertyVal(ping, "client_id", FAKE_TELEMETRY_ID);
+      assert.propertyVal(ping, "bucket_id", "cfr_bucket_01");
       assert.propertyVal(ping, "message_id", "cfr_message_01");
-      assert.isUndefined(ping.bucket_id);
     });
-    it("should use impression_id and bucket_id in release", () => {
+    it("should use impression_id and bucket_id in release", async () => {
       globals.set("UpdateUtils", {
         getUpdateChannel() {
           return "release";
         },
       });
       const data = {
         action: "cfr_user_event",
-        source: "CFR",
         event: "IMPRESSION",
-        client_id: "some_client_id",
-        impression_id: "some_impression_id",
         message_id: "cfr_message_01",
         bucket_id: "cfr_bucket_01",
       };
-      const ping = instance.applyCFRPolicy(data);
+      const { ping, pingType } = await instance.applyCFRPolicy(data);
 
+      assert.equal(pingType, "cfr");
+      assert.isUndefined(ping.client_id);
       assert.propertyVal(ping, "impression_id", FAKE_UUID);
-      assert.propertyVal(ping, "client_id", "n/a");
-      assert.propertyVal(ping, "message_id", "cfr_bucket_01");
-      assert.isUndefined(ping.bucket_id);
+      assert.propertyVal(ping, "message_id", "n/a");
+      assert.propertyVal(ping, "bucket_id", "cfr_bucket_01");
     });
-    it("should use client_id and message_id in the experiment cohort in release", () => {
+    it("should use client_id and message_id in the experiment cohort in release", async () => {
       globals.set("UpdateUtils", {
         getUpdateChannel() {
           return "release";
         },
       });
       sandbox
         .stub(ASRouterPreferences, "providers")
         .get(() => FAKE_ROUTER_MESSAGE_PROVIDER_COHORT);
       const data = {
         action: "cfr_user_event",
-        source: "CFR",
         event: "IMPRESSION",
-        client_id: "some_client_id",
-        impression_id: "some_impression_id",
         message_id: "cfr_message_01",
         bucket_id: "cfr_bucket_01",
       };
-      const ping = instance.applyCFRPolicy(data);
+      const { ping, pingType } = await instance.applyCFRPolicy(data);
 
-      assert.isUndefined(ping.client_id);
-      assert.propertyVal(ping, "impression_id", "n/a");
+      assert.equal(pingType, "cfr");
+      assert.isUndefined(ping.impression_id);
+      assert.propertyVal(ping, "client_id", FAKE_TELEMETRY_ID);
+      assert.propertyVal(ping, "bucket_id", "cfr_bucket_01");
       assert.propertyVal(ping, "message_id", "cfr_message_01");
-      assert.isUndefined(ping.bucket_id);
     });
   });
   describe("#applySnippetsPolicy", () => {
-    it("should drop client_id and unset impression_id", () => {
+    it("should include client_id", async () => {
       const data = {
         action: "snippets_user_event",
-        source: "SNIPPETS",
         event: "IMPRESSION",
-        client_id: "n/a",
-        impression_id: "some_impression_id",
         message_id: "snippets_message_01",
       };
-      const ping = instance.applySnippetsPolicy(data);
+      const { ping, pingType } = await instance.applySnippetsPolicy(data);
 
-      assert.isUndefined(ping.client_id);
-      assert.propertyVal(ping, "impression_id", "n/a");
+      assert.equal(pingType, "snippets");
+      assert.propertyVal(ping, "client_id", FAKE_TELEMETRY_ID);
       assert.propertyVal(ping, "message_id", "snippets_message_01");
     });
   });
   describe("#applyOnboardingPolicy", () => {
-    it("should drop client_id and unset impression_id", () => {
+    it("should include client_id", async () => {
       const data = {
         action: "onboarding_user_event",
-        source: "ONBOARDING",
         event: "CLICK_BUTTION",
-        client_id: "n/a",
-        impression_id: "some_impression_id",
         message_id: "onboarding_message_01",
       };
-      const ping = instance.applyOnboardingPolicy(data);
+      const { ping, pingType } = await instance.applyOnboardingPolicy(data);
 
+      assert.equal(pingType, "onboarding");
+      assert.propertyVal(ping, "client_id", FAKE_TELEMETRY_ID);
+      assert.propertyVal(ping, "message_id", "onboarding_message_01");
+    });
+  });
+  describe("#applyUndesiredEventPolicy", () => {
+    it("should exclude client_id and use impression_id", () => {
+      const data = {
+        action: "asrouter_undesired_event",
+        event: "RS_MISSING_DATA",
+      };
+      const { ping, pingType } = instance.applyUndesiredEventPolicy(data);
+
+      assert.equal(pingType, "undesired-events");
       assert.isUndefined(ping.client_id);
-      assert.propertyVal(ping, "impression_id", "n/a");
-      assert.propertyVal(ping, "message_id", "onboarding_message_01");
+      assert.propertyVal(ping, "impression_id", FAKE_UUID);
     });
   });
   describe("#createASRouterEvent", () => {
     it("should create a valid AS Router event", async () => {
       const data = {
-        action: "snippet_user_event",
-        source: "SNIPPETS",
+        action: "snippets_user_event",
         event: "CLICK",
         message_id: "snippets_message_01",
       };
       const action = ac.ASRouterUserEvent(data);
-      const ping = await instance.createASRouterEvent(action);
+      const { ping } = await instance.createASRouterEvent(action);
 
       assert.validate(ping, ASRouterEventPing);
-      assert.propertyVal(ping, "client_id", "n/a");
-      assert.propertyVal(ping, "source", "SNIPPETS");
       assert.propertyVal(ping, "event", "CLICK");
     });
     it("should call applyCFRPolicy if action equals to cfr_user_event", async () => {
       const data = {
         action: "cfr_user_event",
-        source: "CFR",
         event: "IMPRESSION",
         message_id: "cfr_message_01",
       };
       sandbox.stub(instance, "applyCFRPolicy");
       const action = ac.ASRouterUserEvent(data);
       await instance.createASRouterEvent(action);
 
       assert.calledOnce(instance.applyCFRPolicy);
     });
     it("should call applySnippetsPolicy if action equals to snippets_user_event", async () => {
       const data = {
         action: "snippets_user_event",
-        source: "SNIPPETS",
         event: "IMPRESSION",
         message_id: "snippets_message_01",
       };
       sandbox.stub(instance, "applySnippetsPolicy");
       const action = ac.ASRouterUserEvent(data);
       await instance.createASRouterEvent(action);
 
       assert.calledOnce(instance.applySnippetsPolicy);
     });
     it("should call applyOnboardingPolicy if action equals to onboarding_user_event", async () => {
       const data = {
         action: "onboarding_user_event",
-        source: "ONBOARDING",
         event: "CLICK_BUTTON",
         message_id: "onboarding_message_01",
       };
       sandbox.stub(instance, "applyOnboardingPolicy");
       const action = ac.ASRouterUserEvent(data);
       await instance.createASRouterEvent(action);
 
       assert.calledOnce(instance.applyOnboardingPolicy);
     });
+    it("should call applyUndesiredEventPolicy if action equals to asrouter_undesired_event", async () => {
+      const data = {
+        action: "asrouter_undesired_event",
+        event: "UNDESIRED_EVENT",
+      };
+      sandbox.stub(instance, "applyUndesiredEventPolicy");
+      const action = ac.ASRouterUserEvent(data);
+      await instance.createASRouterEvent(action);
+
+      assert.calledOnce(instance.applyUndesiredEventPolicy);
+    });
+    it("should stringify event_context if it is an Object", async () => {
+      const data = {
+        action: "asrouter_undesired_event",
+        event: "UNDESIRED_EVENT",
+        event_context: { foo: "bar" },
+      };
+      const action = ac.ASRouterUserEvent(data);
+      const { ping } = await instance.createASRouterEvent(action);
+
+      assert.propertyVal(ping, "event_context", JSON.stringify({ foo: "bar" }));
+    });
+    it("should not stringify event_context if it is a String", async () => {
+      const data = {
+        action: "asrouter_undesired_event",
+        event: "UNDESIRED_EVENT",
+        event_context: "foo",
+      };
+      const action = ac.ASRouterUserEvent(data);
+      const { ping } = await instance.createASRouterEvent(action);
+
+      assert.propertyVal(ping, "event_context", "foo");
+    });
   });
   describe("#sendEvent", () => {
     it("should call PingCentre", async () => {
       FakePrefs.prototype.prefs.telemetry = true;
       const event = {};
       instance = new TelemetryFeed();
       sandbox.stub(instance.pingCentre, "sendPing");
 
@@ -969,29 +998,16 @@ describe("TelemetryFeed", () => {
       await instance.sendStructuredIngestionEvent(
         event,
         "http://foo.com/base/"
       );
 
       assert.calledWith(instance.pingCentre.sendStructuredIngestionPing, event);
     });
   });
-  describe("#sendASRouterEvent", () => {
-    it("should call PingCentre for AS Router", async () => {
-      FakePrefs.prototype.prefs.telemetry = true;
-      const event = {};
-      instance = new TelemetryFeed();
-      sandbox.stub(instance.pingCentreForASRouter, "sendPing");
-
-      instance.sendASRouterEvent(event);
-
-      assert.calledWith(instance.pingCentreForASRouter.sendPing, event);
-    });
-  });
-
   describe("#setLoadTriggerInfo", () => {
     it("should call saveSessionPerfData w/load_trigger_{ts,type} data", () => {
       const stub = sandbox.stub(instance, "saveSessionPerfData");
       sandbox.stub(perfService, "getMostRecentAbsMarkStartByName").returns(777);
       instance.addSession("port123");
 
       instance.setLoadTriggerInfo("port123");
 
@@ -1082,23 +1098,16 @@ describe("TelemetryFeed", () => {
     });
     it("should call .utEvents.uninit", () => {
       const stub = sandbox.stub(instance.utEvents, "uninit");
 
       instance.uninit();
 
       assert.calledOnce(stub);
     });
-    it("should call .pingCentreForASRouter.uninit", () => {
-      const stub = sandbox.stub(instance.pingCentreForASRouter, "uninit");
-
-      instance.uninit();
-
-      assert.calledOnce(stub);
-    });
     it("should make this.browserOpenNewtabStart() stop observing browser-open-newtab-start and domwindowopened", async () => {
       await instance.init();
       sandbox.spy(Services.obs, "removeObserver");
       sandbox.stub(instance.pingCentre, "uninit");
 
       await instance.uninit();
 
       assert.calledTwice(Services.obs.removeObserver);
@@ -1558,23 +1567,24 @@ describe("TelemetryFeed", () => {
       const fakeUUIDWithoutBraces = fakeUUID.substring(1, fakeUUID.length - 1);
       FakePrefs.prototype.prefs = {};
       FakePrefs.prototype.prefs[
         STRUCTURED_INGESTION_ENDPOINT_PREF
       ] = fakeEndpoint;
       sandbox.stub(global.gUUIDGenerator, "generateUUID").returns(fakeUUID);
       const feed = new TelemetryFeed();
       const url = feed._generateStructuredIngestionEndpoint(
+        "testNameSpace",
         "testPingType",
         "1"
       );
 
       assert.equal(
         url,
-        `${fakeEndpoint}/testPingType/1/${fakeUUIDWithoutBraces}`
+        `${fakeEndpoint}/testNameSpace/testPingType/1/${fakeUUIDWithoutBraces}`
       );
     });
   });
   describe("#handleTrailheadEnrollEvent", () => {
     it("should send a TRAILHEAD_ENROLL_EVENT if the telemetry is enabled", () => {
       FakePrefs.prototype.prefs[TELEMETRY_PREF] = true;
       const data = { experiment: "foo", type: "bar", branch: "baz" };
       instance = new TelemetryFeed();
@@ -1590,9 +1600,38 @@ describe("TelemetryFeed", () => {
       instance = new TelemetryFeed();
       sandbox.stub(instance.utEvents, "sendTrailheadEnrollEvent");
 
       instance.handleTrailheadEnrollEvent({ data });
 
       assert.notCalled(instance.utEvents.sendTrailheadEnrollEvent);
     });
   });
+  describe("#handleASRouterUserEvent", () => {
+    it("should call sendStructuredIngestionEvent on known pingTypes", async () => {
+      const data = {
+        action: "onboarding_user_event",
+        event: "IMPRESSION",
+        message_id: "12345",
+      };
+      instance = new TelemetryFeed();
+      sandbox.spy(instance, "sendStructuredIngestionEvent");
+
+      await instance.handleASRouterUserEvent({ data });
+
+      assert.calledOnce(instance.sendStructuredIngestionEvent);
+    });
+    it("should reportError on unknown pingTypes", async () => {
+      const data = {
+        action: "unknown_event",
+        event: "IMPRESSION",
+        message_id: "12345",
+      };
+      instance = new TelemetryFeed();
+      sandbox.spy(instance, "sendStructuredIngestionEvent");
+
+      await instance.handleASRouterUserEvent({ data });
+
+      assert.calledOnce(global.Cu.reportError);
+      assert.notCalled(instance.sendStructuredIngestionEvent);
+    });
+  });
 });
--- a/browser/components/newtab/test/unit/lib/ToolbarPanelHub.test.js
+++ b/browser/components/newtab/test/unit/lib/ToolbarPanelHub.test.js
@@ -543,21 +543,19 @@ describe("ToolbarPanelHub", () => {
           }
         );
         assert.calledOnce(fakeDispatch);
         const {
           args: [dispatchPayload],
         } = fakeDispatch.lastCall;
         assert.propertyVal(dispatchPayload, "type", "TOOLBAR_PANEL_TELEMETRY");
         assert.propertyVal(dispatchPayload.data, "message_id", panelPingId);
-        assert.propertyVal(
-          dispatchPayload.data.value,
-          "view",
-          "toolbar_dropdown"
-        );
+        assert.deepEqual(dispatchPayload.data.event_context, {
+          view: "toolbar_dropdown",
+        });
       });
       it("should dispatch a IMPRESSION with application_menu", async () => {
         // means panel is triggered as a subview in the application menu
         fakeElementById.hasAttribute.returns(false);
         const messages = (await PanelTestProvider.getMessages()).filter(
           m => m.template === "whatsnew_panel_message"
         );
         getMessagesStub.resolves(messages);
@@ -584,21 +582,19 @@ describe("ToolbarPanelHub", () => {
           }
         );
         assert.calledOnce(fakeDispatch);
         const {
           args: [dispatchPayload],
         } = fakeDispatch.lastCall;
         assert.propertyVal(dispatchPayload, "type", "TOOLBAR_PANEL_TELEMETRY");
         assert.propertyVal(dispatchPayload.data, "message_id", panelPingId);
-        assert.propertyVal(
-          dispatchPayload.data.value,
-          "view",
-          "application_menu"
-        );
+        assert.deepEqual(dispatchPayload.data.event_context, {
+          view: "application_menu",
+        });
       });
     });
     describe("#forceShowMessage", () => {
       const panelSelector = "PanelUI-whatsNew-message-container";
       let removeMessagesSpy;
       let renderMessagesStub;
       let addEventListenerStub;
       let message;
--- a/browser/modules/PingCentre.jsm
+++ b/browser/modules/PingCentre.jsm
@@ -310,27 +310,25 @@ class PingCentre {
     if (profileCreationDate) {
       payload.profile_creation_date = profileCreationDate;
     }
     payload.region = this._getRegion();
 
     return payload;
   }
 
-  async _createStructuredIngestionPing(data, options) {
-    let filter = options && options.filter;
+  _createStructuredIngestionPing(data, options = {}) {
+    let { filter } = options;
     let experiments = TelemetryEnvironment.getActiveExperiments();
     let experimentsString = this._createExperimentsString(experiments, filter);
 
-    let clientID = data.client_id || (await this.telemetryClientId);
     let locale = data.locale || Services.locale.appLocaleAsLangTag;
     const payload = Object.assign(
       {
         locale,
-        client_id: clientID,
         version: AppConstants.MOZ_APP_VERSION,
         release_channel: UpdateUtils.getUpdateChannel(false),
       },
       data
     );
     if (experimentsString) {
       payload.shield_id = experimentsString;
     }
@@ -443,22 +441,22 @@ class PingCentre {
    *
    * @param {Object} data     The payload to be sent.
    * @param {String} endpoint The destination endpoint. Note that Structured Ingestion
    *                          requires a different endpoint for each ping. It's up to the
    *                          caller to provide that. See more details at
    *                          https://github.com/mozilla/gcp-ingestion/blob/master/docs/edge.md#postput-request
    * @param {Object} options  Other options for this ping.
    */
-  async sendStructuredIngestionPing(data, endpoint, options) {
+  sendStructuredIngestionPing(data, endpoint, options) {
     if (!this.enabled) {
       return Promise.resolve();
     }
 
-    const ping = await this._createStructuredIngestionPing(data, options);
+    const ping = this._createStructuredIngestionPing(data, options);
     const payload = JSON.stringify(ping);
 
     if (this.logging) {
       Services.console.logStringMessage(
         `TELEMETRY PING (STRUCTURED INGESTION): ${payload}\n`
       );
     }