Skip to content

Commit

Permalink
Bug 1586738 - Add Glean-iOS MetricsPingScheduler (#655)
Browse files Browse the repository at this point in the history
Bug 1586738 - Add Glean-iOS MetricsPingScheduler
  • Loading branch information
travis79 authored Jan 22, 2020
2 parents 62c64f2 + a418f5b commit 19af88c
Show file tree
Hide file tree
Showing 5 changed files with 648 additions and 36 deletions.
18 changes: 17 additions & 1 deletion glean-core/ios/Glean.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
/* Begin PBXBuildFile section */
1F39E7B0239F0505009B13B3 /* GleanDebugTools.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F39E7AF239F0505009B13B3 /* GleanDebugTools.swift */; };
1F39E7B3239F0777009B13B3 /* GleanDebugUtilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F39E7B2239F0777009B13B3 /* GleanDebugUtilityTests.swift */; };
1F58920D23C7D615007D2D80 /* MetricsPingScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F58920C23C7D615007D2D80 /* MetricsPingScheduler.swift */; };
1F58921223C923C4007D2D80 /* MetricsPingSchedulerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F58921123C923C4007D2D80 /* MetricsPingSchedulerTests.swift */; };
1F6058932314863400307A9F /* Configuration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F6058922314863400307A9F /* Configuration.swift */; };
1F605895231489AB00307A9F /* HttpPingUploader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F605894231489AB00307A9F /* HttpPingUploader.swift */; };
1F60589723148BF800307A9F /* GleanLifecycleObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F60589623148BF800307A9F /* GleanLifecycleObserver.swift */; };
Expand Down Expand Up @@ -79,6 +81,8 @@
/* Begin PBXFileReference section */
1F39E7AF239F0505009B13B3 /* GleanDebugTools.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GleanDebugTools.swift; sourceTree = "<group>"; };
1F39E7B2239F0777009B13B3 /* GleanDebugUtilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GleanDebugUtilityTests.swift; sourceTree = "<group>"; };
1F58920C23C7D615007D2D80 /* MetricsPingScheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsPingScheduler.swift; sourceTree = "<group>"; };
1F58921123C923C4007D2D80 /* MetricsPingSchedulerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsPingSchedulerTests.swift; sourceTree = "<group>"; };
1F6058922314863400307A9F /* Configuration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Configuration.swift; sourceTree = "<group>"; };
1F605894231489AB00307A9F /* HttpPingUploader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HttpPingUploader.swift; sourceTree = "<group>"; };
1F60589623148BF800307A9F /* GleanLifecycleObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GleanLifecycleObserver.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -179,12 +183,21 @@
path = Debug;
sourceTree = "<group>";
};
1F58921323C923CB007D2D80 /* Scheduler */ = {
isa = PBXGroup;
children = (
1F58921123C923C4007D2D80 /* MetricsPingSchedulerTests.swift */,
);
path = Scheduler;
sourceTree = "<group>";
};
1F60588D231483D600307A9F /* Scheduler */ = {
isa = PBXGroup;
children = (
1F60589623148BF800307A9F /* GleanLifecycleObserver.swift */,
AC54D224233133DB0019319A /* PingUploadOperation.swift */,
AC54D226233134A30019319A /* GleanOperation.swift */,
1F58920C23C7D615007D2D80 /* MetricsPingScheduler.swift */,
);
path = Scheduler;
sourceTree = "<group>";
Expand Down Expand Up @@ -276,12 +289,12 @@
BF43A8C5232A4B5F00545310 /* Metrics */,
1F60588E2314840B00307A9F /* Net */,
1F60588D231483D600307A9F /* Scheduler */,
1FB70AED23301BFC00C7CF09 /* Utils */,
BF3DE3942243A2F20018E23F /* Glean.h */,
BFFE33A82328F4EF005348FE /* GleanFfi.h */,
BF3DE3952243A2F20018E23F /* Info.plist */,
BF93C697224BFC57006CE7D8 /* Glean.swift */,
BFE1CDCD233B989A0019EE47 /* GleanMetrics.swift */,
1FB70AED23301BFC00C7CF09 /* Utils */,
1F70B785232995A9007395FB /* Dispatchers.swift */,
);
path = Glean;
Expand All @@ -294,6 +307,7 @@
1FB8F8392326EBA500618E47 /* Config */,
BF43A8CB232A613100545310 /* Metrics */,
BF80AA5923992FFB00A8B172 /* Net */,
1F58921323C923CB007D2D80 /* Scheduler */,
1F70B787232A81A4007395FB /* DispatchersTest.swift */,
BF3DE39F2243A2F20018E23F /* GleanTests.swift */,
BF3DE3A12243A2F20018E23F /* Info.plist */,
Expand Down Expand Up @@ -579,6 +593,7 @@
1F60589723148BF800307A9F /* GleanLifecycleObserver.swift in Sources */,
1FD4527523395B4500F4C7E8 /* UuidMetric.swift in Sources */,
BFE1CDC8233B73B30019EE47 /* Unreachable.swift in Sources */,
1F58920D23C7D615007D2D80 /* MetricsPingScheduler.swift in Sources */,
BF30FDC82332640400840607 /* TimeUnit.swift in Sources */,
AC54D225233133DB0019319A /* PingUploadOperation.swift in Sources */,
BF43A8C9232A4CB200545310 /* Lifetime.swift in Sources */,
Expand Down Expand Up @@ -611,6 +626,7 @@
1F39E7B3239F0777009B13B3 /* GleanDebugUtilityTests.swift in Sources */,
BFAED50A2369752400DF293D /* StringListMetricTests.swift in Sources */,
BF890561232BC227003CA2BA /* StringMetricTests.swift in Sources */,
1F58921223C923C4007D2D80 /* MetricsPingSchedulerTests.swift in Sources */,
1FD4527723395EEB00F4C7E8 /* UuidMetricTests.swift in Sources */,
BF80AA5B2399301300A8B172 /* HttpPingUploaderTests.swift in Sources */,
1FB8F8382326EABD00618E47 /* ConfigurationTests.swift in Sources */,
Expand Down
64 changes: 39 additions & 25 deletions glean-core/ios/Glean/Glean.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ public class Glean {
/// ```
public static let shared = Glean()

var metricsPingScheduler: MetricsPingScheduler = MetricsPingScheduler()

var handle: UInt64 = 0
private var uploadEnabled: Bool = true
var configuration: Configuration?
Expand Down Expand Up @@ -109,6 +111,9 @@ public class Glean {
}
}

