Skip to content

Commit

Permalink
Add API to decorate link with user/session info
Browse files Browse the repository at this point in the history
  • Loading branch information
greg-el committed Sep 21, 2023
1 parent 1f76199 commit b77a05c
Show file tree
Hide file tree
Showing 6 changed files with 321 additions and 1 deletion.
92 changes: 91 additions & 1 deletion Sources/Core/Tracker/Tracker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,24 @@

import Foundation

extension String {
func toBase64() -> String {
var encoded = Data(self.utf8).base64EncodedString()
// 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
}
}

func uncaughtExceptionHandler(_ exception: NSException) {
let symbols = exception.callStackSymbols
let stacktrace = "Stacktrace:\n\(symbols)"
Expand Down Expand Up @@ -43,7 +61,6 @@ class Tracker: NSObject {

private var builderFinished = false


/// The object used for sessionization, i.e. it characterizes user activity.
private(set) var session: Session?
/// Previous screen view state.
Expand Down Expand Up @@ -385,6 +402,79 @@ class Tracker: NSObject {
emitter.resumeTimer()
session?.startChecker()
}

enum PropertyError: Error {
case notSet(_ message: String)
}

/// - Returns: The associated value of the extended parameter if enabled and set in the tracker, an empty string if the parameter is not enabled, or nil if parameter is enabled without an avilable value
func tryExtendedParameter(_ extendedParameterSet: Bool, _ value: String?) -> String? {
switch (extendedParameterSet, value) {
case (true, .some(let val)):
return val
case (true, .none):
return nil
case (false, _):
return ""
}
}

@objc func decorateLink(_ url: URL, extendedParameters: CrossDeviceParameterConfiguration? = nil) -> URL? {
var userId: String
switch self.session {
case .none:
logError(message: "\(url) could not be decorated as session.userId is nil")
return nil
case .some(let s):
userId = s.userId
}

let extendedParameters = extendedParameters ?? CrossDeviceParameterConfiguration()

guard let sessionId = tryExtendedParameter(extendedParameters.sessionId, self.session?.state?.sessionId) else {
logError(message: "sessionId not set")
return nil
}

guard let sourceId = tryExtendedParameter(extendedParameters.sourceId, self.appId) else {
logError(message: "appId not set")
return nil
}

guard let sourcePlatform = tryExtendedParameter(extendedParameters.sourcePlatform, devicePlatformToString(self.devicePlatform)) else {
logError(message: "platform not set")
return nil
}

guard let subjectUserId = tryExtendedParameter(extendedParameters.subjectUserId, self.subject?.userId) else {
logError(message: "subjectUserId not set")
return nil
}

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)
if var existingSp = components?.queryItems?.first(where: { $0.name == "_sp" }) {
existingSp.value = spParameters
} else {
var queryItems = components?.queryItems ?? []
queryItems.append(URLQueryItem(name: "_sp", value: spParameters))
components?.queryItems = queryItems
}

return components?.url
}


// MARK: - Notifications management

Expand Down
8 changes: 8 additions & 0 deletions Sources/Core/Tracker/TrackerControllerImpl.swift
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,14 @@ class TrackerControllerImpl: Controller, TrackerController {
func track(_ event: Event) -> UUID? {
return tracker.track(event)
}

func decorateLink(_ url: URL) -> URL? {
return tracker.decorateLink(url)
}

func decorateLink(_ url: URL, extendedParameters: CrossDeviceParameterConfiguration) -> URL? {
return tracker.decorateLink(url, extendedParameters: extendedParameters)
}

// MARK: - Properties' setters and getters

Expand Down
54 changes: 54 additions & 0 deletions Sources/Snowplow/Controllers/TrackerController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -75,4 +75,58 @@ 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
/// - null 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`
///
/// Required (auto-filled by this method):
/// - `domainUserId`: Value of ``SessionController.userId``
/// - `timestamp`: ms precision epoch timestamp
///
/// Optional:
/// - `sessionId`: Value of ``SessionController.sessionId``
/// - `subjectUserId`: Value of ``Subject.userId``
/// - `sourceId`: Value of ``Tracker.appId``
/// - `platform`: Value of ``Tracker.platform``
/// - `reason`: Value of ``CrossDeviceParameterConfiguration/reason``
///
///
/// - Parameter url The URL to add the query string to
/// - Parameter extendedParameters Any optional parameters to include in the query string.
///
/// Optional parameters are:
/// - sessionId (``CrossDeviceParameterConfiguration.sessionId``)
/// - subjectUserId (``CrossDeviceParameterConfiguration.subjectUserId``)
/// - appId (``CrossDeviceParameterConfiguration.sourceId``)
/// - platform (``CrossDeviceParameterConfiguration.sourcePlatform``)
/// - reason (``CrossDeviceParameterConfiguration.reason``)
///
/// Fields enabled by default are sourceId and sessionId
///
/// - Returns Optional URL
/// - null if ``SnowplowTracker/SessionController/userId`` is null from `sessionContext(false)` being passed in ``TrackerConfiguration``
/// - otherwise, decorated URL
@objc
func decorateLink(_ url: URL, extendedParameters: CrossDeviceParameterConfiguration) -> URL?
}
36 changes: 36 additions & 0 deletions Sources/Snowplow/Tracker/CrossDeviceParameterConfiguration.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// 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

public class CrossDeviceParameterConfiguration : NSObject {
var sessionId: Bool
var subjectUserId: Bool
var sourceId: Bool
var sourcePlatform: Bool
var reason: String?

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
}
}
113 changes: 113 additions & 0 deletions Tests/TestLinkDecorator.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// 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 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 testArgs() {
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 epoch = "\\d{13}"
let appId = tracker.appId.toBase64()
let subjectUserId = tracker.subject!.userId!.toBase64()
let platform = devicePlatformToString(tracker.devicePlatform)!
let reason = "reason"
let reasonb64 = reason.toBase64()

