From 87e34149e3862fa7fab42f5cf9f62f6dc742e2d3 Mon Sep 17 00:00:00 2001 From: Billy Zhou Date: Tue, 2 Dec 2025 14:01:36 -0800 Subject: [PATCH 1/7] refactor!: record sessions as log events --- Sources/Instrumentation/Sessions/README.md | 4 +- .../Instrumentation/Sessions/Session.swift | 4 +- .../Sessions/SessionConfig.swift | 8 +- .../Sessions/SessionConstants.swift | 13 +- .../SessionEventInstrumentation.swift | 61 +-- .../Sessions/SessionLogRecordProcessor.swift | 24 +- .../Sessions/SessionManager.swift | 45 +- .../SessionTests/SessionConstantsTests.swift | 2 - .../SessionEventInstrumentationTests.swift | 450 +++++++++++------- .../SessionTests/SessionManagerTests.swift | 87 +++- 10 files changed, 432 insertions(+), 266 deletions(-) diff --git a/Sources/Instrumentation/Sessions/README.md b/Sources/Instrumentation/Sessions/README.md index a46e7557..addb520b 100644 --- a/Sources/Instrumentation/Sessions/README.md +++ b/Sources/Instrumentation/Sessions/README.md @@ -19,7 +19,7 @@ import Sessions import OpenTelemetrySdk // Record session start and end events -let sessionInstrumentation = SessionEventInstrumentation() +SessionEventInstrumentation.install() // Add session attributes to spans let sessionSpanProcessor = SessionSpanProcessor() @@ -107,7 +107,7 @@ let processor = SessionLogRecordProcessor(nextProcessor: yourProcessor) Creates OpenTelemetry log records for session lifecycle events. ```swift -let instrumentation = SessionEventInstrumentation() +SessionEventInstrumentation.install() // Emits session.start and session.end log records ``` diff --git a/Sources/Instrumentation/Sessions/Session.swift b/Sources/Instrumentation/Sessions/Session.swift index acd9e4d7..a96c0627 100644 --- a/Sources/Instrumentation/Sessions/Session.swift +++ b/Sources/Instrumentation/Sessions/Session.swift @@ -84,7 +84,7 @@ public struct Session: Equatable { /// Calculates the time between session start and end. Only available for expired sessions. /// - Returns: The session duration in seconds, or nil if the session is still active public var duration: TimeInterval? { - guard let endTime = endTime else { return nil } + guard let endTime else { return nil } return endTime.timeIntervalSince(startTime) } -} \ No newline at end of file +} diff --git a/Sources/Instrumentation/Sessions/SessionConfig.swift b/Sources/Instrumentation/Sessions/SessionConfig.swift index fa8a91be..4df22df3 100644 --- a/Sources/Instrumentation/Sessions/SessionConfig.swift +++ b/Sources/Instrumentation/Sessions/SessionConfig.swift @@ -49,8 +49,6 @@ public struct SessionConfig { public class SessionConfigBuilder { public private(set) var sessionTimeout: TimeInterval = 30 * 60 - public init() {} - /// Sets the session timeout duration /// - Parameter sessionTimeout: Duration in seconds after which a session expires if left inactive /// - Returns: The builder instance for method chaining @@ -67,10 +65,10 @@ public class SessionConfigBuilder { } /// Extension to SessionConfig for builder pattern support -extension SessionConfig { +public extension SessionConfig { /// Creates a new SessionConfigBuilder instance /// - Returns: A new builder for creating SessionConfig - public static func builder() -> SessionConfigBuilder { + static func builder() -> SessionConfigBuilder { return SessionConfigBuilder() } -} \ No newline at end of file +} diff --git a/Sources/Instrumentation/Sessions/SessionConstants.swift b/Sources/Instrumentation/Sessions/SessionConstants.swift index 6b650979..b1ec76a0 100644 --- a/Sources/Instrumentation/Sessions/SessionConstants.swift +++ b/Sources/Instrumentation/Sessions/SessionConstants.swift @@ -9,12 +9,15 @@ /// semantic conventions for session tracking. /// /// Reference: https://opentelemetry.io/docs/specs/semconv/general/session/ + +import Foundation + public class SessionConstants { // MARK: - OpenTelemetry Semantic Conventions /// Event name for session start events public static let sessionStartEvent = "session.start" - /// Event name for session end events + /// Event name for session end events public static let sessionEndEvent = "session.end" /// Attribute name for session identifier public static let id = "session.id" @@ -23,10 +26,6 @@ public class SessionConstants { // MARK: - Extension Attributes - /// Attribute name for session start timestamp - public static let startTime = "session.start_time" - /// Attribute name for session end timestamp - public static let endTime = "session.end_time" /// Attribute name for session duration public static let duration = "session.duration" @@ -34,4 +33,6 @@ public class SessionConstants { /// Notification name for session events public static let sessionEventNotification = "SessionEventInstrumentation.SessionEvent" -} \ No newline at end of file +} + +let SessionEventNotification = Notification.Name(SessionConstants.sessionEventNotification) diff --git a/Sources/Instrumentation/Sessions/SessionEventInstrumentation.swift b/Sources/Instrumentation/Sessions/SessionEventInstrumentation.swift index 9e4ae1de..f6614e03 100644 --- a/Sources/Instrumentation/Sessions/SessionEventInstrumentation.swift +++ b/Sources/Instrumentation/Sessions/SessionEventInstrumentation.swift @@ -30,7 +30,9 @@ public struct SessionEvent { /// - All session events are converted to OpenTelemetry log records with appropriate attributes /// - Session end events include duration and end time attributes public class SessionEventInstrumentation { - private let logger: Logger + private static var logger: Logger { + return OpenTelemetry.instance.loggerProvider.get(instrumentationScopeName: SessionEventInstrumentation.instrumentationKey) + } /// Queue for storing session events that were created before instrumentation was initialized. /// This allows capturing session events that occur during application startup before @@ -43,34 +45,27 @@ public class SessionEventInstrumentation { /// Notification name for new session events. /// Used to broadcast session creation and expiration events after instrumentation is applied. - static let sessionEventNotification = Notification.Name(SessionConstants.sessionEventNotification) + @available(*, deprecated, message: "Use SessionEventNotification instead") + static let sessionEventNotification = SessionEventNotification static let instrumentationKey = "io.opentelemetry.sessions" + @available(*, deprecated, message: "Use SessionEventInstrumentation.install() instead") + public init() { + SessionEventInstrumentation.install() + } + /// Flag to track if the instrumentation has been applied. /// Controls whether new sessions are queued or immediately processed via notifications. static var isApplied = false - - public init() { - logger = OpenTelemetry.instance.loggerProvider.get(instrumentationScopeName: SessionEventInstrumentation.instrumentationKey) - guard !SessionEventInstrumentation.isApplied else { + public static func install() { + guard !isApplied else { return } - SessionEventInstrumentation.isApplied = true + isApplied = true // Process any queued sessions processQueuedSessions() - - // Start observing for new session notifications - NotificationCenter.default.addObserver( - forName: SessionEventInstrumentation.sessionEventNotification, - object: nil, - queue: nil - ) { notification in - if let sessionEvent = notification.object as? SessionEvent { - self.createSessionEvent(session: sessionEvent.session, eventType: sessionEvent.eventType) - } - } } /// Process any sessions that were queued before instrumentation was applied. @@ -78,7 +73,7 @@ public class SessionEventInstrumentation { /// This method is called during the `apply()` process to handle any sessions that /// were created before the instrumentation was initialized. It creates log records /// for all queued sessions and then clears the queue. - private func processQueuedSessions() { + private static func processQueuedSessions() { let sessionEvents = SessionEventInstrumentation.queue if sessionEvents.isEmpty { @@ -97,7 +92,7 @@ public class SessionEventInstrumentation { /// - Parameters: /// - session: The session to create an event for /// - eventType: The type of event to create (start or end) - private func createSessionEvent(session: Session, eventType: SessionEventType) { + private static func createSessionEvent(session: Session, eventType: SessionEventType) { switch eventType { case .start: createSessionStartEvent(session: session) @@ -111,10 +106,9 @@ public class SessionEventInstrumentation { /// Creates an OpenTelemetry log record with session attributes including ID, start time, /// and previous session ID (if available). /// - Parameter session: The session that has started - private func createSessionStartEvent(session: Session) { + private static func createSessionStartEvent(session: Session) { var attributes: [String: AttributeValue] = [ - SessionConstants.id: AttributeValue.string(session.id), - SessionConstants.startTime: AttributeValue.double(Double(session.startTime.timeIntervalSince1970.toNanoseconds)) + SessionConstants.id: AttributeValue.string(session.id) ] if let previousId = session.previousId { @@ -124,8 +118,9 @@ public class SessionEventInstrumentation { /// Create `session.start` log record according to otel semantic convention /// https://opentelemetry.io/docs/specs/semconv/general/session/ logger.logRecordBuilder() - .setBody(AttributeValue.string(SessionConstants.sessionStartEvent)) + .setEventName(SessionConstants.sessionStartEvent) .setAttributes(attributes) + .setTimestamp(session.startTime) .emit() } @@ -134,16 +129,14 @@ public class SessionEventInstrumentation { /// Creates an OpenTelemetry log record with session attributes including ID, start time, /// end time, duration, and previous session ID (if available). /// - Parameter session: The expired session - private func createSessionEndEvent(session: Session) { + private static func createSessionEndEvent(session: Session) { guard let endTime = session.endTime, - let duration = session.duration else { + let duration = session.duration else { return } var attributes: [String: AttributeValue] = [ SessionConstants.id: AttributeValue.string(session.id), - SessionConstants.startTime: AttributeValue.double(Double(session.startTime.timeIntervalSince1970.toNanoseconds)), - SessionConstants.endTime: AttributeValue.double(Double(endTime.timeIntervalSince1970.toNanoseconds)), SessionConstants.duration: AttributeValue.double(Double(duration.toNanoseconds)) ] @@ -154,24 +147,22 @@ public class SessionEventInstrumentation { /// Create `session.end`` log record according to otel semantic convention /// https://opentelemetry.io/docs/specs/semconv/general/session/ logger.logRecordBuilder() - .setBody(AttributeValue.string(SessionConstants.sessionEndEvent)) + .setEventName(SessionConstants.sessionEndEvent) .setAttributes(attributes) + .setTimestamp(endTime) .emit() } /// Add a session to the queue or send notification if instrumentation is already applied. /// /// This static method is the main entry point for handling new sessions. It either: - /// - Adds the session to the static queue if instrumentation hasn't been applied yet (max 32 items) + /// - Adds the session to the static queue if instrumentation hasn't been applied yet (max 10 items) /// - Posts a notification with the session if instrumentation has been applied /// /// - Parameter session: The session to process static func addSession(session: Session, eventType: SessionEventType) { if isApplied { - NotificationCenter.default.post( - name: sessionEventNotification, - object: SessionEvent(session: session, eventType: eventType) - ) + createSessionEvent(session: session, eventType: eventType) } else { /// SessionManager creates sessions before SessionEventInstrumentation is applied, /// which the notification observer cannot see. So we need to keep the sessions in a queue. @@ -181,4 +172,4 @@ public class SessionEventInstrumentation { queue.append(SessionEvent(session: session, eventType: eventType)) } } -} \ No newline at end of file +} diff --git a/Sources/Instrumentation/Sessions/SessionLogRecordProcessor.swift b/Sources/Instrumentation/Sessions/SessionLogRecordProcessor.swift index 8c723911..ed7f1363 100644 --- a/Sources/Instrumentation/Sessions/SessionLogRecordProcessor.swift +++ b/Sources/Instrumentation/Sessions/SessionLogRecordProcessor.swift @@ -24,18 +24,18 @@ public class SessionLogRecordProcessor: LogRecordProcessor { public func onEmit(logRecord: ReadableLogRecord) { var enhancedRecord = logRecord - // For session.start and session.end events, preserve existing session attributes - if let body = logRecord.body, - case let .string(bodyString) = body, - bodyString == SessionConstants.sessionStartEvent || bodyString == SessionConstants.sessionEndEvent { - // Session start and end events already have their intended session ids - // Overwriting them would cause session end to have wrong current and previous session ids - } else { - // For other log records, add current session attributes + // Only add session attributes if they don't already exist + if logRecord.attributes[SessionConstants.id] == nil || logRecord.attributes[SessionConstants.previousId] == nil { let session = sessionManager.getSession() - enhancedRecord.setAttribute(key: SessionConstants.id, value: AttributeValue.string(session.id)) - if let previousId = session.previousId { - enhancedRecord.setAttribute(key: SessionConstants.previousId, value: AttributeValue.string(previousId)) + + // Add session.id if not already present + if logRecord.attributes[SessionConstants.id] == nil { + enhancedRecord.setAttribute(key: SessionConstants.id, value: session.id) + } + + // Add session.previous_id if not already present and session has a previous ID + if logRecord.attributes[SessionConstants.previousId] == nil, let previousId = session.previousId { + enhancedRecord.setAttribute(key: SessionConstants.previousId, value: previousId) } } @@ -51,4 +51,4 @@ public class SessionLogRecordProcessor: LogRecordProcessor { public func forceFlush(explicitTimeout: TimeInterval?) -> ExportResult { return .success } -} \ No newline at end of file +} diff --git a/Sources/Instrumentation/Sessions/SessionManager.swift b/Sources/Instrumentation/Sessions/SessionManager.swift index bc2bc5ad..53bd65ae 100644 --- a/Sources/Instrumentation/Sessions/SessionManager.swift +++ b/Sources/Instrumentation/Sessions/SessionManager.swift @@ -10,8 +10,21 @@ import Foundation /// Sessions are automatically extended on access and persisted to UserDefaults. public class SessionManager { private var configuration: SessionConfig - private var session: Session? - private var lock = NSLock() + private var _session: Session? + + private var session: Session? { + get { + return sessionQueue.sync { _session } + } + set { + sessionQueue.sync { _session = newValue } + } + } + + private let sessionQueue = DispatchQueue( + label: "io.opentelemetry.SessionManager", + qos: .userInitiated // increase priority because session are synchronously fetched + ) /// Initializes the session manager and restores any previous session from disk /// - Parameter configuration: Session configuration settings @@ -25,11 +38,8 @@ public class SessionManager { /// - Returns: The current active session @discardableResult public func getSession() -> Session { - // We only lock once when fetching the current session to expire with thread safety - return lock.withLock { - refreshSession() - return session! - } + refreshSession() + return session! } /// Gets the current session without extending its expireTime time @@ -44,11 +54,9 @@ public class SessionManager { let previousId = session?.id let newId = UUID().uuidString - /// Queue the previous session for a `session.end` event - if session != nil { - SessionEventInstrumentation.addSession(session: session!, eventType: .end) - } + let previousSession = session + // Create new session session = Session( id: newId, expireTime: now.addingTimeInterval(Double(configuration.sessionTimeout)), @@ -57,14 +65,23 @@ public class SessionManager { sessionTimeout: configuration.sessionTimeout ) + /// Queue the previous session for a `session.end` event + if let previousSession { + SessionEventInstrumentation.addSession(session: previousSession, eventType: .end) + } + // Queue the new session for a `session.start`` event SessionEventInstrumentation.addSession(session: session!, eventType: .start) + + // Post notification for session start + if let session { + NotificationCenter.default.post(name: SessionEventNotification, object: session) + } } /// Refreshes the current session, creating new one if expired or extending existing one private func refreshSession() { if session == nil || session!.isExpired() { - // Start new session if none exists or expired startSession() } else { // Otherwise, extend the existing session but preserve the startTime @@ -73,7 +90,7 @@ public class SessionManager { expireTime: Date(timeIntervalSinceNow: Double(configuration.sessionTimeout)), previousId: session!.previousId, startTime: session!.startTime, - sessionTimeout: TimeInterval(configuration.sessionTimeout) + sessionTimeout: configuration.sessionTimeout ) } saveSessionToDisk() @@ -90,4 +107,4 @@ public class SessionManager { private func restoreSessionFromDisk() { session = SessionStore.load() } -} \ No newline at end of file +} diff --git a/Tests/InstrumentationTests/SessionTests/SessionConstantsTests.swift b/Tests/InstrumentationTests/SessionTests/SessionConstantsTests.swift index fc8e6e19..83450b43 100644 --- a/Tests/InstrumentationTests/SessionTests/SessionConstantsTests.swift +++ b/Tests/InstrumentationTests/SessionTests/SessionConstantsTests.swift @@ -7,8 +7,6 @@ final class SessionConstantsTests: XCTestCase { XCTAssertEqual(SessionConstants.sessionEndEvent, "session.end") XCTAssertEqual(SessionConstants.id, "session.id") XCTAssertEqual(SessionConstants.previousId, "session.previous_id") - XCTAssertEqual(SessionConstants.startTime, "session.start_time") - XCTAssertEqual(SessionConstants.endTime, "session.end_time") XCTAssertEqual(SessionConstants.duration, "session.duration") XCTAssertEqual(SessionConstants.sessionEventNotification, "SessionEventInstrumentation.SessionEvent") } diff --git a/Tests/InstrumentationTests/SessionTests/SessionEventInstrumentationTests.swift b/Tests/InstrumentationTests/SessionTests/SessionEventInstrumentationTests.swift index b7078887..b4b41677 100644 --- a/Tests/InstrumentationTests/SessionTests/SessionEventInstrumentationTests.swift +++ b/Tests/InstrumentationTests/SessionTests/SessionEventInstrumentationTests.swift @@ -28,7 +28,8 @@ final class SessionEventInstrumentationTests: XCTestCase { lazy var sessionExpired = Session( id: sessionIdExpired, - expireTime: Date().addingTimeInterval(-3600) + expireTime: Date().addingTimeInterval(-3600), + startTime: Date().addingTimeInterval(-7200) ) override func setUp() { @@ -37,31 +38,23 @@ final class SessionEventInstrumentationTests: XCTestCase { startTime1 = Date() startTime2 = Date().addingTimeInterval(60) - SessionEventInstrumentation.queue = [] + // Reset static state FIRST + SessionStore.teardown() + SessionEventInstrumentation.queue.removeAll() SessionEventInstrumentation.isApplied = false + // Then setup LoggerProvider logExporter = InMemoryLogRecordExporter() let loggerProvider = LoggerProviderBuilder() .with(processors: [SimpleLogRecordProcessor(logRecordExporter: logExporter)]) .build() OpenTelemetry.registerLoggerProvider(loggerProvider: loggerProvider) - - NotificationCenter.default.removeObserver( - self, - name: SessionEventInstrumentation.sessionEventNotification, - object: nil - ) } override func tearDown() { super.tearDown() - NotificationCenter.default.removeObserver( - self, - name: SessionEventInstrumentation.sessionEventNotification, - object: nil - ) - + SessionStore.teardown() OpenTelemetry.registerTracerProvider(tracerProvider: DefaultTracerProvider.instance) OpenTelemetry.registerLoggerProvider(loggerProvider: DefaultLoggerProvider.instance) } @@ -76,7 +69,6 @@ final class SessionEventInstrumentationTests: XCTestCase { XCTAssertEqual(SessionEventInstrumentation.queue.count, 1) XCTAssertEqual(SessionEventInstrumentation.queue[0].session.id, sessionId1) - XCTAssertEqual(SessionEventInstrumentation.queue[0].eventType, .start) } func testInstrumentationEmptiesQueue() { @@ -86,213 +78,166 @@ final class SessionEventInstrumentationTests: XCTestCase { XCTAssertEqual(SessionEventInstrumentation.queue.count, 2) XCTAssertFalse(SessionEventInstrumentation.isApplied) - _ = SessionEventInstrumentation() + SessionEventInstrumentation.install() XCTAssertTrue(SessionEventInstrumentation.isApplied) XCTAssertEqual(SessionEventInstrumentation.queue.count, 0) } func testQueueDoesNotFillAfterApplied() { - // XCTAssertFalse(SessionEventInstrumentation.isApplied) - _ = SessionEventInstrumentation() + SessionEventInstrumentation.install() SessionEventInstrumentation.addSession(session: session2, eventType: .start) XCTAssertEqual(SessionEventInstrumentation.queue.count, 0) } - func testNotificationPostedAfterInstrumentationApplied() { - let expectation = XCTestExpectation(description: "Session notification posted") - var receivedSessionEvent: SessionEvent? - - NotificationCenter.default.addObserver( - forName: SessionEventInstrumentation.sessionEventNotification, - object: nil, - queue: nil - ) { notification in - receivedSessionEvent = notification.object as? SessionEvent - expectation.fulfill() - } + func testMultipleInstallationDoesNotProcessQueueTwice() { + SessionEventInstrumentation.addSession(session: session1, eventType: .start) + SessionEventInstrumentation.addSession(session: session2, eventType: .start) + XCTAssertEqual(SessionEventInstrumentation.queue.count, 2) - _ = SessionEventInstrumentation() + SessionEventInstrumentation.install() + XCTAssertTrue(SessionEventInstrumentation.isApplied) + XCTAssertEqual(SessionEventInstrumentation.queue.count, 0) - SessionEventInstrumentation.addSession(session: session1, eventType: .start) + // Second installation should not process queue again + SessionEventInstrumentation.install() + XCTAssertTrue(SessionEventInstrumentation.isApplied) + } - wait(for: [expectation], timeout: 0) + func testMultipleInstallationIsSafe() { + SessionEventInstrumentation.install() + SessionEventInstrumentation.install() - XCTAssertNotNil(receivedSessionEvent) - XCTAssertEqual(receivedSessionEvent?.session.id, sessionId1) - XCTAssertEqual(receivedSessionEvent?.eventType, .start) - XCTAssertEqual(SessionEventInstrumentation.queue.count, 0) + SessionEventInstrumentation.addSession(session: session1, eventType: .start) + + let logRecords = logExporter.getFinishedLogRecords() + XCTAssertEqual(logRecords.count, 1) + guard logRecords.count > 0 else { + XCTFail("No log records found") + return + } + XCTAssertEqual(logRecords[0].eventName, "session.start") } - func testMultipleInitializationDoesNotProcessQueueTwice() { + func testSessionStartLogRecord() { SessionEventInstrumentation.addSession(session: session1, eventType: .start) - SessionEventInstrumentation.addSession(session: session2, eventType: .start) - XCTAssertEqual(SessionEventInstrumentation.queue.count, 2) - - _ = SessionEventInstrumentation() - XCTAssertTrue(SessionEventInstrumentation.isApplied) - XCTAssertEqual(SessionEventInstrumentation.queue.count, 0) - - // Second initialization should not process queue again - SessionEventInstrumentation.queue = [SessionEvent(session: sessionExpired, eventType: .end)] - _ = SessionEventInstrumentation() - XCTAssertEqual(SessionEventInstrumentation.queue.count, 1) // Queue unchanged + SessionEventInstrumentation.install() + + let logRecords = logExporter.getFinishedLogRecords() + XCTAssertEqual(logRecords.count, 1) + + let record = logRecords[0] + XCTAssertEqual(record.eventName, "session.start") + XCTAssertNotNil(record.observedTimestamp, "Observed timestamp should be set") + XCTAssertEqual(record.attributes["session.id"], AttributeValue.string(sessionId1)) + + XCTAssertNil(record.attributes["session.previous_id"]) } - func testMultipleInitializationDoesNotAddDuplicateObservers() { - _ = SessionEventInstrumentation() - _ = SessionEventInstrumentation() - + func testSessionStartApplyAfter() { SessionEventInstrumentation.addSession(session: session1, eventType: .start) - + SessionEventInstrumentation.install() + let logRecords = logExporter.getFinishedLogRecords() XCTAssertEqual(logRecords.count, 1) - XCTAssertEqual(logRecords[0].body, AttributeValue.string("session.start")) + + let record = logRecords[0] + XCTAssertEqual(record.eventName, "session.start") + XCTAssertEqual(record.attributes["session.id"], AttributeValue.string(sessionId1)) + XCTAssertNil(record.attributes["session.previous_id"]) } - func testSessionStartLogRecord() { + func testSessionStartApplyBefore() { + SessionEventInstrumentation.install() SessionEventInstrumentation.addSession(session: session1, eventType: .start) - _ = SessionEventInstrumentation() - + let logRecords = logExporter.getFinishedLogRecords() XCTAssertEqual(logRecords.count, 1) - + let record = logRecords[0] - XCTAssertEqual(record.body, AttributeValue.string("session.start")) + XCTAssertEqual(record.eventName, "session.start") XCTAssertEqual(record.attributes["session.id"], AttributeValue.string(sessionId1)) - XCTAssertEqual(record.attributes["session.start_time"], AttributeValue.double(Double(startTime1.timeIntervalSince1970.toNanoseconds))) + XCTAssertNil(record.attributes["session.previous_id"]) + } + + func testSessionEndApplyBefore() { + SessionEventInstrumentation.install() + SessionEventInstrumentation.addSession(session: sessionExpired, eventType: .end) + + let logRecords = logExporter.getFinishedLogRecords() + XCTAssertEqual(logRecords.count, 1) + + let record = logRecords[0] + XCTAssertEqual(record.eventName, "session.end") + XCTAssertEqual(record.attributes["session.id"], AttributeValue.string(sessionIdExpired)) XCTAssertNil(record.attributes["session.previous_id"]) } func testSessionStartLogRecordWithPreviousId() { SessionEventInstrumentation.addSession(session: session2, eventType: .start) - _ = SessionEventInstrumentation() - + SessionEventInstrumentation.install() + let logRecords = logExporter.getFinishedLogRecords() XCTAssertEqual(logRecords.count, 1) - + let record = logRecords[0] - XCTAssertEqual(record.body, AttributeValue.string("session.start")) + XCTAssertEqual(record.eventName, "session.start") XCTAssertEqual(record.attributes["session.id"], AttributeValue.string(sessionId2)) - XCTAssertEqual(record.attributes["session.start_time"], AttributeValue.double(Double(startTime2.timeIntervalSince1970.toNanoseconds))) + XCTAssertEqual(record.attributes["session.previous_id"], AttributeValue.string(sessionId1)) } func testSessionEndLogRecord() { SessionEventInstrumentation.addSession(session: sessionExpired, eventType: .end) - _ = SessionEventInstrumentation() - + SessionEventInstrumentation.install() + let logRecords = logExporter.getFinishedLogRecords() XCTAssertEqual(logRecords.count, 1) - + let record = logRecords[0] - XCTAssertEqual(record.body, AttributeValue.string("session.end")) + XCTAssertEqual(record.eventName, "session.end") + XCTAssertNotNil(record.observedTimestamp, "Observed timestamp should be set") XCTAssertEqual(record.attributes["session.id"], AttributeValue.string(sessionIdExpired)) - XCTAssertEqual(record.attributes["session.start_time"], AttributeValue.double(Double(sessionExpired.startTime.timeIntervalSince1970.toNanoseconds))) - XCTAssertEqual(record.attributes["session.end_time"], AttributeValue.double(Double(sessionExpired.endTime!.timeIntervalSince1970.toNanoseconds))) XCTAssertEqual(record.attributes["session.duration"], AttributeValue.double(Double(sessionExpired.duration!.toNanoseconds))) + XCTAssertNil(record.attributes["session.previous_id"]) } - + func testInstrumentationScopeName() { SessionEventInstrumentation.addSession(session: session1, eventType: .start) - _ = SessionEventInstrumentation() - + SessionEventInstrumentation.install() + let logRecords = logExporter.getFinishedLogRecords() + XCTAssertEqual(SessionEventInstrumentation.instrumentationKey, "io.opentelemetry.sessions") XCTAssertEqual(logRecords.first?.instrumentationScopeInfo.name, "io.opentelemetry.sessions") } - - func testQueueSizeLimit() { - // Fill queue to max capacity - for i in 0.. 0 else { + XCTFail("No log records found. isApplied: \(SessionEventInstrumentation.isApplied), queue: \(SessionEventInstrumentation.queue.count)") + return + } + XCTAssertEqual(logRecords[0].eventName, "session.start") + XCTAssertEqual(logRecords[0].attributes["session.id"], AttributeValue.string(sessionId1)) + } + + func testAddSessionWithExplicitEndEventType() { + let sessionWithEndTime = Session( + id: sessionIdExpired, + expireTime: Date().addingTimeInterval(-3600), + startTime: Date().addingTimeInterval(-7200) + ) + + SessionEventInstrumentation.addSession(session: sessionWithEndTime, eventType: .end) + SessionEventInstrumentation.install() + + let logRecords = logExporter.getFinishedLogRecords() + XCTAssertEqual(logRecords.count, 1) + XCTAssertEqual(logRecords[0].eventName, "session.end") + XCTAssertEqual(logRecords[0].attributes["session.id"], AttributeValue.string(sessionIdExpired)) + } + + func testObservedTimestampIsSetOnSessionEvents() { + SessionEventInstrumentation.addSession(session: session1, eventType: .start) + SessionEventInstrumentation.install() + + let logRecords = logExporter.getFinishedLogRecords() + XCTAssertEqual(logRecords.count, 1) + + let record = logRecords[0] + XCTAssertNotNil(record.observedTimestamp) + XCTAssertNotNil(record.timestamp) + + // Verify the observed timestamp equals the timestamp + XCTAssertNotEqual(record.observedTimestamp, record.timestamp) + } + + func testQueueStoresEventType() { + SessionEventInstrumentation.addSession(session: session1, eventType: .start) + SessionEventInstrumentation.addSession(session: sessionExpired, eventType: .end) + + XCTAssertEqual(SessionEventInstrumentation.queue.count, 2) + XCTAssertEqual(SessionEventInstrumentation.queue[0].eventType, .start) + XCTAssertEqual(SessionEventInstrumentation.queue[1].eventType, .end) + } + + func testDeprecatedConstructorCallsInstall() { + SessionEventInstrumentation.addSession(session: session1, eventType: .start) + XCTAssertFalse(SessionEventInstrumentation.isApplied) + XCTAssertEqual(SessionEventInstrumentation.queue.count, 1) + _ = SessionEventInstrumentation() - SessionEventInstrumentation.addSession(session: activeSession, eventType: .end) + + XCTAssertTrue(SessionEventInstrumentation.isApplied) + XCTAssertEqual(SessionEventInstrumentation.queue.count, 0) - // Should not create log record for active session end event let logRecords = logExporter.getFinishedLogRecords() - XCTAssertEqual(logRecords.count, 0) + XCTAssertEqual(logRecords.count, 1) + XCTAssertEqual(logRecords[0].eventName, "session.start") + } + + func testDeprecatedSessionEventNotification() { + XCTAssertEqual(SessionEventInstrumentation.sessionEventNotification, SessionEventNotification) } -} \ No newline at end of file +} diff --git a/Tests/InstrumentationTests/SessionTests/SessionManagerTests.swift b/Tests/InstrumentationTests/SessionTests/SessionManagerTests.swift index da7d8820..49a83b41 100644 --- a/Tests/InstrumentationTests/SessionTests/SessionManagerTests.swift +++ b/Tests/InstrumentationTests/SessionTests/SessionManagerTests.swift @@ -11,6 +11,7 @@ final class SessionManagerTests: XCTestCase { } override func tearDown() { + NotificationCenter.default.removeObserver(self) SessionStore.teardown() super.tearDown() } @@ -59,19 +60,20 @@ final class SessionManagerTests: XCTestCase { func testGetSessionSavedToDisk() { let session = sessionManager.getSession() let savedId = UserDefaults.standard.object(forKey: SessionStore.idKey) as? String - let savedTimeout = UserDefaults.standard.object(forKey: SessionStore.sessionTimeoutKey) as? TimeInterval + let savedTimeout = UserDefaults.standard.object(forKey: SessionStore.sessionTimeoutKey) as? Double XCTAssertEqual(session.id, savedId) - XCTAssertEqual(session.sessionTimeout, savedTimeout) + XCTAssertEqual(session.sessionTimeout, TimeInterval(savedTimeout ?? -1)) } func testLoadSessionMissingExpiry() { - let id1 = "session-1" - UserDefaults.standard.set(id1, forKey: SessionStore.idKey) - XCTAssertNil(SessionStore.load()) + UserDefaults.standard.removeObject(forKey: SessionStore.expireTimeKey) + UserDefaults.standard.set("test-id", forKey: SessionStore.idKey) + UserDefaults.standard.set(Date(), forKey: SessionStore.startTimeKey) + UserDefaults.standard.set(1800, forKey: SessionStore.sessionTimeoutKey) - let id2 = sessionManager.getSession().id - XCTAssertNotEqual(id1, id2) + let loadedSession = SessionStore.load() + XCTAssertNil(loadedSession) } func testLoadSessionMissingID() { @@ -107,7 +109,7 @@ final class SessionManagerTests: XCTestCase { sessionManager = SessionManager(configuration: SessionConfig(sessionTimeout: customLength)) let session1 = sessionManager.getSession() - let expectedExpiry = Date(timeIntervalSinceNow: customLength) + let expectedExpiry = Date(timeIntervalSinceNow: Double(customLength)) XCTAssertEqual(session1.expireTime.timeIntervalSince1970, expectedExpiry.timeIntervalSince1970, accuracy: 1.0) XCTAssertEqual(session1.sessionTimeout, customLength) @@ -132,37 +134,84 @@ final class SessionManagerTests: XCTestCase { func testStartSessionAddsToQueueWhenInstrumentationNotApplied() { SessionEventInstrumentation.queue = [] SessionEventInstrumentation.isApplied = false - + sessionManager = SessionManager(configuration: SessionConfig(sessionTimeout: 0)) let session = sessionManager.getSession() + // Wait for async session event processing + let expectation = XCTestExpectation(description: "Session event queued") + DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now()) { + expectation.fulfill() + } + wait(for: [expectation], timeout: 2.0) + XCTAssertEqual(SessionEventInstrumentation.queue.count, 1) XCTAssertEqual(SessionEventInstrumentation.queue[0].session.id, session.id) } - func testStartSessionTriggersNotificationWhenInstrumentationApplied() { + func testStartSessionProcessesDirectlyWhenInstrumentationApplied() { SessionEventInstrumentation.queue = [] SessionEventInstrumentation.isApplied = true - let expectation = XCTestExpectation(description: "Session notification posted") - var receivedSessionEvent: SessionEvent? + let session = sessionManager.getSession() + + // When instrumentation is applied, sessions are processed directly, not queued + XCTAssertEqual(SessionEventInstrumentation.queue.count, 0) + XCTAssertNotNil(session.id) + } + + func testSessionStartNotificationPosted() { + let expectation = XCTestExpectation(description: "Session start notification") + var receivedSession: Session? let observer = NotificationCenter.default.addObserver( - forName: SessionEventInstrumentation.sessionEventNotification, + forName: SessionEventNotification, object: nil, queue: nil ) { notification in - receivedSessionEvent = notification.object as? SessionEvent + receivedSession = notification.object as? Session expectation.fulfill() } let session = sessionManager.getSession() - wait(for: [expectation], timeout: 0.1) + wait(for: [expectation], timeout: 2.0) // Increased timeout for async processing + XCTAssertEqual(receivedSession?.id, session.id) - XCTAssertNotNil(receivedSessionEvent) - XCTAssertEqual(receivedSessionEvent?.session.id, session.id) - XCTAssertEqual(SessionEventInstrumentation.queue.count, 0) + NotificationCenter.default.removeObserver(observer) + } + + func testMultipleSessionStartNotifications() { + // Clean up any existing state + SessionStore.teardown() + sessionManager = SessionManager(configuration: SessionConfig(sessionTimeout: 0)) + + var receivedSessions: [String] = [] + let expectation = XCTestExpectation(description: "Multiple session notifications") + expectation.expectedFulfillmentCount = 3 + + let observer = NotificationCenter.default.addObserver( + forName: SessionEventNotification, + object: nil, + queue: nil + ) { notification in + if let session = notification.object as? Session { + receivedSessions.append(session.id) + } + expectation.fulfill() + } + + let session1 = sessionManager.getSession() + let session2 = sessionManager.getSession() + let session3 = sessionManager.getSession() + + wait(for: [expectation], timeout: 2.0) NotificationCenter.default.removeObserver(observer) + + // Only check the count and that we got the expected sessions + XCTAssertEqual(receivedSessions.count, 3) + XCTAssertTrue(receivedSessions.contains(session1.id)) + XCTAssertTrue(receivedSessions.contains(session2.id)) + XCTAssertTrue(receivedSessions.contains(session3.id)) } -} \ No newline at end of file +} From 4bb7ca635bf21ec6a47337ed5fc51876ee495f16 Mon Sep 17 00:00:00 2001 From: Billy Zhou Date: Wed, 3 Dec 2025 11:03:20 -0800 Subject: [PATCH 2/7] revert to locks --- .../Sessions/SessionManager.swift | 93 ++++++++----------- 1 file changed, 40 insertions(+), 53 deletions(-) diff --git a/Sources/Instrumentation/Sessions/SessionManager.swift b/Sources/Instrumentation/Sessions/SessionManager.swift index 53bd65ae..a93530c9 100644 --- a/Sources/Instrumentation/Sessions/SessionManager.swift +++ b/Sources/Instrumentation/Sessions/SessionManager.swift @@ -10,22 +10,14 @@ import Foundation /// Sessions are automatically extended on access and persisted to UserDefaults. public class SessionManager { private var configuration: SessionConfig - private var _session: Session? + private var session: Session? + private let lock = NSLock() - private var session: Session? { - get { - return sessionQueue.sync { _session } - } - set { - sessionQueue.sync { _session = newValue } - } + private struct SessionTransition { + let current: Session + let previous: Session? } - private let sessionQueue = DispatchQueue( - label: "io.opentelemetry.SessionManager", - qos: .userInitiated // increase priority because session are synchronously fetched - ) - /// Initializes the session manager and restores any previous session from disk /// - Parameter configuration: Session configuration settings public init(configuration: SessionConfig = .default) { @@ -38,22 +30,38 @@ public class SessionManager { /// - Returns: The current active session @discardableResult public func getSession() -> Session { - refreshSession() + let transition : SessionTransition? = lock.withLock { + if session == nil || session!.isExpired() { + return startSession() + } + refreshSession() + return nil + } + + // Call external code outside the lock only if new session was created + if let transition { + if let previousSession = transition.previous { + SessionEventInstrumentation.addSession(session: previousSession, eventType: .end) + } + SessionEventInstrumentation.addSession(session: transition.current, eventType: .start) + NotificationCenter.default.post(name: SessionEventNotification, object: transition.current) + } + + SessionStore.scheduleSave(session: session!) return session! } /// Gets the current session without extending its expireTime time /// - Returns: The current session if one exists, nil otherwise public func peekSession() -> Session? { - return session + return lock.withLock { session } } - /// Creates a new session with a unique identifier - private func startSession() { + /// Creates a new session with a unique identifier (called within lock) + private func startSession() -> SessionTransition { let now = Date() let previousId = session?.id let newId = UUID().uuidString - let previousSession = session // Create new session @@ -65,46 +73,25 @@ public class SessionManager { sessionTimeout: configuration.sessionTimeout ) - /// Queue the previous session for a `session.end` event - if let previousSession { - SessionEventInstrumentation.addSession(session: previousSession, eventType: .end) - } - - // Queue the new session for a `session.start`` event - SessionEventInstrumentation.addSession(session: session!, eventType: .start) - - // Post notification for session start - if let session { - NotificationCenter.default.post(name: SessionEventNotification, object: session) - } + return SessionTransition(current: session!, previous: previousSession) } - /// Refreshes the current session, creating new one if expired or extending existing one + /// Extends the current session expiry time (called within lock) private func refreshSession() { - if session == nil || session!.isExpired() { - startSession() - } else { - // Otherwise, extend the existing session but preserve the startTime - session = Session( - id: session!.id, - expireTime: Date(timeIntervalSinceNow: Double(configuration.sessionTimeout)), - previousId: session!.previousId, - startTime: session!.startTime, - sessionTimeout: configuration.sessionTimeout - ) - } - saveSessionToDisk() - } - - /// Schedules the current session to be persisted to UserDefaults - private func saveSessionToDisk() { - if session != nil { - SessionStore.scheduleSave(session: session!) - } + session = Session( + id: session!.id, + expireTime: Date(timeIntervalSinceNow: Double(configuration.sessionTimeout)), + previousId: session!.previousId, + startTime: session!.startTime, + sessionTimeout: configuration.sessionTimeout + ) } /// Restores a previously saved session from UserDefaults private func restoreSessionFromDisk() { - session = SessionStore.load() + let loadedSession = SessionStore.load() + lock.withLock { + session = loadedSession + } } -} +} \ No newline at end of file From 0ea632b09ce0136b1dac05c19a1a14b0dfe733b7 Mon Sep 17 00:00:00 2001 From: Billy Zhou Date: Thu, 4 Dec 2025 08:55:01 -0800 Subject: [PATCH 3/7] refactor: remove scary force unwrap nils --- .../Sessions/SessionManager.swift | 73 ++++++++++--------- 1 file changed, 37 insertions(+), 36 deletions(-) diff --git a/Sources/Instrumentation/Sessions/SessionManager.swift b/Sources/Instrumentation/Sessions/SessionManager.swift index a93530c9..861a3771 100644 --- a/Sources/Instrumentation/Sessions/SessionManager.swift +++ b/Sources/Instrumentation/Sessions/SessionManager.swift @@ -13,11 +13,6 @@ public class SessionManager { private var session: Session? private let lock = NSLock() - private struct SessionTransition { - let current: Session - let previous: Session? - } - /// Initializes the session manager and restores any previous session from disk /// - Parameter configuration: Session configuration settings public init(configuration: SessionConfig = .default) { @@ -30,25 +25,37 @@ public class SessionManager { /// - Returns: The current active session @discardableResult public func getSession() -> Session { - let transition : SessionTransition? = lock.withLock { - if session == nil || session!.isExpired() { - return startSession() + let (currentSession, + previousSession, + sessionDidExpire) = lock.withLock { + if let session, + !session.isExpired() { + // extend session + let extendedSession = refreshSession(session: session) + self.session = extendedSession + return (extendedSession, nil as Session?, false) + } else { + // start new session + let prev = session + let nextSession = startSession() + self.session = nextSession + return (nextSession, prev, true) } - refreshSession() - return nil } - + // Call external code outside the lock only if new session was created - if let transition { - if let previousSession = transition.previous { + if sessionDidExpire { + if let previousSession { SessionEventInstrumentation.addSession(session: previousSession, eventType: .end) } - SessionEventInstrumentation.addSession(session: transition.current, eventType: .start) - NotificationCenter.default.post(name: SessionEventNotification, object: transition.current) + if previousSession != nil { + SessionEventInstrumentation.addSession(session: currentSession, eventType: .start) + NotificationCenter.default.post(name: SessionEventNotification, object: currentSession) + } } - - SessionStore.scheduleSave(session: session!) - return session! + + SessionStore.scheduleSave(session: currentSession) + return currentSession } /// Gets the current session without extending its expireTime time @@ -57,32 +64,26 @@ public class SessionManager { return lock.withLock { session } } - /// Creates a new session with a unique identifier (called within lock) - private func startSession() -> SessionTransition { + /// Creates a new session with a unique identifier + private func startSession() -> Session { let now = Date() - let previousId = session?.id - let newId = UUID().uuidString - let previousSession = session - // Create new session - session = Session( - id: newId, + return Session( + id: UUID().uuidString, expireTime: now.addingTimeInterval(Double(configuration.sessionTimeout)), - previousId: previousId, + previousId: session?.id, startTime: now, sessionTimeout: configuration.sessionTimeout ) - - return SessionTransition(current: session!, previous: previousSession) } - /// Extends the current session expiry time (called within lock) - private func refreshSession() { - session = Session( - id: session!.id, + /// Extends the current session expiry time + private func refreshSession(session: Session) -> Session { + return Session( + id: session.id, expireTime: Date(timeIntervalSinceNow: Double(configuration.sessionTimeout)), - previousId: session!.previousId, - startTime: session!.startTime, + previousId: session.previousId, + startTime: session.startTime, sessionTimeout: configuration.sessionTimeout ) } @@ -94,4 +95,4 @@ public class SessionManager { session = loadedSession } } -} \ No newline at end of file +} From fd6d352c13c3b549cd3b2070e9451bc1343e2bba Mon Sep 17 00:00:00 2001 From: Billy Zhou Date: Thu, 4 Dec 2025 09:26:08 -0800 Subject: [PATCH 4/7] fix: off by one error --- Sources/Instrumentation/Sessions/SessionManager.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/Instrumentation/Sessions/SessionManager.swift b/Sources/Instrumentation/Sessions/SessionManager.swift index 861a3771..7e49bef7 100644 --- a/Sources/Instrumentation/Sessions/SessionManager.swift +++ b/Sources/Instrumentation/Sessions/SessionManager.swift @@ -48,10 +48,8 @@ public class SessionManager { if let previousSession { SessionEventInstrumentation.addSession(session: previousSession, eventType: .end) } - if previousSession != nil { - SessionEventInstrumentation.addSession(session: currentSession, eventType: .start) - NotificationCenter.default.post(name: SessionEventNotification, object: currentSession) - } + SessionEventInstrumentation.addSession(session: currentSession, eventType: .start) + NotificationCenter.default.post(name: SessionEventNotification, object: currentSession) } SessionStore.scheduleSave(session: currentSession) @@ -65,6 +63,7 @@ public class SessionManager { } /// Creates a new session with a unique identifier + /// *Warning* - this must be a pure function since it is used inside a lock private func startSession() -> Session { let now = Date() @@ -78,6 +77,7 @@ public class SessionManager { } /// Extends the current session expiry time + /// *Warning* - this must be a pure function since it is used inside a lock private func refreshSession(session: Session) -> Session { return Session( id: session.id, From 2ec6fb5f9661319ec36d0963a226347b34ef6375 Mon Sep 17 00:00:00 2001 From: Billy Zhou Date: Thu, 4 Dec 2025 09:33:09 -0800 Subject: [PATCH 5/7] chore: add locked_ prefix --- Sources/Instrumentation/Sessions/SessionManager.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/Instrumentation/Sessions/SessionManager.swift b/Sources/Instrumentation/Sessions/SessionManager.swift index 7e49bef7..d89db988 100644 --- a/Sources/Instrumentation/Sessions/SessionManager.swift +++ b/Sources/Instrumentation/Sessions/SessionManager.swift @@ -31,13 +31,13 @@ public class SessionManager { if let session, !session.isExpired() { // extend session - let extendedSession = refreshSession(session: session) + let extendedSession = locked_refreshSession(session: session) self.session = extendedSession return (extendedSession, nil as Session?, false) } else { // start new session let prev = session - let nextSession = startSession() + let nextSession = locked_startSession() self.session = nextSession return (nextSession, prev, true) } @@ -64,7 +64,7 @@ public class SessionManager { /// Creates a new session with a unique identifier /// *Warning* - this must be a pure function since it is used inside a lock - private func startSession() -> Session { + private func locked_startSession() -> Session { let now = Date() return Session( @@ -78,7 +78,7 @@ public class SessionManager { /// Extends the current session expiry time /// *Warning* - this must be a pure function since it is used inside a lock - private func refreshSession(session: Session) -> Session { + private func locked_refreshSession(session: Session) -> Session { return Session( id: session.id, expireTime: Date(timeIntervalSinceNow: Double(configuration.sessionTimeout)), From 325829c05375f521a030f631e99a24e760c08af0 Mon Sep 17 00:00:00 2001 From: Billy Zhou Date: Thu, 4 Dec 2025 09:56:15 -0800 Subject: [PATCH 6/7] deprecate SessionConstants.id and prevId --- .../Sessions/SessionConstants.swift | 4 ++- .../SessionEventInstrumentation.swift | 8 ++--- .../Sessions/SessionLogRecordProcessor.swift | 10 +++---- .../Sessions/SessionSpanProcessor.swift | 4 +-- .../SessionLogRecordProcessorTests.swift | 30 +++++++++---------- 5 files changed, 29 insertions(+), 27 deletions(-) diff --git a/Sources/Instrumentation/Sessions/SessionConstants.swift b/Sources/Instrumentation/Sessions/SessionConstants.swift index b1ec76a0..17640010 100644 --- a/Sources/Instrumentation/Sessions/SessionConstants.swift +++ b/Sources/Instrumentation/Sessions/SessionConstants.swift @@ -20,8 +20,10 @@ public class SessionConstants { /// Event name for session end events public static let sessionEndEvent = "session.end" /// Attribute name for session identifier + @available(*, deprecated, message: "Use SemanticConventions.Session.id instead") public static let id = "session.id" /// Attribute name for previous session identifier + @available(*, deprecated, message: "Use SemanticConventions.Session.previousId instead") public static let previousId = "session.previous_id" // MARK: - Extension Attributes @@ -35,4 +37,4 @@ public class SessionConstants { public static let sessionEventNotification = "SessionEventInstrumentation.SessionEvent" } -let SessionEventNotification = Notification.Name(SessionConstants.sessionEventNotification) +let SessionEventNotification = Notification.Name(SessionConstants.sessionEventNotification) \ No newline at end of file diff --git a/Sources/Instrumentation/Sessions/SessionEventInstrumentation.swift b/Sources/Instrumentation/Sessions/SessionEventInstrumentation.swift index f6614e03..5f0570e6 100644 --- a/Sources/Instrumentation/Sessions/SessionEventInstrumentation.swift +++ b/Sources/Instrumentation/Sessions/SessionEventInstrumentation.swift @@ -108,11 +108,11 @@ public class SessionEventInstrumentation { /// - Parameter session: The session that has started private static func createSessionStartEvent(session: Session) { var attributes: [String: AttributeValue] = [ - SessionConstants.id: AttributeValue.string(session.id) + SemanticConventions.Session.id.rawValue: AttributeValue.string(session.id) ] if let previousId = session.previousId { - attributes[SessionConstants.previousId] = AttributeValue.string(previousId) + attributes[SemanticConventions.Session.previousId.rawValue] = AttributeValue.string(previousId) } /// Create `session.start` log record according to otel semantic convention @@ -136,12 +136,12 @@ public class SessionEventInstrumentation { } var attributes: [String: AttributeValue] = [ - SessionConstants.id: AttributeValue.string(session.id), + SemanticConventions.Session.id.rawValue: AttributeValue.string(session.id), SessionConstants.duration: AttributeValue.double(Double(duration.toNanoseconds)) ] if let previousId = session.previousId { - attributes[SessionConstants.previousId] = AttributeValue.string(previousId) + attributes[SemanticConventions.Session.previousId.rawValue] = AttributeValue.string(previousId) } /// Create `session.end`` log record according to otel semantic convention diff --git a/Sources/Instrumentation/Sessions/SessionLogRecordProcessor.swift b/Sources/Instrumentation/Sessions/SessionLogRecordProcessor.swift index ed7f1363..4e77e5d5 100644 --- a/Sources/Instrumentation/Sessions/SessionLogRecordProcessor.swift +++ b/Sources/Instrumentation/Sessions/SessionLogRecordProcessor.swift @@ -25,17 +25,17 @@ public class SessionLogRecordProcessor: LogRecordProcessor { var enhancedRecord = logRecord // Only add session attributes if they don't already exist - if logRecord.attributes[SessionConstants.id] == nil || logRecord.attributes[SessionConstants.previousId] == nil { + if logRecord.attributes[SemanticConventions.Session.id.rawValue] == nil || logRecord.attributes[SemanticConventions.Session.previousId.rawValue] == nil { let session = sessionManager.getSession() // Add session.id if not already present - if logRecord.attributes[SessionConstants.id] == nil { - enhancedRecord.setAttribute(key: SessionConstants.id, value: session.id) + if logRecord.attributes[SemanticConventions.Session.id.rawValue] == nil { + enhancedRecord.setAttribute(key: SemanticConventions.Session.id.rawValue, value: session.id) } // Add session.previous_id if not already present and session has a previous ID - if logRecord.attributes[SessionConstants.previousId] == nil, let previousId = session.previousId { - enhancedRecord.setAttribute(key: SessionConstants.previousId, value: previousId) + if logRecord.attributes[SemanticConventions.Session.previousId.rawValue] == nil, let previousId = session.previousId { + enhancedRecord.setAttribute(key: SemanticConventions.Session.previousId.rawValue, value: previousId) } } diff --git a/Sources/Instrumentation/Sessions/SessionSpanProcessor.swift b/Sources/Instrumentation/Sessions/SessionSpanProcessor.swift index 2aa9a792..fe848479 100644 --- a/Sources/Instrumentation/Sessions/SessionSpanProcessor.swift +++ b/Sources/Instrumentation/Sessions/SessionSpanProcessor.swift @@ -29,9 +29,9 @@ public class SessionSpanProcessor: SpanProcessor { /// - span: The span being started public func onStart(parentContext: SpanContext?, span: ReadableSpan) { let session = sessionManager.getSession() - span.setAttribute(key: SessionConstants.id, value: session.id) + span.setAttribute(key: SemanticConventions.Session.id.rawValue, value: session.id) if session.previousId != nil { - span.setAttribute(key: SessionConstants.previousId, value: session.previousId!) + span.setAttribute(key: SemanticConventions.Session.previousId.rawValue, value: session.previousId!) } } diff --git a/Tests/InstrumentationTests/SessionTests/SessionLogRecordProcessorTests.swift b/Tests/InstrumentationTests/SessionTests/SessionLogRecordProcessorTests.swift index 2ce085a5..77d70571 100644 --- a/Tests/InstrumentationTests/SessionTests/SessionLogRecordProcessorTests.swift +++ b/Tests/InstrumentationTests/SessionTests/SessionLogRecordProcessorTests.swift @@ -36,7 +36,7 @@ final class SessionLogRecordProcessorTests: XCTestCase { XCTAssertEqual(mockNextProcessor.receivedLogRecords.count, 1) let enhancedRecord = mockNextProcessor.receivedLogRecords[0] - if case let .string(sessionId) = enhancedRecord.attributes[SessionConstants.id] { + if case let .string(sessionId) = enhancedRecord.attributes[SemanticConventions.Session.id.rawValue] { XCTAssertEqual(sessionId, expectedSessionId) } else { XCTFail("Expected session.id attribute to be a string value") @@ -75,13 +75,13 @@ final class SessionLogRecordProcessorTests: XCTestCase { let enhancedRecord = mockNextProcessor.receivedLogRecords[0] - if case let .string(sessionId) = enhancedRecord.attributes[SessionConstants.id] { + if case let .string(sessionId) = enhancedRecord.attributes[SemanticConventions.Session.id.rawValue] { XCTAssertEqual(sessionId, expectedSessionId) } else { XCTFail("Expected session.id attribute to be a string value") } - if case let .string(previousSessionId) = enhancedRecord.attributes[SessionConstants.previousId] { + if case let .string(previousSessionId) = enhancedRecord.attributes[SemanticConventions.Session.previousId.rawValue] { XCTAssertEqual(previousSessionId, expectedPreviousSessionId) } else { XCTFail("Expected session.previous_id attribute to be a string value") @@ -97,13 +97,13 @@ final class SessionLogRecordProcessorTests: XCTestCase { let enhancedRecord = mockNextProcessor.receivedLogRecords[0] - if case let .string(sessionId) = enhancedRecord.attributes[SessionConstants.id] { + if case let .string(sessionId) = enhancedRecord.attributes[SemanticConventions.Session.id.rawValue] { XCTAssertEqual(sessionId, expectedSessionId) } else { XCTFail("Expected session.id attribute to be a string value") } - XCTAssertNil(enhancedRecord.attributes[SessionConstants.previousId], "Previous session ID should not be set when nil") + XCTAssertNil(enhancedRecord.attributes[SemanticConventions.Session.previousId.rawValue], "Previous session ID should not be set when nil") } func testOnEmitWithDifferentSessionIds() { @@ -115,13 +115,13 @@ final class SessionLogRecordProcessorTests: XCTestCase { XCTAssertEqual(mockNextProcessor.receivedLogRecords.count, 2) - if case let .string(sessionId1) = mockNextProcessor.receivedLogRecords[0].attributes[SessionConstants.id] { + if case let .string(sessionId1) = mockNextProcessor.receivedLogRecords[0].attributes[SemanticConventions.Session.id.rawValue] { XCTAssertEqual(sessionId1, "session-1") } else { XCTFail("Expected first log record to have session-1") } - if case let .string(sessionId2) = mockNextProcessor.receivedLogRecords[1].attributes[SessionConstants.id] { + if case let .string(sessionId2) = mockNextProcessor.receivedLogRecords[1].attributes[SemanticConventions.Session.id.rawValue] { XCTAssertEqual(sessionId2, "session-2") } else { XCTFail("Expected second log record to have session-2") @@ -148,8 +148,8 @@ final class SessionLogRecordProcessorTests: XCTestCase { severity: .info, body: AttributeValue.string("session.start"), attributes: [ - SessionConstants.id: AttributeValue.string("existing-session-123"), - SessionConstants.previousId: AttributeValue.string("existing-previous-456") + SemanticConventions.Session.id.rawValue: AttributeValue.string("existing-session-123"), + SemanticConventions.Session.previousId.rawValue: AttributeValue.string("existing-previous-456") ] ) @@ -158,13 +158,13 @@ final class SessionLogRecordProcessorTests: XCTestCase { let enhancedRecord = mockNextProcessor.receivedLogRecords[0] - if case let .string(sessionId) = enhancedRecord.attributes[SessionConstants.id] { + if case let .string(sessionId) = enhancedRecord.attributes[SemanticConventions.Session.id.rawValue] { XCTAssertEqual(sessionId, "existing-session-123", "Should preserve existing session ID for session.start") } else { XCTFail("Expected existing session.id to be preserved") } - if case let .string(previousId) = enhancedRecord.attributes[SessionConstants.previousId] { + if case let .string(previousId) = enhancedRecord.attributes[SemanticConventions.Session.previousId.rawValue] { XCTAssertEqual(previousId, "existing-previous-456", "Should preserve existing previous session ID") } else { XCTFail("Expected existing session.previous_id to be preserved") @@ -181,7 +181,7 @@ final class SessionLogRecordProcessorTests: XCTestCase { severity: .info, body: AttributeValue.string("session.end"), attributes: [ - SessionConstants.id: AttributeValue.string("ending-session-789"), + SemanticConventions.Session.id.rawValue: AttributeValue.string("ending-session-789"), "session.duration": AttributeValue.double(123.45) ] ) @@ -191,7 +191,7 @@ final class SessionLogRecordProcessorTests: XCTestCase { let enhancedRecord = mockNextProcessor.receivedLogRecords[0] - if case let .string(sessionId) = enhancedRecord.attributes[SessionConstants.id] { + if case let .string(sessionId) = enhancedRecord.attributes[SemanticConventions.Session.id.rawValue] { XCTAssertEqual(sessionId, "ending-session-789", "Should preserve existing session ID for session.end") } else { XCTFail("Expected existing session.id to be preserved") @@ -234,7 +234,7 @@ final class SessionLogRecordProcessorTests: XCTestCase { XCTAssertEqual(enhancedRecord.spanContext, logRecordWithEventName.spanContext) // Verify session attributes were added - if case let .string(sessionId) = enhancedRecord.attributes[SessionConstants.id] { + if case let .string(sessionId) = enhancedRecord.attributes[SemanticConventions.Session.id.rawValue] { XCTAssertEqual(sessionId, "test-session-123") } else { XCTFail("Expected session.id attribute to be added") @@ -276,7 +276,7 @@ final class SessionLogRecordProcessorTests: XCTestCase { XCTAssertEqual(mockNextProcessor.receivedLogRecords.count, 10) for record in mockNextProcessor.receivedLogRecords { - XCTAssertTrue(record.attributes.keys.contains(SessionConstants.id)) + XCTAssertTrue(record.attributes.keys.contains(SemanticConventions.Session.id.rawValue)) } } } From 330a1c6f1286ab72cb7168d88753336f54f4e8fe Mon Sep 17 00:00:00 2001 From: Billy Zhou Date: Thu, 4 Dec 2025 11:16:52 -0800 Subject: [PATCH 7/7] remove session.duration --- Sources/Instrumentation/Sessions/README.md | 5 +---- .../Instrumentation/Sessions/SessionConstants.swift | 13 ------------- .../Sessions/SessionEventInstrumentation.swift | 6 ++---- .../SessionTests/SessionConstantsTests.swift | 3 --- .../SessionEventInstrumentationTests.swift | 1 - .../SessionLogRecordProcessorTests.swift | 9 +-------- 6 files changed, 4 insertions(+), 33 deletions(-) diff --git a/Sources/Instrumentation/Sessions/README.md b/Sources/Instrumentation/Sessions/README.md index addb520b..9d41aa8b 100644 --- a/Sources/Instrumentation/Sessions/README.md +++ b/Sources/Instrumentation/Sessions/README.md @@ -123,7 +123,6 @@ let session = Session( ) print("Expired: \(session.isExpired())") -print("Duration: \(session.duration ?? 0)") ``` ## Configuration @@ -188,7 +187,6 @@ A `session.end` log record is created when a session expires. "session.id": "550e8400-e29b-41d4-a716-446655440000", "session.start_time": 1692123456789000000, "session.end_time": 1692125256789000000, - "session.duration": 1800000000000, "session.previous_id": "71260ACC-5286-455F-9955-5DA8C5109A07" } } @@ -201,8 +199,7 @@ A `session.end` log record is created when a session expires. | `session.id` | string | Unique identifier for the ended session | `"550e8400-e29b-41d4-a716-446655440000"` | | `session.start_time` | double | Session start time in nanoseconds since epoch | `1692123456789000000` | | `session.end_time` | double | Session end time in nanoseconds since epoch | `1692125256789000000` | -| `session.duration` | double | Session duration in nanoseconds | `1800000000000` (30 minutes) | -| `session.previous_id` | string | Identifier of the previous session (if any) | `"71260ACC-5286-455F-9955-5DA8C5109A07"` | +| `session.previous_id` | string | Identifier of the previous session (if any) | `"71260ACC-5286-455F-9955-5DA8C5109A07"` | ## Span and Log Attribution diff --git a/Sources/Instrumentation/Sessions/SessionConstants.swift b/Sources/Instrumentation/Sessions/SessionConstants.swift index 17640010..fd0d79d7 100644 --- a/Sources/Instrumentation/Sessions/SessionConstants.swift +++ b/Sources/Instrumentation/Sessions/SessionConstants.swift @@ -19,19 +19,6 @@ public class SessionConstants { public static let sessionStartEvent = "session.start" /// Event name for session end events public static let sessionEndEvent = "session.end" - /// Attribute name for session identifier - @available(*, deprecated, message: "Use SemanticConventions.Session.id instead") - public static let id = "session.id" - /// Attribute name for previous session identifier - @available(*, deprecated, message: "Use SemanticConventions.Session.previousId instead") - public static let previousId = "session.previous_id" - - // MARK: - Extension Attributes - - /// Attribute name for session duration - public static let duration = "session.duration" - - // MARK: - Internal Constants /// Notification name for session events public static let sessionEventNotification = "SessionEventInstrumentation.SessionEvent" diff --git a/Sources/Instrumentation/Sessions/SessionEventInstrumentation.swift b/Sources/Instrumentation/Sessions/SessionEventInstrumentation.swift index 5f0570e6..e174c783 100644 --- a/Sources/Instrumentation/Sessions/SessionEventInstrumentation.swift +++ b/Sources/Instrumentation/Sessions/SessionEventInstrumentation.swift @@ -130,14 +130,12 @@ public class SessionEventInstrumentation { /// end time, duration, and previous session ID (if available). /// - Parameter session: The expired session private static func createSessionEndEvent(session: Session) { - guard let endTime = session.endTime, - let duration = session.duration else { + guard let endTime = session.endTime else { return } var attributes: [String: AttributeValue] = [ - SemanticConventions.Session.id.rawValue: AttributeValue.string(session.id), - SessionConstants.duration: AttributeValue.double(Double(duration.toNanoseconds)) + SemanticConventions.Session.id.rawValue: AttributeValue.string(session.id) ] if let previousId = session.previousId { diff --git a/Tests/InstrumentationTests/SessionTests/SessionConstantsTests.swift b/Tests/InstrumentationTests/SessionTests/SessionConstantsTests.swift index 83450b43..2d34d858 100644 --- a/Tests/InstrumentationTests/SessionTests/SessionConstantsTests.swift +++ b/Tests/InstrumentationTests/SessionTests/SessionConstantsTests.swift @@ -5,9 +5,6 @@ final class SessionConstantsTests: XCTestCase { func testSessionEventConstants() { XCTAssertEqual(SessionConstants.sessionStartEvent, "session.start") XCTAssertEqual(SessionConstants.sessionEndEvent, "session.end") - XCTAssertEqual(SessionConstants.id, "session.id") - XCTAssertEqual(SessionConstants.previousId, "session.previous_id") - XCTAssertEqual(SessionConstants.duration, "session.duration") XCTAssertEqual(SessionConstants.sessionEventNotification, "SessionEventInstrumentation.SessionEvent") } } \ No newline at end of file diff --git a/Tests/InstrumentationTests/SessionTests/SessionEventInstrumentationTests.swift b/Tests/InstrumentationTests/SessionTests/SessionEventInstrumentationTests.swift index b4b41677..c4a98438 100644 --- a/Tests/InstrumentationTests/SessionTests/SessionEventInstrumentationTests.swift +++ b/Tests/InstrumentationTests/SessionTests/SessionEventInstrumentationTests.swift @@ -200,7 +200,6 @@ final class SessionEventInstrumentationTests: XCTestCase { XCTAssertEqual(record.eventName, "session.end") XCTAssertNotNil(record.observedTimestamp, "Observed timestamp should be set") XCTAssertEqual(record.attributes["session.id"], AttributeValue.string(sessionIdExpired)) - XCTAssertEqual(record.attributes["session.duration"], AttributeValue.double(Double(sessionExpired.duration!.toNanoseconds))) XCTAssertNil(record.attributes["session.previous_id"]) } diff --git a/Tests/InstrumentationTests/SessionTests/SessionLogRecordProcessorTests.swift b/Tests/InstrumentationTests/SessionTests/SessionLogRecordProcessorTests.swift index 77d70571..7ca37c8d 100644 --- a/Tests/InstrumentationTests/SessionTests/SessionLogRecordProcessorTests.swift +++ b/Tests/InstrumentationTests/SessionTests/SessionLogRecordProcessorTests.swift @@ -181,8 +181,7 @@ final class SessionLogRecordProcessorTests: XCTestCase { severity: .info, body: AttributeValue.string("session.end"), attributes: [ - SemanticConventions.Session.id.rawValue: AttributeValue.string("ending-session-789"), - "session.duration": AttributeValue.double(123.45) + SemanticConventions.Session.id.rawValue: AttributeValue.string("ending-session-789") ] ) @@ -196,12 +195,6 @@ final class SessionLogRecordProcessorTests: XCTestCase { } else { XCTFail("Expected existing session.id to be preserved") } - - if case let .double(duration) = enhancedRecord.attributes["session.duration"] { - XCTAssertEqual(duration, 123.45, "Should preserve existing session.duration") - } else { - XCTFail("Expected existing session.duration to be preserved") - } } func testDataIsPreserved() {