diff --git a/Sources/Core/InternalQueue/TrackerControllerIQWrapper.swift b/Sources/Core/InternalQueue/TrackerControllerIQWrapper.swift index 95a7845d9..c4cd2fcf6 100644 --- a/Sources/Core/InternalQueue/TrackerControllerIQWrapper.swift +++ b/Sources/Core/InternalQueue/TrackerControllerIQWrapper.swift @@ -110,6 +110,14 @@ class TrackerControllerIQWrapper: TrackerController { InternalQueue.async { self.controller.track(event, eventId: eventId) } return eventId } + + func decorateLink(_ url: URL) -> URL? { + return InternalQueue.sync { controller.decorateLink(url) } + } + + func decorateLink(_ url: URL, extendedParameters: CrossDeviceParameterConfiguration) -> URL? { + return InternalQueue.sync { controller.decorateLink(url, extendedParameters: extendedParameters) } + } // MARK: - Properties' setters and getters diff --git a/Sources/Core/Tracker/TrackerControllerImpl.swift b/Sources/Core/Tracker/TrackerControllerImpl.swift index f4b6822df..0307803aa 100644 --- a/Sources/Core/Tracker/TrackerControllerImpl.swift +++ b/Sources/Core/Tracker/TrackerControllerImpl.swift @@ -77,6 +77,56 @@ class TrackerControllerImpl: Controller, TrackerController { func track(_ event: Event) -> UUID { return tracker.track(event) } + + func decorateLink(_ url: URL) -> URL? { + self.decorateLink(url, extendedParameters: CrossDeviceParameterConfiguration()) + } + + func decorateLink(_ url: URL, extendedParameters: CrossDeviceParameterConfiguration) -> URL? { + var userId: String + switch self.session?.userId { + case .none: + logError(message: "\(url) could not be decorated as session.userId is nil") + return nil + case .some(let id): + userId = id + } + + let sessionId = extendedParameters.sessionId ? self.session?.sessionId ?? "" : "" + if (extendedParameters.sessionId && sessionId.isEmpty) { + logDebug(message: "\(decorateLinkErrorTemplate("sessionId")) Ensure an event has been tracked to generate a session before calling this method.") + } + + let sourceId = extendedParameters.sourceId ? self.appId : "" + + let sourcePlatform = extendedParameters.sourcePlatform ? devicePlatformToString(self.devicePlatform) : "" + + let subjectUserId = extendedParameters.subjectUserId ? self.subject?.userId ?? "" : "" + if (extendedParameters.subjectUserId && subjectUserId.isEmpty) { + logDebug(message: "\(decorateLinkErrorTemplate("subjectUserId")) Ensure SubjectConfiguration.userId has been set on your tracker.") + } + + let reason = extendedParameters.reason ?? "" + + let spParameters = [ + userId, + String(Int(Date().timeIntervalSince1970 * 1000)), + sessionId, + subjectUserId.toBase64(), + sourceId.toBase64(), + sourcePlatform, + reason.toBase64() + ].joined(separator: ".").trimmingCharacters(in: ["."]) + + var components = URLComponents(url: url, resolvingAgainstBaseURL: false) + let spQueryParam = URLQueryItem(name: "_sp", value: spParameters) + + // Modification requires exclusive access, we must make a copy + let queryItems = components?.queryItems + components?.queryItems = (queryItems?.filter { $0.name != "_sp" } ?? []) + [spQueryParam] + + return components?.url + } // MARK: - Properties' setters and getters @@ -328,4 +378,8 @@ class TrackerControllerImpl: Controller, TrackerController { private var dirtyConfig: TrackerConfiguration { return serviceProvider.trackerConfiguration } + + private func decorateLinkErrorTemplate(_ extendedParameterName: String) -> String { + "\(extendedParameterName) has been requested in CrossDeviceParameterConfiguration, but it is not set." + } } diff --git a/Sources/Core/Utils/Stringb64.swift b/Sources/Core/Utils/Stringb64.swift new file mode 100644 index 000000000..b1c697588 --- /dev/null +++ b/Sources/Core/Utils/Stringb64.swift @@ -0,0 +1,33 @@ +// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// +// This program is licensed to you under the Apache License Version 2.0, +// and you may not use this file except in compliance with the Apache License +// Version 2.0. You may obtain a copy of the Apache License Version 2.0 at +// http://www.apache.org/licenses/LICENSE-2.0. +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the Apache License Version 2.0 is distributed on +// an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +// express or implied. See the Apache License Version 2.0 for the specific +// language governing permissions and limitations there under. + +import Foundation + +extension String { + func toBase64(urlSafe: Bool = true) -> String { + var encoded = Data(self.utf8).base64EncodedString() + if urlSafe { + // We need URL safe with no padding. Since there is no built-in way to do this, we transform + // the encoded payload to make it URL safe by replacing chars that are different in the URL-safe + // alphabet. Namely, 62 is - instead of +, and 63 _ instead of /. + // See: https://tools.ietf.org/html/rfc4648#section-5 + encoded = encoded + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "+", with: "-") + + // There is also no padding since the length is implicitly known. + encoded = encoded.trimmingCharacters(in: CharacterSet(charactersIn: "=")) + } + return encoded + } +} diff --git a/Sources/Snowplow/Controllers/TrackerController.swift b/Sources/Snowplow/Controllers/TrackerController.swift index 9be50d4c5..2d8b53bf8 100644 --- a/Sources/Snowplow/Controllers/TrackerController.swift +++ b/Sources/Snowplow/Controllers/TrackerController.swift @@ -75,4 +75,40 @@ public protocol TrackerController: TrackerConfigurationProtocol { /// The tracker will start tracking again. @objc func resume() + /// Adds user and session information to a URL. + /// + /// For example, calling decorateLink on `appSchema://path/to/page` will return: + /// + /// `appSchema://path/to/page?_sp=domainUserId.timestamp.sessionId..sourceId` + /// + /// Filled by this method: + /// - `domainUserId`: Value of ``SessionController.userId`` + /// - `timestamp`: ms precision epoch timestamp + /// - `sessionId`: Value of ``SessionController.sessionId`` + /// - `sourceId`: Value of ``Tracker.appId`` + /// + /// - Parameter uri The URI to add the query string to + /// + /// - Returns Optional URL + /// - nil if ``SnowplowTracker/SessionController/userId`` is null from `sessionContext(false)` being passed in ``TrackerConfiguration`` + /// - otherwise, decorated URL + @objc + func decorateLink(_ url: URL) -> URL? + /// Adds user and session information to a URL. + /// + /// For example, calling decorateLink on `appSchema://path/to/page` with all extended parameters enabled will return: + /// + /// `appSchema://path/to/page?_sp=domainUserId.timestamp.sessionId.subjectUserId.sourceId.platform.reason` + /// + /// - Parameter url The URL to add the query string to + /// - Parameter extendedParameters Any optional parameters to include in the query string. + /// + /// - Returns Optional URL + /// - nil if: + /// + /// - ``SnowplowTracker/SessionController/userId`` is null from `sessionContext(false)` being passed in ``TrackerConfiguration`` + /// - An enabled CrossDeviceParameter isn't set in the tracker + /// - otherwise, decorated URL + @objc + func decorateLink(_ url: URL, extendedParameters: CrossDeviceParameterConfiguration) -> URL? } diff --git a/Sources/Snowplow/Tracker/CrossDeviceParameterConfiguration.swift b/Sources/Snowplow/Tracker/CrossDeviceParameterConfiguration.swift new file mode 100644 index 000000000..ea4cee547 --- /dev/null +++ b/Sources/Snowplow/Tracker/CrossDeviceParameterConfiguration.swift @@ -0,0 +1,42 @@ +// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// +// This program is licensed to you under the Apache License Version 2.0, +// and you may not use this file except in compliance with the Apache License +// Version 2.0. You may obtain a copy of the Apache License Version 2.0 at +// http://www.apache.org/licenses/LICENSE-2.0. +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the Apache License Version 2.0 is distributed on +// an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +// express or implied. See the Apache License Version 2.0 for the specific +// language governing permissions and limitations there under. + +import Foundation + +/// Configuration object for ``TrackerController/decorateLink`` +@objc public class CrossDeviceParameterConfiguration : NSObject { + /// Whether to include the value of ``SessionController.sessionId`` when decorating a link (enabled by default) + @objc var sessionId: Bool + /// Whether to include the value of ``Subject.userId`` when decorating a link + @objc var subjectUserId: Bool + /// Whether to include the value of ``Tracker.appId`` when decorating a link (enabled by default) + @objc var sourceId: Bool + /// Whether to include the value of ``Tracker.platform`` when decorating a link + @objc var sourcePlatform: Bool + /// Optional identifier/information for cross-navigation + @objc var reason: String? + + @objc init( + sessionId: Bool = true, + subjectUserId: Bool = false, + sourceId: Bool = true, + sourcePlatform: Bool = false, + reason: String? = nil + ) { + self.sessionId = sessionId + self.subjectUserId = subjectUserId + self.sourceId = sourceId + self.sourcePlatform = sourcePlatform + self.reason = reason + } +} diff --git a/Sources/Snowplow/Tracker/DevicePlatform.swift b/Sources/Snowplow/Tracker/DevicePlatform.swift index c8fdf96d9..e44b6cacf 100644 --- a/Sources/Snowplow/Tracker/DevicePlatform.swift +++ b/Sources/Snowplow/Tracker/DevicePlatform.swift @@ -26,7 +26,7 @@ public enum DevicePlatform : Int { case headset } -func devicePlatformToString(_ devicePlatform: DevicePlatform) -> String? { +func devicePlatformToString(_ devicePlatform: DevicePlatform) -> String { switch devicePlatform { case .web: return "web" diff --git a/Tests/TestLinkDecorator.swift b/Tests/TestLinkDecorator.swift new file mode 100644 index 000000000..9cdae847c --- /dev/null +++ b/Tests/TestLinkDecorator.swift @@ -0,0 +1,169 @@ +// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// +// This program is licensed to you under the Apache License Version 2.0, +// and you may not use this file except in compliance with the Apache License +// Version 2.0. You may obtain a copy of the Apache License Version 2.0 at +// http://www.apache.org/licenses/LICENSE-2.0. +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the Apache License Version 2.0 is distributed on +// an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +// express or implied. See the Apache License Version 2.0 for the specific +// language governing permissions and limitations there under. + +import XCTest +@testable import SnowplowTracker + +class TestLinkDecorator: XCTestCase { + let epoch = "\\d{13}" + + let replacements = [".", "/", "?"] + func matches(for regex: String, in text: String) { + var regex = "^\(regex)$" + + do { + for replacement in replacements { + regex = regex.replacingOccurrences(of: replacement, with: "\\" + replacement) + } + let pattern = try NSRegularExpression(pattern: regex) + let nsString = text as NSString + let results = pattern.matches(in: text, range: NSRange(location: 0, length: nsString.length)) + let fullMatch = results.map { nsString.substring(with: $0.range)} + if (fullMatch.count == 0) { + XCTFail("URL does not match pattern:\n\(text)\n\(regex)") + } + XCTAssertEqual(fullMatch.count, 1) + } catch let error { + print("invalid regex: \(error.localizedDescription)") + } + } + + func testParameterConfiguration() { + let tracker = getTracker() + let _ = tracker.track(ScreenView(name: "test")) + + let link = URL(string: "https://example.com")! + let userId = tracker.session!.userId! + let sessionId = tracker.session!.sessionId! + let subjectUserId = tracker.subject!.userId!.toBase64() + let appId = tracker.appId.toBase64() + let platform = devicePlatformToString(tracker.devicePlatform) + let reason = "reason" + let reasonb64 = reason.toBase64() + + // All false + matches( + for: "https://example.com?_sp=\(userId).\(epoch)", + in: tracker.decorateLink(link, extendedParameters: CrossDeviceParameterConfiguration(sessionId: false, sourceId: false))!.absoluteString + ) + + // Default + matches( + for: "https://example.com?_sp=\(userId).\(epoch).\(sessionId)..\(appId)", + in: tracker.decorateLink(link, extendedParameters: CrossDeviceParameterConfiguration())!.absoluteString + ) + + matches( + for: "https://example.com?_sp=\(userId).\(epoch).\(sessionId).\(subjectUserId).\(appId)", + in: tracker.decorateLink(link, extendedParameters: CrossDeviceParameterConfiguration(subjectUserId: true))!.absoluteString + ) + + matches( + for: "https://example.com?_sp=\(userId).\(epoch).\(sessionId).\(subjectUserId).\(appId).\(platform)", + in: tracker.decorateLink(link, extendedParameters: CrossDeviceParameterConfiguration(subjectUserId: true, sourcePlatform: true))!.absoluteString + ) + + matches( + for: "https://example.com?_sp=\(userId).\(epoch).\(sessionId).\(subjectUserId).\(appId).\(platform).\(reasonb64)", + in: tracker.decorateLink(link, extendedParameters: CrossDeviceParameterConfiguration(subjectUserId: true, sourcePlatform: true, reason: reason))!.absoluteString + ) + + matches( + for: "https://example.com?_sp=\(userId).\(epoch).....\(reasonb64)", + in: tracker.decorateLink(link, extendedParameters: CrossDeviceParameterConfiguration(sessionId: false, sourceId: false, reason: reason))!.absoluteString + ) + } + + func testWithExistingSpQueryParameter() { + let tracker = getTracker() + let link = URL(string: "https://example.com?_sp=test")! + + let result = tracker.decorateLink(link, extendedParameters: CrossDeviceParameterConfiguration(sessionId: false, sourceId: false))! + + matches(for: "https://example.com?_sp=\(tracker.session!.userId!).\(epoch)", in: result.absoluteString) + } + + func testWithOtherQueryParameters() { + let tracker = getTracker() + let link = URL(string: "https://example.com?a=a&b=b")! + let userId = tracker.session!.userId! + + let result = tracker.decorateLink(link, extendedParameters: CrossDeviceParameterConfiguration(sessionId: false, sourceId: false))! + + matches(for: "https://example.com?a=a&b=b&_sp=\(userId).\(epoch)", in: result.absoluteString) + } + + func testExistingSpQueryParameterInMiddleOfOtherQueryParameters() { + let tracker = getTracker() + let link = URL(string: "https://example.com?a=a&_sp=test&b=b")! + + let result = tracker.decorateLink(link, extendedParameters: CrossDeviceParameterConfiguration(sessionId: false, sourceId: false))! + + matches(for: "https://example.com?a=a&b=b&_sp=\(tracker.session!.userId!).\(epoch)", in: result.absoluteString) + } + + func testMissingFields() { + let tracker = getTrackerNoSubjectUserId() + let link = URL(string: "https://example.com")! + + let result = tracker.decorateLink(link, extendedParameters: CrossDeviceParameterConfiguration(sessionId: true, subjectUserId: true))! + + // Resulting _sp param will have nothing for: + // - sessionId, as an event has not been tracked + // - subjectUserId, as it has not been set + matches( + for: "https://example.com?_sp=\(tracker.session!.userId!).\(epoch)...\(tracker.appId.toBase64())", + in: result.absoluteString + ) + } + + func testMissingSessionUserId() { + let tracker = getTrackerNoSessionUserId() + let link = URL(string: "https://example.com")! + + let result = tracker.decorateLink(link, extendedParameters: CrossDeviceParameterConfiguration(sessionId: true, subjectUserId: true)) + + XCTAssertNil(result) + } + + var (emitterConfig, networkConfig, trackerConfig) = ( + EmitterConfiguration().eventStore(MockEventStore()).bufferOption(.single), + NetworkConfiguration(networkConnection: MockNetworkConnection(requestOption: .post, statusCode: 200)), + TrackerConfiguration().installAutotracking(false).screenViewAutotracking(false).lifecycleAutotracking(false).sessionContext(true) + ) + + func getTracker() -> TrackerController { + let subjectConfig = SubjectConfiguration().userId("userId") + + let namespace = "testEmitter" + String(describing: Int.random(in: 0..<100)) + return Snowplow.createTracker(namespace: namespace, + network: networkConfig, + configurations: [trackerConfig, emitterConfig, subjectConfig]) + } + + private func getTrackerNoSubjectUserId() -> TrackerController { + let namespace = "testEmitter" + String(describing: Int.random(in: 0..<100)) + return Snowplow.createTracker(namespace: namespace, + network: networkConfig, + configurations: [trackerConfig, emitterConfig]) + } + + private func getTrackerNoSessionUserId() -> TrackerController { + trackerConfig.sessionContext = false + + let namespace = "testEmitter" + String(describing: Int.random(in: 0..<100)) + return Snowplow.createTracker(namespace: namespace, + network: networkConfig, + configurations: [trackerConfig, emitterConfig]) + } +}