Repository: open-telemetry/opentelemetry-swift (swift)

Issue:
add recordException() to span
should capture Error, NSError, and NSException

Pull request:
This pull request aims to resolve #117.

Since Swift does not offer a superclass like [Throwable in Java](https://developer.android.com/reference/java/lang/Throwable) to group all exception types, one challenge facing these changes is a polluted API, with a long list of methods to encompass `NSException`, `NSError` and `Error`.

To work around that problem I have chosen to include a new protocol named `SpanException`, describing the basic properties required to create a meaningful exception event. That, alongside extensions that automatically conform both `NSError` (and, automatically, `Error`) and `NSException`, should cater for most use cases. For everything else, users should simply need to conform their custom types to `SpanException`.

If there is any feedback on this, please let me know – happy to update as we go.

Patch:
diff --git a/Examples/Logging Tracer/LoggingSpan.swift b/Examples/Logging Tracer/LoggingSpan.swift
index f87f69436..30f6332f5 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 f13787a96..313c941b6 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 85f867157..997c5ae59 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 000000000..5bd2a3e82
--- /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 f52f28407..8b3a04c44 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
+        }
+    }
 }


Test patch:
diff --git a/Tests/OpenTelemetryApiTests/Trace/PropagatedSpanTests.swift b/Tests/OpenTelemetryApiTests/Trace/PropagatedSpanTests.swift
index 8dd291524..7e0f75d44 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 000000000..86711d6f8
--- /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 70aac329b..327aa3e4b 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 b5f3cea44..4f80f4969 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 8f0101e82..bfba0b701 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)