Skip to content

Commit

Permalink
Add API to decorate link with user/session info (close #819)
Browse files Browse the repository at this point in the history
  • Loading branch information
greg-el authored and matus-tomlein committed Jan 30, 2024
1 parent 20231c6 commit 49c5ee0
Show file tree
Hide file tree
Showing 7 changed files with 343 additions and 1 deletion.
8 changes: 8 additions & 0 deletions Sources/Core/InternalQueue/TrackerControllerIQWrapper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
54 changes: 54 additions & 0 deletions Sources/Core/Tracker/TrackerControllerImpl.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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."
}
}
33 changes: 33 additions & 0 deletions Sources/Core/Utils/Stringb64.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
36 changes: 36 additions & 0 deletions Sources/Snowplow/Controllers/TrackerController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
}
42 changes: 42 additions & 0 deletions Sources/Snowplow/Tracker/CrossDeviceParameterConfiguration.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
2 changes: 1 addition & 1 deletion Sources/Snowplow/Tracker/DevicePlatform.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
169 changes: 169 additions & 0 deletions Tests/TestLinkDecorator.swift
Original file line number Diff line number Diff line change
@@ -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])
}
}

0 comments on commit 49c5ee0

Please sign in to comment.