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)
  • Loading branch information
matus-tomlein committed Dec 6, 2023
1 parent 28a7164 commit 4572e05
Show file tree
Hide file tree
Showing 28 changed files with 625 additions and 6 deletions.
1 change: 1 addition & 0 deletions Integrationtests/TestTrackEventsToMicro.swift
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
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
64 changes: 64 additions & 0 deletions Sources/Core/ScreenViewTracking/ScreenSummaryState.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// 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 data: [String: Any] {
var data: [String: Any] = [
"foreground_sec": foregroundSeconds,
"background_sec": backgroundSeconds
]
if let lastItemIndex = lastItemIndex { data["last_item_index"] = lastItemIndex }
if let itemsCount = itemsCount { data["items_count"] = itemsCount }
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)
}
}

}
91 changes: 91 additions & 0 deletions Sources/Core/ScreenViewTracking/ScreenSummaryStateMachine.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// 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]
}

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

var subscribedEventSchemasForPayloadUpdating: [String] {
return []
}

var subscribedEventSchemasForAfterTrackCallback: [String] {
return []
}

var subscribedEventSchemasForFiltering: [String] {
return [kSPListItemViewSchema, 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)
default:
break
}
}
return currentState
}

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

return [
SelfDescribingJson(schema: kSPViewSummarySchema, 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 events
return false
}

func afterTrack(event: InspectableEvent) {
}
}
8 changes: 8 additions & 0 deletions Sources/Core/StateMachine/DeepLinkStateMachine.swift
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Loading

0 comments on commit 4572e05

Please sign in to comment.