From 99212277b2de261e28931041f47b73ebf63b0ddd Mon Sep 17 00:00:00 2001 From: Matus Tomlein Date: Wed, 5 Apr 2023 09:33:36 +0200 Subject: [PATCH 1/2] Add configuration to send requests with user ID to a Focal Meter endpoint (close #745) PR #754 --- .../FocalMeterConfiguration.swift | 106 ++++++++++++++ .../TestFocalMeterConfiguration.swift | 137 ++++++++++++++++++ 2 files changed, 243 insertions(+) create mode 100644 Sources/Snowplow/Configurations/FocalMeterConfiguration.swift create mode 100644 Tests/Configurations/TestFocalMeterConfiguration.swift 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 +} From a06c7c5ee2d996567d4435bfcf3989f4f79b5258 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matu=CC=81s=CC=8C=20Tomlein?= Date: Thu, 12 Oct 2023 14:56:43 +0200 Subject: [PATCH 2/2] Prepare for 5.6.0 release --- CHANGELOG | 4 ++++ Examples | 2 +- SnowplowTracker.podspec | 2 +- Sources/Core/TrackerConstants.swift | 2 +- VERSION | 2 +- 5 files changed, 8 insertions(+), 4 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 8981befa3..a7808edb5 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,7 @@ +Version 5.6.0 (2023-10-12) +-------------------------- +Add configuration to send requests with user ID to a Focal Meter endpoint (#745) + Version 5.5.0 (2023-10-02) -------------------------- Add option to disable retrying any failed requests (#826) diff --git a/Examples b/Examples index 0b6dba961..3d383f24e 160000 --- a/Examples +++ b/Examples @@ -1 +1 @@ -Subproject commit 0b6dba961a97758e66d064b7f0201140e96865d0 +Subproject commit 3d383f24e908727f709ae45cf408db0929c3c156 diff --git a/SnowplowTracker.podspec b/SnowplowTracker.podspec index e332c59c9..1d6f8435e 100644 --- a/SnowplowTracker.podspec +++ b/SnowplowTracker.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = "SnowplowTracker" - s.version = "5.5.0" + s.version = "5.6.0" s.summary = "Snowplow event tracker for iOS, macOS, tvOS, watchOS for apps and games." s.description = <<-DESC Snowplow is a mobile and event analytics platform with a difference: rather than tell our users how they should analyze their data, we deliver their event-level data in their own data warehouse, on their own Amazon Redshift or Postgres database, so they can analyze it any way they choose. Snowplow mobile is used by data-savvy games companies and app developers to better understand their users and how they engage with their games and applications. Snowplow is open source using the business-friendly Apache License, Version 2.0 and scales horizontally to many billions of events. diff --git a/Sources/Core/TrackerConstants.swift b/Sources/Core/TrackerConstants.swift index 9d2133622..253749b51 100644 --- a/Sources/Core/TrackerConstants.swift +++ b/Sources/Core/TrackerConstants.swift @@ -14,7 +14,7 @@ import Foundation // --- Version -let kSPRawVersion = "5.5.0" +let kSPRawVersion = "5.6.0" #if os(iOS) let kSPVersion = "ios-\(kSPRawVersion)" #elseif os(tvOS) diff --git a/VERSION b/VERSION index d50359de1..1bc788d3b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.5.0 +5.6.0