Skip to content

Commit

Permalink
Add screen engagement tracking of time spent and list items scrolled …
Browse files Browse the repository at this point in the history
…on a screen (close #851)

PR #853
  • Loading branch information
matus-tomlein committed Jan 11, 2024
1 parent 5f8dadd commit 6210a1b
Show file tree
Hide file tree
Showing 30 changed files with 806 additions and 7 deletions.
1 change: 1 addition & 0 deletions Integrationtests/TestTrackEventsToMicro.swift
Expand Up @@ -21,6 +21,7 @@ class TestTrackEventsToMicro: XCTestCase {
super.setUp()

let trackerConfig = TrackerConfiguration()
.screenEngagementAutotracking(false)
.logLevel(.debug)

tracker = Snowplow.createTracker(namespace: "testMicro-" + UUID().uuidString,
Expand Down
26 changes: 26 additions & 0 deletions Sources/Core/Events/ScreenEnd.swift
@@ -0,0 +1,26 @@
// 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

class ScreenEnd: SelfDescribingAbstract {

override var schema: String {
return kSPScreenEndSchema
}

override var payload: [String : Any] {
return [:]
}

}
5 changes: 5 additions & 0 deletions Sources/Core/InternalQueue/TrackerControllerIQWrapper.swift
Expand Up @@ -196,6 +196,11 @@ class TrackerControllerIQWrapper: TrackerController {
get { return InternalQueue.sync { controller.screenViewAutotracking } }
set { InternalQueue.sync { controller.screenViewAutotracking = newValue } }
}

var screenEngagementAutotracking: Bool {
get { return InternalQueue.sync { controller.screenEngagementAutotracking } }
set { InternalQueue.sync { controller.screenEngagementAutotracking = newValue } }
}

var trackerVersionSuffix: String? {
get { return InternalQueue.sync { controller.trackerVersionSuffix } }
Expand Down
54 changes: 54 additions & 0 deletions Sources/Core/ScreenViewTracking/ListItemViewModifier.swift
@@ -0,0 +1,54 @@
// 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.

#if canImport(SwiftUI)

import SwiftUI
import Foundation

@available(iOS 13.0, macOS 10.15, macCatalyst 13.0, tvOS 13.0, *)
@available(watchOS, unavailable)
internal struct ListItemViewModifier: ViewModifier {
let index: Int
let itemsCount: Int?
let trackerNamespace: String?

/// Get tracker by namespace if configured, otherwise return the default tracker
private var tracker: TrackerController? {
if let namespace = trackerNamespace {
return Snowplow.tracker(namespace: namespace)
} else {
return Snowplow.defaultTracker()
}
}

/// Modifies the view to track the list item view when it appears
func body(content: Content) -> some View {
content.onAppear {
trackListItemView()
}
}

func trackListItemView() {
let event = ListItemView(index: index)
event.itemsCount = itemsCount

if let tracker = tracker {
_ = tracker.track(event)
} else {
logError(message: "List item view not tracked – tracker not initialized.")
}
}
}

#endif
8 changes: 8 additions & 0 deletions Sources/Core/ScreenViewTracking/ScreenStateMachine.swift
Expand Up @@ -17,6 +17,10 @@ class ScreenStateMachine: StateMachineProtocol {
static var identifier: String { return "ScreenContext" }
var identifier: String { return ScreenStateMachine.identifier }

var subscribedEventSchemasForEventsBefore: [String] {
return []
}

var subscribedEventSchemasForTransitions: [String] {
return [kSPScreenViewSchema]
}
Expand All @@ -37,6 +41,10 @@ class ScreenStateMachine: StateMachineProtocol {
return []
}

func eventsBefore(event: Event) -> [Event]? {
return nil
}

func transition(from event: Event, state currentState: State?) -> State? {
if let screenView = event as? ScreenView {
let newState: ScreenState = screenState(from: screenView)
Expand Down
95 changes: 95 additions & 0 deletions Sources/Core/ScreenViewTracking/ScreenSummaryState.swift
@@ -0,0 +1,95 @@
// 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

class ScreenSummaryState: State {

static var dateGenerator: () -> TimeInterval = { Date().timeIntervalSince1970 }

private var lastUpdateTimestamp: TimeInterval = ScreenSummaryState.dateGenerator()
var foregroundSeconds: TimeInterval = 0
var backgroundSeconds: TimeInterval = 0
var lastItemIndex: Int?
var itemsCount: Int?
var minYOffset: Int?
var maxYOffset: Int?
var minXOffset: Int?
var maxXOffset: Int?
var contentHeight: Int?
var contentWidth: Int?

var data: [String: Any] {
var data: [String: Any] = [
"foreground_sec": round(foregroundSeconds * 100) / 100,
"background_sec": round(backgroundSeconds * 100) / 100
]
if let lastItemIndex = lastItemIndex { data["last_item_index"] = lastItemIndex }
if let itemsCount = itemsCount { data["items_count"] = itemsCount }
if let minXOffset = minXOffset { data["min_x_offset"] = minXOffset }
if let maxXOffset = maxXOffset { data["max_x_offset"] = maxXOffset }
if let minYOffset = minYOffset { data["min_y_offset"] = minYOffset }
if let maxYOffset = maxYOffset { data["max_y_offset"] = maxYOffset }
if let contentHeight = contentHeight { data["content_height"] = contentHeight }
if let contentWidth = contentWidth { data["content_width"] = contentWidth }
return data
}

func updateTransitionToForeground() {
let currentTimestamp = ScreenSummaryState.dateGenerator()

backgroundSeconds += currentTimestamp - lastUpdateTimestamp
lastUpdateTimestamp = currentTimestamp
}

func updateTransitionToBackground() {
let currentTimestamp = ScreenSummaryState.dateGenerator()

foregroundSeconds += currentTimestamp - lastUpdateTimestamp
lastUpdateTimestamp = currentTimestamp
}

func updateForScreenEnd() {
let currentTimestamp = ScreenSummaryState.dateGenerator()

foregroundSeconds += currentTimestamp - lastUpdateTimestamp
lastUpdateTimestamp = currentTimestamp
}

func updateWithListItemView(_ event: ListItemView) {
lastItemIndex = max(event.index, lastItemIndex ?? 0)
if let totalItems = event.itemsCount {
self.itemsCount = max(totalItems, self.itemsCount ?? 0)
}
}

func updateWithScrollChanged(_ event: ScrollChanged) {
if let yOffset = event.yOffset {
var maxYOffset = yOffset
if let viewHeight = event.viewHeight { maxYOffset += viewHeight }

minYOffset = min(yOffset, minYOffset ?? yOffset)
self.maxYOffset = max(maxYOffset, self.maxYOffset ?? maxYOffset)
}
if let xOffset = event.xOffset {
var maxXOffset = xOffset
if let viewWidth = event.viewWidth { maxXOffset += viewWidth }

minXOffset = min(xOffset, minXOffset ?? xOffset)
self.maxXOffset = max(maxXOffset, self.maxXOffset ?? maxXOffset)
}
if let height = event.contentHeight { contentHeight = max(height, contentHeight ?? 0) }
if let width = event.contentWidth { contentWidth = max(width, contentWidth ?? 0) }
}

}
93 changes: 93 additions & 0 deletions Sources/Core/ScreenViewTracking/ScreenSummaryStateMachine.swift
@@ -0,0 +1,93 @@
// 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

class ScreenSummaryStateMachine: StateMachineProtocol {
static var identifier: String { return "ScreenSummaryContext" }
var identifier: String { return ScreenSummaryStateMachine.identifier }

var subscribedEventSchemasForEventsBefore: [String] {
return [kSPScreenViewSchema]
}

var subscribedEventSchemasForTransitions: [String] {
return [kSPScreenViewSchema, kSPScreenEndSchema, kSPForegroundSchema, kSPBackgroundSchema, kSPListItemViewSchema, kSPScrollChangedSchema]
}

var subscribedEventSchemasForEntitiesGeneration: [String] {
return [kSPScreenEndSchema, kSPForegroundSchema, kSPBackgroundSchema]
}

var subscribedEventSchemasForPayloadUpdating: [String] {
return []
}

var subscribedEventSchemasForAfterTrackCallback: [String] {
return []
}

var subscribedEventSchemasForFiltering: [String] {
return [kSPListItemViewSchema, kSPScrollChangedSchema, kSPScreenEndSchema]
}

func eventsBefore(event: Event) -> [Event]? {
return [ScreenEnd()]
}

func transition(from event: Event, state currentState: State?) -> State? {
if event is ScreenView {
return ScreenSummaryState()
}
else if let state = currentState as? ScreenSummaryState {
switch event {
case is Foreground:
state.updateTransitionToForeground()
case is Background:
state.updateTransitionToBackground()
case is ScreenEnd:
state.updateForScreenEnd()
case let itemView as ListItemView:
state.updateWithListItemView(itemView)
case let scrollChanged as ScrollChanged:
state.updateWithScrollChanged(scrollChanged)
default:
break
}
}
return currentState
}

func entities(from event: InspectableEvent, state: State?) -> [SelfDescribingJson]? {
guard let state = state as? ScreenSummaryState else { return nil }

return [
SelfDescribingJson(schema: kSPScreenSummarySchema, andData: state.data)
]
}

func payloadValues(from event: InspectableEvent, state: State?) -> [String : Any]? {
return nil
}

func filter(event: InspectableEvent, state: State?) -> Bool? {
if event.schema == kSPScreenEndSchema {
return state != nil
}
// do not track list item view or scroll changed events
return false
}

func afterTrack(event: InspectableEvent) {
}
}
8 changes: 8 additions & 0 deletions Sources/Core/StateMachine/DeepLinkStateMachine.swift
Expand Up @@ -29,6 +29,10 @@ class DeepLinkStateMachine: StateMachineProtocol {
static var identifier: String { return "DeepLinkContext" }
var identifier: String { return DeepLinkStateMachine.identifier }

var subscribedEventSchemasForEventsBefore: [String] {
return []
}

var subscribedEventSchemasForTransitions: [String] {
return [DeepLinkReceived.schema, kSPScreenViewSchema]
}
Expand All @@ -49,6 +53,10 @@ class DeepLinkStateMachine: StateMachineProtocol {
return []
}

func eventsBefore(event: Event) -> [Event]? {
return nil
}

func transition(from event: Event, state: State?) -> State? {
if let dlEvent = event as? DeepLinkReceived {
return DeepLinkState(url: dlEvent.url, referrer: dlEvent.referrer)
Expand Down
8 changes: 7 additions & 1 deletion Sources/Core/StateMachine/LifecycleStateMachine.swift
Expand Up @@ -17,10 +17,16 @@ class LifecycleStateMachine: StateMachineProtocol {
static var identifier: String { return "Lifecycle" }
var identifier: String { return LifecycleStateMachine.identifier }

var subscribedEventSchemasForEventsBefore: [String] = []

func eventsBefore(event: Event) -> [Event]? {
return nil
}

var subscribedEventSchemasForTransitions: [String] {
return [kSPBackgroundSchema, kSPForegroundSchema]
}

func transition(from event: Event, state currentState: State?) -> State? {
if let e = event as? Foreground {
return LifecycleState(asForegroundWithIndex: e.index)
Expand Down
8 changes: 8 additions & 0 deletions Sources/Core/StateMachine/PluginStateMachine.swift
Expand Up @@ -35,6 +35,14 @@ class PluginStateMachine: StateMachineProtocol {
self.filterConfiguration = filterConfiguration
}

var subscribedEventSchemasForEventsBefore: [String] {
return []
}

func eventsBefore(event: Event) -> [Event]? {
return nil
}

var subscribedEventSchemasForTransitions: [String] {
return []
}
Expand Down
4 changes: 4 additions & 0 deletions Sources/Core/StateMachine/StateMachineProtocol.swift
Expand Up @@ -15,12 +15,16 @@ import Foundation

protocol StateMachineProtocol {
var identifier: String { get }
var subscribedEventSchemasForEventsBefore: [String] { get }
var subscribedEventSchemasForTransitions: [String] { get }
var subscribedEventSchemasForEntitiesGeneration: [String] { get }
var subscribedEventSchemasForPayloadUpdating: [String] { get }
var subscribedEventSchemasForAfterTrackCallback: [String] { get }
var subscribedEventSchemasForFiltering: [String] { get }

/// Only available for self-describing events (inheriting from SelfDescribingAbstract)
func eventsBefore(event: Event) -> [Event]?

/// Only available for self-describing events (inheriting from SelfDescribingAbstract)
func transition(from event: Event, state: State?) -> State?

Expand Down

0 comments on commit 6210a1b

Please sign in to comment.