Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Caching remote config to disk #144

Merged
merged 2 commits into from
Dec 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -9,6 +9,8 @@ import EmbraceConfiguration
/// Remote config uses the Embrace Config Service to request config values
public class RemoteConfig {

let logger: InternalLogger

// config requests
@ThreadSafe var payload: RemoteConfigPayload
let fetcher: RemoteConfigFetcher
Expand All @@ -19,6 +21,8 @@ public class RemoteConfig {

@ThreadSafe private(set) var updating = false

let cacheURL: URL?

public convenience init(
options: RemoteConfig.Options,
payload: RemoteConfigPayload = RemoteConfigPayload(),
Expand All @@ -38,6 +42,42 @@ public class RemoteConfig {
self.payload = payload
self.fetcher = fetcher
self.deviceIdHexValue = options.deviceId.intValue(digitCount: Self.deviceIdUsedDigits)
self.logger = logger

if let url = options.cacheLocation {
try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
self.cacheURL = options.cacheLocation?.appendingPathComponent("cache")
loadFromCache()
} else {
self.cacheURL = nil
}
}

func loadFromCache() {
guard let url = cacheURL,
FileManager.default.fileExists(atPath: url.path) else {
return
}

do {
let data = try Data(contentsOf: url)
payload = try JSONDecoder().decode(RemoteConfigPayload.self, from: data)
} catch {
logger.error("Error loading cached remote config!")
}
}

func saveToCache(_ data: Data?) {
guard let url = cacheURL,
let data = data else {
return
}

do {
try data.write(to: url, options: .atomic)
} catch {
logger.warning("Error saving remote config cache!")
}
}
}

Expand Down Expand Up @@ -67,7 +107,7 @@ extension RemoteConfig: EmbraceConfigurable {
}

updating = true
fetcher.fetch { [weak self] newPayload in
fetcher.fetch { [weak self] newPayload, data in
defer { self?.updating = false }
guard let strongSelf = self else {
completion(false, nil)
Expand All @@ -82,6 +122,8 @@ extension RemoteConfig: EmbraceConfigurable {
let didUpdate = strongSelf.payload != newPayload
strongSelf.payload = newPayload

strongSelf.saveToCache(data)

completion(didUpdate, nil)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ public extension RemoteConfig {
let appVersion: String
let userAgent: String

let cacheLocation: URL?

let urlSessionConfiguration: URLSessionConfiguration

public init(
Expand All @@ -28,6 +30,7 @@ public extension RemoteConfig {
sdkVersion: String,
appVersion: String,
userAgent: String,
cacheLocation: URL?,
urlSessionConfiguration: URLSessionConfiguration = URLSessionConfiguration.default
) {
self.apiBaseUrl = apiBaseUrl
Expand All @@ -38,6 +41,7 @@ public extension RemoteConfig {
self.sdkVersion = sdkVersion
self.appVersion = appVersion
self.userAgent = userAgent
self.cacheLocation = cacheLocation
self.urlSessionConfiguration = urlSessionConfiguration
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,9 @@ class RemoteConfigFetcher {
)
}

func fetch(completion: @escaping (RemoteConfigPayload?) -> Void) {
func fetch(completion: @escaping (RemoteConfigPayload?, Data?) -> Void) {
guard let request = newRequest() else {
completion(nil)
completion(nil, nil)
return
}

Expand All @@ -38,19 +38,19 @@ class RemoteConfigFetcher {

guard let data = data, error == nil else {
self?.logger.error("Error fetching remote config:\n\(String(describing: error?.localizedDescription))")
completion(nil)
completion(nil, nil)
return
}

guard let httpResponse = response as? HTTPURLResponse else {
self?.logger.error("Error fetching remote config - Invalid response:\n\(String(describing: response?.description))")
completion(nil)
completion(nil, nil)
return
}

guard httpResponse.statusCode >= 200 && httpResponse.statusCode < 300 else {
self?.logger.error("Error fetching remote config - Invalid response:\n\(httpResponse.description))")
completion(nil)
completion(nil, nil)
return
}

Expand All @@ -59,11 +59,11 @@ class RemoteConfigFetcher {
let payload = try JSONDecoder().decode(RemoteConfigPayload.self, from: data)

self?.logger.info("Successfully fetched remote config")
completion(payload)
completion(payload, data)
} catch {
self?.logger.error("Error decoding remote config:\n\(error.localizedDescription)")
// if a decoding issue happens, instead of returning `nil`, we provide a default `RemoteConfigPayload`
completion(RemoteConfigPayload())
completion(RemoteConfigPayload(), nil)
}
}

Expand Down
16 changes: 14 additions & 2 deletions Sources/EmbraceCore/FileSystem/EmbraceFileSystem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ public struct EmbraceFileSystem {
static let uploadsDirectoryName = "uploads"
static let crashesDirectoryName = "crashes"
static let captureDirectoryName = "capture"
static let configDirectoryName = "config"

static let defaultPartitionId = "default"

Expand Down Expand Up @@ -53,8 +54,7 @@ public struct EmbraceFileSystem {
/// ```
/// - Parameters:
/// - name: The name of the subdirectory
/// - partitionIdentifier: The main partition identifier to use
/// identifier to use
/// - partitionId: The main partition identifier to use
/// - appGroupId: The app group identifier if using an app group container.
static func directoryURL(name: String, partitionId: String, appGroupId: String? = nil) -> URL? {
guard let baseURL = systemDirectory(appGroupId: appGroupId) else {
Expand Down Expand Up @@ -104,4 +104,16 @@ public struct EmbraceFileSystem {
appGroupId: appGroupId
)
}

/// Returns the subdirectory for config cache
/// ```
/// io.embrace.data/<version>/<partition-id>/config
/// ```
static func configDirectoryURL(partitionIdentifier: String, appGroupId: String? = nil) -> URL? {
return directoryURL(
name: configDirectoryName,
partitionId: partitionIdentifier,
appGroupId: appGroupId
)
}
}
3 changes: 2 additions & 1 deletion Sources/EmbraceCore/Internal/Embrace+Config.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ extension Embrace {
osVersion: EMBDevice.appVersion ?? "",
sdkVersion: EmbraceMeta.sdkVersion,
appVersion: EMBDevice.operatingSystemVersion,
userAgent: EmbraceMeta.userAgent
userAgent: EmbraceMeta.userAgent,
cacheLocation: EmbraceFileSystem.configDirectoryURL(partitionIdentifier: appId, appGroupId: options.appGroupId)
)

return RemoteConfig(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ class RemoteConfigFetcherTests: XCTestCase {
sdkVersion: sdkVersion,
appVersion: appVersion,
userAgent: userAgent,
cacheLocation: nil,
urlSessionConfiguration: Self.urlSessionConfig
)
}
Expand Down Expand Up @@ -145,7 +146,7 @@ class RemoteConfigFetcherTests: XCTestCase {
let fetcher = RemoteConfigFetcher(options: options, logger: logger)

let expectation = expectation(description: "URL request")
fetcher.fetch { payload in
fetcher.fetch { payload, data in
XCTAssertNotNil(payload)
expectation.fulfill()
}
Expand All @@ -163,7 +164,7 @@ class RemoteConfigFetcherTests: XCTestCase {
let fetcher = RemoteConfigFetcher(options: options, logger: logger)

let expectation = expectation(description: "URL request")
fetcher.fetch { payload in
fetcher.fetch { payload, data in
XCTAssertNil(payload)
expectation.fulfill()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,33 +21,10 @@ final class RemoteConfigTests: XCTestCase {
sdkVersion: TestConstants.sdkVersion,
appVersion: TestConstants.appVersion,
userAgent: TestConstants.userAgent,
cacheLocation: nil,
urlSessionConfiguration: URLSessionConfiguration.default
)

func mockSuccessfulResponse() throws {
var url = try XCTUnwrap(URL(string: "\(options.apiBaseUrl)/v2/config"))

if #available(iOS 16.0, watchOS 9.0, *) {
url.append(queryItems: [
.init(name: "appId", value: options.appId),
.init(name: "osVersion", value: options.osVersion),
.init(name: "appVersion", value: options.appVersion),
.init(name: "deviceId", value: options.deviceId.hex),
.init(name: "sdkVersion", value: options.sdkVersion)
])
} else {
XCTFail("This will fail on versions prior to iOS 16.0")
}

let path = Bundle.module.path(
forResource: "remote_config",
ofType: "json",
inDirectory: "Fixtures"
)!
let data = try Data(contentsOf: URL(fileURLWithPath: path))
EmbraceHTTPMock.mock(url: url, response: .withData(data, statusCode: 200))
}

// MARK: Tests

func test_isEnabled_returnsCorrectValues() {
Expand Down
Loading