From a4a73b1a927cf584d9f84f2de92faf31906b4fae Mon Sep 17 00:00:00 2001 From: Valere Date: Sat, 1 Jun 2024 13:05:04 +0200 Subject: [PATCH] Analytics | Add support for super properties and appPlatform --- .../xcshareddata/swiftpm/Package.resolved | 4 +- Riot/Modules/Analytics/Analytics.swift | 17 ++- .../Analytics/AnalyticsClientProtocol.swift | 7 + .../Analytics/PostHogAnalyticsClient.swift | 56 +++++++- Riot/Modules/Analytics/PosthogProtocol.swift | 53 +++++++ RiotTests/AnalyticsTests.swift | 12 +- RiotTests/FakeUtils.swift | 133 ++++++++++++++++++ RiotTests/PostHogAnalyticsClientTests.swift | 113 +++++++++++++++ project.yml | 2 +- 9 files changed, 380 insertions(+), 17 deletions(-) create mode 100644 Riot/Modules/Analytics/PosthogProtocol.swift create mode 100644 RiotTests/PostHogAnalyticsClientTests.swift diff --git a/Riot.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Riot.xcworkspace/xcshareddata/swiftpm/Package.resolved index bb13eadb22..c90d47d46f 100644 --- a/Riot.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Riot.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -41,8 +41,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/matrix-org/matrix-analytics-events", "state" : { - "revision" : "44d5a0e898a71f8abbbe12afe9d73e82d370a9a1", - "version" : "0.15.0" + "revision" : "de0cac487e5e7f607ee17045882204c91585461f", + "version" : "0.23.1" } }, { diff --git a/Riot/Modules/Analytics/Analytics.swift b/Riot/Modules/Analytics/Analytics.swift index 270f1b3ef9..f284c2aa4b 100644 --- a/Riot/Modules/Analytics/Analytics.swift +++ b/Riot/Modules/Analytics/Analytics.swift @@ -94,6 +94,13 @@ import AnalyticsEvents guard let session = session else { return } useAnalyticsSettings(from: session) + self.client.updateSuperProperties( + AnalyticsEvent.SuperProperties( + appPlatform: .EI, + cryptoSDK: .Rust, + cryptoSDKVersion: session.crypto.version + ) + ) } /// Stops analytics tracking and calls `reset` to clear any IDs and event queues. @@ -148,6 +155,13 @@ import AnalyticsEvents switch result { case .success(let settings): self.identify(with: settings) + self.client.updateSuperProperties( + AnalyticsEvent.SuperProperties( + appPlatform: .EI, + cryptoSDK: .Rust, + cryptoSDKVersion: session.crypto.version + ) + ) self.service = nil case .failure: MXLog.error("[Analytics] Failed to use analytics settings. Will continue to run without analytics ID.") @@ -242,7 +256,8 @@ extension Analytics { let userProperties = AnalyticsEvent.UserProperties(allChatsActiveFilter: allChatsActiveFilter?.analyticsName, ftueUseCaseSelection: ftueUseCase?.analyticsName, numFavouriteRooms: numFavouriteRooms, - numSpaces: numSpaces) + numSpaces: numSpaces, + recoveryState: nil, verificationState: nil) client.updateUserProperties(userProperties) } diff --git a/Riot/Modules/Analytics/AnalyticsClientProtocol.swift b/Riot/Modules/Analytics/AnalyticsClientProtocol.swift index e16b962e5e..1e0acc54e3 100644 --- a/Riot/Modules/Analytics/AnalyticsClientProtocol.swift +++ b/Riot/Modules/Analytics/AnalyticsClientProtocol.swift @@ -53,4 +53,11 @@ protocol AnalyticsClientProtocol { /// be a delay when updating user properties as these are cached to be included /// as part of the next event that gets captured. func updateUserProperties(_ userProperties: AnalyticsEvent.UserProperties) + + + /// Updates the user properties. + /// Super properties added to all captured events and screen. + /// - Parameter superProperties: The properties event to capture. + func updateSuperProperties(_ event: AnalyticsEvent.SuperProperties) + } diff --git a/Riot/Modules/Analytics/PostHogAnalyticsClient.swift b/Riot/Modules/Analytics/PostHogAnalyticsClient.swift index ac22db68f3..5a70c58373 100644 --- a/Riot/Modules/Analytics/PostHogAnalyticsClient.swift +++ b/Riot/Modules/Analytics/PostHogAnalyticsClient.swift @@ -19,23 +19,39 @@ import AnalyticsEvents /// An analytics client that reports events to a PostHog server. class PostHogAnalyticsClient: AnalyticsClientProtocol { + + private var posthogFactory: PostHogFactory = DefaultPostHogFactory() + + init(posthogFactory: PostHogFactory? = nil) { + if let factory = posthogFactory { + self.posthogFactory = factory + } + } + /// The PHGPostHog object used to report events. - private var postHog: PostHogSDK? + private var postHog: PostHogProtocol? /// Any user properties to be included with the next captured event. private(set) var pendingUserProperties: AnalyticsEvent.UserProperties? + /// Super Properties are properties associated with events that are set once and then sent with every capture call, be it a $screen, an autocaptured button click, or anything else. + /// It is different from user properties that will be attached to the user and not events. + /// Not persisted for now, should be set on start. + private var superProperties: AnalyticsEvent.SuperProperties? + static let shared = PostHogAnalyticsClient() - var isRunning: Bool { postHog != nil && !postHog!.isOptOut() } + var isRunning: Bool { + guard let postHog else { return false } + return !postHog.isOptOut() + } func start() { // Only start if analytics have been configured in BuildSettings guard let configuration = PostHogConfig.standard else { return } if postHog == nil { - PostHogSDK.shared.setup(configuration) - postHog = PostHogSDK.shared + postHog = posthogFactory.createPostHog(config: configuration) } postHog?.optIn() @@ -67,13 +83,13 @@ class PostHogAnalyticsClient: AnalyticsClientProtocol { } func capture(_ event: AnalyticsEventProtocol) { - postHog?.capture(event.eventName, properties: event.properties, userProperties: pendingUserProperties?.properties.compactMapValues { $0 }) + postHog?.capture(event.eventName, properties: attachSuperProperties(to: event.properties), userProperties: pendingUserProperties?.properties.compactMapValues { $0 }) // Pending user properties have been added self.pendingUserProperties = nil } func screen(_ event: AnalyticsScreenProtocol) { - postHog?.screen(event.screenName.rawValue, properties: event.properties) + postHog?.screen(event.screenName.rawValue, properties: attachSuperProperties(to: event.properties)) } func updateUserProperties(_ userProperties: AnalyticsEvent.UserProperties) { @@ -86,9 +102,35 @@ class PostHogAnalyticsClient: AnalyticsClientProtocol { self.pendingUserProperties = AnalyticsEvent.UserProperties(allChatsActiveFilter: userProperties.allChatsActiveFilter ?? pendingUserProperties.allChatsActiveFilter, ftueUseCaseSelection: userProperties.ftueUseCaseSelection ?? pendingUserProperties.ftueUseCaseSelection, numFavouriteRooms: userProperties.numFavouriteRooms ?? pendingUserProperties.numFavouriteRooms, - numSpaces: userProperties.numSpaces ?? pendingUserProperties.numSpaces) + numSpaces: userProperties.numSpaces ?? pendingUserProperties.numSpaces, + // Not yet supported + recoveryState: nil, verificationState: nil) } + func updateSuperProperties(_ updatedProperties: AnalyticsEvent.SuperProperties) { + self.superProperties = AnalyticsEvent.SuperProperties( + appPlatform: updatedProperties.appPlatform ?? superProperties?.appPlatform, + cryptoSDK: updatedProperties.cryptoSDK ?? superProperties?.cryptoSDK, + cryptoSDKVersion: updatedProperties.cryptoSDKVersion ?? superProperties?.cryptoSDKVersion + ) + } + + /// Attach super properties to events. + /// If the property is already set on the event, the already set value will be kept. + private func attachSuperProperties(to properties: [String: Any]) -> [String: Any] { + guard isRunning, let superProperties else { return properties } + + var properties = properties + + superProperties.properties.forEach { (key: String, value: Any) in + if properties[key] == nil { + properties[key] = value + } + } + return properties + } + + } extension PostHogAnalyticsClient: RemoteFeaturesClientProtocol { diff --git a/Riot/Modules/Analytics/PosthogProtocol.swift b/Riot/Modules/Analytics/PosthogProtocol.swift new file mode 100644 index 0000000000..d24d19e170 --- /dev/null +++ b/Riot/Modules/Analytics/PosthogProtocol.swift @@ -0,0 +1,53 @@ +// +// Copyright 2024 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import PostHog + +protocol PostHogProtocol { + func optIn() + + func optOut() + + func reset() + + func flush() + + func capture(_ event: String, properties: [String: Any]?, userProperties: [String: Any]?) + + func screen(_ screenTitle: String, properties: [String: Any]?) + + func isFeatureEnabled(_ feature: String) -> Bool + + func identify(_ distinctId: String) + + func identify(_ distinctId: String, userProperties: [String: Any]?) + + func isOptOut() -> Bool +} + +protocol PostHogFactory { + func createPostHog(config: PostHogConfig) -> PostHogProtocol +} + +class DefaultPostHogFactory: PostHogFactory { + func createPostHog(config: PostHogConfig) -> PostHogProtocol { + PostHogSDK.shared.setup(config) + return PostHogSDK.shared + } +} + +extension PostHogSDK: PostHogProtocol { } diff --git a/RiotTests/AnalyticsTests.swift b/RiotTests/AnalyticsTests.swift index 73f3f66b1c..a8759c0176 100644 --- a/RiotTests/AnalyticsTests.swift +++ b/RiotTests/AnalyticsTests.swift @@ -78,7 +78,7 @@ class AnalyticsTests: XCTestCase { XCTAssertNil(client.pendingUserProperties, "No user properties should have been set yet.") // When updating the user properties - client.updateUserProperties(AnalyticsEvent.UserProperties(allChatsActiveFilter: nil, ftueUseCaseSelection: .PersonalMessaging, numFavouriteRooms: 4, numSpaces: 5)) + client.updateUserProperties(AnalyticsEvent.UserProperties(allChatsActiveFilter: nil, ftueUseCaseSelection: .PersonalMessaging, numFavouriteRooms: 4, numSpaces: 5, recoveryState: nil, verificationState: nil)) // Then the properties should be cached XCTAssertNotNil(client.pendingUserProperties, "The user properties should be cached.") @@ -90,7 +90,7 @@ class AnalyticsTests: XCTestCase { func testMergingUserProperties() { // Given a client with a cached use case user properties let client = PostHogAnalyticsClient() - client.updateUserProperties(AnalyticsEvent.UserProperties(allChatsActiveFilter: nil, ftueUseCaseSelection: .PersonalMessaging, numFavouriteRooms: nil, numSpaces: nil)) + client.updateUserProperties(AnalyticsEvent.UserProperties(allChatsActiveFilter: nil, ftueUseCaseSelection: .PersonalMessaging, numFavouriteRooms: nil, numSpaces: nil, recoveryState: nil, verificationState: nil)) XCTAssertNotNil(client.pendingUserProperties, "The user properties should be cached.") XCTAssertEqual(client.pendingUserProperties?.ftueUseCaseSelection, .PersonalMessaging, "The use case selection should match.") @@ -98,7 +98,7 @@ class AnalyticsTests: XCTestCase { XCTAssertNil(client.pendingUserProperties?.numSpaces, "The number of spaces should not be set.") // When updating the number of spaces - client.updateUserProperties(AnalyticsEvent.UserProperties(allChatsActiveFilter: nil, ftueUseCaseSelection: nil, numFavouriteRooms: 4, numSpaces: 5)) + client.updateUserProperties(AnalyticsEvent.UserProperties(allChatsActiveFilter: nil, ftueUseCaseSelection: nil, numFavouriteRooms: 4, numSpaces: 5, recoveryState: nil, verificationState: nil)) // Then the new properties should be updated and the existing properties should remain unchanged XCTAssertNotNil(client.pendingUserProperties, "The user properties should be cached.") @@ -107,7 +107,7 @@ class AnalyticsTests: XCTestCase { XCTAssertEqual(client.pendingUserProperties?.numSpaces, 5, "The number of spaces should have been updated.") // When updating the number of spaces - client.updateUserProperties(AnalyticsEvent.UserProperties(allChatsActiveFilter: .Favourites, ftueUseCaseSelection: nil, numFavouriteRooms: nil, numSpaces: nil)) + client.updateUserProperties(AnalyticsEvent.UserProperties(allChatsActiveFilter: .Favourites, ftueUseCaseSelection: nil, numFavouriteRooms: nil, numSpaces: nil, recoveryState: nil, verificationState: nil)) // Then the new properties should be updated and the existing properties should remain unchanged XCTAssertNotNil(client.pendingUserProperties, "The user properties should be cached.") @@ -120,7 +120,7 @@ class AnalyticsTests: XCTestCase { func testSendingUserProperties() { // Given a client with user properties set let client = PostHogAnalyticsClient() - client.updateUserProperties(AnalyticsEvent.UserProperties(allChatsActiveFilter: nil, ftueUseCaseSelection: .PersonalMessaging, numFavouriteRooms: nil, numSpaces: nil)) + client.updateUserProperties(AnalyticsEvent.UserProperties(allChatsActiveFilter: nil, ftueUseCaseSelection: .PersonalMessaging, numFavouriteRooms: nil, numSpaces: nil, recoveryState: nil, verificationState: nil)) client.start() XCTAssertNotNil(client.pendingUserProperties, "The user properties should be cached.") @@ -137,7 +137,7 @@ class AnalyticsTests: XCTestCase { func testSendingUserPropertiesWithIdentify() { // Given a client with user properties set let client = PostHogAnalyticsClient() - client.updateUserProperties(AnalyticsEvent.UserProperties(allChatsActiveFilter: nil, ftueUseCaseSelection: .PersonalMessaging, numFavouriteRooms: nil, numSpaces: nil)) + client.updateUserProperties(AnalyticsEvent.UserProperties(allChatsActiveFilter: nil, ftueUseCaseSelection: .PersonalMessaging, numFavouriteRooms: nil, numSpaces: nil, recoveryState: nil, verificationState: nil)) client.start() XCTAssertNotNil(client.pendingUserProperties, "The user properties should be cached.") diff --git a/RiotTests/FakeUtils.swift b/RiotTests/FakeUtils.swift index 6077a449ef..2f2ad4e63a 100644 --- a/RiotTests/FakeUtils.swift +++ b/RiotTests/FakeUtils.swift @@ -15,6 +15,8 @@ // import Foundation +import PostHog +@testable import Element class FakeEvent: MXEvent { @@ -346,3 +348,134 @@ class FakeKeyVerificationManager: NSObject, MXKeyVerificationManager { } } + +class MockPostHog: PostHogProtocol { + + private var enabled = false + + func optIn() { + enabled = true + } + + func optOut() { + enabled = false + } + + func reset() { + + } + + func flush() { + + } + + var capturePropertiesUserPropertiesUnderlyingCallsCount = 0 + var capturePropertiesUserPropertiesCallsCount: Int { + get { + if Thread.isMainThread { + return capturePropertiesUserPropertiesUnderlyingCallsCount + } else { + var returnValue: Int? = nil + DispatchQueue.main.sync { + returnValue = capturePropertiesUserPropertiesUnderlyingCallsCount + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + capturePropertiesUserPropertiesUnderlyingCallsCount = newValue + } else { + DispatchQueue.main.sync { + capturePropertiesUserPropertiesUnderlyingCallsCount = newValue + } + } + } + } + var capturePropertiesUserPropertiesCalled: Bool { + return capturePropertiesUserPropertiesCallsCount > 0 + } + var capturePropertiesUserPropertiesReceivedArguments: (event: String, properties: [String: Any]?, userProperties: [String: Any]?)? + var capturePropertiesUserPropertiesReceivedInvocations: [(event: String, properties: [String: Any]?, userProperties: [String: Any]?)] = [] + var capturePropertiesUserPropertiesClosure: ((String, [String: Any]?, [String: Any]?) -> Void)? + + func capture(_ event: String, properties: [String: Any]?, userProperties: [String: Any]?) { + if !enabled { return } + capturePropertiesUserPropertiesCallsCount += 1 + capturePropertiesUserPropertiesReceivedArguments = (event: event, properties: properties, userProperties: userProperties) + capturePropertiesUserPropertiesReceivedInvocations.append((event: event, properties: properties, userProperties: userProperties)) + capturePropertiesUserPropertiesClosure?(event, properties, userProperties) + } + + var screenPropertiesUnderlyingCallsCount = 0 + var screenPropertiesCallsCount: Int { + get { + if Thread.isMainThread { + return screenPropertiesUnderlyingCallsCount + } else { + var returnValue: Int? = nil + DispatchQueue.main.sync { + returnValue = screenPropertiesUnderlyingCallsCount + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + screenPropertiesUnderlyingCallsCount = newValue + } else { + DispatchQueue.main.sync { + screenPropertiesUnderlyingCallsCount = newValue + } + } + } + } + + var screenPropertiesCalled: Bool { + return screenPropertiesCallsCount > 0 + } + var screenPropertiesReceivedArguments: (screenTitle: String, properties: [String: Any]?)? + var screenPropertiesReceivedInvocations: [(screenTitle: String, properties: [String: Any]?)] = [] + var screenPropertiesClosure: ((String, [String: Any]?) -> Void)? + + func screen(_ screenTitle: String, properties: [String: Any]?) { + if !enabled { return } + screenPropertiesCallsCount += 1 + screenPropertiesReceivedArguments = (screenTitle: screenTitle, properties: properties) + screenPropertiesReceivedInvocations.append((screenTitle: screenTitle, properties: properties)) + screenPropertiesClosure?(screenTitle, properties) + } + + func isFeatureEnabled(_ feature: String) -> Bool { + return true + } + + func identify(_ distinctId: String) { + + } + + func identify(_ distinctId: String, userProperties: [String : Any]?) { + + } + + func isOptOut() -> Bool { + !enabled + } + + +} + +class MockPostHogFactory: PostHogFactory { + var mock: PostHogProtocol! + + init(mock: PostHogProtocol) { + self.mock = mock + } + + func createPostHog(config: PostHogConfig) -> PostHogProtocol { + mock + } +} + diff --git a/RiotTests/PostHogAnalyticsClientTests.swift b/RiotTests/PostHogAnalyticsClientTests.swift new file mode 100644 index 0000000000..081c54688b --- /dev/null +++ b/RiotTests/PostHogAnalyticsClientTests.swift @@ -0,0 +1,113 @@ +// +// Copyright 2024 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +@testable import Element +import AnalyticsEvents + +class PostHogAnalyticsClientTests: XCTestCase { + + private var posthogMock: MockPostHog! + + override func setUp() { + posthogMock = MockPostHog() + } + + func testSuperPropertiesAddedToAllCaptured() { + let analyticsClient = PostHogAnalyticsClient(posthogFactory: MockPostHogFactory(mock: posthogMock)) + analyticsClient.start() + + let superProperties = AnalyticsEvent.SuperProperties(appPlatform: .EI, cryptoSDK: .Rust, cryptoSDKVersion: "0.0") + + analyticsClient.updateSuperProperties(superProperties) + // It should be the same for any event + let someEvent = AnalyticsEvent.CallEnded(durationMs: 0, isVideo: false, numParticipants: 1, placed: true) + analyticsClient.capture(someEvent) + + let capturedEvent = posthogMock.capturePropertiesUserPropertiesReceivedArguments + + // All the super properties should have been added + XCTAssertEqual(capturedEvent?.properties?["cryptoSDK"] as? String, AnalyticsEvent.SuperProperties.CryptoSDK.Rust.rawValue) + XCTAssertEqual(capturedEvent?.properties?["appPlatform"] as? String, AnalyticsEvent.SuperProperties.AppPlatform.EI.rawValue) + XCTAssertEqual(capturedEvent?.properties?["cryptoSDKVersion"] as? String, "0.0") + + // Other properties should be there + XCTAssertEqual(capturedEvent?.properties?["isVideo"] as? Bool, false) + + // Should also work for screens + + analyticsClient.screen(AnalyticsEvent.MobileScreen.init(durationMs: 0, screenName: .Home)) + + + let capturedScreen = posthogMock.screenPropertiesReceivedArguments + + + XCTAssertEqual(capturedScreen?.properties?["cryptoSDK"] as? String, AnalyticsEvent.SuperProperties.CryptoSDK.Rust.rawValue) + XCTAssertEqual(capturedScreen?.properties?["appPlatform"] as? String, AnalyticsEvent.SuperProperties.AppPlatform.EI.rawValue) + XCTAssertEqual(capturedScreen?.properties?["cryptoSDKVersion"] as? String, "0.0") + + + XCTAssertEqual(capturedScreen?.screenTitle, AnalyticsEvent.MobileScreen.ScreenName.Home.rawValue) + + + } + + func testSuperPropertiesCanBeUdpated() { + let analyticsClient = PostHogAnalyticsClient(posthogFactory: MockPostHogFactory(mock: posthogMock)) + analyticsClient.start() + + let superProperties = AnalyticsEvent.SuperProperties(appPlatform: .EI, cryptoSDK: .Rust, cryptoSDKVersion: "0.0") + + analyticsClient.updateSuperProperties(superProperties) + // It should be the same for any event + let someEvent = AnalyticsEvent.CallEnded(durationMs: 0, isVideo: false, numParticipants: 1, placed: true) + analyticsClient.capture(someEvent) + + let capturedEvent = posthogMock.capturePropertiesUserPropertiesReceivedArguments + + // + XCTAssertEqual(capturedEvent?.properties?["cryptoSDKVersion"] as? String, "0.0") + + analyticsClient.updateSuperProperties(AnalyticsEvent.SuperProperties(appPlatform: .EI, cryptoSDK: .Rust, cryptoSDKVersion: "1.0")) + + + analyticsClient.capture(someEvent) + + let secondCapturedEvent = posthogMock.capturePropertiesUserPropertiesReceivedArguments + + XCTAssertEqual(secondCapturedEvent?.properties?["cryptoSDKVersion"] as? String, "1.0") + } + + func testSuperPropertiesDontOverrideEventProperties() { + let analyticsClient = PostHogAnalyticsClient(posthogFactory: MockPostHogFactory(mock: posthogMock)) + analyticsClient.start() + + // Super property for cryptoSDK is rust + let superProperties = AnalyticsEvent.SuperProperties(appPlatform: nil, cryptoSDK: .Rust, cryptoSDKVersion: nil) + + analyticsClient.updateSuperProperties(superProperties) + + // This event as a similar named property `cryptoSDK` with Legacy value + let someEvent = AnalyticsEvent.Error(context: nil, cryptoModule: nil, cryptoSDK: .Legacy, domain: .E2EE, eventLocalAgeMillis: nil, isFederated: nil, isMatrixDotOrg: nil, name: .OlmKeysNotSentError, timeToDecryptMillis: nil, userTrustsOwnIdentity: nil, wasVisibleToUser: nil) + + analyticsClient.capture(someEvent) + + let capturedEvent = posthogMock.capturePropertiesUserPropertiesReceivedArguments + + XCTAssertEqual(capturedEvent?.properties?["cryptoSDK"] as? String, AnalyticsEvent.Error.CryptoSDK.Legacy.rawValue) + } + +} diff --git a/project.yml b/project.yml index a666fb1b76..f72c75b5a5 100644 --- a/project.yml +++ b/project.yml @@ -45,7 +45,7 @@ include: packages: AnalyticsEvents: url: https://github.com/matrix-org/matrix-analytics-events - exactVersion: 0.15.0 + exactVersion: 0.23.1 Mapbox: url: https://github.com/maplibre/maplibre-gl-native-distribution minVersion: 5.12.2