Commit 46162554 authored by Andrew Creskey's avatar Andrew Creskey
Browse files

Bug 1717920 - Add scrolling metrics to history metadata r=mossop,botond,edgar

Disabled by default, browser.places.interactions.enabled, this adds scrolling metrics (time spent scrolling and distance scrolled) to the history metadata.

Differential Revision: https://phabricator.services.mozilla.com/D120656
parent 97e3a763
Loading
Loading
Loading
Loading
+72 −44
Original line number Diff line number Diff line
@@ -73,28 +73,6 @@ function monotonicNow() {
  return (gLastTime = time);
}

/**
 * The TypingInteraction object measures time spent typing on the current interaction.
 * This is consists of the current typing metrics as well as accumulated typing metrics.
 */
class TypingInteraction {
  /**
   * Returns an object with all current and accumulated typing metrics.
   *
   * @returns {object} with properties typingTime, keypresses
   */
  getTypingInteraction() {
    let typingInteraction = { typingTime: 0, keypresses: 0 };
    const interactionData = ChromeUtils.consumeInteractionData();
    const typing = interactionData.Typing;
    if (typing) {
      typingInteraction.typingTime += typing.interactionTimeInMilliseconds;
      typingInteraction.keypresses += typing.interactionCount;
    }
    return typingInteraction;
  }
}

