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

EMBR-4636 calculate react native bundle id #72

Merged
merged 6 commits into from
Sep 4, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
67 changes: 67 additions & 0 deletions packages/core/ios/RNEmbrace/BundleID.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import Foundation
import EmbraceIO
import CryptoKit

private struct LastBundleComputation: Codable {
private static let STORAGE_KEY = "EMBRACE_LAST_BUNDLE_COMPUTATION"
var path: String
var computedAt: Date?
var id: String

func store () {
if let encoded = try? JSONEncoder().encode(self) {
UserDefaults.standard.set(encoded, forKey: LastBundleComputation.STORAGE_KEY)
}
}

static func fromStorage() -> LastBundleComputation {
if let storedData = UserDefaults.standard.object(forKey: LastBundleComputation.STORAGE_KEY) as? Data,
let loaded = try? JSONDecoder().decode(LastBundleComputation.self, from: storedData) {
return loaded
}

return LastBundleComputation(path: "", computedAt: nil, id: "")
}
}
Copy link
Contributor

@ArielDemarco ArielDemarco Aug 26, 2024

Choose a reason for hiding this comment

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

I'd inject an "own" UserDefaults to prevent any problem in the future (like somebody migrating to another suite, or things like that).
The only bad thing about injecting it (and having an instance variable with an own userDefaults) is that you'll have to implement Codable manually.
So, i'd suggest something in the middle, like providing a utility to create an "Embrace" UserDefaults, like this:

extension UserDefaults {
    static var embrace: UserDefaults {
        UserDefaults(suiteName: "EmbraceReactNative") ?? .standard
    }
}

And then you can replace store and fromStorage using that utility:

func store () {
        if let encoded = try? JSONEncoder().encode(self) {
            UserDefaults.embrace.set(encoded, forKey: LastBundleComputation.STORAGE_KEY)
        }
    }

    static func fromStorage() -> LastBundleComputation {
        if let storedData = UserDefaults.embrace.object(forKey: LastBundleComputation.STORAGE_KEY) as? Data,
           let loaded = try? JSONDecoder().decode(LastBundleComputation.self, from: storedData) {
            return loaded
        }

        return LastBundleComputation(path: "", computedAt: nil, id: "")
    }

Copy link
Contributor Author

Choose a reason for hiding this comment

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

makes sense, added


enum ComputeBundleIDErrors: Error {
case emptyPath
}

struct BundleID {
var id: String
var cached: Bool
}

func computeBundleID(path: String) throws -> BundleID {
if path.isEmpty {
throw ComputeBundleIDErrors.emptyPath
}

var last = LastBundleComputation.fromStorage()

// Check if we can re-use our last computed bundle ID, this is true only if the path hasn't changed and
// the file contents at that path haven't been modified since the last time we computed the ID
// TODO in ios 5.x and Android implementation we are just returning last.id in this case without checking modified at...is that correct?
Copy link
Contributor Author

Choose a reason for hiding this comment

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

@ArielDemarco @TheMutsi this logic feels correct to me, e.g. only re-use the cache if the path is the same and the contents haven't been updated but it does deviate from the previous implementations so wanted to double-check

Copy link
Contributor

Choose a reason for hiding this comment

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

LGTM; I'd wait for @TheMutsi to see if there's nothing we're not considering

Copy link
Collaborator

@TheMutsi TheMutsi Aug 26, 2024

Choose a reason for hiding this comment

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

@ArielDemarco can we move it to DispatchQueue bg? I think it could be very heavy

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think that makes sense, I moved the calls to compute ID to background queues

if path == last.path {
let attributes = try? FileManager.default.attributesOfItem(atPath: path)
if let lastComputed = last.computedAt, let modifiedAt = attributes?[FileAttributeKey.modificationDate] as? Date,
modifiedAt <= lastComputed {
return BundleID(id: last.id, cached: true)
}
}

let fileContents = try String(contentsOf: URL(fileURLWithPath: path))

// https://stackoverflow.com/a/56578995
let bundleID = Insecure.MD5.hash(data: Data(fileContents.utf8)).map {
jpmunz marked this conversation as resolved.
Show resolved Hide resolved
String(format: "%02hhx", $0)
}.joined()

last.computedAt = Date()
last.path = path
last.id = bundleID
last.store()

return BundleID(id: last.id, cached: false)
}
43 changes: 21 additions & 22 deletions packages/core/ios/RNEmbrace/EmbraceManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import CodePush
private let JAVASCRIPT_PATCH_NUMBER_RESOURCE_KEY = "javascript_patch_number"
private let HOSTED_PLATFORM_VERSION_RESOURCE_KEY = "hosted_platform_version"
private let HOSTED_SDK_VERSION_RESOURCE_KEY = "hosted_sdk_version"
private let REACT_NATIVE_BUNDLE_ID_RESOURCE_KEY = "react_native_bundle_id"

