Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,16 @@
ReferencedContainer = "container:">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "SessionReplayTests"
BuildableName = "SessionReplayTests"
BlueprintName = "SessionReplayTests"
ReferencedContainer = "container:">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
Expand Down
7 changes: 7 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -92,5 +92,12 @@ let package = Package(
],
resources: [.process("GraphQL/Queries")]
),
.testTarget(
name: "SessionReplayTests",
dependencies: [
"LaunchDarklySessionReplay",
"LaunchDarklyObservability"
]
),
]
)
2 changes: 1 addition & 1 deletion Sources/Common/GraphQL/GraphQLClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import Foundation
import DataCompression

public final class GraphQLClient {
public let endpoint: URL
private let endpoint: URL
private let network: HttpServicing
private let decoder: JSONDecoder
private let defaultHeaders: [String: String]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,25 +8,36 @@ import OSLog
import Common
#endif

enum RRWebPlayerConstants {
// padding requiered by used html dom structure
static let padding = CGSize(width: 11, height: 11)
// size limit of accumulated continues canvas operations on the RRWeb player
static let canvasBufferLimit = 10_000_000 // ~10mb

static let canvasDrawEntourage = 300 // bytes
}

actor SessionReplayEventGenerator {
private var title: String
let padding = CGSize(width: 11, height: 11)
var sid = 0
var nextSid: Int {
private let padding = RRWebPlayerConstants.padding
private var sid = 0
private var nextSid: Int {
sid += 1
return sid
}

var id = 16
var nextId: Int {
private var id = 16
private var nextId: Int {
id += 1
return id
}
private var pushedCanvasSize: Int = 0
private var generatingCanvasSize: Int = 0

var imageId: Int?
var lastExportImage: ExportImage?
var stats: SessionReplayStats?
let isDebug = false
private var imageId: Int?
private var lastExportImage: ExportImage?
private var stats: SessionReplayStats?
private let isDebug = false

init(log: OSLog, title: String) {
if isDebug {
Expand All @@ -37,6 +48,7 @@ actor SessionReplayEventGenerator {

func generateEvents(items: [EventQueueItem]) -> [Event] {
var events = [Event]()
self.generatingCanvasSize = pushedCanvasSize
for item in items {
appendEvents(item: item, events: &events)
}
Expand All @@ -56,15 +68,15 @@ actor SessionReplayEventGenerator {
fileprivate func wakeUpPlayerEvents(_ events: inout [Event], _ imageId: Int, _ timestamp: TimeInterval) {
// artificial mouse movement to wake up session replay player
events.append(Event(type: .IncrementalSnapshot,
data: AnyEventData(EventData(source: .mouseInteraction,
data: AnyEventData(MouseInteractionData(source: .mouseInteraction,
type: .mouseDown,
id: imageId,
x: padding.width,
y: padding.height)),
timestamp: timestamp,
_sid: nextSid))
events.append(Event(type: .IncrementalSnapshot,
data: AnyEventData(EventData(source: .mouseInteraction,
data: AnyEventData(MouseInteractionData(source: .mouseInteraction,
type: .mouseUp,
id: imageId,
x: padding.width,
Expand All @@ -91,7 +103,8 @@ actor SessionReplayEventGenerator {
if let imageId,
let lastExportImage,
lastExportImage.originalWidth == exportImage.originalWidth,
lastExportImage.originalHeight == exportImage.originalHeight {
lastExportImage.originalHeight == exportImage.originalHeight,
generatingCanvasSize < RRWebPlayerConstants.canvasBufferLimit {
events.append(drawImageEvent(exportImage: exportImage, timestamp: timestamp, imageId: imageId))
} else {
// if screen changed size we send fullSnapshot as canvas resizing might take to many hours on the server
Expand Down Expand Up @@ -122,14 +135,14 @@ actor SessionReplayEventGenerator {
fileprivate func appendTouchInteraction(interaction: TouchInteraction, events: inout [Event]) {
if let touchEventData: EventDataProtocol = switch interaction.kind {
case .touchDown(let point):
EventData(source: .mouseInteraction,
MouseInteractionData(source: .mouseInteraction,
type: .touchStart,
id: imageId,
x: point.x + padding.width,
y: point.y + padding.height)

case .touchUp(let point):
EventData(source: .mouseInteraction,
MouseInteractionData(source: .mouseInteraction,
type: .touchEnd,
id: imageId,
x: point.x + padding.width,
Expand All @@ -145,7 +158,7 @@ actor SessionReplayEventGenerator {
timeOffset: p.timestamp - interaction.timestamp) })

default:
Optional<EventData>.none
Optional<MouseInteractionData>.none
} {
let event = Event(type: .IncrementalSnapshot,
data: AnyEventData(touchEventData),
Expand All @@ -162,7 +175,6 @@ actor SessionReplayEventGenerator {
func clickEvent(interaction: TouchInteraction) -> Event? {
guard case .touchDown = interaction.kind else { return nil }

let viewName = interaction.target?.className
let eventData = CustomEventData(tag: .click, payload: ClickPayload(
clickTarget: interaction.target?.className ?? "",
clickTextContent: interaction.target?.accessibilityIdentifier ?? "",
Expand All @@ -174,10 +186,8 @@ actor SessionReplayEventGenerator {
return event
}



func windowEvent(href: String, width: Int, height: Int, timestamp: TimeInterval) -> Event {
let eventData = EventData(href: href, width: width, height: height)
let eventData = WindowData(href: href, width: width, height: height)
let event = Event(type: .Meta,
data: AnyEventData(eventData),
timestamp: timestamp,
Expand Down Expand Up @@ -232,7 +242,8 @@ actor SessionReplayEventGenerator {

func drawImageEvent(exportImage: ExportImage, timestamp: TimeInterval, imageId: Int) -> Event {
let clearRectCommand = ClearRect(x: 0, y: 0, width: exportImage.originalWidth, height: exportImage.originalHeight)
let arrayBuffer = RRArrayBuffer(base64: exportImage.data.base64EncodedString())
let base64String = exportImage.data.base64EncodedString()
let arrayBuffer = RRArrayBuffer(base64: base64String)
let blob = AnyRRNode(RRBlob(data: [AnyRRNode(arrayBuffer)], type: exportImage.mimeType))
let drawImageCommand = DrawImage(image: AnyRRNode(RRImageBitmap(args: [blob])),
dx: 0,
Expand All @@ -244,10 +255,13 @@ actor SessionReplayEventGenerator {
id: imageId,
type: .mouseUp,
commands: [
AnyCommand(clearRectCommand),
AnyCommand(drawImageCommand)
AnyCommand(clearRectCommand, canvasSize: 80),
AnyCommand(drawImageCommand, canvasSize: base64String.count)
])
let event = Event(type: .IncrementalSnapshot, data: AnyEventData(eventData), timestamp: timestamp, _sid: nextSid)
let event = Event(type: .IncrementalSnapshot,
data: AnyEventData(eventData),
timestamp: timestamp, _sid: nextSid)
generatingCanvasSize += eventData.canvasSize + RRWebPlayerConstants.canvasDrawEntourage
return event
}

Expand All @@ -264,32 +278,38 @@ actor SessionReplayEventGenerator {

func fullSnapshotEvent(exportImage: ExportImage, timestamp: TimeInterval) -> Event {
id = 0
let rootNode = fullSnapshotNode(exportImage: exportImage)
let eventData = EventData(node: rootNode)
let eventData = fullSnapshotData(exportImage: exportImage)
let event = Event(type: .FullSnapshot, data: AnyEventData(eventData), timestamp: timestamp, _sid: nextSid)
// start again counting canvasSize
generatingCanvasSize = eventData.canvasSize + RRWebPlayerConstants.canvasDrawEntourage
return event
}

func fullSnapshotNode(exportImage: ExportImage) -> EventNode {
func fullSnapshotData(exportImage: ExportImage) -> DomData {
var rootNode = EventNode(id: nextId, type: .Document)
let htmlDocNode = EventNode(id: nextId, type: .DocumentType, name: "html")
rootNode.childNodes.append(htmlDocNode)

let base64String = exportImage.base64DataURL()

let htmlNode = EventNode(id: nextId, type: .Element, tagName: "html", attributes: ["lang": "en"], childNodes: [
EventNode(id: nextId, type: .Element, tagName: "head", attributes: [:]),
EventNode(id: nextId, type: .Element, tagName: "body", attributes: [:], childNodes: [
exportImage.eventNode(id: nextId)
exportImage.eventNode(id: nextId, rr_dataURL: base64String)
]),
])
imageId = id
rootNode.childNodes.append(htmlNode)

return rootNode
return DomData(node: rootNode, canvasSize: base64String.count)
}

private func appendFullSnapshotEvents(_ exportImage: ExportImage, _ timestamp: TimeInterval, _ events: inout [Event]) {
events.append(windowEvent(href: "", width: paddedWidth(exportImage.originalWidth), height: paddedHeight(exportImage.originalHeight), timestamp: timestamp))
events.append(fullSnapshotEvent(exportImage: exportImage, timestamp: timestamp))
events.append(viewPortEvent(exportImage: exportImage, timestamp: timestamp))
}

func updatePushedCanvasSize() {
pushedCanvasSize = generatingCanvasSize
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ actor SessionReplayExporter: EventExporting {
payloadId += 1
return payloadId
}

private var identifyPayload: IdentifyItemPayload?

init(context: SessionReplayContext,
Expand Down Expand Up @@ -100,7 +99,11 @@ actor SessionReplayExporter: EventExporting {
guard events.isNotEmpty else { return }

let input = PushPayloadVariables(sessionSecureId: initializedSession.secureId, payloadId: "\(nextPayloadId)", events: events)

try await replayApiService.pushPayload(input)

// flushes generating canvas size into pushedCanvasSize
await eventGenerator.updatePushedCanvasSize()
}

private func initializeSession(sessionSecureId: String) async throws -> InitializeSessionResponse {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ import Foundation
#endif

struct PushPayloadVariables: Codable {
public struct EventsInput: Codable {
struct EventsInput: Codable {
var events: [Event]
}

public struct ErrorInput: Codable {
struct ErrorInput: Codable {

}

Expand Down
14 changes: 12 additions & 2 deletions Sources/LaunchDarklySessionReplay/RRWeb/CanvasDrawData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ struct CanvasDrawData: EventDataProtocol {
var id: Int
var type: MouseInteractions
var commands: [AnyCommand]

var canvasSize: Int {
commands.reduce(0) { $0 + $1.canvasSize }
}
}

enum CommandName: String, Codable {
Expand All @@ -24,6 +28,8 @@ protocol CommandPayload: Codable {

struct AnyCommand: Codable {
let value: any CommandPayload
// Transitional
let canvasSize: Int

private enum K: String, CodingKey { case property }

Expand All @@ -32,8 +38,11 @@ struct AnyCommand: Codable {
.drawImage: { try DrawImage(from: $0) }
]

init(_ value: any CommandPayload) { self.value = value }

init(_ value: any CommandPayload, canvasSize: Int) {
self.value = value
self.canvasSize = canvasSize
}

init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: K.self)
let name = try c.decode(CommandName.self, forKey: .property)
Expand All @@ -42,6 +51,7 @@ struct AnyCommand: Codable {
debugDescription: "Unknown command \(name)"))
}
self.value = try factory(decoder)
self.canvasSize = 0
}

func encode(to encoder: Encoder) throws {
Expand Down
53 changes: 53 additions & 0 deletions Sources/LaunchDarklySessionReplay/RRWeb/DomData.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import Foundation

struct DomData: EventDataProtocol {
var node: EventNode
// Transitional
var canvasSize: Int

init(node: EventNode, canvasSize: Int) {
self.node = node
self.canvasSize = canvasSize
}

private enum CodingKeys: String, CodingKey {
case node
}

init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.node = try container.decode(EventNode.self, forKey: .node)
self.canvasSize = 0
}

func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(node, forKey: .node)
}
}

struct EventNode: Codable {
var type: NodeType
var name: String?
var tagName: String?
var attributes: [String: String]?
var childNodes: [EventNode]
var rootId: Int?
var id: Int?

init(id: Int? = nil,
rootId: Int? = nil,
type: NodeType,
name: String? = nil,
tagName: String? = nil,
attributes: [String : String]? = nil,
childNodes: [EventNode] = []) {
self.id = id
self.rootId = rootId
self.type = type
self.name = name
self.tagName = tagName
self.attributes = attributes
self.childNodes = childNodes
}
}
Loading