Skip to content

Commit

Permalink
RUM-3569 Refactor to scope and add telemetry on errors
Browse files Browse the repository at this point in the history
  • Loading branch information
maciejburda committed Mar 28, 2024
1 parent 0dead9d commit 4aea59f
Show file tree
Hide file tree
Showing 4 changed files with 93 additions and 80 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ internal class SessionReplayFeature: DatadogRemoteFeature {
)
let resourceProcessor = ResourceProcessor(
queue: processorsQueue,
resourcesWriter: ResourcesWriter(core: core)
resourcesWriter: ResourcesWriter(scope: core.scope(for: ResourcesFeature.self))
)
let recorder = try Recorder(
snapshotProcessor: snapshotProcessor,
Expand Down
67 changes: 37 additions & 30 deletions DatadogSessionReplay/Sources/Writers/ResourcesWriter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,13 @@ internal protocol ResourcesWriting {
}

internal class ResourcesWriter: ResourcesWriting {
/// An instance of SDK core the SR feature is registered to.
private let scope: FeatureScope?
private let telemetry: Telemetry
private let scope: FeatureScope

@ReadWriteLock
private var knownIdentifiers = Set<String>() {
didSet {
if let knownIdentifiers = knownIdentifiers.asData() {
scope?.dataStore.setValue(
scope.dataStore.setValue(
knownIdentifiers,
forKey: Constants.knownResourcesKey
)
Expand All @@ -32,40 +30,45 @@ internal class ResourcesWriter: ResourcesWriting {
}

init(
core: DatadogCoreProtocol,
scope: FeatureScope,
dataStoreResetTime: TimeInterval = TimeInterval(30).days
) {
self.scope = core.scope(for: ResourcesFeature.self)
self.telemetry = core.telemetry
self.scope = scope

self.scope?.dataStore.value(forKey: Constants.storeCreationKey) { result in
if let storeCreation = result.data()?.asTimeInterval(), Date().timeIntervalSince1970 - storeCreation < dataStoreResetTime {
self.scope?.dataStore.value(forKey: Constants.knownResourcesKey) { [weak self] result in
switch result {
case .value(let data, _):
if let knownIdentifiers = data.asKnownIdentifiers() {
self?.knownIdentifiers.formUnion(knownIdentifiers)
self.scope.dataStore.value(forKey: Constants.storeCreationKey) { [weak self] result in
do {
if let storeCreation = try result.data()?.asTimeInterval(), Date().timeIntervalSince1970 - storeCreation < dataStoreResetTime {
self?.scope.dataStore.value(forKey: Constants.knownResourcesKey) { result in
switch result {
case .value(let data, _):
do {
if let knownIdentifiers = try data.asKnownIdentifiers() {
self?.knownIdentifiers.formUnion(knownIdentifiers)
}
} catch let error {
self?.scope.telemetry.error("Failed to decode known identifiers", error: error)
}
default:
break
}
case .error(let error):
self?.telemetry.error("Failed to read processed resources from data store: \(error)")
case .noValue:
break
}
} else { // Reset if store was created more than 30 days ago
self?.scope.dataStore.setValue(
Date().timeIntervalSince1970.asData(),
forKey: Constants.storeCreationKey
)
self?.scope.dataStore.removeValue(forKey: Constants.knownResourcesKey)
}
} else { // Reset if store was created more than 30 days ago
self.scope?.dataStore.setValue(
Date().timeIntervalSince1970.asData(),
forKey: Constants.storeCreationKey
)
self.scope?.dataStore.removeValue(forKey: Constants.knownResourcesKey)
} catch let error {
self?.scope.telemetry.error("Failed to decode store creation", error: error)
}
}
}

// MARK: - Writing

func write(resources: [EnrichedResource]) {
scope?.eventWriteContext { [weak self] _, recordWriter in
scope.eventWriteContext { [weak self] _, recordWriter in
let unknownResources = resources.filter { self?.knownIdentifiers.contains($0.identifier) == false }
for resource in unknownResources {
recordWriter.write(value: resource)
Expand All @@ -81,19 +84,23 @@ internal class ResourcesWriter: ResourcesWriting {
}

extension Data {
func asTimeInterval() -> TimeInterval? {
enum SerializationError: Error {
case invalidData
}

func asTimeInterval() throws -> TimeInterval {
var value: TimeInterval = 0
guard count >= MemoryLayout.size(ofValue: value) else {
return nil
throw SerializationError.invalidData
}
_ = Swift.withUnsafeMutableBytes(of: &value) {
copyBytes(to: $0)
}
return value
}

func asKnownIdentifiers() -> Set<String>? {
return try? JSONDecoder().decode(Set<String>.self, from: self)
func asKnownIdentifiers() throws -> Set<String>? {
return try JSONDecoder().decode(Set<String>.self, from: self)
}
}

Expand All @@ -107,7 +114,7 @@ extension TimeInterval {

extension Set<String> {
func asData() -> Data? {
return try? JSONEncoder().encode(self)
return try? JSONEncoder().encode(self) // Never fails
}
}
#endif
102 changes: 54 additions & 48 deletions DatadogSessionReplay/Tests/Writer/ResourcesWriterTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,100 +5,106 @@
*/

import XCTest
import DatadogInternal

@testable import DatadogSessionReplay
@testable import TestUtilities

class ResourcesWriterTests: XCTestCase {
func testWhenFeatureScopeIsConnected_itWritesResourcesToCore() {
// Given
let core = PassthroughCoreMock()
var scopeMock: FeatureScopeMock! // swiftlint:disable:this implicitly_unwrapped_optional
var writer: ResourcesWriter! // swiftlint:disable:this implicitly_unwrapped_optional

// When
let writer = ResourcesWriter(core: core)
override func setUp() {
scopeMock = FeatureScopeMock()
writer = ResourcesWriter(scope: scopeMock)
}

// Then
override func tearDown() {
writer = nil
scopeMock = nil
}

func testWhenInitialized_itSetsUpDataStore() {
XCTAssertNotNil(scopeMock.dataStoreMock.values[ResourcesWriter.Constants.storeCreationKey])
XCTAssertNil(scopeMock.dataStoreMock.values[ResourcesWriter.Constants.knownResourcesKey])
XCTAssertTrue(scopeMock.telemetryMock.messages.isEmpty)
}

func test_whenWritesResources_itDoesWriteRecordsToScope() {
// When
writer.write(resources: [.mockRandom()])
writer.write(resources: [.mockRandom()])
writer.write(resources: [.mockRandom()])

XCTAssertEqual(core.events(ofType: EnrichedResource.self).count, 3)
// Then
XCTAssertEqual(scopeMock.eventsWritten(ofType: EnrichedResource.self).count, 3)
XCTAssertTrue(scopeMock.telemetryMock.messages.isEmpty)
}

func testWhenFeatureScopeIsNotConnected_itDoesNotWriteRecordsToCore() throws {
func test_whenWritesSameResourcesToCore_itRemovesDuplicates() throws {
// Given
let core = SingleFeatureCoreMock<MockFeature>()
let feature = MockFeature()
try core.register(feature: feature)
scopeMock.dataStoreMock.values[ResourcesWriter.Constants.storeCreationKey] = Date().timeIntervalSince1970.asData()

// When
let writer = ResourcesWriter(core: core)
writer.write(resources: [.mockWith(identifier: "1")])
writer.write(resources: [.mockWith(identifier: "1")])

// Then
writer.write(resources: [.mockRandom()])

XCTAssertEqual(core.events(ofType: EnrichedResource.self).count, 0)
XCTAssertEqual(scopeMock.eventsWritten(ofType: EnrichedResource.self).count, 1)
XCTAssertTrue(scopeMock.telemetryMock.messages.isEmpty)
let data = try XCTUnwrap(scopeMock.dataStoreMock.values[ResourcesWriter.Constants.knownResourcesKey])
XCTAssertGreaterThan(data.count, 0)
}

func testWritesSameResourcesToCore_andRemovesDuplicates() throws {
func test_whenReadsKnownDuplicates_itDoesNotWriteRecordsToScope() throws {
// Given
let dataStore = DataStoreMock()
dataStore.values[ResourcesWriter.Constants.storeCreationKey] = Date().timeIntervalSince1970.asData()
let core = PassthroughCoreMock(dataStore: dataStore)
scopeMock.dataStoreMock.values[ResourcesWriter.Constants.knownResourcesKey] = Set(["1"]).asData()
scopeMock.dataStoreMock.values[ResourcesWriter.Constants.storeCreationKey] = Date().timeIntervalSince1970.asData()
let writer = ResourcesWriter(scope: scopeMock)

// When
let writer = ResourcesWriter(core: core)
writer.write(resources: [.mockWith(identifier: "1")])
writer.write(resources: [.mockWith(identifier: "1")])

// Then
XCTAssertEqual(core.events(ofType: EnrichedResource.self).count, 1)
let data = try XCTUnwrap(dataStore.values[ResourcesWriter.Constants.knownResourcesKey])
XCTAssertGreaterThan(data.count, 0)
XCTAssertEqual(scopeMock.eventsWritten(ofType: EnrichedResource.self).count, 0)
XCTAssertTrue(scopeMock.telemetryMock.messages.isEmpty)
}

func testWhenReadsKnownDuplicates_itDoesNotWriteRecordsToCore() throws {
func test_whenDataStoreIsOlderThan30Days_itClearsKnownDuplicates() throws {
// Given
let dataStore = DataStoreMock()
dataStore.values[ResourcesWriter.Constants.knownResourcesKey] = Set(["1"]).asData()
dataStore.values[ResourcesWriter.Constants.storeCreationKey] = Date().timeIntervalSince1970.asData()
let core = PassthroughCoreMock(dataStore: dataStore)
scopeMock.dataStoreMock.values[ResourcesWriter.Constants.knownResourcesKey] = Set(["2", "1"]).asData()
scopeMock.dataStoreMock.values[ResourcesWriter.Constants.storeCreationKey] = (Date().timeIntervalSince1970 - 31.days).asData()
let writer = ResourcesWriter(scope: scopeMock)

// When
let writer = ResourcesWriter(core: core)
XCTAssertNil(scopeMock.dataStoreMock.values[ResourcesWriter.Constants.knownResourcesKey])
writer.write(resources: [.mockWith(identifier: "1")])

// Then
writer.write(resources: [.mockWith(identifier: "1")])
XCTAssertEqual(core.events(ofType: EnrichedResource.self).count, 0)
XCTAssertEqual(scopeMock.eventsWritten(ofType: EnrichedResource.self).count, 1)
XCTAssertEqual(scopeMock.dataStoreMock.values[ResourcesWriter.Constants.knownResourcesKey], Set(["1"]).asData())
XCTAssertTrue(scopeMock.telemetryMock.messages.isEmpty)
}

func testWhenDataStoreIsOlderThan30Days_itClearsKnownDuplicates() throws {
func test_whenKnownResourcesAreBroken_itLogsTelemetry() {
// Given
let dataStore = DataStoreMock()
dataStore.values[ResourcesWriter.Constants.knownResourcesKey] = Set(["2", "1"]).asData()
dataStore.values[ResourcesWriter.Constants.storeCreationKey] = (Date().timeIntervalSince1970 - 31.days).asData()
let core = PassthroughCoreMock(dataStore: dataStore)
scopeMock.dataStoreMock.values[ResourcesWriter.Constants.knownResourcesKey] = "broken".data(using: .utf8)

// When
let writer = ResourcesWriter(core: core)
XCTAssertNil(dataStore.values[ResourcesWriter.Constants.knownResourcesKey])
_ = ResourcesWriter(scope: scopeMock)

// Then
writer.write(resources: [.mockWith(identifier: "1")])
XCTAssertEqual(core.events(ofType: EnrichedResource.self).count, 1)
XCTAssertEqual(dataStore.values[ResourcesWriter.Constants.knownResourcesKey], Set(["1"]).asData())
XCTAssertTrue(scopeMock.telemetryMock.messages[0].asError?.message.contains("Failed to decode known identifiers - ") ?? false)
}

func testWhenInitialized_itSetsUpDataStore() {
func test_whenDataStoreCreationIsBroken_itLogsTelemetry() {
// Given
let dataStore = DataStoreMock()
let core = PassthroughCoreMock(dataStore: dataStore)
scopeMock.dataStoreMock.values[ResourcesWriter.Constants.storeCreationKey] = "broken".data(using: .utf8)

// When
_ = ResourcesWriter(core: core)
_ = ResourcesWriter(scope: scopeMock)

// Then
XCTAssertNotNil(dataStore.values[ResourcesWriter.Constants.storeCreationKey])
XCTAssertNil(dataStore.values[ResourcesWriter.Constants.knownResourcesKey])
XCTAssertEqual(scopeMock.telemetryMock.messages[0].asError?.message, "Failed to decode store creation - invalidData")
}
}
2 changes: 1 addition & 1 deletion TestUtilities/Mocks/CoreMocks/FeatureScopeMock.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ public class FeatureScopeMock: FeatureScope {
}

/// Retrieve data written in Data Store.
public let dataStoreMock: DataStore = NOPDataStore()
public let dataStoreMock = DataStoreMock()

/// Retrieve telemetries sent to Telemetry endpoint.
public let telemetryMock = TelemetryMock()
Expand Down

0 comments on commit 4aea59f

Please sign in to comment.