Skip to content

Commit

Permalink
Add configuration to send requests with user ID to a Focal Meter endp…
Browse files Browse the repository at this point in the history
…oint (close #745)

PR #754
  • Loading branch information
matus-tomlein authored and greg-el committed Oct 9, 2023
1 parent 48a0d06 commit 9921227
Show file tree
Hide file tree
Showing 2 changed files with 243 additions and 0 deletions.
106 changes: 106 additions & 0 deletions Sources/Snowplow/Configurations/FocalMeterConfiguration.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
137 changes: 137 additions & 0 deletions Tests/Configurations/TestFocalMeterConfiguration.swift
Original file line number Diff line number Diff line change
@@ -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
}

0 comments on commit 9921227

Please sign in to comment.