Skip to content

Commit f4b70c8

Browse files
williazzbryce-b
andauthored
feat: sessions (#899)
* feat: sessions * feat: add SessionLogRecordProcessor * fix test * sessionTimeout as TimeInterval * 100% test coverage * update previous-id in docs --------- Co-authored-by: Bryce Buchanan <[email protected]>
1 parent babc27a commit f4b70c8

21 files changed

+2455
-0
lines changed

Package.swift

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ let package = Package(
2424
.library(name: "InMemoryExporter", targets: ["InMemoryExporter"]),
2525
.library(name: "OTelSwiftLog", targets: ["OTelSwiftLog"]),
2626
.library(name: "BaggagePropagationProcessor", targets: ["BaggagePropagationProcessor"]),
27+
.library(name: "Sessions", targets: ["Sessions"]),
2728
.executable(name: "loggingTracer", targets: ["LoggingTracer"]),
2829
.executable(name: "StableMetricSample", targets: ["StableMetricSample"])
2930
],
@@ -113,6 +114,16 @@ let package = Package(
113114
],
114115
path: "Sources/Contrib/Processors/BaggagePropagationProcessor"
115116
),
117+
.target(
118+
name: "Sessions",
119+
dependencies: [
120+
.product(name: "OpenTelemetryApi", package: "opentelemetry-swift-core"),
121+
.product(name: "OpenTelemetrySdk", package: "opentelemetry-swift-core")
122+
123+
],
124+
path: "Sources/Instrumentation/Sessions",
125+
exclude: ["README.md"]
126+
),
116127
.testTarget(
117128
name: "OTelSwiftLogTests",
118129
dependencies: ["OTelSwiftLog"],
@@ -159,6 +170,14 @@ let package = Package(
159170
"InMemoryExporter"
160171
]
161172
),
173+
.testTarget(
174+
name: "SessionTests",
175+
dependencies: [
176+
"Sessions",
177+
.product(name: "OpenTelemetrySdk", package: "opentelemetry-swift-core")
178+
],
179+
path: "Tests/InstrumentationTests/SessionTests"
180+
),
162181
.executableTarget(
163182
name: "LoggingTracer",
164183
dependencies: [

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ Metrics is implemented using an outdated spec, is fully functional but will chan
9494
* `NetworkStatus`
9595
* `SDKResourceExtension`
9696
* `SignPostIntegration`
97+
* `SessionsEventInstrumentation`
9798

9899
### Third-party exporters
99100
In addition to the specified OpenTelemetry exporters, some third-party exporters have been contributed and can be found in the following repos:
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
# Session Instrumentation
2+
3+
Automatic session tracking for OpenTelemetry Swift applications. Creates unique session identifiers, tracks session lifecycle events, and automatically adds session context to all telemetry data.
4+
5+
## Features
6+
7+
- **Automatic Session Management** - Creates and manages session lifecycles with configurable timeouts
8+
- **Session Events** - Emits OpenTelemetry log records for session start/end events
9+
- **Span Attribution** - Automatically adds session IDs to all spans via span processor
10+
- **Persistence** - Sessions persist across app restarts using UserDefaults
11+
- **Thread Safety** - All components are thread-safe for concurrent access
12+
13+
## Setup
14+
15+
**Basic Setup** (default 30-minute timeout):
16+
17+
```swift
18+
import Sessions
19+
import OpenTelemetrySdk
20+
21+
// Record session start and end events
22+
let sessionInstrumentation = SessionEventInstrumentation()
23+
24+
// Add session attributes to spans
25+
let sessionSpanProcessor = SessionSpanProcessor()
26+
let tracerProvider = TracerProviderBuilder()
27+
.add(spanProcessor: sessionSpanProcessor)
28+
.build()
29+
30+
// Add session atttributes to log records
31+
let sessionProcessor = SessionLogRecordProcessor(
32+
nextProcessor: SimpleLogRecordProcessor(logRecordExporter: ConsoleLogRecordExporter())
33+
)
34+
let builder = LoggerProviderBuilder()
35+
.with(processors: [sessionProcessor])
36+
.with(resource: resource)
37+
```
38+
39+
**Custom Configuration**:
40+
41+
```swift
42+
let sessionConfig = SessionConfigBuilder()
43+
.with(sessionTimeout: 45 * 60) // 45 minutes
44+
.build()
45+
let sessionManager = SessionManager(configuration: sessionConfig)
46+
SessionManagerProvider.register(sessionManager: sessionManager)
47+
```
48+
49+
**Getting Session Information**:
50+
51+
```swift
52+
// Get current session (extends session if active)
53+
let session = SessionManagerProvider.getInstance().getSession()
54+
print("Session ID: \(session.id)")
55+
56+
// Peek at session without extending it
57+
if let session = SessionManagerProvider.getInstance().peekSession() {
58+
print("Current session: \(session.id)")
59+
}
60+
```
61+
62+
## Components
63+
64+
### SessionManager
65+
66+
Manages session lifecycle with automatic expiration and renewal.
67+
68+
```swift
69+
let manager = SessionManager(configuration: SessionConfig(sessionTimeout: 1800))
70+
let session = manager.getSession() // Creates or extends session
71+
let session = manager.peekSession() // Peek without extending
72+
```
73+
74+
### SessionManagerProvider
75+
76+
Provides thread-safe singleton access to SessionManager.
77+
78+
```swift
79+
// Register a custom session manager
80+
let manager = SessionManager(configuration: SessionConfig(sessionTimeout: 3600))
81+
SessionManagerProvider.register(sessionManager: manager)
82+
83+
// Access from anywhere
84+
let session = SessionManagerProvider.getInstance().getSession()
85+
```
86+
87+
### SessionSpanProcessor
88+
89+
Automatically adds session IDs to all spans.
90+
91+
```swift
92+
let processor = SessionSpanProcessor(sessionManager: sessionManager)
93+
// Adds session.id and session.previous_id attributes to spans
94+
```
95+
96+
### SessionLogRecordProcessor
97+
98+
Automatically adds session IDs to all log records.
99+
100+
```swift
101+
let processor = SessionLogRecordProcessor(nextProcessor: yourProcessor)
102+
// Adds session.id and session.previous_id attributes to log records
103+
```
104+
105+
### SessionEventInstrumentation
106+
107+
Creates OpenTelemetry log records for session lifecycle events.
108+
109+
```swift
110+
let instrumentation = SessionEventInstrumentation()
111+
// Emits session.start and session.end log records
112+
```
113+
114+
### Session Model
115+
116+
Represents a session with ID, timestamps, and expiration logic.
117+
118+
```swift
119+
let session = Session(
120+
id: "unique-session-id",
121+
expireTime: Date(timeIntervalSinceNow: 1800),
122+
previousId: "previous-session-id"
123+
)
124+
125+
print("Expired: \(session.isExpired())")
126+
print("Duration: \(session.duration ?? 0)")
127+
```
128+
129+
## Configuration
130+
131+
### SessionConfig
132+
133+
| Field | Type | Description | Default | Required |
134+
| ---------------- | ----- | ------------------------------------------------------------------ | --------------- | -------- |
135+
| `sessionTimeout` | `TimeInterval` | Duration in seconds after which a session expires if left inactive | `1800` (30 min) | No |
136+
137+
```swift
138+
let config = SessionConfigBuilder()
139+
.with(sessionTimeout: 30 * 60)
140+
.build()
141+
```
142+
143+
### Session Timeout Behavior
144+
145+
- Sessions automatically expire after the configured timeout period of inactivity
146+
- Accessing a session via `getSession()` extends the expiration time
147+
- Expired sessions trigger `session.end` events and create new sessions with `previous_id` links
148+
149+
## Session Events
150+
151+
Emits OpenTelemetry log records following semantic conventions:
152+
153+
### Session Start
154+
155+
A `session.start` log record is created when a new session begins.
156+
157+
**Example session.start Event**:
158+
159+
```json
160+
{
161+
"body": "session.start",
162+
"attributes": {
163+
"session.id": "550e8400-e29b-41d4-a716-446655440000",
164+
"session.start_time": 1692123456789000000,
165+
"session.previous_id": "71260ACC-5286-455F-9955-5DA8C5109A07"
166+
}
167+
}
168+
```
169+
170+
**Session Start Attributes**:
171+
172+
| Attribute | Type | Description | Example |
173+
| --------------------- | ------ | --------------------------------------------- | ---------------------------------------- |
174+
| `session.id` | string | Unique identifier for the current session | `"550e8400-e29b-41d4-a716-446655440000"` |
175+
| `session.start_time` | double | Session start time in nanoseconds since epoch | `1692123456789000000` |
176+
| `session.previous_id` | string | Identifier of the previous session (if any) | `"71260ACC-5286-455F-9955-5DA8C5109A07"` |
177+
178+
### Session End
179+
180+
A `session.end` log record is created when a session expires.
181+
182+
**Example session.end Event**:
183+
184+
```json
185+
{
186+
"body": "session.end",
187+
"attributes": {
188+
"session.id": "550e8400-e29b-41d4-a716-446655440000",
189+
"session.start_time": 1692123456789000000,
190+
"session.end_time": 1692125256789000000,
191+
"session.duration": 1800000000000,
192+
"session.previous_id": "71260ACC-5286-455F-9955-5DA8C5109A07"
193+
}
194+
}
195+
```
196+
197+
**Session End Attributes**:
198+
199+
| Attribute | Type | Description | Example |
200+
| --------------------- | ------ | --------------------------------------------- | ---------------------------------------- |
201+
| `session.id` | string | Unique identifier for the ended session | `"550e8400-e29b-41d4-a716-446655440000"` |
202+
| `session.start_time` | double | Session start time in nanoseconds since epoch | `1692123456789000000` |
203+
| `session.end_time` | double | Session end time in nanoseconds since epoch | `1692125256789000000` |
204+
| `session.duration` | double | Session duration in nanoseconds | `1800000000000` (30 minutes) |
205+
| `session.previous_id` | string | Identifier of the previous session (if any) | `"71260ACC-5286-455F-9955-5DA8C5109A07"` |
206+
207+
## Span and Log Attribution
208+
209+
`SessionSpanProcessor` and `SessionLogRecordProcessor` automatically add session attributes to all spans and log records:
210+
211+
| Attribute | Type | Description | Example |
212+
| --------------------- | ------ | -------------------------------------------- | ---------------------------------------- |
213+
| `session.id` | string | Current active session identifier | `"550e8400-e29b-41d4-a716-446655440000"` |
214+
| `session.previous_id` | string | Previous session identifier (when available) | `"71260ACC-5286-455F-9955-5DA8C5109A07"` |
215+
216+
**Special Handling**: For `session.start` and `session.end` log records, the processors preserve the existing session attributes rather than overriding them with current session data, ensuring historical accuracy of session events.
217+
218+
## Best Practices
219+
220+
1. **Use SessionManagerProvider** - Register your session manager as a singleton for consistent access
221+
2. **Configure Appropriate Timeouts** - Set session timeouts based on your app's usage patterns
222+
3. **Add Span Processor Early** - Register the SessionSpanProcessor before creating spans
223+
4. **Handle Session Events** - Set up SessionEventInstrumentation to capture session lifecycle
224+
225+
## Persistence
226+
227+
Sessions are automatically persisted to UserDefaults and restored on app restart:
228+
229+
- Active sessions continue from their previous state
230+
- Expired sessions create new sessions with proper `previous_id` linking
231+
- Session data is saved periodically (every 30 seconds) to minimize disk I/O
232+
233+
## Thread Safety
234+
235+
All components are designed for concurrent access:
236+
237+
- `SessionManager` uses locks for thread-safe session access
238+
- `SessionManagerProvider` provides thread-safe singleton access
239+
- `SessionStore` handles concurrent persistence operations safely
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/*
2+
* Copyright The OpenTelemetry Authors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import Foundation
7+
8+
/// Represents an OpenTelemetry session with lifecycle management.
9+
///
10+
/// A session tracks user activity with automatic expiration and renewal capabilities.
11+
/// Sessions include unique identifiers, timestamps, and linkage to previous sessions.
12+
///
13+
/// Example:
14+
/// ```swift
15+
/// let session = Session(
16+
/// id: UUID().uuidString,
17+
/// expireTime: Date(timeIntervalSinceNow: 1800),
18+
/// previousId: "previous-session-id"
19+
/// )
20+
///
21+
/// if session.isExpired() {
22+
/// print("Session ended at: \(session.endTime!)")
23+
/// print("Duration: \(session.duration!) seconds")
24+
/// }
25+
/// ```
26+
public struct Session: Equatable {
27+
/// Unique identifier for the session
28+
public let id: String
29+
/// Expiration time for the session
30+
public let expireTime: Date
31+
/// Unique identifier of the user's previous session, if any
32+
public let previousId: String?
33+
/// Start time of the session
34+
public let startTime: Date
35+
/// The duration in seconds after which this session expires if inactive
36+
public let sessionTimeout: TimeInterval
37+
38+
/// Creates a new session
39+
/// - Parameters:
40+
/// - id: Unique identifier for the session
41+
/// - expireTime: Expiration time for the session
42+
/// - previousId: Unique identifier of the user's previous session, if any
43+
/// - startTime: Start time of the session, defaults to current time
44+
/// - sessionTimeout: Duration in seconds after which the session expires if inactive
45+
public init(id: String,
46+
expireTime: Date,
47+
previousId: String? = nil,
48+
startTime: Date = Date(),
49+
sessionTimeout: TimeInterval = SessionConfig.default.sessionTimeout) {
50+
self.id = id
51+
self.expireTime = expireTime
52+
self.previousId = previousId
53+
self.startTime = startTime
54+
self.sessionTimeout = sessionTimeout
55+
}
56+
57+
/// Two sessions are considered equal if they have the same ID, prevID, startTime, and expiry timestamp
58+
public static func == (lhs: Session, rhs: Session) -> Bool {
59+
return lhs.expireTime == rhs.expireTime &&
60+
lhs.id == rhs.id &&
61+
lhs.previousId == rhs.previousId &&
62+
lhs.startTime == rhs.startTime &&
63+
lhs.sessionTimeout == rhs.sessionTimeout
64+
}
65+
66+
/// Checks if the session has expired
67+
/// - Returns: True if the current time is past the session's expireTime time
68+
public func isExpired() -> Bool {
69+
return expireTime <= Date()
70+
}
71+
72+
/// The time when the session ended (only available for expired sessions).
73+
///
74+
/// For expired sessions, this returns the calculated end time based on when the session
75+
/// was last active. For active sessions, this returns nil.
76+
/// - Returns: The session end time, or nil if the session is still active
77+
public var endTime: Date? {
78+
guard isExpired() else { return nil }
79+
return expireTime.addingTimeInterval(-Double(sessionTimeout))
80+
}
81+
82+
/// The total duration the session was active (only available for expired sessions).
83+
///
84+
/// Calculates the time between session start and end. Only available for expired sessions.
85+
/// - Returns: The session duration in seconds, or nil if the session is still active
86+
public var duration: TimeInterval? {
87+
guard let endTime = endTime else { return nil }
88+
return endTime.timeIntervalSince(startTime)
89+
}
90+
}

0 commit comments

Comments
 (0)