// Keys defined in packages/spans/interfaces/ISpans.ts
private let EVENT_NAME_KEY = "name"
Expand Down Expand Up @@ -43,10 +44,9 @@ class EmbraceManager: NSObject {
@objc(setJavaScriptBundlePath:resolver:rejecter:)
func setJavaScriptBundlePath(_ path: String, resolver resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock) {
do {

// TODO, use path to compute a hash of the assets and add as a 'react_native_bundle_id' key to the resource
// try Embrace.client?.metadata.addResource(key: EmbraceKeys.javaScriptBundleURL.rawValue, value: path, lifespan: .process)
resolve(true)
let bundleID = try computeBundleID(path: path)
try Embrace.client?.metadata.addResource(key: REACT_NATIVE_BUNDLE_ID_RESOURCE_KEY, value: bundleID.id, lifespan: .process)
resolve(true)
} catch let error {
reject("SET_JS_BUNDLE_PATH_ERROR", "Error setting JavaScript bundle path", error)
}
Expand Down Expand Up @@ -204,23 +204,22 @@ class EmbraceManager: NSObject {
_ resolve: @escaping RCTPromiseResolveBlock,
rejecter reject: @escaping RCTPromiseRejectBlock
) {
// do {
// #if canImport(CodePush)
// guard let url = try CodePush.bundleURL() else {
// reject("GET_CODEPUSH_BUNDLE_URL", "Error getting Codepush Bundle URL", nil)
// return
// }

// // TODO, use path to compute a hash of the assets and add as a 'react_native_bundle_id' key to the resource
// try Embrace.client?.metadata.addResource(key: EmbraceKeys.javaScriptBundleURL.rawValue, value: url.path, .process)
// resolve(true)

// #else
// resolve(false)
// #endif
// } catch let error {
// reject("CHECK_AND_SET_CODEPUSH_BUNDLE_URL", "Error setting Codepush Bundle URL", error)
// }
do {
#if canImport(CodePush)
guard let url = try CodePush.bundleURL() else {
reject("GET_CODEPUSH_BUNDLE_URL", "Error getting Codepush Bundle URL", nil)
return
Copy link
Collaborator

Choose a reason for hiding this comment

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

I wonder if it's better not maintain "codepush" in a specific method, and just let the user set the javascript url if they are using codepush. @jpmunz what do you think? we can just remove this function and update the doc so we dont care if Codepush changes its name or method in the future

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think I'd keep it as is for the purposes of the iOS 6.x migration but I'd like to revisit along with calculating the bundle ID altogether to see if we can reduce code here, noted in https://www.notion.so/embraceio/Rethink-maintaining-support-for-OTA-updates-efe0b4b089ba487d9ab6678b7f7ca491?pvs=4

}

let bundleID = try computeBundleID(path: path)
try Embrace.client?.metadata.addResource(key: REACT_NATIVE_BUNDLE_ID_RESOURCE_KEY, value: bundleID.id, lifespan: .process)
resolve(true)
#else
resolve(false)
#endif
} catch let error {
reject("CHECK_AND_SET_CODEPUSH_BUNDLE_URL", "Error setting Codepush Bundle URL", error)
}
resolve(false)
}

Expand Down Expand Up @@ -381,7 +380,6 @@ class EmbraceManager: NSObject {
}
}


@objc(logNetworkRequest:httpMethod:startInMillis:endInMillis:bytesSent:bytesReceived:statusCode:resolver:rejecter:)
func logNetworkRequest(
_ url: String,
Expand Down Expand Up @@ -718,4 +716,5 @@ class EmbraceManager: NSObject {

resolve(true)
}

}
2 changes: 1 addition & 1 deletion packages/core/ios/RNEmbrace/SpanRepository.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ class SpanRepository {
activeSpansQueue.async(flags: .barrier) {
if self.activeSpans.count > MAX_STORED_SPANS {
os_log("too many active spans being tracked, ignoring", log: self.log, type: .error)
key = "";
key = ""

return
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
30DFDA6A2C486677009F2ECC /* RNEmbrace.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 30DFDA622C486677009F2ECC /* RNEmbrace.framework */; };
30DFDA6F2C486677009F2ECC /* RNEmbraceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30DFDA6E2C486677009F2ECC /* RNEmbraceTests.swift */; };
30DFDA7A2C4866AA009F2ECC /* EmbraceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30DFDA782C4866AA009F2ECC /* EmbraceManager.swift */; };
30FA20DB2C6FC44200340B1E /* BundleID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30FA20DA2C6FC44200340B1E /* BundleID.swift */; };
B4299F22FA7B4BCC15BA5909 /* libPods-RNEmbrace-RNEmbraceTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 558DC3867B10B4FE49B73190 /* libPods-RNEmbrace-RNEmbraceTests.a */; };
DFB266F6D6D6B83330A0A1C0 /* libPods-RNEmbrace.a in Frameworks */ = {isa = PBXBuildFile; fileRef = FDEF5231D91290910B8BF4B8 /* libPods-RNEmbrace.a */; };
/* End PBXBuildFile section */
Expand All @@ -33,6 +34,7 @@
30DFDA692C486677009F2ECC /* RNEmbraceTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RNEmbraceTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
30DFDA6E2C486677009F2ECC /* RNEmbraceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RNEmbraceTests.swift; sourceTree = "<group>"; };
30DFDA782C4866AA009F2ECC /* EmbraceManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EmbraceManager.swift; sourceTree = "<group>"; };
30FA20DA2C6FC44200340B1E /* BundleID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundleID.swift; sourceTree = "<group>"; };
558DC3867B10B4FE49B73190 /* libPods-RNEmbrace-RNEmbraceTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-RNEmbrace-RNEmbraceTests.a"; sourceTree = BUILT_PRODUCTS_DIR; };
9A6DF13D181A3FCB1CB97565 /* Pods-RNEmbrace-RNEmbraceTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RNEmbrace-RNEmbraceTests.release.xcconfig"; path = "Target Support Files/Pods-RNEmbrace-RNEmbraceTests/Pods-RNEmbrace-RNEmbraceTests.release.xcconfig"; sourceTree = "<group>"; };
9E3D177C261BFCB36EE71F4A /* Pods-RNEmbrace.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RNEmbrace.release.xcconfig"; path = "Target Support Files/Pods-RNEmbrace/Pods-RNEmbrace.release.xcconfig"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -108,6 +110,7 @@
308F092A2C4EF1FE00C1EB4C /* SpanRepository.swift */,
308F09282C4EED4200C1EB4C /* EmbraceManager.m */,
30DFDA782C4866AA009F2ECC /* EmbraceManager.swift */,
30FA20DA2C6FC44200340B1E /* BundleID.swift */,
);
name = RNEmbrace;
path = ../../ios/RNEmbrace;
Expand Down Expand Up @@ -351,6 +354,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
30FA20DB2C6FC44200340B1E /* BundleID.swift in Sources */,
30DFDA7A2C4866AA009F2ECC /* EmbraceManager.swift in Sources */,
308F092B2C4EF1FE00C1EB4C /* SpanRepository.swift in Sources */,
308F09292C4EED4200C1EB4C /* EmbraceManager.m in Sources */,
Expand Down Expand Up @@ -438,10 +442,7 @@
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
OTHER_LDFLAGS = (
"$(inherited)",
" ",
);
OTHER_LDFLAGS = "$(inherited) ";
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
Expand Down Expand Up @@ -507,10 +508,7 @@
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
OTHER_LDFLAGS = (
"$(inherited)",
" ",
);
OTHER_LDFLAGS = "$(inherited) ";
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
Expand Down
81 changes: 74 additions & 7 deletions packages/core/test-project/ios/RNEmbraceTests/RNEmbraceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -544,13 +544,13 @@ class EmbraceSpansTests: XCTestCase {
let exportedSpans = try await getExportedSpans()
XCTAssertEqual(exportedSpans.count, 2)
}

