Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Deserializing Events #4724

Draft
wants to merge 13 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 9 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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,14 @@
- Allow hybrid SDK to set replay options tags information (#4710)
- Add threshold to always log fatal logs (#4707)

### Improvements

- Add error logging for invalid `cacheDirectoryPath` (#4693)

### Internal

- Change macros TEST and TESTCI to SENTRY_TEST and SENTRY_TEST_CI (#4712)
- Deserializing SentryEvents with Decodable (#4724)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • 🚫 The changelog entry seems to be part of an already released section ## 8.44.0.
    Consider moving the entry to the ## Unreleased section, please.

### Internal

- Change macros TEST and TESTCI to SENTRY_TEST and SENTRY_TEST_CI (#4712)
Expand Down
52 changes: 52 additions & 0 deletions Sentry.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

18 changes: 16 additions & 2 deletions Sources/Sentry/SentryGeo.m
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#import "SentryGeo.h"

#import "SentrySwift.h"
#import <Foundation/Foundation.h>

NS_ASSUME_NONNULL_BEGIN
Expand All @@ -21,7 +21,21 @@ - (id)copyWithZone:(nullable NSZone *)zone

- (NSDictionary<NSString *, id> *)serialize
{
return @{ @"city" : self.city, @"country_code" : self.countryCode, @"region" : self.region };
NSMutableDictionary *serializedData = [[NSMutableDictionary alloc] init];

if (self.city) {
[serializedData setValue:self.city forKey:@"city"];
}

if (self.countryCode) {
[serializedData setValue:self.countryCode forKey:@"country_code"];
}

if (self.region) {
[serializedData setValue:self.region forKey:@"region"];
}

return serializedData;
}

- (BOOL)isEqual:(id _Nullable)other
Expand Down
2 changes: 2 additions & 0 deletions Sources/Sentry/include/SentryDateUtils.h
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

NS_ASSUME_NONNULL_BEGIN

SENTRY_EXTERN NSDateFormatter *sentryGetIso8601FormatterWithMillisecondPrecision(void);

SENTRY_EXTERN NSDate *sentry_fromIso8601String(NSString *string);

SENTRY_EXTERN NSString *sentry_toIso8601String(NSDate *date);
Expand Down
1 change: 1 addition & 0 deletions Sources/Sentry/include/SentryPrivate.h
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
// Headers that also import SentryDefines should be at the end of this list
// otherwise it wont compile
#import "SentryDateUtil.h"
#import "SentryDateUtils.h"
#import "SentryDisplayLinkWrapper.h"
#import "SentryLevelHelper.h"
#import "SentryLogC.h"
Expand Down
105 changes: 105 additions & 0 deletions Sources/Swift/Protocol/Codable/DecodeArbitraryData.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
@_implementationOnly import _SentryPrivate

/// Represents arbitrary data that can be decoded from JSON with Decodable.
///
/// - Note: Some classes on the protocol allow adding extra data in a dictionary of type String:Any.
/// Users can put anything in there that can be serialized to JSON. The SDK uses JSONSerialization to
/// serialize these dictionaries. At first glance, you could assume that we can use JSONSerialization.jsonObject(with:options)
/// to deserialize these dictionaries, but we can't. When using Decodable, you don't have access to the raw
/// data of the JSON. The Decoder and the DecodingContainers don't offer methods to access the underlying
/// data. The Swift Decodable converts the raw data to a JSON object and then casts the JSON object to the
/// class that implements the Decodable protocol, see:
/// https://github.com/swiftlang/swift-foundation/blob/e9d59b6065ad909fee15f174bd5ca2c580490388/Sources/FoundationEssentials/JSON/JSONDecoder.swift#L360-L386
/// https://github.com/swiftlang/swift-foundation/blob/e9d59b6065ad909fee15f174bd5ca2c580490388/Sources/FoundationEssentials/JSON/JSONScanner.swift#L343-L383

/// Therefore, we have to implement decoding the arbitrary dictionary manually.
///
/// A discarded option is to decode the JSON raw data twice: once with Decodable and once with the JSONSerialization.
/// This has two significant downsides: First, we deserialize the JSON twice, which is a performance overhead. Second,
/// we don't conform to the Decodable protocol, which could lead to unwanted hard-to-detect problems in the future.
enum ArbitraryData: Decodable {
case string(String)
case int(Int)
case number(Double)
case boolean(Bool)
case date(Date)
case dict([String: ArbitraryData])
case array([ArbitraryData])
case null

init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()

if let dateValue = try? container.decode(Date.self) {
self = .date(dateValue)
} else if let stringValue = try? container.decode(String.self) {
self = .string(stringValue)
} else if let intValue = try? container.decode(Int.self) {
self = .int(intValue)
} else if let numberValue = try? container.decode(Double.self) {
self = .number(numberValue)
} else if let boolValue = try? container.decode(Bool.self) {
self = .boolean(boolValue)
} else if let objectValue = try? container.decode([String: ArbitraryData].self) {
self = .dict(objectValue)
} else if let arrayValue = try? container.decode([ArbitraryData].self) {
self = .array(arrayValue)
} else if container.decodeNil() {
self = .null
} else {
throw DecodingError.dataCorruptedError(
in: container,
debugDescription: "Invalid JSON value"
)
}
}
}

func decodeArbitraryData(decode: () throws -> [String: ArbitraryData]?) -> [String: Any]? {
do {
let rawData = try decode()
return unwrapArbitraryDict(rawData)
} catch {
SentryLog.error("Failed to decode raw data: \(error)")
return nil

Check warning on line 64 in Sources/Swift/Protocol/Codable/DecodeArbitraryData.swift

View check run for this annotation

Codecov / codecov/patch

Sources/Swift/Protocol/Codable/DecodeArbitraryData.swift#L63-L64

Added lines #L63 - L64 were not covered by tests
}
}

private func unwrapArbitraryDict(_ dict: [String: ArbitraryData]?) -> [String: Any]? {
guard let nonNullDict = dict else {
return nil
}

return nonNullDict.mapValues { unwrapArbitraryValue($0) as Any }
}

private func unwrapArbitraryArray(_ array: [ArbitraryData]?) -> [Any]? {
guard let nonNullArray = array else {
return nil

Check warning on line 78 in Sources/Swift/Protocol/Codable/DecodeArbitraryData.swift

View check run for this annotation

Codecov / codecov/patch

Sources/Swift/Protocol/Codable/DecodeArbitraryData.swift#L78

Added line #L78 was not covered by tests
}

return nonNullArray.map { unwrapArbitraryValue($0) as Any }
}

private func unwrapArbitraryValue(_ value: ArbitraryData?) -> Any? {
switch value {
case .string(let stringValue):
return stringValue
case .number(let numberValue):
return numberValue
case .int(let intValue):
return intValue
case .boolean(let boolValue):
return boolValue
case .date(let dateValue):
return dateValue
case .dict(let dictValue):
return unwrapArbitraryDict(dictValue)
case .array(let arrayValue):
return unwrapArbitraryArray(arrayValue)
case .null:
return NSNull()
case .none:
return nil
}
}
22 changes: 22 additions & 0 deletions Sources/Swift/Protocol/Codable/NSNumberDecodableWrapper.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
struct NSNumberDecodableWrapper: Decodable {
let value: NSNumber?

init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let intValue = try? container.decode(Int.self) {
value = NSNumber(value: intValue)
}
// On 32-bit platforms UInt is UInt32, so we use UInt64 to cover all platforms.
// We don't need UInt128 because NSNumber doesn't support it.
else if let uint64Value = try? container.decode(UInt64.self) {
value = NSNumber(value: uint64Value)
} else if let doubleValue = try? container.decode(Double.self) {
value = NSNumber(value: doubleValue)
} else if let boolValue = try? container.decode(Bool.self) {
value = NSNumber(value: boolValue)
} else {
SentryLog.warning("Failed to decode NSNumber from container for key: \(container.codingPath.last?.stringValue ?? "unknown")")
value = nil
}
}
}
19 changes: 19 additions & 0 deletions Sources/Swift/Protocol/Codable/SentryCodable.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
@_implementationOnly import _SentryPrivate
import Foundation

func decodeFromJSONData<T: Decodable>(jsonData: Data) -> T? {
if jsonData.isEmpty {
return nil
}

do {
let decoder = JSONDecoder()
let formatter = sentryGetIso8601FormatterWithMillisecondPrecision()
decoder.dateDecodingStrategy = .formatted(formatter)
return try decoder.decode(T.self, from: jsonData)
} catch {
SentryLog.error("Could not decode object of type \(T.self) from JSON data due to error: \(error)")
}

return nil
}
42 changes: 42 additions & 0 deletions Sources/Swift/Protocol/Codable/SentryFrameCodable.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
@_implementationOnly import _SentryPrivate
import Foundation

extension Frame: Decodable {

enum CodingKeys: String, CodingKey {
case symbolAddress = "symbol_addr"
case fileName = "filename"
case function
case module
case package
case imageAddress = "image_addr"
case platform
case instructionAddress = "instruction_addr"
// Leaving out instruction on purpose. The event payload does not contain this field
// and SentryFrame.serialize doesn't add it to the serialized dict.
// We will remove the property in the next major see:
// https://github.com/getsentry/sentry-cocoa/issues/4738
case lineNumber = "lineno"
case columnNumber = "colno"
case inApp = "in_app"
case stackStart = "stack_start"
}

required convenience public init(from decoder: any Decoder) throws {
self.init()

let container = try decoder.container(keyedBy: CodingKeys.self)
self.symbolAddress = try container.decodeIfPresent(String.self, forKey: .symbolAddress)
self.fileName = try container.decodeIfPresent(String.self, forKey: .fileName)
self.function = try container.decodeIfPresent(String.self, forKey: .function)
self.module = try container.decodeIfPresent(String.self, forKey: .module)
self.package = try container.decodeIfPresent(String.self, forKey: .package)
self.imageAddress = try container.decodeIfPresent(String.self, forKey: .imageAddress)
self.platform = try container.decodeIfPresent(String.self, forKey: .platform)
self.instructionAddress = try container.decodeIfPresent(String.self, forKey: .instructionAddress)
self.lineNumber = (try container.decodeIfPresent(NSNumberDecodableWrapper.self, forKey: .lineNumber))?.value
self.columnNumber = (try container.decodeIfPresent(NSNumberDecodableWrapper.self, forKey: .columnNumber))?.value
self.inApp = (try container.decodeIfPresent(NSNumberDecodableWrapper.self, forKey: .inApp))?.value
self.stackStart = (try container.decodeIfPresent(NSNumberDecodableWrapper.self, forKey: .stackStart))?.value
}
}
19 changes: 19 additions & 0 deletions Sources/Swift/Protocol/Codable/SentryGeoCodable.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
@_implementationOnly import _SentryPrivate
import Foundation

extension Geo: Decodable {

private enum CodingKeys: String, CodingKey {
case city
case countryCode = "country_code"
case region
}

required convenience public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.init()
self.city = try container.decodeIfPresent(String.self, forKey: .city)
self.countryCode = try container.decodeIfPresent(String.self, forKey: .countryCode)
self.region = try container.decodeIfPresent(String.self, forKey: .region)
}
}
41 changes: 41 additions & 0 deletions Sources/Swift/Protocol/Codable/SentryUserCodable.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
@_implementationOnly import _SentryPrivate
import Foundation

extension User: Decodable {

enum CodingKeys: String, CodingKey {
case userId = "id"
case email
case username
case ipAddress = "ip_address"
case segment
case name
case geo
case data
}

@available(*, deprecated, message: """
This method is only deprecated to silence the deprecation warning of the property \
segment. Our Xcode project has deprecations as warnings and warnings as errors \
configured. Therefore, compilation fails without marking this init method as \
deprecated. It is safe to use this deprecated init method. Instead of turning off \
deprecation warnings for the whole project, we accept the tradeoff of marking this \
init method as deprecated because we don't expect many users to use it. Sadly, \
Swift doesn't offer a better way of silencing a deprecation warning.
""")
required convenience public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.init()
self.userId = try container.decodeIfPresent(String.self, forKey: .userId)
self.email = try container.decodeIfPresent(String.self, forKey: .email)
self.username = try container.decodeIfPresent(String.self, forKey: .username)
self.ipAddress = try container.decodeIfPresent(String.self, forKey: .ipAddress)
self.segment = try container.decodeIfPresent(String.self, forKey: .segment)
self.name = try container.decodeIfPresent(String.self, forKey: .name)
self.geo = try container.decodeIfPresent(Geo.self, forKey: .geo)

self.data = decodeArbitraryData {
try container.decodeIfPresent([String: ArbitraryData].self, forKey: .data)
}
}
}
Loading
Loading