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
4 changes: 2 additions & 2 deletions Sources/DatadogSDKTesting/Models.swift
Original file line number Diff line number Diff line change
Expand Up @@ -233,15 +233,15 @@ struct TestError: Error, CustomDebugStringConvertible {
self.crashLog = nil
return
}
if stack.count < 5000 {
if stack.count < AttributesSanitizer.Constraints.maxAttributeValueLength {
self.message = message
self.stack = stack
self.crashLog = nil
} else {
self.message = message.map { $0 + ". " } ?? ""
+ "Check error.crash_log for the full crash log."
self.stack = DDSymbolicator.calculateCrashedThread(stack: stack)
self.crashLog = stack.split(by: 5000)
self.crashLog = stack.split(by: AttributesSanitizer.Constraints.maxAttributeValueLength)
}
}

Expand Down
15 changes: 15 additions & 0 deletions Sources/EventsExporter/Logs/LogSanitizer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ internal struct LogSanitizer {
/// Maximum number of attributes in log.
/// If this number is exceeded, extra attributes will be ignored.
static let maxNumberOfAttributes: Int = 256
/// Maximum number of characters the backend accepts per attribute value.
/// String values exceeding this length will be truncated.
static let maxAttributeValueLength: Int = AttributesSanitizer.Constraints.maxAttributeValueLength
/// Allowed first character of a tag name (given as ASCII values ranging from lowercased `a` to `z`) .
/// Tags with name starting with different character will be dropped.
static let allowedTagNameFirstCharacterASCIIRange: [UInt8] = Array(97 ... 122)
Expand Down Expand Up @@ -62,6 +65,7 @@ internal struct LogSanitizer {
userAttributes = removeInvalidAttributes(userAttributes)
userAttributes = removeReservedAttributes(userAttributes)
userAttributes = sanitizeAttributeNames(userAttributes)
userAttributes = limitAttributeValueLength(userAttributes)
let userAttributesLimit = Constraints.maxNumberOfAttributes - (rawAttributes.internalAttributes?.count ?? 0)
userAttributes = limitToMaxNumberOfAttributes(userAttributes, limit: userAttributesLimit)

Expand Down Expand Up @@ -119,6 +123,17 @@ internal struct LogSanitizer {
return sanitized
}

private func limitAttributeValueLength(_ attributes: [String: Encodable]) -> [String: Encodable] {
// Only string values can realistically exceed the limit; other types are left untouched.
return attributes.mapValues { value in
guard let string = value as? String, string.count > Constraints.maxAttributeValueLength else {
return value
}
Log.print("Attribute value exceeds the limit of \(Constraints.maxAttributeValueLength) characters. It will be truncated.")
return String(string.prefix(Constraints.maxAttributeValueLength))
}
}

private func limitToMaxNumberOfAttributes(_ attributes: [String: Encodable], limit: Int) -> [String: Encodable] {
// Only `limit` number of attributes are allowed.
if attributes.count > limit {
Expand Down
18 changes: 18 additions & 0 deletions Sources/EventsExporter/Spans/SpanMetadata.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,24 @@ public struct SpanMetadata {
return mapped.count > 0 ? mapped : nil
}
}

/// Returns a copy with every metadata key and string value truncated to
/// `maxLength` characters, to satisfy the backend per-tag length limit.
func trimmed(toMaxLength maxLength: Int) -> SpanMetadata {
var result = SpanMetadata()
for (type, values) in meta {
let spanType = SpanType(type)
for (key, value) in values {
let trimmedKey = key.count > maxLength ? String(key.prefix(maxLength)) : key
if case .string(let string) = value, string.count > maxLength {
result[spanType, trimmedKey] = .string(String(string.prefix(maxLength)))
} else {
result[spanType, trimmedKey] = value
}
}
}
return result
}
}

public extension SpanMetadata {
Expand Down
9 changes: 7 additions & 2 deletions Sources/EventsExporter/Spans/SpanSanitizer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,16 @@ internal struct SpanSanitizer {
private let attributesSanitizer = AttributesSanitizer(featureName: "Span")

func sanitize(span: DDSpan) -> DDSpan {
// Sanitize attribute names
// Sanitize attribute names, then trim values to the backend per-tag length limit.
let sanitizedTags = attributesSanitizer.sanitizeKeys(for: span.tags)

var sanitizedSpan = span
sanitizedSpan.tags = sanitizedTags
sanitizedSpan.tags = attributesSanitizer.sanitizeValues(for: sanitizedTags)
return sanitizedSpan
}

/// Trims span metadata keys and string values to the backend per-tag length limit.
func sanitize(metadata: SpanMetadata) -> SpanMetadata {
metadata.trimmed(toMaxLength: AttributesSanitizer.Constraints.maxAttributeValueLength)
}
}
4 changes: 2 additions & 2 deletions Sources/EventsExporter/Spans/SpansExporter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@
import OpenTelemetrySdk

internal final class SpansExporter: SpanExporter {
let configuration: ExporterConfiguration

Check warning on line 11 in Sources/EventsExporter/Spans/SpansExporter.swift

View workflow job for this annotation

GitHub Actions / Xcode_26.2 / tvOSsim

stored property 'configuration' of 'Sendable'-conforming class 'SpansExporter' has non-Sendable type 'ExporterConfiguration'; this is an error in the Swift 6 language mode

Check warning on line 11 in Sources/EventsExporter/Spans/SpansExporter.swift

View workflow job for this annotation

GitHub Actions / Xcode_26.4 / tvOSsim

stored property 'configuration' of 'Sendable'-conforming class 'SpansExporter' has non-Sendable type 'ExporterConfiguration'; this is an error in the Swift 6 language mode

Check warning on line 11 in Sources/EventsExporter/Spans/SpansExporter.swift

View workflow job for this annotation

GitHub Actions / Xcode_26.4 / macOS

stored property 'configuration' of 'Sendable'-conforming class 'SpansExporter' has non-Sendable type 'ExporterConfiguration'; this is an error in the Swift 6 language mode

Check warning on line 11 in Sources/EventsExporter/Spans/SpansExporter.swift

View workflow job for this annotation

GitHub Actions / Xcode_26.4 / macOS

stored property 'configuration' of 'Sendable'-conforming class 'SpansExporter' has non-Sendable type 'ExporterConfiguration'; this is an error in the Swift 6 language mode

Check warning on line 11 in Sources/EventsExporter/Spans/SpansExporter.swift

View workflow job for this annotation

GitHub Actions / Xcode_26.4 / watchOSsim

stored property 'configuration' of 'Sendable'-conforming class 'SpansExporter' has non-Sendable type 'ExporterConfiguration'; this is an error in the Swift 6 language mode

Check warning on line 11 in Sources/EventsExporter/Spans/SpansExporter.swift

View workflow job for this annotation

GitHub Actions / Xcode_26.4 / watchOSsim

stored property 'configuration' of 'Sendable'-conforming class 'SpansExporter' has non-Sendable type 'ExporterConfiguration'; this is an error in the Swift 6 language mode

Check warning on line 11 in Sources/EventsExporter/Spans/SpansExporter.swift

View workflow job for this annotation

GitHub Actions / Xcode_26.2 / macOS

stored property 'configuration' of 'Sendable'-conforming class 'SpansExporter' has non-Sendable type 'ExporterConfiguration'; this is an error in the Swift 6 language mode

Check warning on line 11 in Sources/EventsExporter/Spans/SpansExporter.swift

View workflow job for this annotation

GitHub Actions / Xcode_26.4 / iOSsim

stored property 'configuration' of 'Sendable'-conforming class 'SpansExporter' has non-Sendable type 'ExporterConfiguration'; this is an error in the Swift 6 language mode

Check warning on line 11 in Sources/EventsExporter/Spans/SpansExporter.swift

View workflow job for this annotation

GitHub Actions / Xcode_26.2 / watchOSsim

stored property 'configuration' of 'Sendable'-conforming class 'SpansExporter' has non-Sendable type 'ExporterConfiguration'; this is an error in the Swift 6 language mode

Check warning on line 11 in Sources/EventsExporter/Spans/SpansExporter.swift

View workflow job for this annotation

GitHub Actions / Xcode_26.2 / watchOSsim

stored property 'configuration' of 'Sendable'-conforming class 'SpansExporter' has non-Sendable type 'ExporterConfiguration'; this is an error in the Swift 6 language mode

Check warning on line 11 in Sources/EventsExporter/Spans/SpansExporter.swift

View workflow job for this annotation

GitHub Actions / Xcode_26.2 / iOSsim

stored property 'configuration' of 'Sendable'-conforming class 'SpansExporter' has non-Sendable type 'ExporterConfiguration'; this is an error in the Swift 6 language mode

Check warning on line 11 in Sources/EventsExporter/Spans/SpansExporter.swift

View workflow job for this annotation

GitHub Actions / Xcode_26.2 / iOSsim

stored property 'configuration' of 'Sendable'-conforming class 'SpansExporter' has non-Sendable type 'ExporterConfiguration'; this is an error in the Swift 6 language mode

Check warning on line 11 in Sources/EventsExporter/Spans/SpansExporter.swift

View workflow job for this annotation

GitHub Actions / Xcode_26.4 / visionOSsim

stored property 'configuration' of 'Sendable'-conforming class 'SpansExporter' has non-Sendable type 'ExporterConfiguration'; this is an error in the Swift 6 language mode

Check warning on line 11 in Sources/EventsExporter/Spans/SpansExporter.swift

View workflow job for this annotation

GitHub Actions / Xcode_26.4 / visionOSsim

stored property 'configuration' of 'Sendable'-conforming class 'SpansExporter' has non-Sendable type 'ExporterConfiguration'; this is an error in the Swift 6 language mode

Check warning on line 11 in Sources/EventsExporter/Spans/SpansExporter.swift

View workflow job for this annotation

GitHub Actions / Xcode_26.2 / visionOSsim

stored property 'configuration' of 'Sendable'-conforming class 'SpansExporter' has non-Sendable type 'ExporterConfiguration'; this is an error in the Swift 6 language mode
let runtimeId: String
let spansStorage: FeatureStoreAndUpload

Check warning on line 13 in Sources/EventsExporter/Spans/SpansExporter.swift

View workflow job for this annotation

GitHub Actions / Xcode_26.2 / tvOSsim

stored property 'spansStorage' of 'Sendable'-conforming class 'SpansExporter' has non-Sendable type 'FeatureStoreAndUpload'; this is an error in the Swift 6 language mode

Check warning on line 13 in Sources/EventsExporter/Spans/SpansExporter.swift

View workflow job for this annotation

GitHub Actions / Xcode_26.4 / tvOSsim

stored property 'spansStorage' of 'Sendable'-conforming class 'SpansExporter' has non-Sendable type 'FeatureStoreAndUpload'; this is an error in the Swift 6 language mode

Check warning on line 13 in Sources/EventsExporter/Spans/SpansExporter.swift

View workflow job for this annotation

GitHub Actions / Xcode_26.4 / macOS

stored property 'spansStorage' of 'Sendable'-conforming class 'SpansExporter' has non-Sendable type 'FeatureStoreAndUpload'; this is an error in the Swift 6 language mode

Check warning on line 13 in Sources/EventsExporter/Spans/SpansExporter.swift

View workflow job for this annotation

GitHub Actions / Xcode_26.4 / macOS

stored property 'spansStorage' of 'Sendable'-conforming class 'SpansExporter' has non-Sendable type 'FeatureStoreAndUpload'; this is an error in the Swift 6 language mode

Check warning on line 13 in Sources/EventsExporter/Spans/SpansExporter.swift

View workflow job for this annotation

GitHub Actions / Xcode_26.4 / watchOSsim

stored property 'spansStorage' of 'Sendable'-conforming class 'SpansExporter' has non-Sendable type 'FeatureStoreAndUpload'; this is an error in the Swift 6 language mode

Check warning on line 13 in Sources/EventsExporter/Spans/SpansExporter.swift

View workflow job for this annotation

GitHub Actions / Xcode_26.4 / watchOSsim

stored property 'spansStorage' of 'Sendable'-conforming class 'SpansExporter' has non-Sendable type 'FeatureStoreAndUpload'; this is an error in the Swift 6 language mode

Check warning on line 13 in Sources/EventsExporter/Spans/SpansExporter.swift

View workflow job for this annotation

GitHub Actions / Xcode_26.2 / macOS

stored property 'spansStorage' of 'Sendable'-conforming class 'SpansExporter' has non-Sendable type 'FeatureStoreAndUpload'; this is an error in the Swift 6 language mode

Check warning on line 13 in Sources/EventsExporter/Spans/SpansExporter.swift

View workflow job for this annotation

GitHub Actions / Xcode_26.4 / iOSsim

stored property 'spansStorage' of 'Sendable'-conforming class 'SpansExporter' has non-Sendable type 'FeatureStoreAndUpload'; this is an error in the Swift 6 language mode

Check warning on line 13 in Sources/EventsExporter/Spans/SpansExporter.swift

View workflow job for this annotation

GitHub Actions / Xcode_26.2 / watchOSsim

stored property 'spansStorage' of 'Sendable'-conforming class 'SpansExporter' has non-Sendable type 'FeatureStoreAndUpload'; this is an error in the Swift 6 language mode

Check warning on line 13 in Sources/EventsExporter/Spans/SpansExporter.swift

View workflow job for this annotation

GitHub Actions / Xcode_26.2 / watchOSsim

stored property 'spansStorage' of 'Sendable'-conforming class 'SpansExporter' has non-Sendable type 'FeatureStoreAndUpload'; this is an error in the Swift 6 language mode

Check warning on line 13 in Sources/EventsExporter/Spans/SpansExporter.swift

View workflow job for this annotation

GitHub Actions / Xcode_26.2 / iOSsim

stored property 'spansStorage' of 'Sendable'-conforming class 'SpansExporter' has non-Sendable type 'FeatureStoreAndUpload'; this is an error in the Swift 6 language mode

Check warning on line 13 in Sources/EventsExporter/Spans/SpansExporter.swift

View workflow job for this annotation

GitHub Actions / Xcode_26.2 / iOSsim

stored property 'spansStorage' of 'Sendable'-conforming class 'SpansExporter' has non-Sendable type 'FeatureStoreAndUpload'; this is an error in the Swift 6 language mode

Check warning on line 13 in Sources/EventsExporter/Spans/SpansExporter.swift

View workflow job for this annotation

GitHub Actions / Xcode_26.4 / visionOSsim

stored property 'spansStorage' of 'Sendable'-conforming class 'SpansExporter' has non-Sendable type 'FeatureStoreAndUpload'; this is an error in the Swift 6 language mode

Check warning on line 13 in Sources/EventsExporter/Spans/SpansExporter.swift

View workflow job for this annotation

GitHub Actions / Xcode_26.4 / visionOSsim

stored property 'spansStorage' of 'Sendable'-conforming class 'SpansExporter' has non-Sendable type 'FeatureStoreAndUpload'; this is an error in the Swift 6 language mode

Check warning on line 13 in Sources/EventsExporter/Spans/SpansExporter.swift

View workflow job for this annotation

GitHub Actions / Xcode_26.2 / visionOSsim

stored property 'spansStorage' of 'Sendable'-conforming class 'SpansExporter' has non-Sendable type 'FeatureStoreAndUpload'; this is an error in the Swift 6 language mode
private let encoder: JSONEncoder

init(config: ExporterConfiguration, storage: Directory, api: SpansApi) throws {
Expand All @@ -22,7 +22,7 @@
dateProvider: SystemDateProvider()
)

var metadata = config.metadata
var metadata = SpanSanitizer().sanitize(metadata: config.metadata)
self.runtimeId = metadata[string: "runtime-id"] ?? UUID().uuidString.lowercased()
metadata[string: "runtime-id"] = self.runtimeId

Expand Down Expand Up @@ -51,7 +51,7 @@
/// and rotate the writable file so the new header takes effect on the
/// next batch. The runtime-id stays pinned across updates.
func setMetadata(_ meta: SpanMetadata) {
var meta = meta
var meta = SpanSanitizer().sanitize(metadata: meta)
meta[string: "runtime-id"] = self.runtimeId
// `try!` is safe: `Header` is a fixed-shape struct that always encodes.
let dataFormat = try! DataFormat(header: Header(metadata: meta.metadata),
Expand Down
7 changes: 4 additions & 3 deletions Sources/EventsExporter/Spans/TestSpan.swift
Original file line number Diff line number Diff line change
Expand Up @@ -108,10 +108,11 @@ struct TestSpan: Encodable {
self.resource = spanData.attributes.resource ?? spanData.name
self.service = spanData.resource.service ?? ""

// Sanitize attribute keys (escape over-deep dot paths) before
// splitting into meta/metrics.
// Sanitize attribute keys (escape over-deep dot paths) and trim
// over-long string values before splitting into meta/metrics.
let filtered = spanData.attributes.filter { !Self.filteredKeys.contains($0.key) }
let sanitized = AttributesSanitizer(featureName: "TestSpan").sanitizeKeys(for: filtered)
let sanitizer = AttributesSanitizer(featureName: "TestSpan")
let sanitized = sanitizer.sanitizeValues(for: sanitizer.sanitizeKeys(for: filtered))

var meta = sanitized.meta
var metrics = sanitized.metrics
Expand Down
33 changes: 31 additions & 2 deletions Sources/EventsExporter/Utils/AttributesSanitizer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,20 @@
*/

import Foundation
import OpenTelemetryApi

/// Common attributes sanitizer for all features.
internal struct AttributesSanitizer {
enum Constraints {
public struct AttributesSanitizer {
public enum Constraints {
/// Maximum number of nested levels in attribute name. E.g. `person.address.street` has 3 levels.
/// If attribute name exceeds this number, extra levels are escaped by using `_` character (`one.two.(...).nine.ten_eleven_twelve`).
static let maxNestedLevelsInAttributeName: Int = 10
/// Maximum number of attributes in log.
/// If this number is exceeded, extra attributes will be ignored.
static let maxNumberOfAttributes: Int = 256
/// Maximum number of characters the backend accepts per attribute value
/// (and per span tag / metadata key). Longer string values are truncated.
public static let maxAttributeValueLength: Int = 5_000
}

let featureName: String
Expand Down Expand Up @@ -57,4 +61,29 @@ internal struct AttributesSanitizer {
}
return sanitized
}

// MARK: - Attribute values sanitization

/// Trims attribute values to `Constraints.maxAttributeValueLength` characters
/// to match the backend per-tag length limit.
func sanitizeValues(for attributes: [String: AttributeValue]) -> [String: AttributeValue] {
attributes.mapValues { Self.trim($0) }
}

/// Trims a single attribute value to `Constraints.maxAttributeValueLength` characters.
///
/// Numeric values (`.int` / `.double`) are sent as numbers and left unchanged.
/// Every other value is serialized to its `description` (the same representation
/// the encoders emit), so it is converted to `.string` here and truncated, ensuring
/// the value that actually reaches the backend respects the length limit.
static func trim(_ value: AttributeValue) -> AttributeValue {
switch value {
case .int, .double:
return value
default:
let maxLength = Constraints.maxAttributeValueLength
let string = value.description
return .string(string.count > maxLength ? String(string.prefix(maxLength)) : string)
}
}
}
20 changes: 20 additions & 0 deletions Tests/EventsExporter/Logs/LogSanitizerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,26 @@ class LogSanitizerTests: XCTestCase {
XCTAssertEqual(sanitized.attributes.userAttributes.count, LogSanitizer.Constraints.maxNumberOfAttributes)
}

func testWhenStringAttributeValueExceedsMaxLength_itIsTruncated() {
let longValue = String(repeating: "x", count: LogSanitizer.Constraints.maxAttributeValueLength + 100)
let shortValue = String(repeating: "y", count: 10)
let log = DDLog.mockWith(
attributes: .mockWith(
userAttributes: [
"long": longValue,
"short": shortValue,
"number": 42,
]
)
)

let sanitized = LogSanitizer().sanitize(log: log)

XCTAssertEqual((sanitized.attributes.userAttributes["long"] as? String)?.count, LogSanitizer.Constraints.maxAttributeValueLength)
XCTAssertEqual(sanitized.attributes.userAttributes["short"] as? String, shortValue)
XCTAssertEqual(sanitized.attributes.userAttributes["number"] as? Int, 42)
}

func testInternalAttributesAreNotSanitized() {
let log = DDLog.mockWith(
attributes: .mockWith(
Expand Down
65 changes: 65 additions & 0 deletions Tests/EventsExporter/Spans/SpanSanitizerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,69 @@ class SpanSanitizerTests: XCTestCase {
XCTAssertNotNil(sanitized.tags["tag-one.two.three.four.five.six.seven.eight.nine.ten_eleven"])
XCTAssertNotNil(sanitized.tags["tag-one.two.three.four.five.six.seven.eight.nine.ten_eleven_twelve"])
}

func testWhenTagValueExceedsMaxLength_itIsTruncated() {
let longValue = String(repeating: "x", count: AttributesSanitizer.Constraints.maxAttributeValueLength + 100)
let shortValue = String(repeating: "y", count: 10)

let spanData = SpanData(traceId: TraceId(), spanId: SpanId(), name: "spanName", kind: .client, startTime: Date(), attributes: [
"long-tag": AttributeValue(longValue)!,
"short-tag": AttributeValue(shortValue)!,
], endTime: Date().addingTimeInterval(1.0))

let ddSpan = DDSpan(spanData: spanData)

// When
let sanitized = SpanSanitizer().sanitize(span: ddSpan)

// Then
XCTAssertEqual(sanitized.tags["long-tag"]?.description.count, AttributesSanitizer.Constraints.maxAttributeValueLength)
XCTAssertEqual(sanitized.tags["short-tag"]?.description, shortValue)
}

func testWhenNonStringTagValueExceedsMaxLength_itIsStringifiedAndTruncated() {
let maxLength = AttributesSanitizer.Constraints.maxAttributeValueLength
let longArray = Array(repeating: "item", count: maxLength)

let spanData = SpanData(traceId: TraceId(), spanId: SpanId(), name: "spanName", kind: .client, startTime: Date(), attributes: [
"array-tag": AttributeValue(longArray),
"bool-tag": AttributeValue(true),
"int-tag": AttributeValue(42),
], endTime: Date().addingTimeInterval(1.0))

let ddSpan = DDSpan(spanData: spanData)

// When
let sanitized = SpanSanitizer().sanitize(span: ddSpan)

// Then
// The array is serialized to its `description` and truncated to the limit.
if case .string(let value)? = sanitized.tags["array-tag"] {
XCTAssertEqual(value.count, maxLength)
} else {
XCTFail("Expected array tag to be converted to a string")
}
// Booleans are stringified (short, so not truncated); numbers are left as-is.
XCTAssertEqual(sanitized.tags["bool-tag"], .string("true"))
XCTAssertEqual(sanitized.tags["int-tag"], .int(42))
}

func testWhenMetadataKeyOrValueExceedsMaxLength_itIsTruncated() {
let longKey = String(repeating: "k", count: AttributesSanitizer.Constraints.maxAttributeValueLength + 100)
let longValue = String(repeating: "v", count: AttributesSanitizer.Constraints.maxAttributeValueLength + 100)
let shortValue = String(repeating: "s", count: 10)

var metadata = SpanMetadata()
metadata[string: longKey] = longValue
metadata[string: "short-key"] = shortValue

// When
let sanitized = SpanSanitizer().sanitize(metadata: metadata).metadata[SpanMetadata.SpanType.generic.rawValue]

// Then
let trimmedKey = String(longKey.prefix(AttributesSanitizer.Constraints.maxAttributeValueLength))
XCTAssertNil(sanitized?[longKey])
XCTAssertEqual(sanitized?[trimmedKey]?.string?.count, AttributesSanitizer.Constraints.maxAttributeValueLength)
XCTAssertEqual(sanitized?["short-key"]?.string, shortValue)
}
}
Loading