diff --git a/glean-core/ios/Glean.xcodeproj/project.pbxproj b/glean-core/ios/Glean.xcodeproj/project.pbxproj index 3384697c3e..f089d3a62f 100644 --- a/glean-core/ios/Glean.xcodeproj/project.pbxproj +++ b/glean-core/ios/Glean.xcodeproj/project.pbxproj @@ -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 */; }; @@ -79,6 +81,8 @@ /* Begin PBXFileReference section */ 1F39E7AF239F0505009B13B3 /* GleanDebugTools.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GleanDebugTools.swift; sourceTree = ""; }; 1F39E7B2239F0777009B13B3 /* GleanDebugUtilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GleanDebugUtilityTests.swift; sourceTree = ""; }; + 1F58920C23C7D615007D2D80 /* MetricsPingScheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsPingScheduler.swift; sourceTree = ""; }; + 1F58921123C923C4007D2D80 /* MetricsPingSchedulerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsPingSchedulerTests.swift; sourceTree = ""; }; 1F6058922314863400307A9F /* Configuration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Configuration.swift; sourceTree = ""; }; 1F605894231489AB00307A9F /* HttpPingUploader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HttpPingUploader.swift; sourceTree = ""; }; 1F60589623148BF800307A9F /* GleanLifecycleObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GleanLifecycleObserver.swift; sourceTree = ""; }; @@ -179,12 +183,21 @@ path = Debug; sourceTree = ""; }; + 1F58921323C923CB007D2D80 /* Scheduler */ = { + isa = PBXGroup; + children = ( + 1F58921123C923C4007D2D80 /* MetricsPingSchedulerTests.swift */, + ); + path = Scheduler; + sourceTree = ""; + }; 1F60588D231483D600307A9F /* Scheduler */ = { isa = PBXGroup; children = ( 1F60589623148BF800307A9F /* GleanLifecycleObserver.swift */, AC54D224233133DB0019319A /* PingUploadOperation.swift */, AC54D226233134A30019319A /* GleanOperation.swift */, + 1F58920C23C7D615007D2D80 /* MetricsPingScheduler.swift */, ); path = Scheduler; sourceTree = ""; @@ -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; @@ -294,6 +307,7 @@ 1FB8F8392326EBA500618E47 /* Config */, BF43A8CB232A613100545310 /* Metrics */, BF80AA5923992FFB00A8B172 /* Net */, + 1F58921323C923CB007D2D80 /* Scheduler */, 1F70B787232A81A4007395FB /* DispatchersTest.swift */, BF3DE39F2243A2F20018E23F /* GleanTests.swift */, BF3DE3A12243A2F20018E23F /* Info.plist */, @@ -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 */, @@ -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 */, diff --git a/glean-core/ios/Glean/Glean.swift b/glean-core/ios/Glean/Glean.swift index e53467cfec..d34ada1642 100644 --- a/glean-core/ios/Glean/Glean.swift +++ b/glean-core/ios/Glean/Glean.swift @@ -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? @@ -109,6 +111,9 @@ public class Glean { } } + // Check for overdue metrics pings + metricsPingScheduler.schedule() + // Signal Dispatcher that init is complete Dispatchers.shared.flushQueuedInitialTasks() @@ -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() } } } diff --git a/glean-core/ios/Glean/Scheduler/MetricsPingScheduler.swift b/glean-core/ios/Glean/Scheduler/MetricsPingScheduler.swift new file mode 100644 index 0000000000..13ff1eb452 --- /dev/null +++ b/glean-core/ios/Glean/Scheduler/MetricsPingScheduler.swift @@ -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 + ) + } +} diff --git a/glean-core/ios/Glean/Utils/Utils.swift b/glean-core/ios/Glean/Utils/Utils.swift index 69cf69df00..09ce2c4a42 100644 --- a/glean-core/ios/Glean/Utils/Utils.swift +++ b/glean-core/ios/Glean/Utils/Utils.swift @@ -25,6 +25,17 @@ extension String: Error { public var errorDescription: String? { return self } } +/// Format specifiers for different ISO8601 timestamps +let dateFormatPatterns: [TimeUnit: String] = [ + .nanosecond: "yyyy-MM-dd'T'HH:mm:ss.SSSZZZZZ", + .microsecond: "yyyy-MM-dd'T'HH:mm:ss.SSSZZZZZ", + .millisecond: "yyyy-MM-dd'T'HH:mm:ss.SSSZZZZZ", + .second: "yyyy-MM-dd'T'HH:mm:ssZZZZZ", + .minute: "yyyy-MM-dd'T'HH:mmZZZZZ", + .hour: "yyyy-MM-dd'T'HHZZZZZ", + .day: "yyyy-MM-ddZZZZZ" +] + extension Date { /// Convenience function to convert ISO8601 string to a Date. /// @@ -36,20 +47,25 @@ extension Date { /// * dateString: The `String` representing the date to convert, i.e.: `2004-12-09T08:03-08:00` /// * precision: The `TimeUnit` precision to use for selecting the correct format to parse against static func fromISO8601String(dateString: String, precision: TimeUnit) -> Date? { - let dateFormatPatterns: [TimeUnit: String] = [ - .nanosecond: "yyyy-MM-dd'T'HH:mm:ss.SSSZZZZZ", - .microsecond: "yyyy-MM-dd'T'HH:mm:ss.SSSZZZZZ", - .millisecond: "yyyy-MM-dd'T'HH:mm:ss.SSSZZZZZ", - .second: "yyyy-MM-dd'T'HH:mm:ssZZZZZ", - .minute: "yyyy-MM-dd'T'HH:mmZZZZZ", - .hour: "yyyy-MM-dd'T'HHZZZZZ", - .day: "yyyy-MM-ddZZZZZ" - ] - let dateFormatter = DateFormatter() dateFormatter.dateFormat = dateFormatPatterns[precision] return dateFormatter.date(from: dateString) } + + /// Convenience function to convert a Date to an ISO8601 string + /// + /// - returns: An ISO8601 `String` representing the current value of the `Date` object + func toISO8601String(precision: TimeUnit) -> String { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = dateFormatPatterns[precision] + return dateFormatter.string(from: self) + } + + /// Overloads the operator so that subtraction between two dates results in a TimeInterval representing + /// the difference between them + static func - (lhs: Date, rhs: Date) -> TimeInterval { + return lhs.timeIntervalSinceReferenceDate - rhs.timeIntervalSinceReferenceDate + } } extension String { diff --git a/glean-core/ios/GleanTests/Scheduler/MetricsPingSchedulerTests.swift b/glean-core/ios/GleanTests/Scheduler/MetricsPingSchedulerTests.swift new file mode 100644 index 0000000000..20868354f8 --- /dev/null +++ b/glean-core/ios/GleanTests/Scheduler/MetricsPingSchedulerTests.swift @@ -0,0 +1,350 @@ +/* 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/. */ + +@testable import Glean +import OHHTTPStubs +import XCTest + +class MetricsPingSchedulerTests: XCTestCase { + var expectation: XCTestExpectation? + + override func setUp() { + Glean.shared.enableTestingMode() + } + + override func tearDown() { + expectation = nil + } + + func testIsAfterDueTime() { + let mps = MetricsPingScheduler() + var fakeNow = DateComponents() + var fakeDate = Date() + + // Must report false before the due time on the same calendar day + // Shortly before + fakeNow.year = 2015 + fakeNow.month = 6 + fakeNow.day = 11 + fakeNow.hour = 3 + fakeNow.minute = 0 + fakeDate = Calendar.current.date(from: fakeNow)! + XCTAssertFalse( + mps.isAfterDueTime(fakeDate, dueHourOfTheDay: 4), + "isAfterDueTime must report false before the due time on the same calendar day" + ) + // The same hour + fakeNow.year = 2015 + fakeNow.month = 6 + fakeNow.day = 11 + fakeNow.hour = 4 + fakeNow.minute = 0 + fakeDate = Calendar.current.date(from: fakeNow)! + XCTAssertFalse( + mps.isAfterDueTime(fakeDate, dueHourOfTheDay: 4), + "isAfterDueTime must report false before the due time on the same calendar day" + ) + // Midnight + fakeNow.year = 2015 + fakeNow.month = 6 + fakeNow.day = 11 + fakeNow.hour = 0 + fakeNow.minute = 0 + fakeDate = Calendar.current.date(from: fakeNow)! + XCTAssertFalse( + mps.isAfterDueTime(fakeDate, dueHourOfTheDay: 4), + "isAfterDueTime must report false before the due time on the same calendar day" + ) + + // Must report true after the due time on the same calendar day + // Shortly After + fakeNow.year = 2015 + fakeNow.month = 6 + fakeNow.day = 11 + fakeNow.hour = 4 + fakeNow.minute = 1 + fakeDate = Calendar.current.date(from: fakeNow)! + XCTAssertTrue( + mps.isAfterDueTime(fakeDate, dueHourOfTheDay: 4), + "isAfterDueTime must report false before the due time on the same calendar day" + ) + } + + func testGetLastCollectedDate() { + let mps = MetricsPingScheduler() + + // getLastCollectedDate must report nil when no stored date is available + UserDefaults.standard.set(nil, forKey: MetricsPingScheduler.Constants.lastMetricsPingSentDateTime) + XCTAssertNil( + mps.getLastCollectedDate(), + "getLastCollectedDate must report nil when no date is stored" + ) + + // getLastCollectedDate must report nil when the stored date is corrupted + UserDefaults.standard.set(123, forKey: MetricsPingScheduler.Constants.lastMetricsPingSentDateTime) + XCTAssertNil( + mps.getLastCollectedDate(), + "getLastCollectedDate must report nil if date is wrong type" + ) + + // getLastCollectedDate must report nil when the date is of unexpected format + UserDefaults.standard.set("not-an-ISO-date", forKey: MetricsPingScheduler.Constants.lastMetricsPingSentDateTime) + XCTAssertNil( + mps.getLastCollectedDate(), + "getLastCollectedDate must report nil when the date is of unexpected format" + ) + + // getLastCollectedDate must report the stored last collected date, if available + let testDate = "2018-12-19T12:36:00.000-06:00" + UserDefaults.standard.set(testDate, forKey: MetricsPingScheduler.Constants.lastMetricsPingSentDateTime) + XCTAssertEqual( + Date.fromISO8601String(dateString: testDate, precision: .millisecond), + mps.getLastCollectedDate(), + "getLastCollectedDate must report the stored last collected date, if available" + ) + } + + func testSchedulePingCollection() { + let mps = MetricsPingScheduler() + let now = Date() + + UserDefaults.standard.set(nil, forKey: MetricsPingScheduler.Constants.lastMetricsPingSentDateTime) + mps.collectPingAndReschedule(now) + XCTAssertEqual( + now.toISO8601String(precision: .second), + mps.getLastCollectedDate()?.toISO8601String(precision: .second), + "schedulePingCollection must update last sent date" + ) + + let fireDate = Calendar.current.date( + bySettingHour: MetricsPingScheduler.Constants.dueHourOfTheDay, + minute: 0, + second: 0, + of: Calendar.current.date( + byAdding: .day, + value: 1, + to: now, + wrappingComponents: true + )! + )! + XCTAssertEqual( + fireDate.toISO8601String(precision: .second), + mps.timer?.fireDate.toISO8601String(precision: .second), + "schedulePingCollection must schedule next collection on the next day" + ) + } + + // swiftlint:disable function_body_length + // REASON: Used in a test + func testQueuedDataNotInOverdueMetricsPings() { + // Reset Glean and do not start it right away + Glean.shared.testDestroyGleanHandle() + Dispatchers.shared.setTaskQueuing(enabled: true) + + // Set the last time the "metrics" ping was sent to now. This is required for us to not + // send a metrics pings the first time we initialize Glean. + let now = Date() + Glean.shared.metricsPingScheduler.updateSentDate(now) + + // Create a metric and set its value. We expect this to be sent in the first ping + // that gets generated the SECOND time we start Glean. + let expectedStringMetric = StringMetricType( + category: "telemetry", + name: "expected_metric", + sendInPings: ["metrics"], + lifetime: Lifetime.ping, + disabled: false + ) + let expectedValue = "must-exist-in-the-first-ping" + + Glean.shared.resetGlean(clearStores: false) + expectedStringMetric.set(expectedValue) + + // Destroy Glean, it will retain the recorded metric. + Glean.shared.testDestroyGleanHandle() + Dispatchers.shared.setTaskQueuing(enabled: true) + + // Create data and attempt to record data before Glean is initialized. This will + // be queued in the dispatcher. + let stringMetric = StringMetricType( + category: "telemetry", + name: "canary_metric", + sendInPings: ["metrics"], + lifetime: Lifetime.ping, + disabled: false + ) + let canaryValue = "must-not-be-in-the-first-ping" + stringMetric.set(canaryValue) + + // Set the last time the "metrics" ping was sent to yesterday, which should make + // the ping overdue and trigger it at startup. + let yesterday = Calendar.current.date(byAdding: Calendar.Component.day, value: -1, to: now) + Glean.shared.metricsPingScheduler.updateSentDate(yesterday!) + + let host = URL(string: Configuration.Constants.defaultTelemetryEndpoint)!.host! + stub(condition: isHost(host)) { data in + let body = (data as NSURLRequest).ohhttpStubs_HTTPBody() + let json = try! JSONSerialization.jsonObject(with: body!, options: []) as? [String: Any] + XCTAssert(json != nil) + let metrics = json?["metrics"] as? [String: Any] + let strings = metrics?["string"] as? [String: Any] + + // Ensure there is only the expected metric + XCTAssertEqual(1, strings?.count, "Must contain only the expected metric") + + // Check the received metric's value against the expected value + let receivedValue = strings?["telemetry.expected_metric"] as? String + XCTAssertEqual(expectedValue, receivedValue, "Values must match") + + DispatchQueue.main.async { + // let the response get processed before we mark the expectation fulfilled + self.expectation?.fulfill() + } + + return OHHTTPStubsResponse( + jsonObject: [], + statusCode: 200, + headers: ["Content-Type": "application/json"] + ) + } + + // Set our expectation that will be fulfilled by the stub above + expectation = expectation(description: "Metrics Ping Received") + + // Initialize Glean the SECOND time: it will send the expected string metric (stored from + // the previous run) but must not send the canary string, which would be sent the next time + // the "metrics" ping is collected after this one. + // Glean.shared.initialize(uploadEnabled: true) + Glean.shared.initialize(uploadEnabled: true) + waitForExpectations(timeout: 5.0) { error in + XCTAssertNil(error, "Test timed out waiting for upload: \(error!)") + } + + // Clean up + Glean.shared.resetGlean(clearStores: true) + Glean.shared.testDestroyGleanHandle() + } + + func testGleanPreservesLifetimeApplicationMetrics() { + // Reset Glean and do not start it right away + Glean.shared.testDestroyGleanHandle() + Dispatchers.shared.setTaskQueuing(enabled: true) + + // Set the last time the "metrics" ping was sent to now. This is required for us to not + // send a metrics pings the first time we initialize Glean. + let now = Date() + Glean.shared.metricsPingScheduler.updateSentDate(now) + + // Create a metric and set its value. We expect this to be sent in the first ping + // that gets generated the SECOND time we start Glean. + let testMetric = StringMetricType( + category: "telemetry", + name: "test_applifetime_metric", + sendInPings: ["metrics"], + lifetime: Lifetime.application, + disabled: false + ) + let expectedValue = "I-will-survive!" + + // Reset Glean and start it for the FIRST time, then record a value. + Glean.shared.resetGlean(clearStores: false) + testMetric.set(expectedValue) + + // Set the last time the "metrics" ping was sent to yesterday, which should make + // the ping overdue and trigger it at startup. + let yesterday = Calendar.current.date(byAdding: Calendar.Component.day, value: -1, to: now) + Glean.shared.metricsPingScheduler.updateSentDate(yesterday!) + + // Set up the interception of the ping for inspection + let host = URL(string: Configuration.Constants.defaultTelemetryEndpoint)!.host! + stub(condition: isHost(host)) { data in + let body = (data as NSURLRequest).ohhttpStubs_HTTPBody() + let json = try! JSONSerialization.jsonObject(with: body!, options: []) as? [String: Any] + XCTAssert(json != nil) + let metrics = json?["metrics"] as? [String: Any] + let strings = metrics?["string"] as? [String: Any] + + // Ensure there is only the expected metric + XCTAssertEqual(1, strings?.count, "Must contain only the expected metric") + + // Check the received metric's value against the expected value + let receivedValue = strings?["telemetry.test_applifetime_metric"] as? String + XCTAssertEqual(expectedValue, receivedValue, "Values must match") + + DispatchQueue.main.async { + // let the response get processed before we mark the expectation fulfilled + self.expectation?.fulfill() + } + + return OHHTTPStubsResponse( + jsonObject: [], + statusCode: 200, + headers: ["Content-Type": "application/json"] + ) + } + + // Set our expectation that will be fulfilled by the stub above + expectation = expectation(description: "Metrics Ping Received") + + // Initialize Glean the SECOND time: it will send the expected string metric (stored from + // the previous run) but must not send the canary string, which would be sent the next time + // the "metrics" ping is collected after this one. + // Glean.shared.initialize(uploadEnabled: true) + Glean.shared.resetGlean(clearStores: false) + waitForExpectations(timeout: 5.0) { error in + XCTAssertNil(error, "Test timed out waiting for upload: \(error!)") + } + + // Clean up + Glean.shared.resetGlean(clearStores: true) + Glean.shared.testDestroyGleanHandle() + } + + // Simple mock of the MetricsPingScheduler to override the collectPingAndReschedule + // function in order to make it easier to test. + private class FakeMPS: MetricsPingScheduler { + let mpsExpectation: XCTestExpectation? + + init(expectation: XCTestExpectation?) { + mpsExpectation = expectation + } + + override func collectPingAndReschedule(_: Date, startupPing _: Bool = false) { + mpsExpectation?.fulfill() + } + } + + func testTimerInvocation() { + // Set up the expectation + expectation = expectation(description: "Timer fired") + + // Build the mock MPS passing in the expectation to be fulfilled later + let mps = FakeMPS(expectation: expectation) + + // Set the last time the "metrics" ping was set to now. This will be updated if + // the timer fires so we can detect the change to determine success + let now = Date() + mps.updateSentDate(now) + // Converting to strings here because comparing dates is more difficult + XCTAssertEqual( + now.toISO8601String(precision: .second), + mps.getLastCollectedDate()?.toISO8601String(precision: .second) + ) + + // Create a fake date/time that is just a few seconds before the 4 AM time so + // that it will fire off after a few seconds. + let fakeNow = Calendar.current.date( + bySettingHour: MetricsPingScheduler.Constants.dueHourOfTheDay - 1, + minute: 59, + second: 55, + of: now + )! + + // Calling `schedulePingCollection` with our `fakeNow` should cause the timer to + // be set to fire in @ 5 seconds + mps.schedulePingCollection(fakeNow, sendTheNextCalendarDay: false) + + waitForExpectations(timeout: 10.0) + } +}