Skip to content

Commit c436a9a

Browse files
authored
Merge pull request #3096 from DataDog/fix/telemetry-error-encoding
[RUM-17396] Standardize and sanitize error reporting for internal SDK telemetry
2 parents e0f7f3f + f83c16e commit c436a9a

22 files changed

Lines changed: 711 additions & 44 deletions

File tree

AGENTS.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,11 @@ CONTRIBUTING.md ← General contribution guidelines
2020
2121
docs/
2222
├── ARCHITECTURE.md ← Module structure, data flow, key abstractions, protocols,
23-
error handling, thread safety, HTTP upload, dependencies
23+
│ thread safety, HTTP upload, dependencies
2424
├── CONVENTIONS.md ← Naming, SwiftLint rules, conditional compilation,
2525
│ generated models, file headers, commit/PR format
26+
├── ERROR_HANDLING.md ← Customer-facing error safety + internal telemetry
27+
│ error reporting (TelemetrySanitizableError)
2628
├── DEVELOPMENT.md ← Recipes for adding features/commands/providers,
2729
│ RFC process, build & test quick reference
2830
├── TESTING.md ← Test conventions, mock infrastructure (.mockAny(),
@@ -50,6 +52,7 @@ Feature-specific docs (in each module directory):
5052
| Write or fix tests | `docs/TESTING.md` |
5153
| Check naming, lint, commit format | `docs/CONVENTIONS.md` |
5254
| Touch swizzling code | `docs/SWIZZLING.md` |
55+
| Report an error to internal telemetry | `docs/ERROR_HANDLING.md` |
5356
| Modify a fragile area | `docs/KNOWN_CONCERNS.md` |
5457
| Work on RUM specifically | `DatadogRUM/RUM_FEATURE.md` |
5558
| Work on Session Replay specifically | `DatadogSessionReplay/SESSION_REPLAY_FEATURE.md` |
@@ -71,6 +74,7 @@ Feature-specific docs (in each module directory):
7174
- **Do NOT name branches with `codex`.** Use repo branch naming conventions instead.
7275
- **Never mention AI assistant names** (Claude, ChatGPT, Cursor, Copilot, etc.) in commit messages, PR descriptions, code comments, or co-author tags.
7376
- **Never write or modify a swizzle without reading `docs/SWIZZLING.md` first.** Past incidents have caused production crashes.
77+
- **Never report a raw `Error` to internal telemetry.** `"\(error)"` can embed customer data (e.g. `EncodingError.invalidValue` embeds the offending value). Always go through `Telemetry.error(_:)`, which sanitizes via `TelemetrySanitizableError`; see `docs/ERROR_HANDLING.md`. Past incidents have leaked customer PII and auth tokens this way.
7478

7579
## Quick Reference
7680

Datadog/Datadog.xcodeproj/project.pbxproj

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1014,6 +1014,8 @@
10141014
D23039F8298D5236001A1FA3 /* InternalLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = D23039CE298D5235001A1FA3 /* InternalLogger.swift */; };
10151015
D23039F9298D5236001A1FA3 /* CoreLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = D23039CF298D5235001A1FA3 /* CoreLogger.swift */; };
10161016
D23039FA298D5236001A1FA3 /* Telemetry.swift in Sources */ = {isa = PBXBuildFile; fileRef = D23039D0298D5235001A1FA3 /* Telemetry.swift */; };
1017+
6165D7353013888F0069D0E4 /* TelemetrySanitizableError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6165D7343013888F0069D0E4 /* TelemetrySanitizableError.swift */; };
1018+
6165D7373017AA010069D0E4 /* TelemetrySanitizableErrorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6165D7363017AA010069D0E4 /* TelemetrySanitizableErrorTests.swift */; };
10171019
D23039FB298D5236001A1FA3 /* URLRequestBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = D23039D2298D5235001A1FA3 /* URLRequestBuilder.swift */; };
10181020
D23039FC298D5236001A1FA3 /* DataFormat.swift in Sources */ = {isa = PBXBuildFile; fileRef = D23039D3298D5235001A1FA3 /* DataFormat.swift */; };
10191021
D23039FD298D5236001A1FA3 /* DataCompression.swift in Sources */ = {isa = PBXBuildFile; fileRef = D23039D4298D5235001A1FA3 /* DataCompression.swift */; };
@@ -2975,6 +2977,8 @@
29752977
D23039CE298D5235001A1FA3 /* InternalLogger.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InternalLogger.swift; sourceTree = "<group>"; };
29762978
D23039CF298D5235001A1FA3 /* CoreLogger.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CoreLogger.swift; sourceTree = "<group>"; };
29772979
D23039D0298D5235001A1FA3 /* Telemetry.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Telemetry.swift; sourceTree = "<group>"; };
2980+
6165D7343013888F0069D0E4 /* TelemetrySanitizableError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelemetrySanitizableError.swift; sourceTree = "<group>"; };
2981+
6165D7363017AA010069D0E4 /* TelemetrySanitizableErrorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelemetrySanitizableErrorTests.swift; sourceTree = "<group>"; };
29782982
D23039D2298D5235001A1FA3 /* URLRequestBuilder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = URLRequestBuilder.swift; sourceTree = "<group>"; };
29792983
D23039D3298D5235001A1FA3 /* DataFormat.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DataFormat.swift; sourceTree = "<group>"; };
29802984
D23039D4298D5235001A1FA3 /* DataCompression.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DataCompression.swift; sourceTree = "<group>"; };
@@ -6300,6 +6304,7 @@
63006304
isa = PBXGroup;
63016305
children = (
63026306
D21AE6BB29E5EDAF0064BF29 /* TelemetryTests.swift */,
6307+
6165D7363017AA010069D0E4 /* TelemetrySanitizableErrorTests.swift */,
63036308
);
63046309
path = Telemetry;
63056310
sourceTree = "<group>";
@@ -6503,6 +6508,7 @@
65036508
D23039CE298D5235001A1FA3 /* InternalLogger.swift */,
65046509
11EA5C322DC5077000E8DFA2 /* InternalLogger+objc.swift */,
65056510
D23039D0298D5235001A1FA3 /* Telemetry.swift */,
6511+
6165D7343013888F0069D0E4 /* TelemetrySanitizableError.swift */,
65066512
);
65076513
path = Telemetry;
65086514
sourceTree = "<group>";
@@ -8968,6 +8974,7 @@
89688974
D23039EE298D5236001A1FA3 /* FeatureMessageReceiver.swift in Sources */,
89698975
D23039DE298D5235001A1FA3 /* Writer.swift in Sources */,
89708976
D23039FA298D5236001A1FA3 /* Telemetry.swift in Sources */,
8977+
6165D7353013888F0069D0E4 /* TelemetrySanitizableError.swift in Sources */,
89718978
D23039FC298D5236001A1FA3 /* DataFormat.swift in Sources */,
89728979
D2160CED29C0E0E600FAA9A5 /* DatadogURLSessionHandler.swift in Sources */,
89738980
1166C5A52F76EC0B008E34BC /* OperationOptions.swift in Sources */,
@@ -9500,6 +9507,7 @@
95009507
61F3E3662BC595F600C7881E /* HTTPHeadersReaderTests.swift in Sources */,
95019508
D2EBEE3C29BA163E00B15732 /* B3HTTPHeadersWriterTests.swift in Sources */,
95029509
D21AE6BC29E5EDAF0064BF29 /* TelemetryTests.swift in Sources */,
9510+
6165D7373017AA010069D0E4 /* TelemetrySanitizableErrorTests.swift in Sources */,
95039511
D2DA23A3298D58F400C6C7E6 /* AnyEncodableTests.swift in Sources */,
95049512
3CCECDAF2BC688120013C125 /* SpanIDGeneratorTests.swift in Sources */,
95059513
D263BCB429DB014900FA0E21 /* FixedWidthInteger+ConvenienceTests.swift in Sources */,

