From d1ccfa2d978c9b71d915bee3ee8ebe4701140b5f Mon Sep 17 00:00:00 2001 From: Cody Garvin Date: Thu, 23 Sep 2021 21:22:36 -0700 Subject: [PATCH 1/3] Added Comscore destination --- .../project.pbxproj | 21 + .../DestinationsExample/AppDelegate.swift | 5 +- .../ComscoreDestination.swift | 649 ++++++++++++++++++ 3 files changed, 674 insertions(+), 1 deletion(-) create mode 100644 Examples/destination_plugins/ComscoreDestination.swift diff --git a/Examples/apps/DestinationsExample/DestinationsExample.xcodeproj/project.pbxproj b/Examples/apps/DestinationsExample/DestinationsExample.xcodeproj/project.pbxproj index f03b7a12..49a9a529 100644 --- a/Examples/apps/DestinationsExample/DestinationsExample.xcodeproj/project.pbxproj +++ b/Examples/apps/DestinationsExample/DestinationsExample.xcodeproj/project.pbxproj @@ -27,6 +27,8 @@ 965DC1262671656C00DDF9C7 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 965DC1252671656C00DDF9C7 /* GoogleService-Info.plist */; }; 9697C1F52679156C00B87EC1 /* Segment_Logo_Avatar_Grey-1024.png in Resources */ = {isa = PBXBuildFile; fileRef = 9697C1F42679156C00B87EC1 /* Segment_Logo_Avatar_Grey-1024.png */; }; 96D8F16F26EFFA09007F8B28 /* ExampleDestination.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96D8F16E26EFFA09007F8B28 /* ExampleDestination.swift */; }; + 96DBF37D26FA943300724B0B /* ComscoreDestination.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96DBF37C26FA943300724B0B /* ComscoreDestination.swift */; }; + 96DBF38026FA984A00724B0B /* ComScore in Frameworks */ = {isa = PBXBuildFile; productRef = 96DBF37F26FA984A00724B0B /* ComScore */; }; BA384C9826824F3700AFEA1B /* AppsFlyerLib in Frameworks */ = {isa = PBXBuildFile; productRef = BA384C9726824F3700AFEA1B /* AppsFlyerLib */; }; BA384C9A2682973300AFEA1B /* AppsFlyerDestination.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA384C992682973300AFEA1B /* AppsFlyerDestination.swift */; }; /* End PBXBuildFile section */ @@ -50,6 +52,7 @@ 965DC1252671656C00DDF9C7 /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; 9697C1F42679156C00B87EC1 /* Segment_Logo_Avatar_Grey-1024.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "Segment_Logo_Avatar_Grey-1024.png"; sourceTree = ""; }; 96D8F16E26EFFA09007F8B28 /* ExampleDestination.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ExampleDestination.swift; sourceTree = ""; }; + 96DBF37C26FA943300724B0B /* ComscoreDestination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComscoreDestination.swift; sourceTree = ""; }; BA384C992682973300AFEA1B /* AppsFlyerDestination.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppsFlyerDestination.swift; sourceTree = ""; }; BA384C9D2686609000AFEA1B /* DestinationsExample.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DestinationsExample.entitlements; sourceTree = ""; }; /* End PBXFileReference section */ @@ -62,6 +65,7 @@ BA384C9826824F3700AFEA1B /* AppsFlyerLib in Frameworks */, 469EC8E0266828860068F9E3 /* FlurryAnalyticsSPM in Frameworks */, 469EC8D0266066130068F9E3 /* SystemConfiguration.framework in Frameworks */, + 96DBF38026FA984A00724B0B /* ComScore in Frameworks */, 965DC1212669942800DDF9C7 /* FirebaseAnalytics in Frameworks */, 469F7B1D266011D70038E773 /* Segment in Frameworks */, 965DC0FE2668079400DDF9C7 /* Mixpanel in Frameworks */, @@ -121,6 +125,7 @@ BA384C992682973300AFEA1B /* AppsFlyerDestination.swift */, 469F7B24266013320038E773 /* AdjustDestination.swift */, 965DC0F92668077400DDF9C7 /* AmplitudeSession.swift */, + 96DBF37C26FA943300724B0B /* ComscoreDestination.swift */, 96D8F16E26EFFA09007F8B28 /* ExampleDestination.swift */, 965DC1222669947F00DDF9C7 /* FirebaseDestination.swift */, 469F7B1F266012CB0038E773 /* FlurryDestination.swift */, @@ -162,6 +167,7 @@ 469EC8DF266828860068F9E3 /* FlurryAnalyticsSPM */, 965DC1202669942800DDF9C7 /* FirebaseAnalytics */, BA384C9726824F3700AFEA1B /* AppsFlyerLib */, + 96DBF37F26FA984A00724B0B /* ComScore */, ); productName = DestinationsExample; productReference = 469F7B04266011690038E773 /* DestinationsExample.app */; @@ -196,6 +202,7 @@ 469EC8DE266828860068F9E3 /* XCRemoteSwiftPackageReference "FlurrySwiftPackage" */, 965DC11F2669942800DDF9C7 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */, BA384C9626824F3700AFEA1B /* XCRemoteSwiftPackageReference "AppsFlyerFramework" */, + 96DBF37E26FA984900724B0B /* XCRemoteSwiftPackageReference "Comscore-Swift-Package-Manager" */, ); productRefGroup = 469F7B05266011690038E773 /* Products */; projectDirPath = ""; @@ -231,6 +238,7 @@ 469F7B0C266011690038E773 /* ViewController.swift in Sources */, 96D8F16F26EFFA09007F8B28 /* ExampleDestination.swift in Sources */, 965DC0FA2668077400DDF9C7 /* MixpanelDestination.swift in Sources */, + 96DBF37D26FA943300724B0B /* ComscoreDestination.swift in Sources */, 965DC0FB2668077400DDF9C7 /* AmplitudeSession.swift in Sources */, 469F7B08266011690038E773 /* AppDelegate.swift in Sources */, 469F7B25266013320038E773 /* AdjustDestination.swift in Sources */, @@ -479,6 +487,14 @@ version = 8.1.0; }; }; + 96DBF37E26FA984900724B0B /* XCRemoteSwiftPackageReference "Comscore-Swift-Package-Manager" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "git@github.com:comScore/Comscore-Swift-Package-Manager.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 6.0.0; + }; + }; BA384C9626824F3700AFEA1B /* XCRemoteSwiftPackageReference "AppsFlyerFramework" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/AppsFlyerSDK/AppsFlyerFramework"; @@ -514,6 +530,11 @@ package = 965DC11F2669942800DDF9C7 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; productName = FirebaseAnalytics; }; + 96DBF37F26FA984A00724B0B /* ComScore */ = { + isa = XCSwiftPackageProductDependency; + package = 96DBF37E26FA984900724B0B /* XCRemoteSwiftPackageReference "Comscore-Swift-Package-Manager" */; + productName = ComScore; + }; BA384C9726824F3700AFEA1B /* AppsFlyerLib */ = { isa = XCSwiftPackageProductDependency; package = BA384C9626824F3700AFEA1B /* XCRemoteSwiftPackageReference "AppsFlyerFramework" */; diff --git a/Examples/apps/DestinationsExample/DestinationsExample/AppDelegate.swift b/Examples/apps/DestinationsExample/DestinationsExample/AppDelegate.swift index b03e79e1..e343fbcf 100644 --- a/Examples/apps/DestinationsExample/DestinationsExample/AppDelegate.swift +++ b/Examples/apps/DestinationsExample/DestinationsExample/AppDelegate.swift @@ -38,9 +38,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // Add the Firebase destination plugin analytics?.add(plugin: FirebaseDestination()) - //Add the AppsFlyer destination plugin + // Add the AppsFlyer destination plugin analytics?.add(plugin: AppsFlyerDestination()) + // Add the Comscore destination plugin + analytics?.add(plugin: ComscoreDestination()) + return true } diff --git a/Examples/destination_plugins/ComscoreDestination.swift b/Examples/destination_plugins/ComscoreDestination.swift new file mode 100644 index 00000000..938db0f4 --- /dev/null +++ b/Examples/destination_plugins/ComscoreDestination.swift @@ -0,0 +1,649 @@ +// +// ComscoreDestination.swift +// ComscoreDestination +// +// Created by Cody Garvin on 9/21/21. +// + +import Segment +import ComScore +import CoreMedia + +/** + An implementation of the Comscore Analytics device mode destination as a plugin. + */ + +class ComscoreDestination: DestinationPlugin { + let timeline = Timeline() + let type = PluginType.destination + let key = "comScore" + var analytics: Analytics? = nil + + private var comscoreSettings: ComscoreSettings? + private var comscoreEnrichment: ComscoreEnrichment? + private var streamAnalytics: SCORStreamingAnalytics? + private var configurationLabels = [String: Any]() + + func update(settings: Settings, type: UpdateType) { + // Skip if you have a singleton and don't want to keep updating via settings. + guard type == .initial else { return } + + // Set up the enrichment plugin + comscoreEnrichment = ComscoreEnrichment() + analytics?.add(plugin: comscoreEnrichment!) + + // Grab the settings and assign them for potential later usage. + // Note: Since integrationSettings is generic, strongly type the variable. + guard let tempSettings: ComscoreSettings = settings.integrationSettings(forPlugin: self) else { return } + comscoreSettings = tempSettings + + // Set the update mode + if tempSettings.foregroundOnly && tempSettings.autoUpdate { + SCORAnalytics.configuration().usagePropertiesAutoUpdateMode = .foregroundOnly + } else if tempSettings.autoUpdate { + SCORAnalytics.configuration().usagePropertiesAutoUpdateMode = .foregroundAndBackground + } else { + SCORAnalytics.configuration().usagePropertiesAutoUpdateMode = .disabled + } + + SCORAnalytics.configuration().usagePropertiesAutoUpdateInterval = Int32(tempSettings.autoUpdateInterval) + + SCORAnalytics.configuration().liveTransmissionMode = SCORLiveTransmissionModeLan // No bridge to NS_ENUM, must use full name + + SCORAnalytics.configuration().applicationName = tempSettings.appName + + // Start off with the partner configuration + let publisherConfiguration = SCORPublisherConfiguration { builder in + builder?.publisherId = tempSettings.c2 + builder?.secureTransmissionEnabled = tempSettings.useHTTPS + } + + let partnerConfiguration = SCORPartnerConfiguration { builder in + builder?.partnerId = Self.partnerId + } + + SCORAnalytics.configuration().addClient(with: partnerConfiguration) + SCORAnalytics.configuration().addClient(with: publisherConfiguration) + SCORAnalytics.start() + } + + func identify(event: IdentifyEvent) -> IdentifyEvent? { + + if let traits = event.traits?.dictionaryValue { + let mappedTraits = convertToStringFormatFrom(data: traits) + for (key, value) in mappedTraits { + SCORAnalytics.configuration().setPersistentLabelWithName(key, value: value) + + analytics?.log(message: "SCORAnalytics.configuration.setPersistentLabelWithName(\(key), value: \(value))", + kind: .debug) + } + } + + return event + } + + func track(event: TrackEvent) -> TrackEvent? { + + // Determine if there are any video streaming calls, one is executed + // return so the hiddenEvent can not be fired + if let properties = event.properties { + if parsedVideoInstruction(event: event, properties: properties) { + return event + } + } + + var hiddenLabels = ["name": event.event] + if let properties = event.properties?.dictionaryValue { + hiddenLabels.merge(convertToStringFormatFrom(data: properties)) { _, new in + new + } + } + SCORAnalytics.notifyHiddenEvent(withLabels: hiddenLabels) + analytics?.log(message: "SCORAnalytics.notifyHiddenEvent(withLabels: \(hiddenLabels))", + kind: .debug) + + return event + } + + func screen(event: ScreenEvent) -> ScreenEvent? { + + if let _ = event.properties?.dictionaryValue { + // TODO: Do something with properties if they exist + } + + // TODO: Do something with name, category & properties in partner SDK + + return event + } + + func group(event: GroupEvent) -> GroupEvent? { + + if let _ = event.traits?.dictionaryValue { + // TODO: Do something with traits if they exist + } + + // TODO: Do something with groupId & traits in partner SDK + + return event + } + + func alias(event: AliasEvent) -> AliasEvent? { + + // TODO: Do something with previousId & userId in partner SDK + + return event + } + + func reset() { + // TODO: Do something with resetting partner SDK + } +} + +// Example of what settings may look like. +private struct ComscoreSettings: Codable { + let c2: String + let autoUpdate: Bool + let autoUpdateInterval: Int + let useHTTPS: Bool + let appName: String + let publisherSecret: String? + let foregroundOnly: Bool +} + +// Rules for converting keys and values to the proper formats that bridge +// from Segment to the Partner SDK. These are only examples. +private extension ComscoreDestination { + + static let partnerId = "23243060" + + static let adPropertiesMap = [ + "assetId": "ns_st_ami", + "asset_id": "ns_st_ami", + "title": "ns_st_amt", + "publisher": "ns_st_pu" + ] + static let contentPropertiesMap = [ + "title": "ns_st_ep", + "season": "ns_st_sn", + "episode": "ns_st_en", + "genre": "ns_st_ge", + "program": "ns_st_pr", + "channel": "ns_st_st", + "publisher": "ns_st_pu", + "fullEpisode": "ns_st_ce", + "full_episode": "ns_st_ce", + "podId": "ns_st_pn", + "pod_id": "ns_st_pn" + ] + static let contentIdMap = [ + "assetId": "ns_st_ci", + "asset_id": "ns_st_ci" + ] + static let playbackPropertiesMap = [ + "videoPlayer": "ns_st_mp", + "video_player": "ns_st_mp", + "sound": "ns_st_vo" + ] + + static var eventNameMap = ["ADD_TO_CART": "Product Added", + "PRODUCT_TAPPED": "Product Tapped"] + + func convertToStringFormatFrom(data: Dictionary) -> Dictionary { + var returnDictionary = [String: String]() + for (key, value) in data { + var updatedValue = value + if isValid(data: updatedValue) { + if let arrayData = updatedValue as? Array { + updatedValue = arrayData.joined(separator: ",") + } + returnDictionary[key] = "\(updatedValue)" + } + } + + return returnDictionary + } + + func isValid(data: Any) -> Bool { + var result = data is String || (data is Array && (data as! Array).count > 0) || data is Int || data is Double + if result, let tempData = data as? String { + result = !tempData.isEmpty + } + + return result + } + + func parsedVideoInstruction(event: TrackEvent, properties: JSON) -> Bool { + switch event.event { + case "Video Playback Started": + videoPlaybackStarted(event: event, properties: properties) + return true + case "Video Playback Paused", + "Video Playback Interrupted": + videoPlaybackPaused(event: event, properties: properties) + return true + case "Video Playback Buffer Started": + videoPlaybackBufferStarted(event: event, properties: properties) + return true + case "Video Playback Buffer Completed": + videoPlaybackBufferCompleted(event: event, properties: properties) + return true + case "Video Playback Seek Started": + videoPlaybackSeekStarted(event: event, properties: properties) + return true + case "Video Playback Seek Completed", + "Video Playback Resumed": + videoPlaybackSeekCompleted(event: event, properties: properties) + return true + case "Video Content Started": + videoContentStarted(event: event, properties: properties) + return true + case "Video Content Playing": + videoContentPlaying(event: event, properties: properties) + return true + case "Video Content Completed": + videoContentCompleted(event: event, properties: properties) + return true + case "Video Ad Started": + videoAdStarted(event: event, properties: properties) + return true + case "Video Ad Playing": + videoAdPlaying(event: event, properties: properties) + return true + case "Video Ad Completed": + videoAdCompleted(event: event, properties: properties) + return true + default: + analytics?.log(message: "No video track calls", kind: .debug) + } + + return false + } + + + // MARK: - Playback methods + + func videoPlaybackStarted(event: TrackEvent, properties: JSON) { + streamAnalytics = SCORStreamingAnalytics() + + let convertedProperties = properties.dictionaryValue + let map = ["ns_st_mp" : "\(convertedProperties?["video_player"] ?? "*null")", + "ns_st_ci" : "\(convertedProperties?["content_asset_id"] ?? "0")"] + + streamAnalytics?.createPlaybackSession() + + streamAnalytics?.configuration().addLabels(map) + streamAnalytics?.setMetadata(instantiateContentMetaData(properties: map)) + configurationLabels = configurationLabels.merging(map) { $1 } + analytics?.log(message: "streamAnalytics.createPlaybackSessionWithLabels: \(map)", kind: .debug) + } + + func videoPlaybackPaused(event: TrackEvent, properties: JSON) { + + if let map = mappedPlaybackProperties(event: event, properties: properties) { + streamAnalytics?.configuration().addLabels(map) + configurationLabels = configurationLabels.merging(map) { $1 } + + streamAnalytics?.setMetadata(instantiateContentMetaData(properties: map)) + } + analytics?.log(message: "streamAnalytics.notifyPause", kind: .debug) + } + + func videoPlaybackBufferStarted(event: TrackEvent, properties: JSON) { + + if let map = mappedPlaybackProperties(event: event, properties: properties) { + streamAnalytics?.configuration().addLabels(map) + configurationLabels = configurationLabels.merging(map) { $1 } + + streamAnalytics?.setMetadata(instantiateContentMetaData(properties: map)) + } + + movePosition(properties) + streamAnalytics?.notifyBufferStart() + + analytics?.log(message: "streamAnalytics.notifyBufferStart", kind: .debug) + } + + func videoPlaybackBufferCompleted(event: TrackEvent, properties: JSON) { + + if let map = mappedPlaybackProperties(event: event, properties: properties) { + streamAnalytics?.configuration().addLabels(map) + configurationLabels = configurationLabels.merging(map) { $1 } + + streamAnalytics?.setMetadata(instantiateContentMetaData(properties: map)) + } + + movePosition(properties) + streamAnalytics?.notifyBufferStop() + + analytics?.log(message: "streamAnalytics.notifyBufferStop", kind: .debug) + } + + func videoPlaybackSeekStarted(event: TrackEvent, properties: JSON) { + + if let map = mappedPlaybackProperties(event: event, properties: properties) { + streamAnalytics?.configuration().addLabels(map) + configurationLabels = configurationLabels.merging(map) { $1 } + + streamAnalytics?.setMetadata(instantiateContentMetaData(properties: map)) + } + + seekPosition(properties) + streamAnalytics?.notifySeekStart() + + analytics?.log(message: "streamAnalytics.notifySeekStart", kind: .debug) + } + + func videoPlaybackSeekCompleted(event: TrackEvent, properties: JSON) { + + if let map = mappedPlaybackProperties(event: event, properties: properties) { + streamAnalytics?.configuration().addLabels(map) + configurationLabels = configurationLabels.merging(map) { $1 } + + streamAnalytics?.setMetadata(instantiateContentMetaData(properties: map)) + } + + seekPosition(properties) + streamAnalytics?.notifyPlay() + + analytics?.log(message: "streamAnalytics.notifyPlay", kind: .debug) + } + + // MARK: - Content Methods + func videoContentStarted(event: TrackEvent, properties: JSON) { + + + if let map = mappedContentProperties(event: event, properties: properties) { + streamAnalytics?.configuration().addLabels(map) + configurationLabels = configurationLabels.merging(map) { $1 } + + streamAnalytics?.setMetadata(instantiateContentMetaData(properties: map)) + } + + movePosition(properties) + streamAnalytics?.notifyPlay() + + analytics?.log(message: "streamAnalytics.notifyPlay", kind: .debug) + } + + func videoContentPlaying(event: TrackEvent, properties: JSON) { + + if let map = mappedContentProperties(event: event, properties: properties) { + streamAnalytics?.configuration().addLabels(map) + configurationLabels = configurationLabels.merging(map) { $1 } + + // The presence of ns_st_ad on the StreamingAnalytics's asset means that we just exited an ad break, so + // we need to call setAsset with the content metadata. If ns_st_ad is not present, that means the last + // observed event was related to content, in which case a setAsset call should not be made (because asset + // did not change). + if let _ = configurationLabels["ns_st_ad"] { + streamAnalytics?.setMetadata(instantiateContentMetaData(properties: map)) + } + } + + movePosition(properties) + streamAnalytics?.notifyPlay() + + analytics?.log(message: "streamAnalytics.notifyPlay", kind: .debug) + } + + func videoContentCompleted(event: TrackEvent, properties: JSON) { + streamAnalytics?.notifyEnd() + configurationLabels = [String: Any]() + analytics?.log(message: "streamAnalytics.notifyPlay", kind: .debug) + } + + // MARK: - Ad Methods + + func videoAdStarted(event: TrackEvent, properties: JSON) { + + // The ID for content is not available on Ad Start events, however it will be available on the current + // StreamingAnalytics's asset. This is because ns_st_ci will have already been set via asset_id in a + // Content Started calls (if this is a mid or post-roll), or via content_asset_id on Video Playback + // Started (if this is a pre-roll). + let contentId = configurationLabels["ns_st_ci"] as? String ?? "0" + + if var map = mappedAdProperties(event: event, properties: properties) { + map["ns_st_ci"] = contentId + let contentMetadata = instantiateContentMetaData(properties: map) + + if let tempProperties = properties.dictionaryValue { + var mediaType = SCORStreamingAdvertisementType.other + if let adType = tempProperties["type"] as? String { + if adType == "pre-roll" { + mediaType = SCORStreamingAdvertisementType.onDemandPreRoll + } else if adType == "mid-roll" { + mediaType = SCORStreamingAdvertisementType.onDemandMidRoll + } else if adType == "post-roll" { + mediaType = SCORStreamingAdvertisementType.onDemandPostRoll + } + + } + + let advertisingMetadata = SCORStreamingAdvertisementMetadata { builder in + builder?.setMediaType(mediaType) + builder?.setCustomLabels(map) + builder?.setRelatedContentMetadata(contentMetadata) + } + + streamAnalytics?.setMetadata(advertisingMetadata) + } + + } + + movePosition(properties) + streamAnalytics?.notifyPlay() + + analytics?.log(message: "streamAnalytics.notifyPlay", kind: .debug) + } + + func videoAdPlaying(event: TrackEvent, properties: JSON) { + movePosition(properties) + streamAnalytics?.notifyPlay() + analytics?.log(message: "streamAnalytics.notifyPlay", kind: .debug) + } + + func videoAdCompleted(event: TrackEvent, properties: JSON) { + movePosition(properties) + streamAnalytics?.notifyEnd() + analytics?.log(message: "streamAnalytics.notifyEnd", kind: .debug) + } +} + +// MARK: - Helper methods +private extension ComscoreDestination { + + func mappedPlaybackProperties(event: TrackEvent, properties: JSON) -> Dictionary? { + // Pull out the values for the event out of the enrichment plugin + guard let options = comscoreEnrichment?.fetchAndRemoveMetricsFor(key: event.messageId ?? "0") else { + return nil + } + + var bps = "*null" + var fullscreen = "norm" + let convertedProperties = properties.dictionaryValue + if let tempProperties = properties.dictionaryValue { + bps = convertFromKBPSToBPS(source: tempProperties, key: "bitrate") + fullscreen = returnFullScreenStatus(source: tempProperties, key: "full_screen") + } + let returnMap = ["ns_st_mp" : "\(convertedProperties?["video_player"] ?? "*null")", + "ns_st_vo" : "\(convertedProperties?["sound"] ?? "*null")", + "ns_st_br" : bps, + "ns_st_ws" : fullscreen, + "c3" : "\(options["c3"] ?? "*null")", + "c4" : "\(options["c4"] ?? "*null")", + "c6" : "\(options["c6"] ?? "*null")"] + + return returnMap + } + + func mappedContentProperties(event: TrackEvent, properties: JSON) -> Dictionary? { + // Pull out the values for the event out of the enrichment plugin + guard let options = comscoreEnrichment?.fetchAndRemoveMetricsFor(key: event.messageId ?? "0") else { + return nil + } + + var totalLength = "0" + let convertedProperties = properties.dictionaryValue + if let tempProperties = properties.dictionaryValue { + totalLength = convertFromSecondsToMilliseconds(source: tempProperties, key: "total_length") + } + let returnMap = ["ns_st_ci" : "\(convertedProperties?["asset_id"] ?? "0")", + "ns_st_ep" : "\(convertedProperties?["title"] ?? "*null")", + "ns_st_sn" : "\(convertedProperties?["season"] ?? "*null")", + "ns_st_en" : "\(convertedProperties?["episode"] ?? "*null")", + "ns_st_ge" : "\(convertedProperties?["genre"] ?? "*null")", + "ns_st_pr" : "\(convertedProperties?["program"] ?? "*null")", + "ns_st_pn" : "\(convertedProperties?["pod_id"] ?? "*null")", + "ns_st_ce" : "\(convertedProperties?["full_episode"] ?? "*null")", + "ns_st_cl" : totalLength, + "ns_st_pu" : "\(convertedProperties?["publisher"] ?? "*null")", + "ns_st_st" : "\(convertedProperties?["channel"] ?? "*null")", + "ns_st_ddt" : "\(options["digitalAirdate"] ?? "*null")", + "ns_st_tdt" : "\(options["tvAirdate"] ?? "*null")", + "c3" : "\(options["c3"] ?? "*null")", + "c4" : "\(options["c4"] ?? "*null")", + "c6" : "\(options["c6"] ?? "*null")", + "ns_st_ct" : "\(options["contentClassificationType"] ?? "vc00")"] + + return returnMap + } + + func mappedAdProperties(event: TrackEvent, properties: JSON) -> Dictionary? { + // Pull out the values for the event out of the enrichment plugin + guard let options = comscoreEnrichment?.fetchAndRemoveMetricsFor(key: event.messageId ?? "0") else { + return nil + } + + var adType = "1" + var totalLength = "0" + let convertedProperties = properties.dictionaryValue + if let tempProperties = properties.dictionaryValue { + adType = defaultAdType(source: tempProperties, key: "type") + totalLength = convertFromSecondsToMilliseconds(source: tempProperties, key: "total_length") + } + let returnMap = ["ns_st_ami" : "\(convertedProperties?["asset_id"] ?? "*null")", + "ns_st_ad" : adType, + "ns_st_cl" : totalLength, + "ns_st_amt" : "\(convertedProperties?["title"] ?? "*null")", + "ns_st_pu" : "\(convertedProperties?["publisher"] ?? "*null")", + "c3" : "\(options["c3"] ?? "*null")", + "c4" : "\(options["c4"] ?? "*null")", + "c6" : "\(options["c6"] ?? "*null")", + "ns_st_ct" : "\(options["contentClassificationType"] ?? "vc00")"] + + return returnMap + } + + + // comScore expects bitrate to converted from KBPS be BPS + func convertFromKBPSToBPS(source: [String: Any], key: String) -> String { + var returnValue = "*null" + if let kbps = source[key] as? Int { + returnValue = "\(kbps * 1000)" + } + + return returnValue + } + + // comScore expects milliseconds to be converted from seconds to milliseconds + func convertFromSecondsToMilliseconds(source: [String: Any], key: String) -> String { + var returnValue = "0" + if let seconds = source[key] as? Int { + returnValue = "\(seconds * 1000)" + } + + return returnValue + } + + func returnFullScreenStatus(source: [String: Any], key: String) -> String { + if let value = source[key] as? Bool, + value == true { + return "full" + } else { + return "norm" + } + } + + func instantiateContentMetaData(properties: [String: Any]) -> SCORStreamingContentMetadata? { + let contentMetaData = SCORStreamingContentMetadata { builder in + builder?.setCustomLabels(properties) + if properties.keys.contains("ns_st_ge") { + builder?.setGenreName("ns_st_ge") + } + } + return contentMetaData + } + + func movePosition(_ properties: JSON) { + if let position = properties.dictionaryValue?["position"] as? Int { + streamAnalytics?.start(fromPosition: position) + } + } + + func seekPosition(_ properties: JSON) { + if let position = properties.dictionaryValue?["seek_position"] as? Int { + streamAnalytics?.start(fromPosition: position) + } + } + + func defaultAdType(source: [String: Any], key: String) -> String { + var returnAdType = "1" + + if let value = source[key] as? String { + if value == "pre-roll" || value == "mid-roll" || value == "post-roll" { + returnAdType = value + } + } + + return returnAdType + } +} + +// MARK: - Comscore Enrichment Plugin +private class ComscoreEnrichment: EventPlugin { + var type: PluginType = .before + var analytics: Analytics? + + static let comscoreKey = "videoMetricDictionaryClassification" + + // The dictionary being stored off should look like: + private var videoMetrics = [String: [String: String]]() + + func track(event: TrackEvent) -> TrackEvent? { + + var returnEvent = event + if let properties = event.properties?.dictionaryValue, + var comscoreProperties = properties[Self.comscoreKey] as? [String: String], + !comscoreProperties.isEmpty, let messageId = event.messageId { + + // Store off the options + videoMetrics[messageId] = comscoreProperties + + // Filter out the properties + comscoreProperties = comscoreProperties.filter { (key, value) in + return key != Self.comscoreKey + } + + // Build the updated comscore properties back into JSON + do { + returnEvent.properties = try JSON(comscoreProperties) + } catch { + analytics?.log(message: "Could not convert comscore properties", kind: .debug) + } + } + return returnEvent + } + + + /// Fetches the appropriate options payload if one existed for that particular message Id + /// - Parameter key: The messageId related to the original event + /// - Returns: An optional payload if one is found for the messageId + func fetchAndRemoveMetricsFor(key: String) -> [String: String]? { + let returnMetrics = videoMetrics[key] + + // Remove the metrics now + videoMetrics.removeValue(forKey: key) + + return returnMetrics + } +} From 9671c00f72a76713673e59d372e8257c0e291473 Mon Sep 17 00:00:00 2001 From: Cody Garvin Date: Fri, 24 Sep 2021 10:54:50 -0700 Subject: [PATCH 2/3] Finished initial pass on Comscore --- .../ComscoreDestination.swift | 31 +++++-------------- 1 file changed, 7 insertions(+), 24 deletions(-) diff --git a/Examples/destination_plugins/ComscoreDestination.swift b/Examples/destination_plugins/ComscoreDestination.swift index 938db0f4..1bbf19fa 100644 --- a/Examples/destination_plugins/ComscoreDestination.swift +++ b/Examples/destination_plugins/ComscoreDestination.swift @@ -107,35 +107,18 @@ class ComscoreDestination: DestinationPlugin { func screen(event: ScreenEvent) -> ScreenEvent? { - if let _ = event.properties?.dictionaryValue { - // TODO: Do something with properties if they exist + if let eventName = event.name { + let viewLabels = ["name": eventName] + SCORAnalytics.notifyViewEvent(withLabels: viewLabels) + analytics?.log(message: "SCORAnalytics.notifyViewEvent(withLabels: \(viewLabels)", kind: .debug) } - - // TODO: Do something with name, category & properties in partner SDK - - return event - } - - func group(event: GroupEvent) -> GroupEvent? { - - if let _ = event.traits?.dictionaryValue { - // TODO: Do something with traits if they exist - } - - // TODO: Do something with groupId & traits in partner SDK - - return event - } - - func alias(event: AliasEvent) -> AliasEvent? { - - // TODO: Do something with previousId & userId in partner SDK return event } - func reset() { - // TODO: Do something with resetting partner SDK + func flush() { + SCORAnalytics.flushOfflineCache() + analytics?.log(message: "SCORAnalytics.flushOfflineCache()", kind: .debug) } } From 4328776167ddd0360f6498484fc27444884705ab Mon Sep 17 00:00:00 2001 From: Cody Garvin Date: Fri, 24 Sep 2021 13:17:07 -0700 Subject: [PATCH 3/3] Added cleaner mapping methods to comscore --- .../DestinationsExample/ViewController.swift | 4 + .../ComscoreDestination.swift | 164 +++++++++++------- 2 files changed, 102 insertions(+), 66 deletions(-) diff --git a/Examples/apps/DestinationsExample/DestinationsExample/ViewController.swift b/Examples/apps/DestinationsExample/DestinationsExample/ViewController.swift index 1ca37379..a51def09 100644 --- a/Examples/apps/DestinationsExample/DestinationsExample/ViewController.swift +++ b/Examples/apps/DestinationsExample/DestinationsExample/ViewController.swift @@ -129,11 +129,15 @@ extension ViewController { captureView.alpha = 0.0 let nextKeyField = UITextField() + nextKeyField.autocorrectionType = .no + nextKeyField.autocapitalizationType = .none nextKeyField.placeholder = "Key..." nextKeyField.borderStyle = .roundedRect keysFields.append(nextKeyField) let nextField = UITextField() + nextField.autocorrectionType = .no + nextField.autocapitalizationType = .none nextField.placeholder = "Value..." nextField.borderStyle = .roundedRect propertiesFields.append(nextField) diff --git a/Examples/destination_plugins/ComscoreDestination.swift b/Examples/destination_plugins/ComscoreDestination.swift index 1bbf19fa..f78a8297 100644 --- a/Examples/destination_plugins/ComscoreDestination.swift +++ b/Examples/destination_plugins/ComscoreDestination.swift @@ -140,37 +140,56 @@ private extension ComscoreDestination { static let partnerId = "23243060" static let adPropertiesMap = [ + "adClassificationType": "ns_st_ct", "assetId": "ns_st_ami", "asset_id": "ns_st_ami", + "c3": "c3", + "c4": "c4", + "c6": "c6", + "publisher": "ns_st_pu", "title": "ns_st_amt", - "publisher": "ns_st_pu" + "totalLength": "ns_st_cl", + "total_length": "ns_st_cl", + "type": "ns_st_ad", ] + static let contentPropertiesMap = [ - "title": "ns_st_ep", - "season": "ns_st_sn", - "episode": "ns_st_en", - "genre": "ns_st_ge", - "program": "ns_st_pr", + "assetId": "ns_st_ci", + "asset_id": "ns_st_ci", "channel": "ns_st_st", - "publisher": "ns_st_pu", + "contentClassificationType": "ns_st_ct", + "c3": "c3", + "c4": "c4", + "c6": "c6", + "digitalAirdate": "ns_st_ddt", + "episode": "ns_st_en", "fullEpisode": "ns_st_ce", "full_episode": "ns_st_ce", + "genre": "ns_st_ge", "podId": "ns_st_pn", - "pod_id": "ns_st_pn" - ] - static let contentIdMap = [ - "assetId": "ns_st_ci", - "asset_id": "ns_st_ci" + "pod_id": "ns_st_pn", + "program": "ns_st_pr", + "publisher": "ns_st_pu", + "season": "ns_st_sn", + "title": "ns_st_ep", + "totalLength": "ns_st_cl", + "total_length": "ns_st_cl", + "tvAirdate": "ns_st_tdt", ] + static let playbackPropertiesMap = [ + "bitrate": "ns_st_br", + "full_screen": "ns_st_ws", + "fullScreen": "ns_st_ws", + "fullscreen": "ns_st_ws", + "sound": "ns_st_vo", "videoPlayer": "ns_st_mp", "video_player": "ns_st_mp", - "sound": "ns_st_vo" + "c3": "c3", + "c4": "c4", + "c6": "c6" ] - static var eventNameMap = ["ADD_TO_CART": "Product Added", - "PRODUCT_TAPPED": "Product Tapped"] - func convertToStringFormatFrom(data: Dictionary) -> Dictionary { var returnDictionary = [String: String]() for (key, value) in data { @@ -437,84 +456,91 @@ private extension ComscoreDestination { func mappedPlaybackProperties(event: TrackEvent, properties: JSON) -> Dictionary? { // Pull out the values for the event out of the enrichment plugin - guard let options = comscoreEnrichment?.fetchAndRemoveMetricsFor(key: event.messageId ?? "0") else { - return nil - } + let options = comscoreEnrichment?.fetchAndRemoveMetricsFor(key: event.messageId ?? "0") var bps = "*null" var fullscreen = "norm" - let convertedProperties = properties.dictionaryValue if let tempProperties = properties.dictionaryValue { bps = convertFromKBPSToBPS(source: tempProperties, key: "bitrate") fullscreen = returnFullScreenStatus(source: tempProperties, key: "full_screen") } - let returnMap = ["ns_st_mp" : "\(convertedProperties?["video_player"] ?? "*null")", - "ns_st_vo" : "\(convertedProperties?["sound"] ?? "*null")", - "ns_st_br" : bps, - "ns_st_ws" : fullscreen, - "c3" : "\(options["c3"] ?? "*null")", - "c4" : "\(options["c4"] ?? "*null")", - "c6" : "\(options["c6"] ?? "*null")"] + + let returnMap = try? properties.mapTransform(Self.playbackPropertiesMap) { key, value in + switch key { + case "c3", "c4", "c6": + return "\(options?[key] ?? "*null")" + case "ns_st_br": + return bps + case "ns_st_ws": + return fullscreen + default: + return value + } + } - return returnMap + return returnMap?.dictionaryValue as? [String: String] } func mappedContentProperties(event: TrackEvent, properties: JSON) -> Dictionary? { // Pull out the values for the event out of the enrichment plugin - guard let options = comscoreEnrichment?.fetchAndRemoveMetricsFor(key: event.messageId ?? "0") else { - return nil - } + let options = comscoreEnrichment?.fetchAndRemoveMetricsFor(key: event.messageId ?? "0") var totalLength = "0" - let convertedProperties = properties.dictionaryValue if let tempProperties = properties.dictionaryValue { totalLength = convertFromSecondsToMilliseconds(source: tempProperties, key: "total_length") } - let returnMap = ["ns_st_ci" : "\(convertedProperties?["asset_id"] ?? "0")", - "ns_st_ep" : "\(convertedProperties?["title"] ?? "*null")", - "ns_st_sn" : "\(convertedProperties?["season"] ?? "*null")", - "ns_st_en" : "\(convertedProperties?["episode"] ?? "*null")", - "ns_st_ge" : "\(convertedProperties?["genre"] ?? "*null")", - "ns_st_pr" : "\(convertedProperties?["program"] ?? "*null")", - "ns_st_pn" : "\(convertedProperties?["pod_id"] ?? "*null")", - "ns_st_ce" : "\(convertedProperties?["full_episode"] ?? "*null")", - "ns_st_cl" : totalLength, - "ns_st_pu" : "\(convertedProperties?["publisher"] ?? "*null")", - "ns_st_st" : "\(convertedProperties?["channel"] ?? "*null")", - "ns_st_ddt" : "\(options["digitalAirdate"] ?? "*null")", - "ns_st_tdt" : "\(options["tvAirdate"] ?? "*null")", - "c3" : "\(options["c3"] ?? "*null")", - "c4" : "\(options["c4"] ?? "*null")", - "c6" : "\(options["c6"] ?? "*null")", - "ns_st_ct" : "\(options["contentClassificationType"] ?? "vc00")"] - - return returnMap + let digitalAirdate = "\(options?["digitalAirdate"] ?? "*null")" + let tvAirdate = "\(options?["tvAirdate"] ?? "*null")" + let contentClassification = "\(options?["contentClassificationType"] ?? "vc00")" + + let returnMap = try? properties.mapTransform(Self.contentPropertiesMap, valueTransform: { key, value in + switch key { + case "c3", "c4", "c6": + return "\(options?[key] ?? "*null")" + case "ns_st_ddt": + return digitalAirdate + case "ns_st_tdt": + return tvAirdate + case "ns_st_ct": + return contentClassification + case "ns_st_cl": + return totalLength + default: + return value + } + }) + + return returnMap?.dictionaryValue as? [String: String] } func mappedAdProperties(event: TrackEvent, properties: JSON) -> Dictionary? { // Pull out the values for the event out of the enrichment plugin - guard let options = comscoreEnrichment?.fetchAndRemoveMetricsFor(key: event.messageId ?? "0") else { - return nil - } + let options = comscoreEnrichment?.fetchAndRemoveMetricsFor(key: event.messageId ?? "0") var adType = "1" var totalLength = "0" - let convertedProperties = properties.dictionaryValue if let tempProperties = properties.dictionaryValue { adType = defaultAdType(source: tempProperties, key: "type") totalLength = convertFromSecondsToMilliseconds(source: tempProperties, key: "total_length") } - let returnMap = ["ns_st_ami" : "\(convertedProperties?["asset_id"] ?? "*null")", - "ns_st_ad" : adType, - "ns_st_cl" : totalLength, - "ns_st_amt" : "\(convertedProperties?["title"] ?? "*null")", - "ns_st_pu" : "\(convertedProperties?["publisher"] ?? "*null")", - "c3" : "\(options["c3"] ?? "*null")", - "c4" : "\(options["c4"] ?? "*null")", - "c6" : "\(options["c6"] ?? "*null")", - "ns_st_ct" : "\(options["contentClassificationType"] ?? "vc00")"] - - return returnMap + let adClassification = "\(options?["adClassificationType"] ?? "va00")" + + let returnMap = try? properties.mapTransform(Self.adPropertiesMap, valueTransform: { key, value in + switch key { + case "c3", "c4", "c6": + return "\(options?[key] ?? "*null")" + case "ns_st_ct": + return adClassification + case "ns_st_ad": + return adType + case "ns_st_cl": + return totalLength + default: + return value + } + }) + + return returnMap?.dictionaryValue as? [String: String] } @@ -523,6 +549,9 @@ private extension ComscoreDestination { var returnValue = "*null" if let kbps = source[key] as? Int { returnValue = "\(kbps * 1000)" + } else if let kbps = source[key] as? String, + let kbpsValue = Int(kbps) { + returnValue = "\(kbpsValue * 1000)" } return returnValue @@ -533,6 +562,9 @@ private extension ComscoreDestination { var returnValue = "0" if let seconds = source[key] as? Int { returnValue = "\(seconds * 1000)" + } else if let seconds = source[key] as? String, + let secondsValue = Int(seconds) { + returnValue = "\(secondsValue * 1000)" } return returnValue