From 6d6cb71dc7f741c3d79ab2fdaf8a220c77a7ce6e Mon Sep 17 00:00:00 2001 From: Brunno Ferreira Date: Fri, 20 Sep 2024 02:38:06 +1000 Subject: [PATCH] Add `recordException` (#599) * Add `SpanException` to be attached to spans * Add `SpanExceptionRecorder` to describe how a span should behave when recording exceptions * Conform `Span` to `SpanExceptionRecorder` * Extend `Span` to also allow usage of `Error` for recording exceptions --- Examples/Logging Tracer/LoggingSpan.swift | 16 ++ .../Trace/PropagatedSpan.swift | 8 + Sources/OpenTelemetryApi/Trace/Span.swift | 71 +++++++- .../Trace/SpanException.swift | 43 +++++ .../Trace/RecordEventsReadableSpan.swift | 35 ++++ .../Trace/PropagatedSpanTests.swift | 8 + .../Trace/SpanExceptionTests.swift | 82 +++++++++ .../Trace/Mocks/ReadableSpanMock.swift | 118 +++++++------ .../Trace/Mocks/SpanMock.swift | 8 + .../Trace/RecordEventsReadableSpanTests.swift | 166 +++++++++++++++++- 10 files changed, 490 insertions(+), 65 deletions(-) create mode 100644 Sources/OpenTelemetryApi/Trace/SpanException.swift create mode 100644 Tests/OpenTelemetryApiTests/Trace/SpanExceptionTests.swift diff --git a/Examples/Logging Tracer/LoggingSpan.swift b/Examples/Logging Tracer/LoggingSpan.swift index f87f6943..30f6332f 100644 --- a/Examples/Logging Tracer/LoggingSpan.swift +++ b/Examples/Logging Tracer/LoggingSpan.swift @@ -72,6 +72,22 @@ class LoggingSpan: Span { Logger.log("Span.addEvent(\(name), attributes:\(attributes), timestamp:\(timestamp))") } + public func recordException(_ exception: SpanException) { + Logger.log("Span.recordException(\(exception)") + } + + public func recordException(_ exception: SpanException, timestamp: Date) { + Logger.log("Span.recordException(\(exception), timestamp:\(timestamp))") + } + + public func recordException(_ exception: SpanException, attributes: [String : AttributeValue]) { + Logger.log("Span.recordException(\(exception), attributes:\(attributes)") + } + + public func recordException(_ exception: SpanException, attributes: [String : AttributeValue], timestamp: Date) { + Logger.log("Span.recordException(\(exception), attributes:\(attributes), timestamp:\(timestamp))") + } + public func end() { Logger.log("Span.End, Name: \(name)") } diff --git a/Sources/OpenTelemetryApi/Trace/PropagatedSpan.swift b/Sources/OpenTelemetryApi/Trace/PropagatedSpan.swift index f13787a9..313c941b 100644 --- a/Sources/OpenTelemetryApi/Trace/PropagatedSpan.swift +++ b/Sources/OpenTelemetryApi/Trace/PropagatedSpan.swift @@ -89,4 +89,12 @@ class PropagatedSpan: Span { func addEvent(name: String, attributes: [String: AttributeValue]) {} func addEvent(name: String, attributes: [String: AttributeValue], timestamp: Date) {} + + func recordException(_ exception: SpanException) {} + + func recordException(_ exception: any SpanException, timestamp: Date) {} + + func recordException(_ exception: any SpanException, attributes: [String : AttributeValue]) {} + + func recordException(_ exception: any SpanException, attributes: [String : AttributeValue], timestamp: Date) {} } diff --git a/Sources/OpenTelemetryApi/Trace/Span.swift b/Sources/OpenTelemetryApi/Trace/Span.swift index 85f86715..997c5ae5 100644 --- a/Sources/OpenTelemetryApi/Trace/Span.swift +++ b/Sources/OpenTelemetryApi/Trace/Span.swift @@ -44,30 +44,62 @@ public protocol SpanBase: AnyObject, CustomStringConvertible { /// Use this method to specify an explicit event timestamp. If not called, the implementation /// will use the current timestamp value, which should be the default case. /// - Parameters: - /// - name: the name of the even - /// - timestamp: the explicit event timestamp in nanos since epoch + /// - name: the name of the event. + /// - timestamp: the explicit event timestamp in nanos since epoch. func addEvent(name: String, timestamp: Date) /// Adds a single Event with the attributes to the Span. /// - Parameters: - /// - name: Event name. - /// - attributes: Dictionary of attributes name/value pairs associated with the Event + /// - name: the name of the event. + /// - attributes: Dictionary of attributes name/value pairs associated with the event. func addEvent(name: String, attributes: [String: AttributeValue]) /// Adds an event to the Span /// Use this method to specify an explicit event timestamp. If not called, the implementation /// will use the current timestamp value, which should be the default case. /// - Parameters: - /// - name: the name of the even - /// - attributes: Dictionary of attributes name/value pairs associated with the Event - /// - timestamp: the explicit event timestamp in nanos since epoch + /// - name: the name of the event. + /// - attributes: Dictionary of attributes name/value pairs associated with the event + /// - timestamp: the explicit event timestamp in nanos since epoch. func addEvent(name: String, attributes: [String: AttributeValue], timestamp: Date) } +public protocol SpanExceptionRecorder { + /// Adds an exception event to the Span. + /// - Parameters: + /// - exception: the exception to be recorded. + func recordException(_ exception: SpanException) + + /// Adds an exception event to the Span. + /// Use this method to specify an explicit event timestamp. If not called, the implementation + /// will use the current timestamp value, which should be the default case. + /// - Parameters: + /// - exception: the exception to be recorded. + /// - timestamp: the explicit event timestamp in nanos since epoch. + func recordException(_ exception: SpanException, timestamp: Date) + + /// Adds an exception event to the Span, with additional attributes to go alongside the + /// default attribuites derived from the exception itself. + /// - Parameters: + /// - exception: the exception to be recorded. + /// - attributes: Dictionary of attributes name/value pairs associated with the event. + func recordException(_ exception: SpanException, attributes: [String: AttributeValue]) + + /// Adds an exception event to the Span, with additional attributes to go alongside the + /// default attribuites derived from the exception itself. + /// Use this method to specify an explicit event timestamp. If not called, the implementation + /// will use the current timestamp value, which should be the default case. + /// - Parameters: + /// - exception: the exception to be recorded. + /// - attributes: Dictionary of attributes name/value pairs associated with the event. + /// - timestamp: the explicit event timestamp in nanos since epoch. + func recordException(_ exception: SpanException, attributes: [String: AttributeValue], timestamp: Date) +} + /// An interface that represents a span. It has an associated SpanContext. /// Spans are created by the SpanBuilder.startSpan method. /// Span must be ended by calling end(). -public protocol Span: SpanBase { +public protocol Span: SpanBase, SpanExceptionRecorder { /// End the span. func end() @@ -120,6 +152,29 @@ public extension SpanBase { } } +public extension SpanExceptionRecorder { + /// Adds any Error as an exception event to the Span, with optional additional attributes + /// and timestamp. + /// If additonal attributes are specified, they are merged with the default attributes + /// derived from the error itself. + /// If an explicit timestamp is not provided, the implementation will use the current + /// timestamp value, which should be the default case. + /// - Parameters: + /// - exception: the exception to be recorded. + /// - attributes: Dictionary of attributes name/value pairs associated with the event. + /// - timestamp: the explicit event timestamp in nanos since epoch. + func recordException(_ exception: Error, attributes: [String: AttributeValue]? = nil, timestamp: Date? = nil) { + let exception = exception as NSError + + switch (attributes, timestamp) { + case (.none, .none): recordException(exception) + case (.some(let attributes), .none): recordException(exception, attributes: attributes) + case (.none, .some(let timestamp)): recordException(exception, timestamp: timestamp) + case (.some(let attributes), .some(let timestamp)): recordException(exception, attributes: attributes, timestamp: timestamp) + } + } +} + public extension Span { /// Helper method that populates span properties from host and port /// - Parameters: diff --git a/Sources/OpenTelemetryApi/Trace/SpanException.swift b/Sources/OpenTelemetryApi/Trace/SpanException.swift new file mode 100644 index 00000000..5bd2a3e8 --- /dev/null +++ b/Sources/OpenTelemetryApi/Trace/SpanException.swift @@ -0,0 +1,43 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import Foundation + +/// An interface that represents an exception that can be attached to a span. +public protocol SpanException { + var type: String { get } + var message: String? { get } + var stackTrace: [String]? { get } +} + +extension NSError: SpanException { + public var type: String { + String(reflecting: self) + } + + public var message: String? { + localizedDescription + } + + public var stackTrace: [String]? { + nil + } +} + +#if !os(Linux) +extension NSException: SpanException { + public var type: String { + name.rawValue + } + + public var message: String? { + reason + } + + public var stackTrace: [String]? { + callStackSymbols + } +} +#endif diff --git a/Sources/OpenTelemetrySdk/Trace/RecordEventsReadableSpan.swift b/Sources/OpenTelemetrySdk/Trace/RecordEventsReadableSpan.swift index f52f2840..8b3a04c4 100644 --- a/Sources/OpenTelemetrySdk/Trace/RecordEventsReadableSpan.swift +++ b/Sources/OpenTelemetrySdk/Trace/RecordEventsReadableSpan.swift @@ -327,4 +327,39 @@ public class RecordEventsReadableSpan: ReadableSpan { internal func getDroppedLinksCount() -> Int { return totalRecordedLinks - links.count } + + public func recordException(_ exception: SpanException) { + recordException(exception, timestamp: clock.now) + } + + public func recordException(_ exception: any SpanException, timestamp: Date) { + recordException(exception, attributes: [:], timestamp: timestamp) + } + + public func recordException(_ exception: any SpanException, attributes: [String : AttributeValue]) { + recordException(exception, attributes: attributes, timestamp: clock.now) + } + + public func recordException(_ exception: any SpanException, attributes: [String : AttributeValue], timestamp: Date) { + var limitedAttributes = AttributesDictionary(capacity: maxNumberOfAttributesPerEvent) + limitedAttributes.updateValues(attributes: attributes) + limitedAttributes.updateValues(attributes: exception.eventAttributes) + addEvent(event: SpanData.Event(name: SemanticAttributes.exception.rawValue, timestamp: timestamp, attributes: limitedAttributes.attributes)) + } +} + +extension SpanException { + fileprivate var eventAttributes: [String: AttributeValue] { + [ + SemanticAttributes.exceptionType.rawValue: type, + SemanticAttributes.exceptionMessage.rawValue: message, + SemanticAttributes.exceptionStacktrace.rawValue: stackTrace?.joined(separator: "\n") + ].compactMapValues { value in + if let value, !value.isEmpty { + return .string(value) + } + + return nil + } + } } diff --git a/Tests/OpenTelemetryApiTests/Trace/PropagatedSpanTests.swift b/Tests/OpenTelemetryApiTests/Trace/PropagatedSpanTests.swift index 8dd29152..7e0f75d4 100644 --- a/Tests/OpenTelemetryApiTests/Trace/PropagatedSpanTests.swift +++ b/Tests/OpenTelemetryApiTests/Trace/PropagatedSpanTests.swift @@ -42,6 +42,14 @@ final class PropagatedSpanTest: XCTestCase { span.addEvent(name: "event", timestamp: Date(timeIntervalSinceReferenceDate: 0)) span.addEvent(name: "event", attributes: ["MyBooleanAttributeKey": AttributeValue.bool(true)]) span.addEvent(name: "event", attributes: ["MyBooleanAttributeKey": AttributeValue.bool(true)], timestamp: Date(timeIntervalSinceReferenceDate: 1.5)) + span.recordException(NSError(domain: "test", code: 0), timestamp: Date(timeIntervalSinceReferenceDate: 3)) + span.recordException(NSError(domain: "test", code: 0), attributes: ["MyBooleanAttributeKey": AttributeValue.bool(true)], timestamp: Date(timeIntervalSinceReferenceDate: 4.5)) + +#if !os(Linux) + span.recordException(NSException(name: .genericException, reason: nil)) + span.recordException(NSException(name: .genericException, reason: nil), attributes: ["MyStringAttributeKey": AttributeValue.string("MyStringAttributeValue")]) +#endif + span.status = .ok span.end() span.end(time: Date()) diff --git a/Tests/OpenTelemetryApiTests/Trace/SpanExceptionTests.swift b/Tests/OpenTelemetryApiTests/Trace/SpanExceptionTests.swift new file mode 100644 index 00000000..86711d6f --- /dev/null +++ b/Tests/OpenTelemetryApiTests/Trace/SpanExceptionTests.swift @@ -0,0 +1,82 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import Foundation +import XCTest +import OpenTelemetryApi + +final class SpanExceptionTests: XCTestCase { + func testErrorAsSpanException() { + enum TestError: Error { + case test(code: Int) + } + + let error = TestError.test(code: 5) + + // `Error` can be converted to `NSError`, which automatically makes the cast to + // `SpanException` possible since `NSError` conforms to `SpanException`. + let exception = error as SpanException + + XCTAssertEqual(exception.type, String(reflecting: error)) + XCTAssertEqual(exception.message, error.localizedDescription) + XCTAssertNil(exception.stackTrace) + } + + func testCustomNSErrorAsSpanException() throws { + struct TestCustomNSError: Error, CustomNSError { + let additionalComments: String + + var errorUserInfo: [String : Any] { + [NSLocalizedDescriptionKey: "This is a custom NSError: \(additionalComments)"] + } + } + + let error = TestCustomNSError(additionalComments: "SpanExceptionTests") + + // `Error` can be converted to `NSError`, which automatically makes the cast to + // `SpanException` possible since `NSError` conforms to `SpanException`. + let exception = error as SpanException + + XCTAssertEqual(exception.type, String(reflecting: error)) + XCTAssertEqual(exception.message, error.localizedDescription) + XCTAssertNil(exception.stackTrace) + + // `TestCustomNSError` conforms to `CustomNSError`, so the conversion to `NSError` when casting to + // `SpanException` should utilize that protocol and result in a custom `localizedDescription`. + let localizedDescription = try XCTUnwrap(error.errorUserInfo[NSLocalizedDescriptionKey] as? String) + XCTAssertEqual(exception.message, localizedDescription) + } + + func testNSError() { + let nsError = NSError(domain: "Test Domain", code: 1) + let exception = nsError as SpanException + + XCTAssertEqual(exception.type, String(reflecting: nsError)) + XCTAssertEqual(exception.message, nsError.localizedDescription) + XCTAssertNil(exception.stackTrace) + } + +#if !os(Linux) + func testNSException() { + final class TestException: NSException { + override var callStackSymbols: [String] { + [ + "test-stack-entry-1", + "test-stack-entry-2", + "test-stack-entry-3" + ] + } + } + + let exceptionReason = "This is a test exception" + let nsException = TestException(name: .genericException, reason: exceptionReason) + let exception = nsException as SpanException + + XCTAssertEqual(exception.type, nsException.name.rawValue) + XCTAssertEqual(exception.message, nsException.reason) + XCTAssertEqual(exception.stackTrace, nsException.callStackSymbols) + } +#endif +} diff --git a/Tests/OpenTelemetrySdkTests/Trace/Mocks/ReadableSpanMock.swift b/Tests/OpenTelemetrySdkTests/Trace/Mocks/ReadableSpanMock.swift index 70aac329..327aa3e4 100644 --- a/Tests/OpenTelemetrySdkTests/Trace/Mocks/ReadableSpanMock.swift +++ b/Tests/OpenTelemetrySdkTests/Trace/Mocks/ReadableSpanMock.swift @@ -8,59 +8,67 @@ import OpenTelemetryApi @testable import OpenTelemetrySdk class ReadableSpanMock: ReadableSpan { - var hasEnded: Bool = false - var latency: TimeInterval = 0 - - var kind: SpanKind { - return .client - } - - var instrumentationScopeInfo = InstrumentationScopeInfo() - - var name: String = "ReadableSpanMock" - - var forcedReturnSpanContext: SpanContext? - var forcedReturnSpanData: SpanData? - - func end() { - OpenTelemetry.instance.contextProvider.removeContextForSpan(self) - } - - func end(time: Date) { end() } - - func toSpanData() -> SpanData { - return forcedReturnSpanData ?? SpanData(traceId: context.traceId, - spanId: context.spanId, - traceFlags: context.traceFlags, - traceState: TraceState(), - resource: Resource(attributes: [String: AttributeValue]()), - instrumentationScope: InstrumentationScopeInfo(), - name: "ReadableSpanMock", - kind: .client, - startTime: Date(timeIntervalSinceReferenceDate: 0), - endTime: Date(timeIntervalSinceReferenceDate: 0), - hasRemoteParent: false) - } - - var context: SpanContext { - forcedReturnSpanContext ?? SpanContext.create(traceId: TraceId.random(), spanId: SpanId.random(), traceFlags: TraceFlags(), traceState: TraceState()) - } - - var isRecording: Bool = false - - var status: Status = .unset - - func updateName(name: String) {} - - func setAttribute(key: String, value: AttributeValue?) {} - - func addEvent(name: String) {} - - func addEvent(name: String, attributes: [String: AttributeValue]) {} - - func addEvent(name: String, timestamp: Date) {} - - func addEvent(name: String, attributes: [String: AttributeValue], timestamp: Date) {} - - var description: String = "ReadableSpanMock" + var hasEnded: Bool = false + var latency: TimeInterval = 0 + + var kind: SpanKind { + return .client + } + + var instrumentationScopeInfo = InstrumentationScopeInfo() + + var name: String = "ReadableSpanMock" + + var forcedReturnSpanContext: SpanContext? + var forcedReturnSpanData: SpanData? + + func end() { + OpenTelemetry.instance.contextProvider.removeContextForSpan(self) + } + + func end(time: Date) { end() } + + func toSpanData() -> SpanData { + return forcedReturnSpanData ?? SpanData(traceId: context.traceId, + spanId: context.spanId, + traceFlags: context.traceFlags, + traceState: TraceState(), + resource: Resource(attributes: [String: AttributeValue]()), + instrumentationScope: InstrumentationScopeInfo(), + name: "ReadableSpanMock", + kind: .client, + startTime: Date(timeIntervalSinceReferenceDate: 0), + endTime: Date(timeIntervalSinceReferenceDate: 0), + hasRemoteParent: false) + } + + var context: SpanContext { + forcedReturnSpanContext ?? SpanContext.create(traceId: TraceId.random(), spanId: SpanId.random(), traceFlags: TraceFlags(), traceState: TraceState()) + } + + var isRecording: Bool = false + + var status: Status = .unset + + func updateName(name: String) {} + + func setAttribute(key: String, value: AttributeValue?) {} + + func addEvent(name: String) {} + + func addEvent(name: String, attributes: [String: AttributeValue]) {} + + func addEvent(name: String, timestamp: Date) {} + + func addEvent(name: String, attributes: [String: AttributeValue], timestamp: Date) {} + + func recordException(_ exception: SpanException) {} + + func recordException(_ exception: any SpanException, timestamp: Date) {} + + func recordException(_ exception: any SpanException, attributes: [String : AttributeValue]) {} + + func recordException(_ exception: any SpanException, attributes: [String : AttributeValue], timestamp: Date) {} + + var description: String = "ReadableSpanMock" } diff --git a/Tests/OpenTelemetrySdkTests/Trace/Mocks/SpanMock.swift b/Tests/OpenTelemetrySdkTests/Trace/Mocks/SpanMock.swift index b5f3cea4..4f80f496 100644 --- a/Tests/OpenTelemetrySdkTests/Trace/Mocks/SpanMock.swift +++ b/Tests/OpenTelemetrySdkTests/Trace/Mocks/SpanMock.swift @@ -38,5 +38,13 @@ class SpanMock: Span { func addEvent(name: String, attributes: [String: AttributeValue], timestamp: Date) {} + func recordException(_ exception: SpanException) {} + + func recordException(_ exception: any SpanException, timestamp: Date) {} + + func recordException(_ exception: any SpanException, attributes: [String : AttributeValue]) {} + + func recordException(_ exception: any SpanException, attributes: [String : AttributeValue], timestamp: Date) {} + var description: String = "SpanMock" } diff --git a/Tests/OpenTelemetrySdkTests/Trace/RecordEventsReadableSpanTests.swift b/Tests/OpenTelemetrySdkTests/Trace/RecordEventsReadableSpanTests.swift index 8f0101e8..bfba0b70 100644 --- a/Tests/OpenTelemetrySdkTests/Trace/RecordEventsReadableSpanTests.swift +++ b/Tests/OpenTelemetrySdkTests/Trace/RecordEventsReadableSpanTests.swift @@ -233,6 +233,155 @@ class RecordEventsReadableSpanTest: XCTestCase { XCTAssertEqual(spanData.events.count, 2) } +#if !os(Linux) + func testRecordExceptionWithStackTrace() throws { + final class TestException: NSException { + override var callStackSymbols: [String] { + [ + "test-stack-entry-1", + "test-stack-entry-2", + "test-stack-entry-3" + ] + } + } + + let span = createTestRootSpan() + let exception = TestException(name: .genericException, reason: "test reason") + span.recordException(exception) + span.end() + let spanData = span.toSpanData() + XCTAssertEqual(spanData.events.count, 1) + + let spanException = exception as SpanException + let exceptionMessage = try XCTUnwrap(spanException.message) + let exceptionStackTrace = try XCTUnwrap(spanException.stackTrace) + + let exceptionEvent = try XCTUnwrap(spanData.events.first) + let exceptionAttributes = exceptionEvent.attributes + XCTAssertEqual(exceptionEvent.name, SemanticAttributes.exception.rawValue) + XCTAssertEqual(exceptionAttributes[SemanticAttributes.exceptionType.rawValue], .string(spanException.type)) + XCTAssertEqual(exceptionAttributes[SemanticAttributes.exceptionMessage.rawValue], .string(exceptionMessage)) + XCTAssertNil(exceptionAttributes[SemanticAttributes.exceptionEscaped.rawValue]) + XCTAssertEqual(exceptionAttributes[SemanticAttributes.exceptionStacktrace.rawValue], .string(exceptionStackTrace.joined(separator: "\n"))) + } + + func testRecordExceptionWithoutStackTrace() throws { + let span = createTestRootSpan() + let exception = NSException(name: .genericException, reason: "test reason") + span.recordException(exception) + span.end() + let spanData = span.toSpanData() + XCTAssertEqual(spanData.events.count, 1) + + let spanException = exception as SpanException + let exceptionMessage = try XCTUnwrap(spanException.message) + + let exceptionEvent = try XCTUnwrap(spanData.events.first) + let exceptionAttributes = exceptionEvent.attributes + XCTAssertEqual(exceptionEvent.name, SemanticAttributes.exception.rawValue) + XCTAssertEqual(exceptionAttributes[SemanticAttributes.exceptionType.rawValue], .string(spanException.type)) + XCTAssertEqual(exceptionAttributes[SemanticAttributes.exceptionMessage.rawValue], .string(exceptionMessage)) + XCTAssertNil(exceptionAttributes[SemanticAttributes.exceptionEscaped.rawValue]) + XCTAssertNil(exceptionAttributes[SemanticAttributes.exceptionStacktrace.rawValue]) + } + + func testRecordMultipleExceptions() throws { + let span = createTestRootSpan() + + let firstException = NSException(name: .genericException, reason: "test reason") + span.recordException(firstException) + + let secondException = NSError(domain: "test", code: 0) + span.recordException(secondException) + + span.end() + let spanData = span.toSpanData() + XCTAssertEqual(spanData.events.count, 2) + + let firstSpanException = firstException as SpanException + let secondSpanException = secondException as SpanException + let firstExceptionAttributes = try XCTUnwrap(spanData.events.first?.attributes) + let secondExceptionAttributes = try XCTUnwrap(spanData.events.last?.attributes) + XCTAssertEqual(firstExceptionAttributes[SemanticAttributes.exceptionType.rawValue], .string(firstSpanException.type)) + XCTAssertEqual(secondExceptionAttributes[SemanticAttributes.exceptionType.rawValue], .string(secondSpanException.type)) + } +#endif + + func testExceptionAttributesOverwriteAdditionalAttributes() throws { + let span = createTestRootSpan() + let exception = NSError(domain: "test error", code: 5) + span.recordException( + exception, + attributes: [ + SemanticAttributes.exceptionMessage.rawValue: .string("another, different reason"), + "This-Key-Should-Not-Get-Overwritten": .string("original-value") + ] + ) + span.end() + let spanData = span.toSpanData() + XCTAssertEqual(spanData.events.count, 1) + + let spanException = exception as SpanException + let exceptionMessage = try XCTUnwrap(spanException.message) + + let exceptionEvent = try XCTUnwrap(spanData.events.first) + let exceptionAttributes = exceptionEvent.attributes + + // Custom value specified for `SemanticAttributes.exceptionMessage`, + // but overwritten by the value out of the provided exception. + XCTAssertEqual(exceptionAttributes.count, 3) + XCTAssertNotEqual(exceptionMessage, "another, different reason") + XCTAssertEqual(exceptionAttributes[SemanticAttributes.exceptionMessage.rawValue], .string(exceptionMessage)) + XCTAssertEqual(exceptionAttributes[SemanticAttributes.exceptionType.rawValue], .string(spanException.type)) + XCTAssertEqual(exceptionAttributes["This-Key-Should-Not-Get-Overwritten"], .string("original-value")) + } + + func testDroppingEventAttributesWhenRecordingException() throws { + let maxNumberOfAttributes = 3 + let spanLimits = SpanLimits().settingAttributePerEventCountLimit(UInt(maxNumberOfAttributes)) + let span = createTestSpan(config: spanLimits) + let exception = NSError(domain: "test error", code: 0) + let attributes: [String: AttributeValue] = [ + "Additional-Key-1": .string("Additional-Key-1"), + "Additional-Key-2": .string("Value 2"), + "Additional-Key-3": .string("Value 3"), + ] + + span.recordException(exception, attributes: attributes) + span.end() + + let spanData = span.toSpanData() + XCTAssertEqual(spanData.events.count, 1) + + let spanException = exception as SpanException + let exceptionMessage = try XCTUnwrap(spanException.message) + + let exceptionEvent = try XCTUnwrap(spanData.events.first) + let exceptionAttributes = exceptionEvent.attributes + + // Only 3 attributes per event. Exception events have priority (total of 2), so 1 slot left for additional attributes. + // Attributes are added in the order in which their keys appear in the original Dictionary, and are also removed + // using the same sequence when overflowing. However, since the order of key-value pairs in a dictionary is + // unpredictable, there are no guarantees to how they are ingested in the first place. + // + // With that in mind, this test ensures that out of the 3 attributes in the resulting dictionary, 2 are expected due to + // prioritization and the remaining 1 matches an entry from the additional attributes. We just can't be sure + // which of those entries it will be. + XCTAssertEqual(exceptionAttributes.count, 3) + XCTAssertEqual(exceptionAttributes[SemanticAttributes.exceptionMessage.rawValue], .string(exceptionMessage)) + XCTAssertEqual(exceptionAttributes[SemanticAttributes.exceptionType.rawValue], .string(spanException.type)) + + let remainingExceptionAttributeKeys = exceptionAttributes.keys.filter { key in + key != SemanticAttributes.exceptionMessage.rawValue && + key != SemanticAttributes.exceptionType.rawValue + } + + XCTAssertEqual(remainingExceptionAttributeKeys.count, 1) + let remainingKey = try XCTUnwrap(remainingExceptionAttributeKeys.first) + XCTAssertNotNil(attributes[remainingKey]) + XCTAssertEqual(attributes[remainingKey], exceptionAttributes[remainingKey]) + } + func testWithInitializedAttributes() { let attributes = ["hello": AttributeValue.string("world")] @@ -373,12 +522,25 @@ class RecordEventsReadableSpanTest: XCTestCase { let secondEventTimeNanos = clock.now readableSpan.addEvent(name: "event2", attributes: event2Attributes) + clock.advanceMillis(15) + let exceptionTimeNanos = clock.now + let exception = NSError(domain: "test", code: 0) + readableSpan.recordException(NSError(domain: "test", code: 0)) + clock.advanceMillis(100) readableSpan.end() let endTime = clock.now let event1 = SpanData.Event(name: "event1", timestamp: firstEventTimeNanos, attributes: event1Attributes) let event2 = SpanData.Event(name: "event2", timestamp: secondEventTimeNanos, attributes: event2Attributes) - let events = [event1, event2] + let exceptionEvent = SpanData.Event( + name: SemanticAttributes.exception.rawValue, + timestamp: exceptionTimeNanos, + attributes: [ + SemanticAttributes.exceptionType.rawValue: .string(exception.type), + SemanticAttributes.exceptionMessage.rawValue: .string(exception.message!) + ] + ) + let events = [event1, event2, exceptionEvent] let expected = SpanData(traceId: traceId, spanId: spanId, traceFlags: TraceFlags(), @@ -396,7 +558,7 @@ class RecordEventsReadableSpanTest: XCTestCase { endTime: endTime, hasRemoteParent: false, hasEnded: true, - totalRecordedEvents: 2, + totalRecordedEvents: events.count, totalRecordedLinks: links.count, totalAttributeCount: 1)