DatadogCore/Sources/Core/DataStore/DataStoreFileWriter.swift

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,25 @@ internal enum DataStoreFileWritingError: Error {
1212
case failedToEncodeData(Error)
1313
}
1414

15+
extension DataStoreFileWritingError: TelemetrySanitizableError {
16+
func sanitize() -> TelemetrySanitizedError {
17+
let kind: String
18+
let wrapped: Error
19+
switch self {
20+
case .failedToEncodeVersion(let error):
21+
kind = "failedToEncodeVersion"
22+
wrapped = error
23+
case .failedToEncodeData(let error):
24+
kind = "failedToEncodeData"
25+
wrapped = error
26+
}
27+
return TelemetrySanitizedError(
28+
kind: "DataStoreFileWritingError",
29+
message: "\(kind)(\(TelemetrySanitizedError(sanitizing: wrapped).message))"
30+
)
31+
}
32+
}
33+
1534
internal struct DataStoreFileWriter {
1635
let file: File
1736

DatadogCore/Sources/Core/DataStore/FeatureDataStore.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ internal final class FeatureDataStore: DataStore {
5050
try self.write(data: value, forKey: key, version: version)
5151
} catch let error {
5252
DD.logger.error("[Data Store] Error on setting `\(key)` value for `\(self.feature)`", error: error)
53-
self.telemetry.error("[Data Store] Error on setting `\(key)` value for `\(self.feature)`", error: DDError(error: error))
53+
self.telemetry.error("[Data Store] Error on setting `\(key)` value for `\(self.feature)`", error: error)
5454
}
5555
}
5656
}
@@ -67,7 +67,7 @@ internal final class FeatureDataStore: DataStore {
6767
} catch let error {
6868
callback(.error(error))
6969
DD.logger.error("[Data Store] Error on getting `\(key)` value for `\(self.feature)`", error: error)
70-
self.telemetry.error("[Data Store] Error on getting `\(key)` value for `\(self.feature)`", error: DDError(error: error))
70+
self.telemetry.error("[Data Store] Error on getting `\(key)` value for `\(self.feature)`", error: error)
7171
}
7272
}
7373
}
@@ -82,7 +82,7 @@ internal final class FeatureDataStore: DataStore {
8282
try self.deleteData(forKey: key)
8383
} catch let error {
8484
DD.logger.error("[Data Store] Error on deleting `\(key)` value for `\(self.feature)`", error: error)
85-
self.telemetry.error("[Data Store] Error on deleting `\(key)` value for `\(self.feature)`", error: DDError(error: error))
85+
self.telemetry.error("[Data Store] Error on deleting `\(key)` value for `\(self.feature)`", error: error)
8686
}
8787
}
8888
}
@@ -94,7 +94,7 @@ internal final class FeatureDataStore: DataStore {
9494
try directory?.deleteAllFiles()
9595
} catch let error {
9696
DD.logger.error("[Data Store] Error on clearing all data for `\(self.feature)`", error: error)
97-
self.telemetry.error("[Data Store] Error on clearing all data for `\(self.feature)`", error: DDError(error: error))
97+
self.telemetry.error("[Data Store] Error on clearing all data for `\(self.feature)`", error: error)
9898
}
9999
}
100100
}