/**
 * @typedef {object} DocumentInfo
 *   DocumentInfo is used to pass document information from the child process
@@ -151,13 +129,6 @@ class _Interactions {
   */
  #interactions = new WeakMap();

  /**
   * This tracks and reports the typing interactions
   *
   * @type {TypingInteraction}
   */
  #typingInteraction = new TypingInteraction();

  /**
   * Tracks the currently active window so that we can avoid recording
   * interactions in non-active windows.
@@ -240,6 +211,7 @@ class _Interactions {
    this.#userIsIdle = false;
    this._pageViewStartTime = Cu.now();
    ChromeUtils.consumeInteractionData();
    await _Interactions.interactionUpdatePromise;
    await this.store.reset();
  }

@@ -320,7 +292,7 @@ class _Interactions {
  }

  /**
   * Updates the current interaction.
   * Updates the current interaction
   *
   * @param {Browser} [browser]
   *   The browser object that has triggered the update, if known. This is
@@ -328,10 +300,48 @@ class _Interactions {
   *   optimization to avoid obtaining the browser object.
   */
  #updateInteraction(browser = undefined) {
    if (
      !this.#activeWindow ||
      (browser && browser.ownerGlobal != this.#activeWindow)
    _Interactions.#updateInteraction_async(
      browser,
      this.#activeWindow,
      this.#userIsIdle,
      this.#interactions,
      this._pageViewStartTime,
      this.store
    );
  }

  /**
   * Stores the promise created in updateInteraction_async so that we can await its fulfillment
   * when sychronization is needed.
   */
  static interactionUpdatePromise = Promise.resolve();

  /**
   * Returns the interactions update promise to be used when sychronization is needed from tests.
   */
  get interactionUpdatePromise() {
    return _Interactions.interactionUpdatePromise;
  }

  /**
   * Updates the current interaction on fulfillment of the asynchronous collection of scrolling interactions.
   *
   *  @param {Browser} browser
   *  @param {DOMWindow} activeWindow
   *  @param {boolean} userIsIdle
   *  @param {WeakMap<browser, InteractionInfo>} interactions
   *  @param {number} pageViewStartTime
   *  @param {InteractionsStore} store
   */
  static async #updateInteraction_async(
    browser,
    activeWindow,
    userIsIdle,
    interactions,
    pageViewStartTime,
    store
  ) {
    if (!activeWindow || (browser && browser.ownerGlobal != activeWindow)) {
      logConsole.debug("Not updating interaction as there is no active window");
      return;
    }
@@ -340,30 +350,48 @@ class _Interactions {
    // have already updated it when idle was signalled.
    // Sometimes an interaction may be signalled before idle is cleared, however
    // worst case we'd only loose approx 2 seconds of interaction detail.
    if (this.#userIsIdle) {
    if (userIsIdle) {
      logConsole.debug("Not updating interaction as the user is idle");
      return;
    }

    if (!browser) {
      browser = this.#activeWindow.gBrowser.selectedTab.linkedBrowser;
      browser = activeWindow.gBrowser.selectedTab.linkedBrowser;
    }

    let interaction = this.#interactions.get(browser);
    let interaction = interactions.get(browser);
    if (!interaction) {
      logConsole.debug("No interaction to update");
      return;
    }

    interaction.totalViewTime += Cu.now() - this._pageViewStartTime;
    this._pageViewStartTime = Cu.now();
    interaction.totalViewTime += Cu.now() - pageViewStartTime;
    Interactions._pageViewStartTime = Cu.now();

    const interactionData = ChromeUtils.consumeInteractionData();
    const typing = interactionData.Typing;
    if (typing) {
      interaction.typingTime += typing.interactionTimeInMilliseconds;
      interaction.keypresses += typing.interactionCount;
    }

    // Collect the scrolling data and add the interaction to the store on completion
    _Interactions.interactionUpdatePromise = _Interactions.interactionUpdatePromise
      .then(async () => ChromeUtils.collectScrollingData())
      .then(
        result => {
          interaction.scrollingTime += result.interactionTimeInMilliseconds;
          interaction.scrollingDistance += result.scrollingDistanceInPixels;

    const typingInteraction = this.#typingInteraction.getTypingInteraction();
    interaction.typingTime += typingInteraction.typingTime;
    interaction.keypresses += typingInteraction.keypresses;
          interaction.updated_at = monotonicNow();

    this.store.add(interaction);
          logConsole.debug("Add to store: ", interaction);
          store.add(interaction);
        },
        reason => {
          Cu.reportError(reason);
        }
      );
  }

  /**
+1 −0
Original line number Diff line number Diff line
@@ -480,6 +480,7 @@ function setupListeners() {
  document.getElementById("export").addEventListener("click", async e => {
    e.preventDefault();
    const data = await metadataHandler.export();

    const blob = new Blob([JSON.stringify(data)], {
      type: "text/json;charset=utf-8",
    });
+5 −0
Original line number Diff line number Diff line
@@ -7,13 +7,18 @@ prefs =
  browser.places.interactions.enabled=true
  browser.places.interactions.log=true
  browser.places.interactions.typing_timeout_ms=50
  browser.places.interactions.scrolling_timeout_ms=50

support-files =
  head.js
  ../keyword_form.html
  scrolling.html
  scrolling_subframe.html

[browser_interactions_blocklist.js]
[browser_interactions_referrer.js]
[browser_interactions_view_time.js]
[browser_interactions_typing.js]
[browser_interactions_scrolling.js]
skip-if =
    apple_silicon && fission
+152 −0
Original line number Diff line number Diff line
/* 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/. */

/**
 * Test reporting of scrolling interactions.
 */

"use strict";

const TEST_URL =
  "https://example.com/browser/browser/components/places/tests/browser/interactions/scrolling.html";
const TEST_URL2 = "https://example.com/browser";

async function waitForScrollEvent(aBrowser) {
  await BrowserTestUtils.waitForContentEvent(aBrowser, "scroll");
}

add_task(async function test_no_scrolling() {
  await Interactions.reset();
  await BrowserTestUtils.withNewTab(TEST_URL, async browser => {
    BrowserTestUtils.loadURI(browser, TEST_URL2);
    await BrowserTestUtils.browserLoaded(browser, false, TEST_URL2);

    await assertDatabaseValues([
      {
        url: TEST_URL,
        exactscrollingDistance: 0,
        exactscrollingTime: 0,
      },
    ]);
  });
});

add_task(async function test_arrow_key_down_scroll() {
  await Interactions.reset();
  await BrowserTestUtils.withNewTab(TEST_URL, async browser => {
    await SpecialPowers.spawn(browser, [], function() {
      const heading = content.document.getElementById("heading");
      heading.focus();
    });

    await EventUtils.synthesizeKey("KEY_ArrowDown");

    await waitForScrollEvent(browser);

    BrowserTestUtils.loadURI(browser, TEST_URL2);
    await BrowserTestUtils.browserLoaded(browser, false, TEST_URL2);

    await assertDatabaseValues([
      {
        url: TEST_URL,
        scrollingDistanceIsGreaterThan: 0,
        scrollingTimeIsGreaterThan: 0,
      },
    ]);
  });
});

add_task(async function test_scrollIntoView() {
  await Interactions.reset();
  await BrowserTestUtils.withNewTab(TEST_URL, async browser => {
    await SpecialPowers.spawn(browser, [], function() {
      const heading = content.document.getElementById("middleHeading");
      heading.scrollIntoView();
    });

    waitForScrollEvent(browser);

    BrowserTestUtils.loadURI(browser, TEST_URL2);
    await BrowserTestUtils.browserLoaded(browser, false, TEST_URL2);

    // JS-triggered scrolling should not be reported
    await assertDatabaseValues([
      {
        url: TEST_URL,
        exactscrollingDistance: 0,
        exactscrollingTime: 0,
      },
    ]);
  });
});

add_task(async function test_anchor_click() {
  await Interactions.reset();
  await BrowserTestUtils.withNewTab(TEST_URL, async browser => {
    await SpecialPowers.spawn(browser, [], function() {
      const anchor = content.document.getElementById("to_bottom_anchor");
      anchor.click();
    });

    waitForScrollEvent(browser);

    BrowserTestUtils.loadURI(browser, TEST_URL2);
    await BrowserTestUtils.browserLoaded(browser, false, TEST_URL2);

    // The scrolling resulting from clicking on an anchor should not be reported
    await assertDatabaseValues([
      {
        url: TEST_URL,
        exactscrollingDistance: 0,
        exactscrollingTime: 0,
      },
    ]);
  });
});

add_task(async function test_window_scrollBy() {
  await Interactions.reset();
  await BrowserTestUtils.withNewTab(TEST_URL, async browser => {
    await SpecialPowers.spawn(browser, [], function() {
      content.scrollBy(0, 100);
    });

    waitForScrollEvent(browser);

    BrowserTestUtils.loadURI(browser, TEST_URL2);
    await BrowserTestUtils.browserLoaded(browser, false, TEST_URL2);

    // The scrolling resulting from the window.scrollBy() call should not be reported
    await assertDatabaseValues([
      {
        url: TEST_URL,
        exactscrollingDistance: 0,
        exactscrollingTime: 0,
      },
    ]);
  });
});

add_task(async function test_window_scrollTo() {
  await Interactions.reset();
  await BrowserTestUtils.withNewTab(TEST_URL, async browser => {
    await SpecialPowers.spawn(browser, [], function() {
      content.scrollTo(0, 200);
    });

    waitForScrollEvent(browser);

    BrowserTestUtils.loadURI(browser, TEST_URL2);
    await BrowserTestUtils.browserLoaded(browser, false, TEST_URL2);

    // The scrolling resulting from the window.scrollTo() call should not be reported
    await assertDatabaseValues([
      {
        url: TEST_URL,
        exactscrollingDistance: 0,
        exactscrollingTime: 0,
      },
    ]);
  });
});
+32 −1
Original line number Diff line number Diff line
@@ -39,13 +39,14 @@ add_task(async function global_setup() {
 * @param {Array} expected list of interactions to be found.
 */
async function assertDatabaseValues(expected) {
  await Interactions.interactionUpdatePromise;
  await Interactions.store.flush();

  let interactions = await PlacesUtils.withConnectionWrapper(
    "head.js::assertDatabaseValues",
    async db => {
      let rows = await db.execute(`
        SELECT h.url AS url, h2.url as referrer_url, total_view_time, key_presses, typing_time
        SELECT h.url AS url, h2.url as referrer_url, total_view_time, key_presses, typing_time, scrolling_time, scrolling_distance
        FROM moz_places_metadata m
        JOIN moz_places h ON h.id = m.place_id
        LEFT JOIN moz_places h2 ON h2.id = m.referrer_place_id
@@ -57,6 +58,8 @@ async function assertDatabaseValues(expected) {
        keypresses: r.getResultByName("key_presses"),
        typingTime: r.getResultByName("typing_time"),
        totalViewTime: r.getResultByName("total_view_time"),
        scrollingTime: r.getResultByName("scrolling_time"),
        scrollingDistance: r.getResultByName("scrolling_distance"),
      }));
    }
  );
@@ -127,6 +130,34 @@ async function assertDatabaseValues(expected) {
        "Should have stored less than this amount of typing time."
      );
    }

    if (expected[i].exactScrollingDistance != undefined) {
      Assert.equal(
        actual.scrollingDistance,
        expected[i].exactScrollingDistance,
        "Should have scrolled by exactly least this distance"
      );
    } else if (expected[i].exactScrollingTime != undefined) {
      Assert.greater(
        actual.scrollingTime,
        expected[i].exactScrollingTime,
        "Should have scrolled for exactly least this duration"
      );
    }

    if (expected[i].scrollingDistanceIsGreaterThan != undefined) {
      Assert.greater(
        actual.scrollingDistance,
        expected[i].scrollingDistanceIsGreaterThan,
        "Should have scrolled by at least this distance"
      );
    } else if (expected[i].scrollingTimeIsGreaterThan != undefined) {
      Assert.greater(
        actual.scrollingTime,
        expected[i].scrollingTimeIsGreaterThan,
        "Should have scrolled for at least this duration"
      );
    }
  }
}

Loading