diff --git a/Sources/Instrumentation/Sessions/README.md b/Sources/Instrumentation/Sessions/README.md index a46e7557..9d41aa8b 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 ``` @@ -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/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..fd0d79d7 100644 --- a/Sources/Instrumentation/Sessions/SessionConstants.swift +++ b/Sources/Instrumentation/Sessions/SessionConstants.swift @@ -9,29 +9,19 @@ /// 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" - /// Attribute name for previous session identifier - public static let previousId = "session.previous_id" - - // 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" - - // MARK: - Internal Constants /// Notification name for session events public static let sessionEventNotification = "SessionEventInstrumentation.SessionEvent" -} \ No newline at end of file +} + +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 9e4ae1de..e174c783 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,21 +106,21 @@ 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)) + 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 /// 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,44 +129,38 @@ 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) { - guard let endTime = session.endTime, - let duration = session.duration else { + private static func createSessionEndEvent(session: Session) { + guard let endTime = session.endTime 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)) + 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.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 +170,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..4e77e5d5 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[SemanticConventions.Session.id.rawValue] == nil || logRecord.attributes[SemanticConventions.Session.previousId.rawValue] == 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[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[SemanticConventions.Session.previousId.rawValue] == nil, let previousId = session.previousId { + enhancedRecord.setAttribute(key: SemanticConventions.Session.previousId.rawValue, 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..d89db988 100644 --- a/Sources/Instrumentation/Sessions/SessionManager.swift +++ b/Sources/Instrumentation/Sessions/SessionManager.swift @@ -11,7 +11,7 @@ import Foundation public class SessionManager { private var configuration: SessionConfig private var session: Session? - private var lock = NSLock() + private let lock = NSLock() /// Initializes the session manager and restores any previous session from disk /// - Parameter configuration: Session configuration settings @@ -25,69 +25,74 @@ 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! + let (currentSession, + previousSession, + sessionDidExpire) = lock.withLock { + if let session, + !session.isExpired() { + // extend 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 = locked_startSession() + self.session = nextSession + return (nextSession, prev, true) + } } + + // Call external code outside the lock only if new session was created + if sessionDidExpire { + if let previousSession { + SessionEventInstrumentation.addSession(session: previousSession, eventType: .end) + } + SessionEventInstrumentation.addSession(session: currentSession, eventType: .start) + NotificationCenter.default.post(name: SessionEventNotification, object: currentSession) + } + + SessionStore.scheduleSave(session: currentSession) + return currentSession } /// 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() { + /// *Warning* - this must be a pure function since it is used inside a lock + private func locked_startSession() -> Session { let now = Date() - 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) - } - - 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 ) - - // Queue the new session for a `session.start`` event - SessionEventInstrumentation.addSession(session: session!, eventType: .start) } - /// 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 - session = Session( - id: session!.id, - expireTime: Date(timeIntervalSinceNow: Double(configuration.sessionTimeout)), - previousId: session!.previousId, - startTime: session!.startTime, - sessionTimeout: TimeInterval(configuration.sessionTimeout) - ) - } - saveSessionToDisk() - } - - /// Schedules the current session to be persisted to UserDefaults - private func saveSessionToDisk() { - if session != nil { - SessionStore.scheduleSave(session: session!) - } + /// Extends the current session expiry time + /// *Warning* - this must be a pure function since it is used inside a lock + private func locked_refreshSession(session: Session) -> Session { + return 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 +} 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/SessionConstantsTests.swift b/Tests/InstrumentationTests/SessionTests/SessionConstantsTests.swift index fc8e6e19..2d34d858 100644 --- a/Tests/InstrumentationTests/SessionTests/SessionConstantsTests.swift +++ b/Tests/InstrumentationTests/SessionTests/SessionConstantsTests.swift @@ -5,11 +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.startTime, "session.start_time") - XCTAssertEqual(SessionConstants.endTime, "session.end_time") - 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 b7078887..c4a98438 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,165 @@ 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/SessionLogRecordProcessorTests.swift b/Tests/InstrumentationTests/SessionTests/SessionLogRecordProcessorTests.swift index 2ce085a5..7ca37c8d 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,8 +181,7 @@ final class SessionLogRecordProcessorTests: XCTestCase { severity: .info, body: AttributeValue.string("session.end"), attributes: [ - SessionConstants.id: AttributeValue.string("ending-session-789"), - "session.duration": AttributeValue.double(123.45) + SemanticConventions.Session.id.rawValue: AttributeValue.string("ending-session-789") ] ) @@ -191,17 +190,11 @@ 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") } - - 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() { @@ -234,7 +227,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 +269,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)) } } } 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 +}