diff --git a/Examples/KlaviyoSwiftExamples/SPMExample/SPMExample/Info.plist b/Examples/KlaviyoSwiftExamples/SPMExample/SPMExample/Info.plist index 88c8f59a7..7f6ba06c2 100644 --- a/Examples/KlaviyoSwiftExamples/SPMExample/SPMExample/Info.plist +++ b/Examples/KlaviyoSwiftExamples/SPMExample/SPMExample/Info.plist @@ -26,6 +26,8 @@ UIBackgroundModes remote-notification + location + fetch diff --git a/Sources/KlaviyoCore/Networking/KlaviyoEndpoint.swift b/Sources/KlaviyoCore/Networking/KlaviyoEndpoint.swift index aa4656201..3cbe40d7b 100644 --- a/Sources/KlaviyoCore/Networking/KlaviyoEndpoint.swift +++ b/Sources/KlaviyoCore/Networking/KlaviyoEndpoint.swift @@ -17,7 +17,7 @@ public enum KlaviyoEndpoint: Equatable, Codable { case aggregateEvent(_ apiKey: String, _ payload: AggregateEventPayload) case resolveDestinationURL(trackingLink: URL, profileInfo: ProfilePayload) case logTrackingLinkClicked(trackingLink: URL, clickTime: Date, profileInfo: ProfilePayload) - case fetchGeofences + case fetchGeofences(_ apiKey: String) private enum HeaderKey { static let profileInfo = "X-Klaviyo-Profile-Info" @@ -50,9 +50,10 @@ public enum KlaviyoEndpoint: Equatable, Codable { let .createEvent(apiKey, _), let .registerPushToken(apiKey, _), let .unregisterPushToken(apiKey, _), - let .aggregateEvent(apiKey, _): + let .aggregateEvent(apiKey, _), + let .fetchGeofences(apiKey): return [URLQueryItem(name: "company_id", value: apiKey)] - case .resolveDestinationURL, .logTrackingLinkClicked, .fetchGeofences: + case .resolveDestinationURL, .logTrackingLinkClicked: return [] } } @@ -114,7 +115,7 @@ public enum KlaviyoEndpoint: Equatable, Codable { case let .resolveDestinationURL(trackingLink, _), let .logTrackingLinkClicked(trackingLink, _, _): return trackingLink.path case .fetchGeofences: - return "/geofences" + return "/client/geofences" } } diff --git a/Sources/KlaviyoCore/Networking/NetworkSession.swift b/Sources/KlaviyoCore/Networking/NetworkSession.swift index 82dac4cf2..5af83d0e0 100644 --- a/Sources/KlaviyoCore/Networking/NetworkSession.swift +++ b/Sources/KlaviyoCore/Networking/NetworkSession.swift @@ -28,7 +28,7 @@ public struct NetworkSession { self.data = data } - fileprivate static let currentApiRevision = "2025-07-15" + fileprivate static let currentApiRevision = "2025-10-15.pre" fileprivate static let applicationJson = "application/json" fileprivate static let acceptedEncodings = ["br", "gzip", "deflate"] fileprivate static let mobileHeader = "1" diff --git a/Sources/KlaviyoLocation/Assets/Geofences.json b/Sources/KlaviyoLocation/Assets/Geofences.json deleted file mode 100644 index d2c5e008c..000000000 --- a/Sources/KlaviyoLocation/Assets/Geofences.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "data": [ - { - "type": "geofence", - "id": "8db4effa-44f1-45e6-a88d-8e7d50516a0f", - "attributes": { - "latitude": 40.7128, - "longitude": -74.006, - "radius": 100 - } - }, - { - "type": "geofence", - "id": "a84011cf-93ef-4e78-b047-c0ce4ea258e4", - "attributes": { - "latitude": 40.6892, - "longitude": -74.0445, - "radius": 200 - } - }, - { - "type": "geofence", - "id": "e1ded9eb-ba47-453d-afba-3f462bc87576", - "attributes": { - "latitude": 40.758, - "longitude": -73.9855, - "radius": 150 - } - } - ] -} diff --git a/Sources/KlaviyoLocation/GeofenceService.swift b/Sources/KlaviyoLocation/GeofenceService.swift index 2d24e7daf..5ddcf457b 100644 --- a/Sources/KlaviyoLocation/GeofenceService.swift +++ b/Sources/KlaviyoLocation/GeofenceService.swift @@ -10,12 +10,12 @@ import KlaviyoCore import KlaviyoSwift import OSLog -internal protocol GeofenceServiceProvider { +protocol GeofenceServiceProvider { func fetchGeofences() async -> Set } -internal struct GeofenceService: GeofenceServiceProvider { - internal func fetchGeofences() async -> Set { +struct GeofenceService: GeofenceServiceProvider { + func fetchGeofences() async -> Set { do { let data = try await fetchGeofenceData() return try await parseGeofences(from: data) @@ -29,36 +29,28 @@ internal struct GeofenceService: GeofenceServiceProvider { /// Fetches raw geofence data from the API private func fetchGeofenceData() async throws -> Data { - // FIXME: Temporarily override the environment's API URL for this mock request - let originalAPIURL = environment.apiURL - environment.apiURL = { - var components = URLComponents() - components.scheme = "https" - components.host = "mock-api.com" - return components - } - - let endpoint = KlaviyoEndpoint.fetchGeofences + let apiKey = try await KlaviyoInternal.fetchAPIKey() + let endpoint = KlaviyoEndpoint.fetchGeofences(apiKey) let klaviyoRequest = KlaviyoRequest(endpoint: endpoint) let attemptInfo = try RequestAttemptInfo(attemptNumber: 1, maxAttempts: 1) let result = await environment.klaviyoAPI.send(klaviyoRequest, attemptInfo) - // FIXME: Restore the original API URL - environment.apiURL = originalAPIURL - switch result { case let .success(data): + if #available(iOS 14.0, *) { + Logger.geoservices.info("Successfully fetched geofences") + } return data case let .failure(error): if #available(iOS 14.0, *) { - Logger.geoservices.error("Failed to fetch geofences from mock endpoint https://mock-api.com/geofences") + Logger.geoservices.error("Failed to fetch geofences; error: \(error, privacy: .public)") } throw error } } /// Parses raw geofence data and transforms it into Geofence objects with the companyId prepended to the id - internal func parseGeofences(from data: Data) async throws -> Set { + func parseGeofences(from data: Data) async throws -> Set { do { let response = try JSONDecoder().decode(GeofenceJSONResponse.self, from: data) let companyId = try await KlaviyoInternal.fetchAPIKey() diff --git a/Sources/KlaviyoLocation/KlaviyoGeofenceManager.swift b/Sources/KlaviyoLocation/KlaviyoGeofenceManager.swift index dc37a7b95..3aa0b56e1 100644 --- a/Sources/KlaviyoLocation/KlaviyoGeofenceManager.swift +++ b/Sources/KlaviyoLocation/KlaviyoGeofenceManager.swift @@ -7,16 +7,17 @@ import CoreLocation import KlaviyoCore +import KlaviyoSwift import OSLog -internal class KlaviyoGeofenceManager { +class KlaviyoGeofenceManager { private let locationManager: LocationManagerProtocol - internal init(locationManager: LocationManagerProtocol) { + init(locationManager: LocationManagerProtocol) { self.locationManager = locationManager } - internal func setupGeofencing() { + func setupGeofencing() { guard CLLocationManager.isMonitoringAvailable(for: CLCircularRegion.self) else { if #available(iOS 14.0, *) { Logger.geoservices.warning("Geofencing is not supported on this device") @@ -32,11 +33,18 @@ internal class KlaviyoGeofenceManager { } Task { + guard let _ = try? await KlaviyoInternal.fetchAPIKey() else { + if #available(iOS 14.0, *) { + Logger.geoservices.info("SDK is not initialized, skipping geofence refresh") + } + return + } + await updateGeofences() } } - internal func destroyGeofencing() { + func destroyGeofencing() { if #available(iOS 14.0, *) { if !locationManager.monitoredRegions.isEmpty { Logger.geoservices.info("Stop monitoring for all regions") @@ -85,3 +93,32 @@ internal class KlaviyoGeofenceManager { } } } + +// MARK: Data Type Conversions + +extension Geofence { + /// Converts this geofence to a Core Location circular region + /// - Returns: A CLCircularRegion instance + func toCLCircularRegion() -> CLCircularRegion { + let region = CLCircularRegion( + center: CLLocationCoordinate2D(latitude: latitude, longitude: longitude), + radius: radius, + identifier: id + ) + return region + } +} + +extension CLCircularRegion { + func toKlaviyoGeofence() throws -> Geofence { + try Geofence(id: identifier, longitude: center.longitude, latitude: center.latitude, radius: radius) + } + + var klaviyoLocationId: String? { + do { + return try toKlaviyoGeofence().locationId + } catch { + return nil + } + } +} diff --git a/Sources/KlaviyoLocation/KlaviyoLocationManager.swift b/Sources/KlaviyoLocation/KlaviyoLocationManager.swift index 34bbbaf38..f91fcb5f4 100644 --- a/Sources/KlaviyoLocation/KlaviyoLocationManager.swift +++ b/Sources/KlaviyoLocation/KlaviyoLocationManager.swift @@ -12,20 +12,20 @@ import KlaviyoCore import KlaviyoSwift import OSLog -public class KlaviyoLocationManager: NSObject { +class KlaviyoLocationManager: NSObject { static let shared = KlaviyoLocationManager() private var locationManager: LocationManagerProtocol private let geofenceManager: KlaviyoGeofenceManager - private let geofencePublisher: PassthroughSubject = .init() - internal init(locationManager: LocationManagerProtocol? = nil, geofenceManager: KlaviyoGeofenceManager? = nil) { + init(locationManager: LocationManagerProtocol? = nil, geofenceManager: KlaviyoGeofenceManager? = nil) { self.locationManager = locationManager ?? CLLocationManager() self.geofenceManager = geofenceManager ?? KlaviyoGeofenceManager(locationManager: self.locationManager) super.init() self.locationManager.delegate = self self.locationManager.allowsBackgroundLocationUpdates = true + self.locationManager.startMonitoringSignificantLocationChanges() } deinit { @@ -36,14 +36,14 @@ public class KlaviyoLocationManager: NSObject { } @MainActor - internal func setupGeofencing() { + func setupGeofencing() { if environment.getLocationAuthorizationStatus() == .authorizedAlways { geofenceManager.setupGeofencing() } } @MainActor - internal func destroyGeofencing() { + func destroyGeofencing() { geofenceManager.destroyGeofencing() } } @@ -90,43 +90,53 @@ extension KlaviyoLocationManager: CLLocationManagerDelegate { // MARK: Geofencing public func locationManager(_ manager: CLLocationManager, didEnterRegion region: CLRegion) { - guard let region = region as? CLCircularRegion else { return } + guard let region = region as? CLCircularRegion, + let klaviyoLocationId = region.klaviyoLocationId else { + if #available(iOS 14.0, *) { + Logger.geoservices.info("Received non-Klaviyo geofence notification. Skipping.") + } + return + } if #available(iOS 14.0, *) { - Logger.geoservices.info("🌎 User entered region \"\(region.identifier)\"") + Logger.geoservices.info("🌎 User entered region \"\(klaviyoLocationId, privacy: .public)\"") } let enterEvent = Event( - name: .locationEvent(.enteredBoundary), + name: .locationEvent(.geofenceEnter), properties: [ - "boundaryIdentifier": region.identifier + "geofence_id": klaviyoLocationId ] ) Task { await MainActor.run { KlaviyoInternal.create(event: enterEvent) - geofencePublisher.send("Entered \(region.identifier)") } } } public func locationManager(_ manager: CLLocationManager, didExitRegion region: CLRegion) { - guard let region = region as? CLCircularRegion else { return } + guard let region = region as? CLCircularRegion, + let klaviyoLocationId = region.klaviyoLocationId else { + if #available(iOS 14.0, *) { + Logger.geoservices.warning("Received non-Klaviyo geofence notification. Skipping.") + } + return + } if #available(iOS 14.0, *) { - Logger.geoservices.info("🌎 User exited region \"\(region.identifier)\"") + Logger.geoservices.info("🌎 User exited region \"\(klaviyoLocationId, privacy: .public)\"") } let exitEvent = Event( - name: .locationEvent(.exitedBoundary), + name: .locationEvent(.geofenceExit), properties: [ - "boundaryIdentifier": region.identifier + "geofence_id": klaviyoLocationId ] ) Task { await MainActor.run { KlaviyoInternal.create(event: exitEvent) - geofencePublisher.send("Exited \(region.identifier)") } } } diff --git a/Sources/KlaviyoLocation/KlaviyoSDK+Location.swift b/Sources/KlaviyoLocation/KlaviyoSDK+Location.swift index 9eb0edd8f..db86b1052 100644 --- a/Sources/KlaviyoLocation/KlaviyoSDK+Location.swift +++ b/Sources/KlaviyoLocation/KlaviyoSDK+Location.swift @@ -5,6 +5,7 @@ // Created by Isobelle Lim on 8/27/25. // +import CoreLocation import Foundation import KlaviyoSwift @@ -20,6 +21,11 @@ extension KlaviyoSDK { } } + /// To be called in didFinishLaunchingWithOptions to ensure geofence events that happen in a backgrounded/terminated state are processed. + public func monitorGeofencesFromBackground() { + _ = KlaviyoLocationManager.shared + } + /// Unregisters app for geofencing. Stops monitoring for geofences and cleans up resources. @MainActor public func unregisterGeofencing() { diff --git a/Sources/KlaviyoLocation/Models/Geofence.swift b/Sources/KlaviyoLocation/Models/Geofence.swift index 5f45a6f72..16b51e901 100644 --- a/Sources/KlaviyoLocation/Models/Geofence.swift +++ b/Sources/KlaviyoLocation/Models/Geofence.swift @@ -11,26 +11,26 @@ import KlaviyoCore import KlaviyoSwift /// Represents a Klaviyo geofence -public struct Geofence: Equatable, Hashable, Codable { +struct Geofence: Equatable, Hashable, Codable { /// The geofence ID is a combination of the company ID and location ID from Klaviyo, separated by a colon. - public let id: String + let id: String /// Longitude of the geofence center - public let longitude: Double + let longitude: Double /// Latitude of the geofence center - public let latitude: Double + let latitude: Double /// Radius of the geofence in meters - public let radius: Double + let radius: Double /// Company ID to which this geofence belongs, extracted from the geofence ID. - public var companyId: String { + var companyId: String { id.split(separator: ":").first.map(String.init) ?? "" } /// Location UUID to which this geofence belongs, extracted from the geofence ID. - public var locationId: String { + var locationId: String { let components = id.split(separator: ":", maxSplits: 1) return components.count > 1 ? String(components[1]) : "" } @@ -42,11 +42,11 @@ public struct Geofence: Equatable, Hashable, Codable { /// - latitude: Latitude coordinate of the geofence center /// - radius: Radius of the geofence in meters /// - Throws: `GeofenceError.invalidIdFormat` if the ID doesn't match the expected format - public init( + init( id: String, longitude: Double, latitude: Double, - radius: Double, + radius: Double ) throws { try Self.validateIdFormat(id) self.id = id @@ -65,26 +65,9 @@ public struct Geofence: Equatable, Hashable, Codable { throw GeofenceError.invalidIdFormat("ID must be in format '{companyId}:{geofenceUUID}', got: '\(id)'") } } - - /// Converts this geofence to a Core Location circular region - /// - Returns: A CLCircularRegion instance - public func toCLCircularRegion() -> CLCircularRegion { - let region = CLCircularRegion( - center: CLLocationCoordinate2D(latitude: latitude, longitude: longitude), - radius: radius, - identifier: id - ) - return region - } } /// Errors that can occur when working with geofences -public enum GeofenceError: Error { +enum GeofenceError: Error { case invalidIdFormat(String) } - -extension CLCircularRegion { - internal func toKlaviyoGeofence() throws -> Geofence { - try Geofence(id: identifier, longitude: center.longitude, latitude: center.latitude, radius: radius) - } -} diff --git a/Sources/KlaviyoLocation/Utilities/CLAuthorizationStatus+Ext.swift b/Sources/KlaviyoLocation/Utilities/CLAuthorizationStatus+Ext.swift index 72c86dde0..5268fdae3 100644 --- a/Sources/KlaviyoLocation/Utilities/CLAuthorizationStatus+Ext.swift +++ b/Sources/KlaviyoLocation/Utilities/CLAuthorizationStatus+Ext.swift @@ -8,7 +8,7 @@ import CoreLocation extension CLAuthorizationStatus { - internal var description: String { + var description: String { switch self { case .notDetermined: "not determined" diff --git a/Sources/KlaviyoLocation/Utilities/CLLocationManager+LocationManagerProtocol.swift b/Sources/KlaviyoLocation/Utilities/CLLocationManager+LocationManagerProtocol.swift index 82ecbefde..c499b7145 100644 --- a/Sources/KlaviyoLocation/Utilities/CLLocationManager+LocationManagerProtocol.swift +++ b/Sources/KlaviyoLocation/Utilities/CLLocationManager+LocationManagerProtocol.swift @@ -9,7 +9,7 @@ import CoreLocation // MARK: - Location Manager Protocol -internal protocol LocationManagerProtocol { +protocol LocationManagerProtocol { var delegate: CLLocationManagerDelegate? { get set } var allowsBackgroundLocationUpdates: Bool { get set } var currentAuthorizationStatus: CLAuthorizationStatus { get } diff --git a/Sources/KlaviyoLocation/Utilities/Logger+Ext.swift b/Sources/KlaviyoLocation/Utilities/Logger+Ext.swift index a967e2a07..e82004aaa 100644 --- a/Sources/KlaviyoLocation/Utilities/Logger+Ext.swift +++ b/Sources/KlaviyoLocation/Utilities/Logger+Ext.swift @@ -12,5 +12,5 @@ extension Logger { private static var subsystem = Bundle.main.bundleIdentifier ?? "" /// Logs events related to location services. - internal static let geoservices = Logger(subsystem: subsystem, category: "geoservices") + static let geoservices = Logger(subsystem: subsystem, category: "geoservices") } diff --git a/Sources/KlaviyoSwift/Models/Event.swift b/Sources/KlaviyoSwift/Models/Event.swift index fd950989a..642a45cbc 100644 --- a/Sources/KlaviyoSwift/Models/Event.swift +++ b/Sources/KlaviyoSwift/Models/Event.swift @@ -23,8 +23,9 @@ public struct Event: Equatable { } public enum LocationEvent: Equatable { - case enteredBoundary - case exitedBoundary + case geofenceEnter + case geofenceExit + case geofenceDwell } } @@ -34,6 +35,11 @@ public struct Event: Equatable { public init(name: EventName) { self.name = name } + + /// Returns true if this event is a geofence-related event + public var isGeofenceEvent: Bool { + if case .locationEvent = name { true } else { false } + } } struct Identifiers: Equatable { @@ -103,10 +109,12 @@ extension Event.EventName { case .startedCheckoutMetric: return "Started Checkout" case let .locationEvent(type): switch type { - case .enteredBoundary: - return "Entered Location" - case .exitedBoundary: - return "Exited Location" + case .geofenceEnter: + return "$geofence_enter" + case .geofenceExit: + return "$geofence_exit" + case .geofenceDwell: + return "$geofence_dwell" } case let .customEvent(value): return "\(value)" } diff --git a/Sources/KlaviyoSwift/StateManagement/StateManagement.swift b/Sources/KlaviyoSwift/StateManagement/StateManagement.swift index 219c87596..2849df872 100644 --- a/Sources/KlaviyoSwift/StateManagement/StateManagement.swift +++ b/Sources/KlaviyoSwift/StateManagement/StateManagement.swift @@ -140,8 +140,8 @@ enum KlaviyoAction: Equatable { var requiresInitialization: Bool { switch self { - // if event metric is opened push we DON'T require initilization in all other event metric cases we DO. - case let .enqueueEvent(event) where event.metric.name == ._openedPush: + // if event metric is opened push or geofence events we DON'T require initialization + case let .enqueueEvent(event) where event.metric.name == ._openedPush || event.metric.isGeofenceEvent: return false case .enqueueAggregateEvent, .enqueueEvent, .enqueueProfile, .resetProfile, .resetStateAndDequeue, .setBadgeCount, .setEmail, .setExternalId, .setPhoneNumber, .setProfileProperty, .setPushEnablement, .setPushToken: @@ -504,11 +504,12 @@ struct KlaviyoReducer: ReducerProtocol { state.enqueueRequest(request: request) /* - if we receive an opened push event we want to flush the queue right away so that + if we receive an opened push event or geofence events we want to flush the queue right away so that we don't miss any user engagement events. In all other cases we will flush the queue using the flush intervals defined above in `StateManagementConstants` */ - let baseEffect = event.metric.name == ._openedPush ? EffectTask.task { .flushQueue } : .none + let baseEffect = event.metric.name == ._openedPush || event.metric.isGeofenceEvent + ? EffectTask.task { .flushQueue } : .none return .merge([ baseEffect, .fireAndForget { KlaviyoInternal.publishEvent(event) } diff --git a/Tests/KlaviyoCoreTests/KlaviyoEndpointTests.swift b/Tests/KlaviyoCoreTests/KlaviyoEndpointTests.swift index d8af26965..b51770ab8 100644 --- a/Tests/KlaviyoCoreTests/KlaviyoEndpointTests.swift +++ b/Tests/KlaviyoCoreTests/KlaviyoEndpointTests.swift @@ -180,13 +180,14 @@ final class KlaviyoEndpointTests: XCTestCase { func testFetchGeofencesEndpointUrlRequest() throws { // Given let apiKey = "test_api_key" - let endpoint = KlaviyoEndpoint.fetchGeofences + let endpoint = KlaviyoEndpoint.fetchGeofences(apiKey) // When let request = try endpoint.urlRequest() // Then XCTAssertEqual(request.httpMethod, "GET") - XCTAssertEqual(request.url?.path, "/geofences") + XCTAssertEqual(request.url?.path, "/client/geofences") + XCTAssertEqual(request.url?.query, "company_id=test_api_key") } } diff --git a/Tests/KlaviyoCoreTests/__Snapshots__/NetworkSessionTests/testCreateEmphemeralSesionHeaders.1.txt b/Tests/KlaviyoCoreTests/__Snapshots__/NetworkSessionTests/testCreateEmphemeralSesionHeaders.1.txt index 5f9d8522e..6dc84941b 100644 --- a/Tests/KlaviyoCoreTests/__Snapshots__/NetworkSessionTests/testCreateEmphemeralSesionHeaders.1.txt +++ b/Tests/KlaviyoCoreTests/__Snapshots__/NetworkSessionTests/testCreateEmphemeralSesionHeaders.1.txt @@ -20,4 +20,4 @@ - value: "application/json" ▿ (2 elements) - key: "revision" - - value: "2025-07-15" + - value: "2025-10-15.pre" diff --git a/Tests/KlaviyoLocationTests/KlaviyoLocationTestUtils.swift b/Tests/KlaviyoLocationTests/KlaviyoLocationTestUtils.swift index 1d48e6d93..2306c1dad 100644 --- a/Tests/KlaviyoLocationTests/KlaviyoLocationTestUtils.swift +++ b/Tests/KlaviyoLocationTests/KlaviyoLocationTestUtils.swift @@ -5,10 +5,10 @@ // Created by Isobelle Lim on 1/27/25. // +@testable import KlaviyoCore import Combine import CoreLocation import Foundation -import KlaviyoCore @_spi(KlaviyoPrivate) @testable import KlaviyoSwift // MARK: - Test Constants @@ -51,7 +51,7 @@ extension KlaviyoEnvironment { SDKName: { "klaviyo-swift-sdk" }, SDKVersion: { "1.0.0" }, formsDataEnvironment: { nil }, - openURL: { _ in } + linkHandler: DeepLinkHandler() ) } } diff --git a/Tests/KlaviyoSwiftTests/EventBufferTests.swift b/Tests/KlaviyoSwiftTests/EventBufferTests.swift index 11674cad4..eb35eb5aa 100644 --- a/Tests/KlaviyoSwiftTests/EventBufferTests.swift +++ b/Tests/KlaviyoSwiftTests/EventBufferTests.swift @@ -5,6 +5,7 @@ // Created by Ajay Subramanya on 10/7/25. // +@testable import KlaviyoLocation @testable import KlaviyoSwift import Foundation import XCTest @@ -269,4 +270,16 @@ class EventBufferTests: XCTestCase { XCTAssertEqual(events.first?.metric.name.value, "$opened_push") XCTAssertEqual(events.first?.properties["message_id"] as? String, "abc123") } + + func testBufferWithLocationEvent() { + let openedPushEvent = Event(name: .locationEvent(.geofenceEnter)) + + // When + eventBuffer.buffer(openedPushEvent) + let events = eventBuffer.getRecentEvents() + + // Then + XCTAssertEqual(events.count, 1) + XCTAssertEqual(events.first?.metric.name.value, "$geofence_enter") + } }