diff --git a/Sources/Snowplow/Configurations/FocalMeterConfiguration.swift b/Sources/Snowplow/Configurations/FocalMeterConfiguration.swift new file mode 100644 index 000000000..62df08806 --- /dev/null +++ b/Sources/Snowplow/Configurations/FocalMeterConfiguration.swift @@ -0,0 +1,106 @@ +// +// FocalMeterConfiguration.swift +// Snowplow +// +// 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. +// +// License: Apache License Version 2.0 +// + +import Foundation + +/// This configuration tells the tracker to send requests with the user ID in session context entity +/// to a Kantar endpoint used with FocalMeter. +/// The request is made when the first event with a new user ID is tracked. +/// The requests are only made if session context is enabled (default). +@objc(SPFocalMeterConfiguration) +public class FocalMeterConfiguration: NSObject, PluginAfterTrackCallable, PluginIdentifiable, ConfigurationProtocol { + public private(set) var identifier = "KantarFocalMeter" + public private(set) var afterTrackConfiguration: PluginAfterTrackConfiguration? + + /// URL of the Kantar endpoint to send the requests to + public private(set) var kantarEndpoint: String + + /// Callback to process user ID before sending it in a request. This may be used to apply hashing to the value. + public private(set) var processUserId: ((String) -> String)? = nil + + private var lastUserId: String? + + /// Creates a configuration for the Kantar FocalMeter. + /// - Parameters: + /// - endpoint: URL of the Kantar endpoint to send the requests to + /// - processUserId: Callback to process user ID before sending it in a request. This may be used to apply hashing to the value. + @objc + public init(kantarEndpoint: String, processUserId: ((String) -> String)? = nil) { + self.kantarEndpoint = kantarEndpoint + super.init() + + self.afterTrackConfiguration = PluginAfterTrackConfiguration { event in + let session = event.entities.first { entity in + entity.schema == kSPSessionContextSchema + } + if let userId = session?.data[kSPSessionUserId] as? String { + if self.shouldUpdate(userId) { + if let processUserId = processUserId { + self.makeRequest(userId: processUserId(userId)) + } else { + self.makeRequest(userId: userId) + } + } + } + } + } + + private func shouldUpdate(_ newUserId: String) -> Bool { + objc_sync_enter(self) + defer { objc_sync_exit(self) } + + if lastUserId == nil || newUserId != lastUserId { + lastUserId = newUserId + return true + } + return false + } + + private func makeRequest(userId: String) { + var components = URLComponents(string: kantarEndpoint) + components?.queryItems = [ + URLQueryItem(name: "vendor", value: "snowplow"), + URLQueryItem(name: "cs_fpid", value: userId), + URLQueryItem(name: "c12", value: "not_set"), + ] + + guard let url = components?.url else { + logError(message: "Failed to build URL to request Kantar endpoint") + return + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + let task = URLSession.shared.dataTask(with: request) { data, response, error in + if let error = error { + logError(message: "Request to Kantar endpoint failed: \(error)") + } + else if let httpResponse = response as? HTTPURLResponse { + if httpResponse.statusCode >= 200 && httpResponse.statusCode < 300 { + logDebug(message: "Request to Kantar endpoint sent with user ID: \(userId)") + return + } else { + logError(message: "Request to Kantar endpoint was not successful") + } + } + } + task.resume() + } +} diff --git a/Tests/Configurations/TestFocalMeterConfiguration.swift b/Tests/Configurations/TestFocalMeterConfiguration.swift new file mode 100644 index 000000000..3016d0498 --- /dev/null +++ b/Tests/Configurations/TestFocalMeterConfiguration.swift @@ -0,0 +1,137 @@ +// +// TestFocalMeterConfiguration.swift +// Snowplow-iOSTests +// +// 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. +// +// Authors: Alex Benini, Matus Tomlein +// License: Apache License Version 2.0 +// + +import XCTest +import Mocker +@testable import SnowplowTracker + +class TestFocalMeterConfiguration: XCTestCase { + let endpoint = "https://fake-snowplow.io" + +#if !os(watchOS) && !os(macOS) // Mocker seems not to currently work on watchOS and macOS + + override class func setUp() { + Mocker.removeAll() + } + + override class func tearDown() { + Mocker.removeAll() + Snowplow.removeAllTrackers() + super.tearDown() + } + + func testMakesRequestToKantarEndpointWithUserId() { + let tracker = createTracker() + + let requestExpectation = expectation(description: "Request made") + mockRequest { query in + let userId = tracker.session!.userId! + XCTAssertTrue(query!.contains(userId)) + requestExpectation.fulfill() + } + + _ = tracker.track(Structured(category: "cat", action: "act")) + wait(for: [requestExpectation], timeout: 1) + } + + func testMakesRequestToKantarEndpointWithProcessedUserId() { + let configuration = FocalMeterConfiguration(kantarEndpoint: endpoint) { userId in + return "processed-" + userId + } + let tracker = createTracker(configuration) + + let requestExpectation = expectation(description: "Request made") + mockRequest { query in + let userId = tracker.session!.userId! + XCTAssertTrue(query!.contains("processed-" + userId)) + requestExpectation.fulfill() + } + + _ = tracker.track(Structured(category: "cat", action: "act")) + wait(for: [requestExpectation], timeout: 1) + } + + func testMakesRequestToKantarEndpointWhenUserIdChanges() { + // log queries of requests + var kantarRequestQueries: [String] = [] + let tracker = createTracker() + var requestExpectation: XCTestExpectation? = expectation(description: "Anonymous request made") + mockRequest { query in + kantarRequestQueries.append(query!) + requestExpectation?.fulfill() + } + + // enable user anonymisation, should trigger request with anonymous user id + tracker.userAnonymisation = true + _ = tracker.track(Structured(category: "cat", action: "act")) + wait(for: [requestExpectation!], timeout: 1) + XCTAssertEqual(1, kantarRequestQueries.count) + XCTAssertTrue(kantarRequestQueries.first!.contains("00000000-0000-0000-0000-000000000000")) + kantarRequestQueries.removeAll() + + // disable user anonymisation, should trigger new request + requestExpectation = expectation(description: "Second request made") + tracker.userAnonymisation = false + _ = tracker.track(ScreenView(name: "sv")) + wait(for: [requestExpectation!], timeout: 1) + XCTAssertEqual(1, kantarRequestQueries.count) + let userId = tracker.session!.userId! + XCTAssertTrue(kantarRequestQueries.first!.contains(userId)) + kantarRequestQueries.removeAll() + + // tracking another should not trigger a request as user ID did not change + requestExpectation = nil + _ = tracker.track(Structured(category: "cat", action: "act")) + let sleep = expectation(description: "Wait for events to be tracked") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { () -> Void in + sleep.fulfill() + } + wait(for: [sleep], timeout: 1) + XCTAssertEqual(0, kantarRequestQueries.count) + } + + private func mockRequest(callback: @escaping (String?) -> Void) { + var mock = Mock(url: URL(string: endpoint)!, ignoreQuery: true, dataType: .json, statusCode: 200, data: [ + .get: Data() + ]) + mock.onRequest = { (request, body) in + callback(request.url?.query) + } + mock.register() + } + + private func createTracker(_ focalMeterConfig: FocalMeterConfiguration? = nil) -> TrackerController { + let connection = MockNetworkConnection(requestOption: .post, statusCode: 200) + let networkConfig = NetworkConfiguration(networkConnection: connection) + let trackerConfig = TrackerConfiguration() + trackerConfig.installAutotracking = false + trackerConfig.diagnosticAutotracking = false + let namespace = "testFocalMeter" + String(describing: Int.random(in: 0..<100)) + return Snowplow.createTracker(namespace: namespace, + network: networkConfig, + configurations: [ + trackerConfig, + focalMeterConfig ?? FocalMeterConfiguration(kantarEndpoint: endpoint) + ])! + } + +#endif +}