From cd62b5cf48276c0df1598789369d3944b743d890 Mon Sep 17 00:00:00 2001 From: Michael Comella Date: Tue, 17 May 2016 14:56:00 -0700 Subject: [PATCH] Bug 1243595 - Add SessionMeasurements and tests. r=ahunt MozReview-Commit-ID: NEKDPblKEE --HG-- extra : rebase_source : d1a75c6ad5c202718a785b4b99dda27d1a08b7b9 --- .../measurements/SessionMeasurements.java | 99 ++++++++++++++ mobile/android/base/moz.build | 1 + .../measurements/TestSessionMeasurements.java | 124 ++++++++++++++++++ 3 files changed, 224 insertions(+) create mode 100644 mobile/android/base/java/org/mozilla/gecko/telemetry/measurements/SessionMeasurements.java create mode 100644 mobile/android/tests/background/junit4/src/org/mozilla/gecko/telemetry/measurements/TestSessionMeasurements.java diff --git a/mobile/android/base/java/org/mozilla/gecko/telemetry/measurements/SessionMeasurements.java b/mobile/android/base/java/org/mozilla/gecko/telemetry/measurements/SessionMeasurements.java new file mode 100644 index 000000000000..6f7d2127a1eb --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/telemetry/measurements/SessionMeasurements.java @@ -0,0 +1,99 @@ +/* + * 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/. + */ + +package org.mozilla.gecko.telemetry.measurements; + +import android.content.Context; +import android.content.SharedPreferences; +import android.support.annotation.UiThread; +import android.support.annotation.VisibleForTesting; +import org.mozilla.gecko.GeckoSharedPrefs; + +import java.util.concurrent.TimeUnit; + +/** + * A class to measure the number of user sessions & their durations. It was created for use with the + * telemetry core ping. A session is the time between {@link #recordSessionStart()} and + * {@link #recordSessionEnd(Context)}. + * + * This class is thread-safe, provided the thread annotations are followed. Under the hood, this class uses + * SharedPreferences & because there is no atomic getAndSet operation, we synchronize access to it. + */ +public class SessionMeasurements { + @VisibleForTesting static final String PREF_SESSION_COUNT = "measurements-session-count"; + @VisibleForTesting static final String PREF_SESSION_DURATION = "measurements-session-duration"; + + private boolean sessionStarted = false; + private long timeAtSessionStartNano = -1; + + @UiThread // we assume this will be called on the same thread as session end so we don't have to synchronize sessionStarted. + public void recordSessionStart() { + if (sessionStarted) { + throw new IllegalStateException("Trying to start session but it is already started"); + } + sessionStarted = true; + timeAtSessionStartNano = getSystemTimeNano(); + } + + @UiThread // we assume this will be called on the same thread as session start so we don't have to synchronize sessionStarted. + public void recordSessionEnd(final Context context) { + if (!sessionStarted) { + throw new IllegalStateException("Expected session to be started before session end is called"); + } + sessionStarted = false; + + final long sessionElapsedSeconds = TimeUnit.NANOSECONDS.toSeconds(getSystemTimeNano() - timeAtSessionStartNano); + final SharedPreferences sharedPrefs = getSharedPreferences(context); + synchronized (this) { + final int sessionCount = sharedPrefs.getInt(PREF_SESSION_COUNT, 0); + final long totalElapsedSeconds = sharedPrefs.getLong(PREF_SESSION_DURATION, 0); + sharedPrefs.edit() + .putInt(PREF_SESSION_COUNT, sessionCount + 1) + .putLong(PREF_SESSION_DURATION, totalElapsedSeconds + sessionElapsedSeconds) + .apply(); + } + } + + /** + * Gets the session measurements since the last time the measurements were last retrieved. + */ + public synchronized SessionMeasurementsContainer getAndResetSessionMeasurements(final Context context) { + final SharedPreferences sharedPrefs = getSharedPreferences(context); + final int sessionCount = sharedPrefs.getInt(PREF_SESSION_COUNT, 0); + final long totalElapsedSeconds = sharedPrefs.getLong(PREF_SESSION_DURATION, 0); + sharedPrefs.edit() + .putInt(PREF_SESSION_COUNT, 0) + .putLong(PREF_SESSION_DURATION, 0) + .apply(); + return new SessionMeasurementsContainer(sessionCount, totalElapsedSeconds); + } + + @VisibleForTesting SharedPreferences getSharedPreferences(final Context context) { + return GeckoSharedPrefs.forProfile(context); + } + + /** + * Returns (roughly) the system uptime in nanoseconds. A less coupled implementation would + * take this value from the caller of recordSession*, however, we do this internally to ensure + * the caller uses both a time system consistent between the start & end calls and uses the + * appropriate time system (i.e. not wall time, which can change when the clock is changed). + */ + @VisibleForTesting long getSystemTimeNano() { // TODO: necessary? + return System.nanoTime(); + } + + public static final class SessionMeasurementsContainer { + /** The number of sessions. */ + public final int sessionCount; + /** The number of seconds elapsed in ALL sessions included in {@link #sessionCount}. */ + public final long elapsedSeconds; + + private SessionMeasurementsContainer(final int sessionCount, final long elapsedSeconds) { + this.sessionCount = sessionCount; + this.elapsedSeconds = elapsedSeconds; + } + } +} diff --git a/mobile/android/base/moz.build b/mobile/android/base/moz.build index cec31238213c..fe0f744e8da7 100644 --- a/mobile/android/base/moz.build +++ b/mobile/android/base/moz.build @@ -580,6 +580,7 @@ gbjar.sources += ['java/org/mozilla/gecko/' + x for x in [ 'tabs/TabsPanelThumbnailView.java', 'Telemetry.java', 'telemetry/measurements/SearchCountMeasurements.java', + 'telemetry/measurements/SessionMeasurements.java', 'telemetry/pingbuilders/TelemetryCorePingBuilder.java', 'telemetry/pingbuilders/TelemetryPingBuilder.java', 'telemetry/schedulers/TelemetryUploadAllPingsImmediatelyScheduler.java', diff --git a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/telemetry/measurements/TestSessionMeasurements.java b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/telemetry/measurements/TestSessionMeasurements.java new file mode 100644 index 000000000000..a5d3ce551010 --- /dev/null +++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/telemetry/measurements/TestSessionMeasurements.java @@ -0,0 +1,124 @@ +/* + * 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/. + */ + +package org.mozilla.gecko.telemetry.measurements; + +import android.content.Context; +import android.content.SharedPreferences; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mozilla.gecko.background.testhelpers.TestRunner; +import org.mozilla.gecko.telemetry.measurements.SessionMeasurements.SessionMeasurementsContainer; +import org.robolectric.RuntimeEnvironment; + +import java.util.concurrent.TimeUnit; + +import static org.junit.Assert.*; +import static org.mockito.Mockito.*; + +/** + * Tests the session measurements class. + */ +@RunWith(TestRunner.class) +public class TestSessionMeasurements { + + private SessionMeasurements testMeasurements; + private SharedPreferences sharedPrefs; + private Context context; + + @Before + public void setUp() throws Exception { + testMeasurements = spy(SessionMeasurements.class); + sharedPrefs = RuntimeEnvironment.application.getSharedPreferences( + TestSessionMeasurements.class.getSimpleName(), Context.MODE_PRIVATE); + doReturn(sharedPrefs).when(testMeasurements).getSharedPreferences(any(Context.class)); + + context = RuntimeEnvironment.application; + } + + private void assertSessionCount(final String postfix, final int expectedSessionCount) { + final int actual = sharedPrefs.getInt(SessionMeasurements.PREF_SESSION_COUNT, -1); + assertEquals("Expected number of sessions occurred " + postfix, expectedSessionCount, actual); + } + + private void assertSessionDuration(final String postfix, final long expectedSessionDuration) { + final long actual = sharedPrefs.getLong(SessionMeasurements.PREF_SESSION_DURATION, -1); + assertEquals("Expected session duration received " + postfix, expectedSessionDuration, actual); + } + + private void mockGetSystemTimeNanosToReturn(final long value) { + doReturn(value).when(testMeasurements).getSystemTimeNano(); + } + + @Test + public void testRecordSessionStartAndEndCalledOnce() throws Exception { + final long expectedElapsedSeconds = 4; + mockGetSystemTimeNanosToReturn(0); + testMeasurements.recordSessionStart(); + mockGetSystemTimeNanosToReturn(TimeUnit.SECONDS.toNanos(expectedElapsedSeconds)); + testMeasurements.recordSessionEnd(context); + + final String postfix = "after recordSessionStart/End called once"; + assertSessionCount(postfix, 1); + assertSessionDuration(postfix, expectedElapsedSeconds); + } + + @Test + public void testRecordSessionStartAndEndCalledTwice() throws Exception { + final long expectedElapsedSeconds = 100; + mockGetSystemTimeNanosToReturn(0L); + for (int i = 1; i <= 2; ++i) { + testMeasurements.recordSessionStart(); + mockGetSystemTimeNanosToReturn(TimeUnit.SECONDS.toNanos((expectedElapsedSeconds / 2) * i)); + testMeasurements.recordSessionEnd(context); + } + + final String postfix = "after recordSessionStart/End called twice"; + assertSessionCount(postfix, 2); + assertSessionDuration(postfix, expectedElapsedSeconds); + } + + @Test(expected = IllegalStateException.class) + public void testRecordSessionStartThrowsIfSessionAlreadyStarted() throws Exception { + // First call will start the session, next expected to throw. + for (int i = 0; i < 2; ++i) { + testMeasurements.recordSessionStart(); + } + } + + @Test(expected = IllegalStateException.class) + public void testRecordSessionEndThrowsIfCalledBeforeSessionStarted() { + testMeasurements.recordSessionEnd(context); + } + + @Test // assumes the underlying format in SessionMeasurements + public void testGetAndResetSessionMeasurementsReturnsSetData() throws Exception { + final int expectedSessionCount = 42; + final long expectedSessionDuration = 1234567890; + sharedPrefs.edit() + .putInt(SessionMeasurements.PREF_SESSION_COUNT, expectedSessionCount) + .putLong(SessionMeasurements.PREF_SESSION_DURATION, expectedSessionDuration) + .apply(); + + final SessionMeasurementsContainer actual = testMeasurements.getAndResetSessionMeasurements(context); + assertEquals("Returned session count matches expected", expectedSessionCount, actual.sessionCount); + assertEquals("Returned session duration matches expected", expectedSessionDuration, actual.elapsedSeconds); + } + + @Test + public void testGetAndResetSessionMeasurementsResetsData() throws Exception { + sharedPrefs.edit() + .putInt(SessionMeasurements.PREF_SESSION_COUNT, 10) + .putLong(SessionMeasurements.PREF_SESSION_DURATION, 10) + .apply(); + + testMeasurements.getAndResetSessionMeasurements(context); + final String postfix = "is reset after retrieval"; + assertSessionCount(postfix, 0); + assertSessionDuration(postfix, 0); + } +} \ No newline at end of file