DatadogCore/Sources/Core/TLV/TLVBlockReader.swift

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
*/
66

77
import Foundation
8+
import DatadogInternal
89

910
/// A block reader can read TLV formatted blocks from a data input.
1011
///
@@ -148,11 +149,24 @@ internal final class TLVBlockReader<BlockType> where BlockType: RawRepresentable
148149
return try read(length: Int(length))
149150
}
150151
}
152+
extension TLVBlockError: TelemetrySanitizableError {
153+
/// Every case only ever describes block types, sizes, limits and stream status codes - never the
154+
/// raw bytes or decoded content of a block - so the full description is safe to report as-is.
155+
/// `readOperationFailed`'s `streamError` is the one exception: it's an arbitrary, foreign `Error`
156+
/// (typically an `NSError`), and `NSError.userInfo` content depends on the error's domain - some
157+
/// domains keep it minimal, others (e.g. `NSCocoaErrorDomain` file errors) can include the file
158+
/// path - so `description` routes it through `TelemetrySanitizedError.init(sanitizing:)` rather than
159+
/// assuming this particular source is safe to interpolate directly.
160+
func sanitize() -> TelemetrySanitizedError {
161+
TelemetrySanitizedError(unsafelyDescribing: self)
162+
}
163+
}
164+
151165
extension TLVBlockError: CustomStringConvertible {
152166
var description: String {
153167
switch self {
154168
case .readOperationFailed(let status, let error):
155-
let error = error.map { "\($0)" } ?? "(null)"
169+
let error = error.map { TelemetrySanitizedError(sanitizing: $0) }.map { "\($0.kind): \($0.message)" } ?? "(null)"
156170
return "DataBlock read operation failed with stream status: \(status.rawValue), error: \(error)"
157171
case .invalidDataType(let type):
158172
return "Invalid DataBlock type: \(type)"

DatadogCore/Tests/Datadog/Core/TLV/TLVBlockTests.swift

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,4 +64,26 @@ class TLVBlockTests: XCTestCase {
6464
)
6565
}
6666
}
67+
68+
func testSanitizingReadOperationFailed_neverReportsRawStreamError() {
69+
// Given
70+
// `streamError`'s `userInfo` content depends on its domain - `NSCocoaErrorDomain` file errors,
71+
// for instance, can include the file path via `NSFilePathErrorKey` - so it must go through the
72+
// same central `NSError` sanitization as any other foreign error, not be interpolated directly
73+
// into `TLVBlockError`'s description.
74+
let sensitiveFilePath = "/var/mobile/Containers/session-\(String.mockRandom(length: 16))/file.dat"
75+
let streamError = NSError(
76+
domain: NSCocoaErrorDomain,
77+
code: 260,
78+
userInfo: [NSFilePathErrorKey: sensitiveFilePath]
79+
)
80+
let error = TLVBlockError.readOperationFailed(streamStatus: .error, streamError: streamError)
81+
82+
// When
83+
let sanitized = error.sanitize()
84+
85+
// Then
86+
XCTAssertFalse(sanitized.message.contains(sensitiveFilePath))
87+
XCTAssertTrue(sanitized.message.contains("domain: \(NSCocoaErrorDomain), code: 260"))
88+
}
6789
}