let testCases = [
// All false
(
config: CrossDeviceParameterConfiguration(sessionId: false, sourceId: false),
expected: "https://example.com?_sp=\(userId).\(epoch)"
)
,
// Default
(
config: CrossDeviceParameterConfiguration(),
expected: "https://example.com?_sp=\(userId).\(epoch).\(sessionId)..\(appId)"
),
(
config: CrossDeviceParameterConfiguration(subjectUserId: true),
expected: "https://example.com?_sp=\(userId).\(epoch).\(sessionId).\(subjectUserId).\(appId)"
),
(
config: CrossDeviceParameterConfiguration(subjectUserId: true, sourcePlatform: true),
expected: "https://example.com?_sp=\(userId).\(epoch).\(sessionId).\(subjectUserId).\(appId).\(platform)"
),
(
config: CrossDeviceParameterConfiguration(subjectUserId: true, sourcePlatform: true, reason: reason),
expected: "https://example.com?_sp=\(userId).\(epoch).\(sessionId).\(subjectUserId).\(appId).\(platform).\(reasonb64)"
),
(
config: CrossDeviceParameterConfiguration(sessionId: false, sourceId: false, reason: reason),
expected: "https://example.com?_sp=\(userId).\(epoch).....\(reasonb64)"
)
]

for (config, expected) in testCases {
matches(
for: expected,
in: tracker.decorateLink(link, extendedParameters: config)!.absoluteString
)
}
}

func getTracker() -> TrackerController {
let networkConnection = MockNetworkConnection(requestOption: .post, statusCode: 200)
let emitterConfig = EmitterConfiguration()
emitterConfig.eventStore = MockEventStore()
emitterConfig.bufferOption = .single
let networkConfig = NetworkConfiguration(networkConnection: networkConnection)

return createTracker(networkConfig: networkConfig, emitterConfig: emitterConfig)
}

private func createTracker(networkConfig: NetworkConfiguration, emitterConfig: EmitterConfiguration) -> TrackerController {
let trackerConfig = TrackerConfiguration().sessionContext(true)
trackerConfig.installAutotracking = false
trackerConfig.screenViewAutotracking = false
trackerConfig.lifecycleAutotracking = false
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])!
}
}
19 changes: 19 additions & 0 deletions Tests/Utils/String.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import Foundation

extension String {
func toBase64() -> String {
var encoded = Data(self.utf8).base64EncodedString()
// 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
}
}

0 comments on commit b77a05c

Please sign in to comment.