Bug 1243595 - Add SessionMeasurements and tests. r=ahunt draft
authorMichael Comella <michael.l.comella@gmail.com>
Tue, 17 May 2016 14:56:00 -0700
changeset 368040 bf95b42c4a2f01f424f6f2cdbfedad728715730d
parent 368039 4b922cc86caddcd5c92310d9220fcf06e7d734f2
child 368041 f748e38737a0e582975b19fd9ee7eb9a2fc43818
push id18421
push usermichael.l.comella@gmail.com
push dateWed, 18 May 2016 00:13:57 +0000
reviewersahunt
bugs1243595
milestone49.0a1
Bug 1243595 - Add SessionMeasurements and tests. r=ahunt MozReview-Commit-ID: NEKDPblKEE
mobile/android/base/java/org/mozilla/gecko/telemetry/measurements/SessionMeasurements.java
mobile/android/base/moz.build
mobile/android/tests/background/junit4/src/org/mozilla/gecko/telemetry/measurements/TestSessionMeasurements.java
new file mode 100644
--- /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;
+        }
+    }
+}
--- a/mobile/android/base/moz.build
+++ b/mobile/android/base/moz.build
@@ -571,16 +571,17 @@ gbjar.sources += ['java/org/mozilla/geck
     'tabs/TabsGridLayout.java',
     'tabs/TabsLayoutAdapter.java',
     'tabs/TabsLayoutItemView.java',
     'tabs/TabsListLayout.java',
     'tabs/TabsPanel.java',
     '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',
     'telemetry/schedulers/TelemetryUploadScheduler.java',
     'telemetry/stores/TelemetryJSONFilePingStore.java',
     'telemetry/stores/TelemetryPingStore.java',
     'telemetry/TelemetryConstants.java',
     'telemetry/TelemetryDispatcher.java',
new file mode 100644
--- /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