DatadogCore/Tests/Datadog/Core/Upload/DataUploadWorkerTests.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -712,7 +712,7 @@ class DataUploadWorkerTests: XCTestCase {
712712
XCTAssertEqual(telemetry.messages.count, 2)
713713

714714
let error = try XCTUnwrap(telemetry.messages.firstError(), "An error should be send to `telemetry`.")
715-
XCTAssertEqual(error.message, #"Data upload finished with error - Error Domain=abc Code=0 "(null)""#)
715+
XCTAssertEqual(error.message, "Data upload finished with error - domain: abc, code: 0")
716716

717717
let metric = try XCTUnwrap(telemetry.messages.firstMetric(named: "upload_quality"), "An upload quality metric should be send to `telemetry`.")
718718
XCTAssertEqual(metric.attributes["failure"] as? String, "\(nserror.code)")
@@ -753,7 +753,7 @@ class DataUploadWorkerTests: XCTestCase {
753753
XCTAssertEqual(telemetry.messages.count, 2)
754754

755755
let error = try XCTUnwrap(telemetry.messages.firstError(), "An error should be send to `telemetry`.")
756-
XCTAssertEqual(error.message, #"Failed to initiate 'some-feature' data upload - Failed to prepare upload"#)
756+
XCTAssertEqual(error.message, "Failed to initiate 'some-feature' data upload - ErrorMock does not conform to TelemetrySanitizableError — reporting type name only")
757757

758758
let metric = try XCTUnwrap(telemetry.messages.firstMetric(named: "upload_quality"), "An upload quality metric should be send to `telemetry`.")
759759
XCTAssertEqual(metric.attributes["failure"] as? String, "invalid")

DatadogInternal/Sources/Telemetry/Telemetry.swift

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -321,50 +321,68 @@ extension Telemetry {
321321
id: "\(file):\(line):\(message)",
322322
message: message,
323323
kind: kind ?? "\(file)",
324-
stack: stack ?? "\(file):\(line)"
324+
stack: stack.map { "\(file):\(line)\n\($0)" } ?? "\(file):\(line)"
325325
)
326326
}
327327

328328
/// Collect execution error.
329329
///
330+
/// - Note: Not exposed publicly — accepts only `TelemetrySanitizedError`, so a raw, unsanitized
331+
/// error description can never reach this sink. Callers should use `error(_ error: Error, ...)`
332+
/// instead, which sanitizes the error via `TelemetrySanitizedError.init(sanitizing:)` before
333+
/// forwarding here.
334+
///
330335
/// - Parameters:
331-
/// - error: The error.
336+
/// - error: The sanitized error.
332337
/// - file: The current file name.
333338
/// - line: The line number in file.
334-
public func error(_ error: DDError, file: String = #fileID, line: Int = #line) {
335-
self.error(error.message, kind: error.type, stack: error.stack, file: file, line: line)
339+
func error(_ error: TelemetrySanitizedError, file: String = #fileID, line: Int = #line) {
340+
self.error(error.message, kind: error.kind, stack: error.stack, file: file, line: line)
336341
}
337342

338343
/// Collect execution error.
339344
///
345+
/// - Note: Not exposed publicly — accepts only `TelemetrySanitizedError`, so a raw, unsanitized
346+
/// error description can never reach this sink. Callers should use
347+
/// `error(_ message: String, error: Error, ...)` instead, which sanitizes the error via
348+
/// `TelemetrySanitizedError.init(sanitizing:)` before forwarding here.
349+
///
340350
/// - Parameters:
341351
/// - message: The error message.
342-
/// - error: The error.
352+
/// - error: The sanitized error.
343353
/// - file: The current file name.
344354
/// - line: The line number in file.
345-
public func error(_ message: String, error: DDError, file: String = #fileID, line: Int = #line) {
346-
self.error("\(message) - \(error.message)", kind: error.type, stack: error.stack, file: file, line: line)
355+
func error(_ message: String, error: TelemetrySanitizedError, file: String = #fileID, line: Int = #line) {
356+
self.error("\(message) - \(error.message)", kind: error.kind, stack: error.stack, file: file, line: line)
347357
}
348358

349359
/// Collect execution error.
350360
///
361+
/// - Note: If `error` conforms to `TelemetrySanitizableError`, its own sanitized context is reported.
362+
/// Otherwise, it falls back to Telemetry's default sanitization, which may drop most contextual
363+
/// information to avoid leaking sensitive data.
364+
///
351365
/// - Parameters:
352366
/// - error: The error.
353367
/// - file: The current file name.
354368
/// - line: The line number in file.
355369
public func error(_ error: Error, file: String = #fileID, line: Int = #line) {
356-
self.error(DDError(error: error), file: file, line: line)
370+
self.error(TelemetrySanitizedError(sanitizing: error), file: file, line: line)
357371
}
358372

359373
/// Collect execution error.
360374
///
375+
/// - Note: If `error` conforms to `TelemetrySanitizableError`, its own sanitized context is reported.
376+
/// Otherwise, it falls back to Telemetry's default sanitization, which may drop most contextual
377+
/// information to avoid leaking sensitive data.
378+
///
361379
/// - Parameters:
362380
/// - message: The error message.
363381
/// - error: The error.
364382
/// - file: The current file name.
365383
/// - line: The line number in file.
366384
public func error(_ message: String, error: Error, file: String = #fileID, line: Int = #line) {
367-
self.error(message, error: DDError(error: error), file: file, line: line)
385+
self.error(message, error: TelemetrySanitizedError(sanitizing: error), file: file, line: line)
368386
}
369387

370388
/// Report a Configuration Telemetry.

0 commit comments

Comments
 (0)