Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
9b5568c
Use expected metric and dimension keys for geofence event
belleklaviyo Oct 20, 2025
8ea7928
Fix test util
belleklaviyo Oct 20, 2025
d018f16
Add optional dwell field on Geofence object
belleklaviyo Oct 23, 2025
0bf2b67
Add default of 5 minutes
belleklaviyo Oct 23, 2025
5211541
Implement dwell events
belleklaviyo Oct 23, 2025
5278606
Simplify by unwrapping klaviyoLocationId at top level and use that in…
belleklaviyo Oct 23, 2025
dfcb9e5
Remove unnecessary dwellEnterTimes
belleklaviyo Oct 23, 2025
b729d93
Don't actually change the apiUrl on the environment to mock fetch
belleklaviyo Oct 24, 2025
c18fcd4
Make dwell optional with default 5 min
belleklaviyo Oct 24, 2025
915ab6a
Immediately flush queue for geofence events
belleklaviyo Oct 24, 2025
9e58bb1
Add helper in KlaviyoSDK+Location to ensure background support
belleklaviyo Oct 28, 2025
183bc4e
Hook up real fetch geofences endpoint (#438)
belleklaviyo Oct 30, 2025
55049a2
Remove dwell (for now)
belleklaviyo Nov 3, 2025
47b7ed1
Clean up warnings, helper functions, PR feedback
belleklaviyo Nov 3, 2025
81c2f94
startMonitoringSignificantLocationChanges should support terminated e…
belleklaviyo Nov 3, 2025
1d42799
Add initialized check to fetchGeofences to protect initial launches
belleklaviyo Nov 4, 2025
4f2e5f7
OSLog privacy changes
belleklaviyo Nov 4, 2025
5aa7e31
Remove mock data
belleklaviyo Nov 5, 2025
58e189a
Remove locationManagerDelegate, reorganize, simplify background support
belleklaviyo Nov 6, 2025
fbd17f9
Remove redundant internal keywords from KlaviyoLocation module (#443)
ajaysubra Nov 7, 2025
6da4b75
Add query to endpoint test
belleklaviyo Nov 7, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
<string>location</string>
<string>fetch</string>
</array>
</dict>
</plist>
9 changes: 5 additions & 4 deletions Sources/KlaviyoCore/Networking/KlaviyoEndpoint.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 []
}
}
Expand Down Expand Up @@ -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"
}
}

Expand Down
2 changes: 1 addition & 1 deletion Sources/KlaviyoCore/Networking/NetworkSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
31 changes: 0 additions & 31 deletions Sources/KlaviyoLocation/Assets/Geofences.json

This file was deleted.

28 changes: 10 additions & 18 deletions Sources/KlaviyoLocation/GeofenceService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,12 @@ import KlaviyoCore
import KlaviyoSwift
import OSLog

internal protocol GeofenceServiceProvider {
protocol GeofenceServiceProvider {
func fetchGeofences() async -> Set<Geofence>
}

internal struct GeofenceService: GeofenceServiceProvider {
internal func fetchGeofences() async -> Set<Geofence> {
struct GeofenceService: GeofenceServiceProvider {
func fetchGeofences() async -> Set<Geofence> {
do {
let data = try await fetchGeofenceData()
return try await parseGeofences(from: data)
Expand All @@ -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<Geofence> {
func parseGeofences(from data: Data) async throws -> Set<Geofence> {
do {
let response = try JSONDecoder().decode(GeofenceJSONResponse.self, from: data)
let companyId = try await KlaviyoInternal.fetchAPIKey()
Expand Down
45 changes: 41 additions & 4 deletions Sources/KlaviyoLocation/KlaviyoGeofenceManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So more context about this -- it's a sensible guard to put in anyways where we would expect there to be a valid API key to use with our fetchGeofences call. But there's some weirdness where when CLLocationManager() is instantiated (which we do on launch to ensure background/terminated support) it also triggers didChangeAuthorization even if the actual auth level has not changed. This fires before initialization is able to complete, so as a result when we trigger setupGeofences because we have the correct auth level, we may not have the actual API key. This results in fetching the geofences for a nil company which returns empty which then clears any existing geofences.

tldr; we need this to make sure we can support being responsive to authorization level changes (such as if they revoke it and we need to unregister our geofences) while also carefully handling the timing issues between when CoreLocation is setting up and the app can be initialized

await updateGeofences()
}
}

internal func destroyGeofencing() {
func destroyGeofencing() {
if #available(iOS 14.0, *) {
if !locationManager.monitoredRegions.isEmpty {
Logger.geoservices.info("Stop monitoring for all regions")
Expand Down Expand Up @@ -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
}
}
}
40 changes: 25 additions & 15 deletions Sources/KlaviyoLocation/KlaviyoLocationManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Never> = .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()
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Unconditional Background Location Causes App Crashhrase

Setting allowsBackgroundLocationUpdates = true unconditionally in the initializer will cause a runtime crash if the host app's Info.plist doesn't include "location" in the UIBackgroundModes array. This is a required configuration per Apple's documentation. The SDK initializes this singleton when monitorGeofencesFromBackground() is called (which just accesses the shared instance), meaning apps will crash immediately if they haven't configured their Info.plist properly. This property should only be set when geofencing is actively being used, or the SDK should add proper error handling and documentation about this requirement.

Fix in Cursor Fix in Web

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this will be part of our documentation/setup requirements so....

}

deinit {
Expand All @@ -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()
}
}
Expand Down Expand Up @@ -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)")
}
}
}
Expand Down
6 changes: 6 additions & 0 deletions Sources/KlaviyoLocation/KlaviyoSDK+Location.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
// Created by Isobelle Lim on 8/27/25.
//

import CoreLocation
import Foundation
import KlaviyoSwift

Expand All @@ -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() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't we do this within our SDK instead of having the developer add this in?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not really, from my understanding, didFinishLaunchingWithOptions is the very very very first thing called in the lifecycle when waking up an app from the terminated state. There isn't any UIApplication notification we can hook into reliably like how we have other app life cycle events that will be an equivalent catch-all for this case.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't we either hook into foregrounded life cycle event and or have something in initialize since that gets called when the app is launch and have a hook in location that observers something in KlaviyoSwift and does this?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm the foregrounded life cycle event only is fired when the app is actually opened, so this wouldn't work in the terminated state. As for the initialize hook, I've been keeping geofencing methods pretty separate from it with the late initialization case in mind. (Part of that will be making KlaviyoLocation observe companyId changes and update the geofences then but unrelated to this monitorGeofencesFromBackground necessity.) But as far as I've explored, I think since we don't actually have any direct insurance to hook into didFinishLaunchingWithOptions from the SDK side, it's most assured to instruct developers to implement it themselves. Let me know if I'm misunderstanding/missing something else here

_ = KlaviyoLocationManager.shared
}

/// Unregisters app for geofencing. Stops monitoring for geofences and cleans up resources.
@MainActor
public func unregisterGeofencing() {
Expand Down
Loading
Loading