Skip to content

Commit 9b16daa

Browse files
ypopovychclaude
andauthored
Trim span tags, metadata and log attribute values to backend 5000-char limit (#269)
The backend accepts at most 5000 characters per tag for spans and span metadata. Add the limit as `AttributesSanitizer.Constraints.maxAttributeValueLength` (the common sanitizer for all features) and enforce it across: - Span tags: trim values via `AttributesSanitizer.sanitizeValues`, reused by both `DDSpan` (`SpanSanitizer`) and `TestSpan`. Non-numeric values are serialized to their `description` (the representation the encoders emit) and truncated; numeric values pass through as metrics. - Span metadata: trim keys and string values via `SpanMetadata.trimmed(toMaxLength:)`, applied when the file header is built/updated in `SpansExporter`. - Log attribute values: trim string values in `LogSanitizer`, referencing the shared constant. - `TestError` crash-log splitting now uses the shared constant instead of a hardcoded 5000. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ee8887b commit 9b16daa

9 files changed

Lines changed: 164 additions & 11 deletions

File tree

Sources/DatadogSDKTesting/Models.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -233,15 +233,15 @@ struct TestError: Error, CustomDebugStringConvertible {
233233
self.crashLog = nil
234234
return
235235
}
236-
if stack.count < 5000 {
236+
if stack.count < AttributesSanitizer.Constraints.maxAttributeValueLength {
237237
self.message = message
238238
self.stack = stack
239239
self.crashLog = nil
240240
} else {
241241
self.message = message.map { $0 + ". " } ?? ""
242242
+ "Check error.crash_log for the full crash log."
243243
self.stack = DDSymbolicator.calculateCrashedThread(stack: stack)
244-
self.crashLog = stack.split(by: 5000)
244+
self.crashLog = stack.split(by: AttributesSanitizer.Constraints.maxAttributeValueLength)
245245
}
246246
}
247247

Sources/EventsExporter/Logs/LogSanitizer.swift

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ internal struct LogSanitizer {
2222
/// Maximum number of attributes in log.
2323
/// If this number is exceeded, extra attributes will be ignored.
2424
static let maxNumberOfAttributes: Int = 256
25+
/// Maximum number of characters the backend accepts per attribute value.
26+
/// String values exceeding this length will be truncated.
27+
static let maxAttributeValueLength: Int = AttributesSanitizer.Constraints.maxAttributeValueLength
2528
/// Allowed first character of a tag name (given as ASCII values ranging from lowercased `a` to `z`) .
2629
/// Tags with name starting with different character will be dropped.
2730
static let allowedTagNameFirstCharacterASCIIRange: [UInt8] = Array(97 ... 122)
@@ -62,6 +65,7 @@ internal struct LogSanitizer {
6265
userAttributes = removeInvalidAttributes(userAttributes)
6366
userAttributes = removeReservedAttributes(userAttributes)
6467
userAttributes = sanitizeAttributeNames(userAttributes)
68+
userAttributes = limitAttributeValueLength(userAttributes)
6569
let userAttributesLimit = Constraints.maxNumberOfAttributes - (rawAttributes.internalAttributes?.count ?? 0)
6670
userAttributes = limitToMaxNumberOfAttributes(userAttributes, limit: userAttributesLimit)
6771

@@ -119,6 +123,17 @@ internal struct LogSanitizer {
119123
return sanitized
120124
}
121125

126+
private func limitAttributeValueLength(_ attributes: [String: Encodable]) -> [String: Encodable] {
127+
// Only string values can realistically exceed the limit; other types are left untouched.
128+
return attributes.mapValues { value in
129+
guard let string = value as? String, string.count > Constraints.maxAttributeValueLength else {
130+
return value
131+
}
132+
Log.print("Attribute value exceeds the limit of \(Constraints.maxAttributeValueLength) characters. It will be truncated.")
133+
return String(string.prefix(Constraints.maxAttributeValueLength))
134+
}
135+
}
136+
122137
private func limitToMaxNumberOfAttributes(_ attributes: [String: Encodable], limit: Int) -> [String: Encodable] {
123138
// Only `limit` number of attributes are allowed.
124139
if attributes.count > limit {

Sources/EventsExporter/Spans/SpanMetadata.swift

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,24 @@ public struct SpanMetadata {
4848
return mapped.count > 0 ? mapped : nil
4949
}
5050
}
51+
52+
/// Returns a copy with every metadata key and string value truncated to
53+
/// `maxLength` characters, to satisfy the backend per-tag length limit.
54+
func trimmed(toMaxLength maxLength: Int) -> SpanMetadata {
55+
var result = SpanMetadata()
56+
for (type, values) in meta {
57+
let spanType = SpanType(type)
58+
for (key, value) in values {
59+
let trimmedKey = key.count > maxLength ? String(key.prefix(maxLength)) : key
60+
if case .string(let string) = value, string.count > maxLength {
61+
result[spanType, trimmedKey] = .string(String(string.prefix(maxLength)))
62+
} else {
63+
result[spanType, trimmedKey] = value
64+
}
65+
}
66+
}
67+
return result
68+
}
5169
}
5270

5371
public extension SpanMetadata {

Sources/EventsExporter/Spans/SpanSanitizer.swift

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,16 @@ internal struct SpanSanitizer {
1111
private let attributesSanitizer = AttributesSanitizer(featureName: "Span")
1212

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

1717
var sanitizedSpan = span
18-
sanitizedSpan.tags = sanitizedTags
18+
sanitizedSpan.tags = attributesSanitizer.sanitizeValues(for: sanitizedTags)
1919
return sanitizedSpan
2020
}
21+
22+
/// Trims span metadata keys and string values to the backend per-tag length limit.
23+
func sanitize(metadata: SpanMetadata) -> SpanMetadata {
24+
metadata.trimmed(toMaxLength: AttributesSanitizer.Constraints.maxAttributeValueLength)
25+
}
2126
}

Sources/EventsExporter/Spans/SpansExporter.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ internal final class SpansExporter: SpanExporter {
2222
dateProvider: SystemDateProvider()
2323
)
2424

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

@@ -51,7 +51,7 @@ internal final class SpansExporter: SpanExporter {
5151
/// and rotate the writable file so the new header takes effect on the
5252
/// next batch. The runtime-id stays pinned across updates.
5353
func setMetadata(_ meta: SpanMetadata) {
54-
var meta = meta
54+
var meta = SpanSanitizer().sanitize(metadata: meta)
5555
meta[string: "runtime-id"] = self.runtimeId
5656
// `try!` is safe: `Header` is a fixed-shape struct that always encodes.
5757
let dataFormat = try! DataFormat(header: Header(metadata: meta.metadata),

Sources/EventsExporter/Spans/TestSpan.swift

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -108,10 +108,11 @@ struct TestSpan: Encodable {
108108
self.resource = spanData.attributes.resource ?? spanData.name
109109
self.service = spanData.resource.service ?? ""
110110

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

116117
var meta = sanitized.meta
117118
var metrics = sanitized.metrics

Sources/EventsExporter/Utils/AttributesSanitizer.swift

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,20 @@
55
*/
66

77
import Foundation
8+
import OpenTelemetryApi
89

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

2024
let featureName: String
@@ -57,4 +61,29 @@ internal struct AttributesSanitizer {
5761
}
5862
return sanitized
5963
}
64+
65+
// MARK: - Attribute values sanitization
66+
67+
/// Trims attribute values to `Constraints.maxAttributeValueLength` characters
68+
/// to match the backend per-tag length limit.
69+
func sanitizeValues(for attributes: [String: AttributeValue]) -> [String: AttributeValue] {
70+
attributes.mapValues { Self.trim($0) }
71+
}
72+
73+
/// Trims a single attribute value to `Constraints.maxAttributeValueLength` characters.
74+
///
75+
/// Numeric values (`.int` / `.double`) are sent as numbers and left unchanged.
76+
/// Every other value is serialized to its `description` (the same representation
77+
/// the encoders emit), so it is converted to `.string` here and truncated, ensuring
78+
/// the value that actually reaches the backend respects the length limit.
79+
static func trim(_ value: AttributeValue) -> AttributeValue {
80+
switch value {
81+
case .int, .double:
82+
return value
83+
default:
84+
let maxLength = Constraints.maxAttributeValueLength
85+
let string = value.description
86+
return .string(string.count > maxLength ? String(string.prefix(maxLength)) : string)
87+
}
88+
}
6089
}

Tests/EventsExporter/Logs/LogSanitizerTests.swift

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,26 @@ class LogSanitizerTests: XCTestCase {
103103
XCTAssertEqual(sanitized.attributes.userAttributes.count, LogSanitizer.Constraints.maxNumberOfAttributes)
104104
}
105105

106+
func testWhenStringAttributeValueExceedsMaxLength_itIsTruncated() {
107+
let longValue = String(repeating: "x", count: LogSanitizer.Constraints.maxAttributeValueLength + 100)
108+
let shortValue = String(repeating: "y", count: 10)
109+
let log = DDLog.mockWith(
110+
attributes: .mockWith(
111+
userAttributes: [
112+
"long": longValue,
113+
"short": shortValue,
114+
"number": 42,
115+
]
116+
)
117+
)
118+
119+
let sanitized = LogSanitizer().sanitize(log: log)
120+
121+
XCTAssertEqual((sanitized.attributes.userAttributes["long"] as? String)?.count, LogSanitizer.Constraints.maxAttributeValueLength)
122+
XCTAssertEqual(sanitized.attributes.userAttributes["short"] as? String, shortValue)
123+
XCTAssertEqual(sanitized.attributes.userAttributes["number"] as? Int, 42)
124+
}
125+
106126
func testInternalAttributesAreNotSanitized() {
107127
let log = DDLog.mockWith(
108128
attributes: .mockWith(

Tests/EventsExporter/Spans/SpanSanitizerTests.swift

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,4 +46,69 @@ class SpanSanitizerTests: XCTestCase {
4646
XCTAssertNotNil(sanitized.tags["tag-one.two.three.four.five.six.seven.eight.nine.ten_eleven"])
4747
XCTAssertNotNil(sanitized.tags["tag-one.two.three.four.five.six.seven.eight.nine.ten_eleven_twelve"])
4848
}
49+
50+
func testWhenTagValueExceedsMaxLength_itIsTruncated() {
51+
let longValue = String(repeating: "x", count: AttributesSanitizer.Constraints.maxAttributeValueLength + 100)
52+
let shortValue = String(repeating: "y", count: 10)
53+
54+
let spanData = SpanData(traceId: TraceId(), spanId: SpanId(), name: "spanName", kind: .client, startTime: Date(), attributes: [
55+
"long-tag": AttributeValue(longValue)!,
56+
"short-tag": AttributeValue(shortValue)!,
57+
], endTime: Date().addingTimeInterval(1.0))
58+
59+
let ddSpan = DDSpan(spanData: spanData)
60+
61+
// When
62+
let sanitized = SpanSanitizer().sanitize(span: ddSpan)
63+
64+
// Then
65+
XCTAssertEqual(sanitized.tags["long-tag"]?.description.count, AttributesSanitizer.Constraints.maxAttributeValueLength)
66+
XCTAssertEqual(sanitized.tags["short-tag"]?.description, shortValue)
67+
}
68+
69+
func testWhenNonStringTagValueExceedsMaxLength_itIsStringifiedAndTruncated() {
70+
let maxLength = AttributesSanitizer.Constraints.maxAttributeValueLength
71+
let longArray = Array(repeating: "item", count: maxLength)
72+
73+
let spanData = SpanData(traceId: TraceId(), spanId: SpanId(), name: "spanName", kind: .client, startTime: Date(), attributes: [
74+
"array-tag": AttributeValue(longArray),
75+
"bool-tag": AttributeValue(true),
76+
"int-tag": AttributeValue(42),
77+
], endTime: Date().addingTimeInterval(1.0))
78+
79+
let ddSpan = DDSpan(spanData: spanData)
80+
81+
// When
82+
let sanitized = SpanSanitizer().sanitize(span: ddSpan)
83+
84+
// Then
85+
// The array is serialized to its `description` and truncated to the limit.
86+
if case .string(let value)? = sanitized.tags["array-tag"] {
87+
XCTAssertEqual(value.count, maxLength)
88+
} else {
89+
XCTFail("Expected array tag to be converted to a string")
90+
}
91+
// Booleans are stringified (short, so not truncated); numbers are left as-is.
92+
XCTAssertEqual(sanitized.tags["bool-tag"], .string("true"))
93+
XCTAssertEqual(sanitized.tags["int-tag"], .int(42))
94+
}
95+
96+
func testWhenMetadataKeyOrValueExceedsMaxLength_itIsTruncated() {
97+
let longKey = String(repeating: "k", count: AttributesSanitizer.Constraints.maxAttributeValueLength + 100)
98+
let longValue = String(repeating: "v", count: AttributesSanitizer.Constraints.maxAttributeValueLength + 100)
99+
let shortValue = String(repeating: "s", count: 10)
100+
101+
var metadata = SpanMetadata()
102+
metadata[string: longKey] = longValue
103+
metadata[string: "short-key"] = shortValue
104+
105+
// When
106+
let sanitized = SpanSanitizer().sanitize(metadata: metadata).metadata[SpanMetadata.SpanType.generic.rawValue]
107+
108+
// Then
109+
let trimmedKey = String(longKey.prefix(AttributesSanitizer.Constraints.maxAttributeValueLength))
110+
XCTAssertNil(sanitized?[longKey])
111+
XCTAssertEqual(sanitized?[trimmedKey]?.string?.count, AttributesSanitizer.Constraints.maxAttributeValueLength)
112+
XCTAssertEqual(sanitized?["short-key"]?.string, shortValue)
113+
}
49114
}

0 commit comments

Comments
 (0)