func testLogNetworkRequest() async throws {
module.logNetworkRequest("https://otest.com/v1/products", httpMethod: "get", startInMillis: 1723221815889, endInMillis: 1723221815891, bytesSent: 1000, bytesReceived: 2000, statusCode: 200, resolver: promise.resolve, rejecter: promise.reject)

XCTAssertEqual(promise.resolveCalls.count, 1)
XCTAssertTrue((promise.resolveCalls[0] as? Bool)!)

module.logNetworkRequest("https://otest.com/", httpMethod: "POST", startInMillis: 1723221815889, endInMillis: 1723221815891, bytesSent: -1, bytesReceived: -2, statusCode: -1, resolver: promise.resolve, rejecter: promise.reject)

module.logNetworkRequest("https://otest.com/v2/error", httpMethod: "POST", startInMillis: 1723221815889, endInMillis: 1723221815891, bytesSent: -1, bytesReceived: -2, statusCode: 500, resolver: promise.resolve, rejecter: promise.reject)
Expand All @@ -566,7 +566,7 @@ class EmbraceSpansTests: XCTestCase {
XCTAssertEqual(exportedSpans[0].attributes["http.response.body.size"]!.description, "2000")
XCTAssertEqual(exportedSpans[0].attributes["http.request.body.size"]!.description, "1000")
XCTAssertEqual(exportedSpans[0].attributes["http.response.status_code"]!.description, "200")

XCTAssertEqual(exportedSpans[1].name, "emb-POST")
XCTAssertEqual(exportedSpans[1].startTime, Date(timeIntervalSince1970: 1723221815.889))
XCTAssertEqual(exportedSpans[1].endTime, Date(timeIntervalSince1970: 1723221815.891))
Expand All @@ -576,22 +576,22 @@ class EmbraceSpansTests: XCTestCase {
XCTAssertNil(exportedSpans[1].attributes["http.response.body.size"])
XCTAssertNil(exportedSpans[1].attributes["http.request.body.size"])
XCTAssertNil(exportedSpans[1].attributes["http.response.status_code"])

XCTAssertEqual(exportedSpans[2].name, "emb-POST /v2/error")
XCTAssertEqual(exportedSpans[2].startTime, Date(timeIntervalSince1970: 1723221815.889))
XCTAssertEqual(exportedSpans[2].endTime, Date(timeIntervalSince1970: 1723221815.891))
XCTAssertEqual(exportedSpans[2].attributes["url.full"]!.description, "https://otest.com/v2/error")
XCTAssertEqual(exportedSpans[2].attributes["http.response.status_code"]!.description, "500")
// NOTE: is this correct? Android is setting `status` as `Error` when the status code represents an error code (like in this case)
XCTAssertEqual(exportedSpans[2].status, Status.unset);
XCTAssertEqual(exportedSpans[2].status, Status.unset)
}

func testLogNetworkClientError() async throws {
module.logNetworkClientError("https://otest.com/v1/products", httpMethod: "get", startInMillis: 1723221815889, endInMillis: 1723221815891, errorType: "custom error", errorMessage: "this is my error", resolver: promise.resolve, rejecter: promise.reject)

XCTAssertEqual(promise.resolveCalls.count, 1)
XCTAssertTrue((promise.resolveCalls[0] as? Bool)!)

let exportedSpans = try await getExportedSpans()

XCTAssertEqual(exportedSpans[0].name, "emb-GET /v1/products")
Expand All @@ -601,7 +601,7 @@ class EmbraceSpansTests: XCTestCase {
XCTAssertEqual(exportedSpans[0].attributes["error.message"]!.description, "this is my error")
XCTAssertEqual(exportedSpans[0].attributes["error.type"]!.description, "custom error")
}

func testStartEndView() async throws {
module.startView("my-view", resolver: promise.resolve, rejecter: promise.reject)
XCTAssertEqual(promise.resolveCalls.count, 1)
Expand Down Expand Up @@ -649,3 +649,70 @@ class EmbraceSpansSDKNotStartedTests: XCTestCase {
}
}

class ComputeBundleIDTests: XCTestCase {
// https://nshipster.com/temporary-files/
Copy link
Contributor

@ArielDemarco ArielDemarco Aug 26, 2024

Choose a reason for hiding this comment

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

Probably many of this tests under the hood, are using UserDefaults. UserDefaults writes things to disk (in a .plist file).
So, to prevent any kind of flakyness, i'd remove all things persisted in UserDefaults between tests. In order to achieve this (considering you've made this change, i'd add this code:

    override func tearDownWithError() throws {
          UserDefaults.standard.removePersistentDomain(forName: "EmbraceReactNative")
    }

Copy link
Contributor Author

Choose a reason for hiding this comment

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

added

func writeTempFile(contents: String, url: URL? = nil) throws -> URL {
var to = url
if to == nil {
let tmpDir = try FileManager.default.url(for: .itemReplacementDirectory,
in: .userDomainMask,
appropriateFor: URL(fileURLWithPath: "sample_bundle.js"),
create: true)

to = tmpDir.appendingPathComponent(ProcessInfo().globallyUniqueString)
}

try contents.write(to: to!, atomically: true, encoding: String.Encoding.utf8)

return to!
}

func testEmptyPath() {
XCTAssertThrowsError(try computeBundleID(path: "")) { error in
XCTAssertEqual(error as? ComputeBundleIDErrors, ComputeBundleIDErrors.emptyPath)
}
}

func testNothingCached() {
let fileURL = try? writeTempFile(contents: "console.log('my js bundle');")
let bundleID = try? computeBundleID(path: fileURL!.path())
XCTAssertEqual(bundleID?.id, "29da484ad2a259e9de64a991cfec7f10")
XCTAssertFalse(bundleID!.cached)
}

func testDifferentPath() {
let fileURL = try? writeTempFile(contents: "console.log('my js bundle');")
var bundleID = try? computeBundleID(path: fileURL!.path())
XCTAssertEqual(bundleID?.id, "29da484ad2a259e9de64a991cfec7f10")
XCTAssertFalse(bundleID!.cached)

let otherURL = try? writeTempFile(contents: "console.log('my other bundle');")
bundleID = try? computeBundleID(path: otherURL!.path())
XCTAssertEqual(bundleID?.id, "9a7ef24cdaf5cdb927eab12cb0bfd30d")
XCTAssertFalse(bundleID!.cached)
}

func testSamePathNotModified() {
let fileURL = try? writeTempFile(contents: "console.log('my js bundle');")
var bundleID = try? computeBundleID(path: fileURL!.path())
XCTAssertEqual(bundleID?.id, "29da484ad2a259e9de64a991cfec7f10")
XCTAssertFalse(bundleID!.cached)

bundleID = try? computeBundleID(path: fileURL!.path())
XCTAssertEqual(bundleID?.id, "29da484ad2a259e9de64a991cfec7f10")
XCTAssertTrue(bundleID!.cached)
}

func testSamePathModified() {
let fileURL = try? writeTempFile(contents: "console.log('my js bundle');")
var bundleID = try? computeBundleID(path: fileURL!.path())
XCTAssertEqual(bundleID?.id, "29da484ad2a259e9de64a991cfec7f10")
XCTAssertFalse(bundleID!.cached)

let sameURL = try? writeTempFile(contents: "console.log('my other bundle');", url: fileURL)
XCTAssertEqual(fileURL, sameURL)
bundleID = try? computeBundleID(path: sameURL!.path())
XCTAssertEqual(bundleID?.id, "9a7ef24cdaf5cdb927eab12cb0bfd30d")
XCTAssertFalse(bundleID!.cached)
}
}
Loading