// Check for overdue metrics pings
metricsPingScheduler.schedule()

// Signal Dispatcher that init is complete
Dispatchers.shared.flushQueuedInitialTasks()

Expand Down Expand Up @@ -230,33 +235,42 @@ public class Glean {
func submitPingsByName(pingNames: [String]) {
// Queue submitting the ping behind all other metric operations to include them in the ping
Dispatchers.shared.launchAPI {
if !self.isInitialized() {
self.logger.error("Glean must be initialized before sending pings")
return
}
self.submitPingsByNameSync(pingNames: pingNames)
}
}

if !self.getUploadEnabled() {
self.logger.error("Glean must be enabled before sending pings")
return
}
/// Collect and submit a list of pings by name for eventual uploading, synchronously
///
/// - parameters:
/// * pingNames: List of ping names to send
///
/// The ping content is assembled as soon as possible, but upload is not
/// guaranteed to happen immediately, as that depends on the upload
/// policies.
///
/// If the ping currently contains no content, it will not be assembled and
/// queued for sending.
func submitPingsByNameSync(pingNames: [String]) {
if !self.isInitialized() {
self.logger.error("Glean must be initialized before sending pings")
return
}

withArrayOfCStrings(pingNames) { pingNames in
let submittedPing = glean_submit_pings_by_name(
self.handle,
pingNames,
Int32(pingNames?.count ?? 0)
)

if submittedPing != 0 {
if let config = self.configuration {
// Run the upload in the background to not block other metric operations.
// Upload is run off of the main thread.
// Please note that the ping uploader will spawn other async
// operations if there are pings to upload.
Dispatchers.shared.launchConcurrent {
HttpPingUploader(configuration: config).process()
}
}
if !self.getUploadEnabled() {
self.logger.error("Glean must be enabled before sending pings")
return
}

withArrayOfCStrings(pingNames) { pingNames in
let submittedPing = glean_submit_pings_by_name(
self.handle,
pingNames,
Int32(pingNames?.count ?? 0)
)

if submittedPing != 0 {
if let config = self.configuration {
HttpPingUploader(configuration: config).process()
}
}
}
Expand Down
216 changes: 216 additions & 0 deletions glean-core/ios/Glean/Scheduler/MetricsPingScheduler.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
/* 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/. */

/// MetricsPingScheduler facilitates scheduling the periodic assembling of metrics pings,
/// at a given time, trying its best to handle the following cases:
///
/// - ping is overdue (due time already passed) for the current calendar day;
/// - ping is soon to be sent in the current calendar day;
/// - ping was already sent, and must be scheduled for the next calendar day.
class MetricsPingScheduler {
// This struct is used for organizational purposes to keep the class constants in a single place
struct Constants {
static let logTag = "glean/MetricsPingSched"
static let lastMetricsPingSentDateTime = "last_metrics_ping_iso_datetime"
static let dueHourOfTheDay = 4
static let lastVersionOfAppUsed = "last_version_of_app_used"
}

private let logger = Logger(tag: Constants.logTag)

var timer: Timer?

/// Schedules the metrics ping collection at the due time.
///
/// - parameters:
/// * now: A `Date` representing the current date/time
/// * sendTheNextCalendarDay: Determines whether to schedule collection for the next calendar day
/// or to attempt to schedule it for the current calendar day. If the latter and
/// we're overdue for the expected collection time, the task is scheduled for
/// immediate execution.
func schedulePingCollection(_ now: Date, sendTheNextCalendarDay: Bool) {
var fireDate = Calendar.current.date(
bySettingHour: Constants.dueHourOfTheDay,
minute: 0,
second: 0,
of: now
)!

// Invalidiate the timer if it's already running
timer?.invalidate()

// Setup timer and schedule
if sendTheNextCalendarDay {
let tomorrow = Calendar.current.date(
byAdding: .day,
value: 1,
to: fireDate,
wrappingComponents: true
)!
fireDate = tomorrow
}

logger.debug(
"Scheduling the 'metrics' ping for \(fireDate), in \(fireDate - now) seconds."
)

// Set the timer to fire at the `fireDate`
timer = Timer.scheduledTimer(withTimeInterval: fireDate - now, repeats: false) { _ in
self.logger.debug("MetricsPingScheduler timer fired!")
// When the timer fires, call `collectPingAndReschedule` with the current
// date/time.
self.collectPingAndReschedule(Date(), startupPing: false)
}
}

/// Determines if the application is a differnet version from the last time it was run. This is used to prevent
/// mixing data from multiple versions of the application in the same ping.
///
/// - returns: `true` if the version is different, `false` if the version is the same.
func isDifferentVersion() -> Bool {
// Determine if the version has changed since the last time we ran.
let currentVersion = AppInfo.displayVersion
let lastVersion = UserDefaults.standard.string(forKey: Constants.lastVersionOfAppUsed)
if currentVersion != lastVersion {
UserDefaults.standard.set(currentVersion, forKey: Constants.lastVersionOfAppUsed)
return true
}

return false
}

/// Check if the provided time is after the ping due time.
///
/// - parameters:
/// * now: A `Date` representing the current time
/// * dueHourOfTheDay: An `Int` representing the due hour of the day, in the [0...23] range
///
/// - returns: `true` if `now` is past the due hour of the day.
func isAfterDueTime(_ now: Date, dueHourOfTheDay: Int = Constants.dueHourOfTheDay) -> Bool {
return now > Calendar.current.date(
bySettingHour: dueHourOfTheDay,
minute: 0,
second: 0,
of: now
)!
}

/// Performs startup checks to decide when to schedule the next metrics ping collection.
func schedule() {
let now = Date()

// If the version of the app is different from the last time we ran the app,
// schedule the metrics ping for immediate collection. We only need to perform
// this check at startup (when overduePingAsFirst is true).
if isDifferentVersion() {
Dispatchers.shared.serialOperationQueue.addOperation {
self.collectPingAndReschedule(now, startupPing: true)
}
return
}

let lastSentDate = getLastCollectedDate()
logger.debug("The 'metrics' ping was last sent on \(String(describing: lastSentDate))")

// We expect to cover 3 cases here:
//
// (1) - the ping was already collected on the current calendar day; only schedule
// one for collecting the next calendar day at the due time;
// (2) - the ping was NOT collected on the current calendar day, and we're later than
// the due time; collect the ping immediately;
// (3) - the ping was NOT collected on the current calendar day, but we still have
// some time to the due time; schedule for sending the current calendar day.

let alreadySentToday = lastSentDate != nil && Calendar.current.isDateInToday(lastSentDate!)
if alreadySentToday {
// The metrics ping was already sent today. Schedule it for the next
// calendar day. This addresses (1).
logger.info("The 'metrics' ping was already sent today, \(now).")
schedulePingCollection(now, sendTheNextCalendarDay: true)
} else if isAfterDueTime(now) {
logger.info("The 'metrics' ping is scheduled for immediate collection, \(now)")
// **IMPORTANT**
//
// The reason why we're collecting the "metrics" ping in the `Dispatchers`
// queue is that we want to make sure no other metric API adds data before
// the ping is collected. All the exposed metrics API dispatch calls to the
// engines through the `Dispatchers.API` context, so this ensures we are enqueued
// before any other recording API call.
//
// - Do not change `Dispatchers.shared.serialOperationQueue.addOperation` to
// `Dispatchers.shared.launchAPI` as this would break startup overdue ping
// collection.
// - `addOperation` schedules the task for immediate execution on the
// `Dispatchers` serial execution queue, before any other enqueued task. For more
// context, see bug 1604861 and the implementation of
// `collectPingAndReschedule`.
Dispatchers.shared.serialOperationQueue.addOperation {
self.collectPingAndReschedule(now, startupPing: true)
}
} else {
// This covers (3).
logger.info("The 'metrics' collection is scheduled for today, \(now)")
schedulePingCollection(now, sendTheNextCalendarDay: false)
}
}

/// Triggers the collection of the "metrics" ping and schedules the next collection.
///
/// - parameters:
/// * now: A `Date` representing the current time
func collectPingAndReschedule(_ now: Date, startupPing: Bool = false) {
logger.info("Collecting the 'metrics' ping, now = \(now), startup = \(startupPing)")
if startupPing {
// **IMPORTANT**
//
// During the Glean initialization, we require any metric recording to be
// batched up and replayed after any startup metrics ping is sent. To guarantee
// that, we dispatch this function from `Dispatchers.API.executeTask`. However,
// Pings.metrics.submit() ends up calling `Dispatchers.API.launch` again which
// will delay the ping collection task after any pending metric recording is
// executed, breaking the 'metrics' ping promise of sending a startup 'metrics'
// ping only containing data from the previous session.
// To prevent that, we synchronously manually dispatch the 'metrics' ping, without
// going through our public API.
//
// * Do not change this line without checking what it implies for the above wall
// of text. *
Glean.shared.submitPingsByNameSync(pingNames: ["metrics"])
} else {
GleanMetrics.Pings.shared.metrics.submit()
}
// Update the collection date: we don't really care if we have data or not, let's
// always update the sent date.
updateSentDate(now)
// Reschedule the collection.
schedulePingCollection(now, sendTheNextCalendarDay: true)
}

/// Get the date the metrics ping was last collected.
///
/// - returns: A `Date` representing the when the metrics ping was last collected, or nil if no metrics
/// ping was previously collected.
func getLastCollectedDate() -> Date? {
var lastCollectedDate: Date?

if let loadedDate = UserDefaults.standard.string(forKey: Constants.lastMetricsPingSentDateTime) {
lastCollectedDate = Date.fromISO8601String(dateString: loadedDate, precision: .millisecond)
} else {
logger.error("MetricsPingScheduler last stored ping time was not valid")
}

return lastCollectedDate
}

/// Update the persisted date when the metrics ping is sent.
///
/// - parameters:
/// * date: The `Date` to store.
func updateSentDate(_ date: Date = Date()) {
UserDefaults.standard.set(
date.toISO8601String(precision: .millisecond),
forKey: Constants.lastMetricsPingSentDateTime
)
}
}
Loading

0 comments on commit 19af88c

Please sign in to comment.