Skip to content

Commit 5c3387b

Browse files
authored
Add opt-in SDK logging (#9)
* Add opt-in SDK logging * Expand logging README guidance * Redact SDK logging output
1 parent e5ac6f6 commit 5c3387b

23 files changed

+1103
-28
lines changed

DemoApp/AssistantRuntimeDemoApp/Shared/AgentDemoRuntimeFactory.swift

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ enum AgentDemoRuntimeFactory {
7373
approvalInbox: ApprovalInbox,
7474
deviceCodePromptCoordinator: DeviceCodePromptCoordinator
7575
) -> AgentRuntime {
76+
let diagnostics = DemoDiagnostics()
77+
let sdkLogging = diagnostics.sdkLoggingConfiguration()
7678
let authProvider: any ChatGPTAuthProviding
7779

7880
switch authenticationMethod {
@@ -98,13 +100,21 @@ enum AgentDemoRuntimeFactory {
98100
configuration: CodexResponsesBackendConfiguration(
99101
model: model,
100102
reasoningEffort: reasoningEffort,
101-
enableWebSearch: enableWebSearch
103+
enableWebSearch: enableWebSearch,
104+
logging: sdkLogging
102105
)
103106
),
104107
approvalPresenter: approvalInbox,
105-
stateStore: try! GRDBRuntimeStateStore(url: stateURL ?? defaultStateURL()),
108+
stateStore: try! GRDBRuntimeStateStore(
109+
url: stateURL ?? defaultStateURL(),
110+
logging: sdkLogging
111+
),
112+
logging: sdkLogging,
106113
memory: .init(
107-
store: try! SQLiteMemoryStore(url: defaultMemoryURL()),
114+
store: try! SQLiteMemoryStore(
115+
url: defaultMemoryURL(),
116+
logging: sdkLogging
117+
),
108118
automaticCapturePolicy: .init(
109119
source: .lastTurn,
110120
options: .init(
@@ -137,6 +147,8 @@ enum AgentDemoRuntimeFactory {
137147
reasoningEffort: ReasoningEffort = .medium,
138148
keychainAccount: String = defaultKeychainAccount
139149
) -> AgentRuntime {
150+
let diagnostics = DemoDiagnostics()
151+
let sdkLogging = diagnostics.sdkLoggingConfiguration()
140152
let authProvider = try! ChatGPTAuthProvider(method: .oauth)
141153

142154
return try! AgentRuntime(configuration: .init(
@@ -149,13 +161,21 @@ enum AgentDemoRuntimeFactory {
149161
configuration: CodexResponsesBackendConfiguration(
150162
model: model,
151163
reasoningEffort: reasoningEffort,
152-
enableWebSearch: enableWebSearch
164+
enableWebSearch: enableWebSearch,
165+
logging: sdkLogging
153166
)
154167
),
155168
approvalPresenter: NonInteractiveApprovalPresenter(),
156-
stateStore: try! GRDBRuntimeStateStore(url: defaultStateURL()),
169+
stateStore: try! GRDBRuntimeStateStore(
170+
url: defaultStateURL(),
171+
logging: sdkLogging
172+
),
173+
logging: sdkLogging,
157174
memory: .init(
158-
store: try! SQLiteMemoryStore(url: defaultMemoryURL()),
175+
store: try! SQLiteMemoryStore(
176+
url: defaultMemoryURL(),
177+
logging: sdkLogging
178+
),
159179
automaticCapturePolicy: .init(
160180
source: .lastTurn,
161181
options: .init(

DemoApp/AssistantRuntimeDemoApp/Shared/AgentDemoView+ChatSections.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ extension AgentDemoView {
5050
)
5151
.toggleStyle(.switch)
5252

53-
Text("Debug builds start with logging enabled. Logs print to the Xcode console for restore, sign-in, thread lifecycle, turn events, and tool activity.")
53+
Text("Debug builds start with logging enabled. Logs print to the Xcode console for both demo actions and SDK internals like history writes, thread creation, network calls, retries, compaction, and tool activity.")
5454
.font(.caption)
5555
.foregroundStyle(.secondary)
5656

DemoApp/AssistantRuntimeDemoApp/Shared/AgentDemoViewModel.swift

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,10 @@ struct DemoDiagnostics {
156156
#endif
157157
}
158158

159+
func developerLoggingEnabled(userDefaults: UserDefaults = .standard) -> Bool {
160+
initialDeveloperLoggingEnabled(userDefaults: userDefaults)
161+
}
162+
159163
func persistDeveloperLoggingEnabled(
160164
_ enabled: Bool,
161165
userDefaults: UserDefaults = .standard
@@ -176,6 +180,46 @@ struct DemoDiagnostics {
176180
logger.error("\(message, privacy: .public)")
177181
print("[CodexKit Demo][Error] \(message)")
178182
}
183+
184+
func sdkLoggingConfiguration() -> AgentLoggingConfiguration {
185+
AgentLoggingConfiguration(
186+
minimumLevel: .debug,
187+
sink: DemoSDKLogSink(diagnostics: self)
188+
)
189+
}
190+
}
191+
192+
struct DemoSDKLogSink: AgentLogSink {
193+
let diagnostics: DemoDiagnostics
194+
195+
func log(_ entry: AgentLogEntry) {
196+
guard diagnostics.developerLoggingEnabled() else {
197+
return
198+
}
199+
200+
let levelLabel: String
201+
switch entry.level {
202+
case .debug:
203+
levelLabel = "DEBUG"
204+
case .info:
205+
levelLabel = "INFO"
206+
case .warning:
207+
levelLabel = "WARN"
208+
case .error:
209+
levelLabel = "ERROR"
210+
}
211+
212+
var message = "[SDK][\(levelLabel)][\(entry.category.rawValue)] \(entry.message)"
213+
if !entry.metadata.isEmpty {
214+
let renderedMetadata = entry.metadata
215+
.sorted { $0.key < $1.key }
216+
.map { "\($0.key)=\($0.value)" }
217+
.joined(separator: " ")
218+
message += " | \(renderedMetadata)"
219+
}
220+
221+
diagnostics.log(message)
222+
}
179223
}
180224

181225
@MainActor

README.md

Lines changed: 86 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ Use `CodexKit` if you are building a SwiftUI app for iOS or macOS and want:
2020
- host-defined tools with approval gates
2121
- persona- and skill-aware agent behavior
2222
- hidden runtime context compaction with preserved user-visible history
23+
- opt-in developer logging across runtime, auth, backend, and bundled stores
2324
- share/import-friendly message construction
2425

2526
The SDK stays tool-agnostic. Your app defines the tool surface and runtime UX.
@@ -227,12 +228,92 @@ let backend = CodexResponsesBackend(
227228
)
228229
```
229230

230-
Available values:
231+
## Developer Logging
231232

232-
- `.low`
233-
- `.medium`
234-
- `.high`
235-
- `.extraHigh`
233+
`CodexKit` includes opt-in developer logging for the SDK itself. Logging is disabled by default and can be enabled independently on the runtime, built-in backend, and bundled stores.
234+
235+
```swift
236+
let logging = AgentLoggingConfiguration.console(
237+
minimumLevel: .debug
238+
)
239+
240+
let backend = CodexResponsesBackend(
241+
configuration: .init(
242+
model: "gpt-5.4",
243+
logging: logging
244+
)
245+
)
246+
247+
let stateStore = try GRDBRuntimeStateStore(
248+
url: stateURL,
249+
logging: logging
250+
)
251+
252+
let runtime = try AgentRuntime(configuration: .init(
253+
authProvider: authProvider,
254+
secureStore: secureStore,
255+
backend: backend,
256+
approvalPresenter: approvalInbox,
257+
stateStore: stateStore,
258+
logging: logging
259+
))
260+
```
261+
262+
You can also filter by category:
263+
264+
```swift
265+
let logging = AgentLoggingConfiguration.osLog(
266+
minimumLevel: .debug,
267+
categories: [.runtime, .persistence, .network, .tools],
268+
subsystem: "com.example.myapp"
269+
)
270+
```
271+
272+
Available logging categories include:
273+
274+
- `auth`
275+
- `runtime`
276+
- `persistence`
277+
- `network`
278+
- `retry`
279+
- `compaction`
280+
- `tools`
281+
- `approvals`
282+
- `structuredOutput`
283+
- `memory`
284+
285+
Use `AgentConsoleLogSink` for stderr-style console logs, `AgentOSLogSink` for unified Apple logging, or provide your own `AgentLogSink`.
286+
287+
Custom sinks make it possible to bridge `CodexKit` logs into your own telemetry or logging pipeline:
288+
289+
```swift
290+
struct RemoteTelemetrySink: AgentLogSink {
291+
func log(_ entry: AgentLogEntry) {
292+
Telemetry.shared.enqueue(
293+
level: entry.level,
294+
category: entry.category.rawValue,
295+
message: entry.message,
296+
metadata: entry.metadata,
297+
timestamp: entry.timestamp
298+
)
299+
}
300+
}
301+
302+
let logging = AgentLoggingConfiguration(
303+
minimumLevel: .info,
304+
sink: RemoteTelemetrySink()
305+
)
306+
```
307+
308+
`AgentLogEntry` includes:
309+
310+
- timestamp
311+
- level
312+
- category
313+
- message
314+
- metadata
315+
316+
For remote telemetry or file-backed logging, prefer a sink that buffers or enqueues work quickly. `AgentLogSink.log(_:)` is called inline, so it should avoid blocking network I/O on the caller's execution path.
236317

237318
## Persistent State And Queries
238319

Sources/CodexKit/Auth/ChatGPTSessionManager.swift

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,28 @@ import Foundation
33
public actor ChatGPTSessionManager {
44
private let authProvider: any ChatGPTAuthProviding
55
private let secureStore: any SessionSecureStoring
6+
private let logger: AgentLogger
67
private var session: ChatGPTSession?
78

89
public init(
910
authProvider: any ChatGPTAuthProviding,
10-
secureStore: any SessionSecureStoring
11+
secureStore: any SessionSecureStoring,
12+
logging: AgentLoggingConfiguration = .disabled
1113
) {
1214
self.authProvider = authProvider
1315
self.secureStore = secureStore
16+
self.logger = AgentLogger(configuration: logging)
1417
}
1518

1619
@discardableResult
1720
public func restore() throws -> ChatGPTSession? {
1821
let restored = try secureStore.loadSession()
1922
session = restored
23+
logger.debug(
24+
.auth,
25+
"Restored session from secure store.",
26+
metadata: ["restored": "\(restored != nil)"]
27+
)
2028
return restored
2129
}
2230

@@ -29,15 +37,33 @@ public actor ChatGPTSessionManager {
2937
let signedInSession = try await authProvider.signInInteractively()
3038
try secureStore.saveSession(signedInSession)
3139
session = signedInSession
40+
logger.info(
41+
.auth,
42+
"Persisted signed-in session.",
43+
metadata: ["account_id": signedInSession.account.id]
44+
)
3245
return signedInSession
3346
}
3447

3548
@discardableResult
3649
public func refresh(reason: ChatGPTAuthRefreshReason) async throws -> ChatGPTSession {
3750
let current = try requireStoredSession()
51+
logger.info(
52+
.auth,
53+
"Refreshing session.",
54+
metadata: [
55+
"reason": String(describing: reason),
56+
"account_id": current.account.id
57+
]
58+
)
3859
let refreshed = try await authProvider.refresh(session: current, reason: reason)
3960
try secureStore.saveSession(refreshed)
4061
session = refreshed
62+
logger.info(
63+
.auth,
64+
"Session refresh completed.",
65+
metadata: ["account_id": refreshed.account.id]
66+
)
4167
return refreshed
4268
}
4369

@@ -46,6 +72,11 @@ public actor ChatGPTSessionManager {
4672
session = nil
4773
try secureStore.deleteSession()
4874
await authProvider.signOut(session: current)
75+
logger.info(
76+
.auth,
77+
"Session signed out.",
78+
metadata: ["had_session": "\(current != nil)"]
79+
)
4980
}
5081

5182
public func requireSession() async throws -> ChatGPTSession {
@@ -61,11 +92,20 @@ public actor ChatGPTSessionManager {
6192
public func recoverUnauthorizedSession(
6293
previousAccessToken: String?
6394
) async throws -> ChatGPTSession {
95+
logger.warning(
96+
.auth,
97+
"Attempting unauthorized-session recovery."
98+
)
6499
if let restored = try secureStore.loadSession() {
65100
session = restored
66101
if let previousAccessToken,
67102
restored.accessToken != previousAccessToken,
68103
!restored.requiresRefresh() {
104+
logger.info(
105+
.auth,
106+
"Recovered session from secure store after unauthorized response.",
107+
metadata: ["account_id": restored.account.id]
108+
)
69109
return restored
70110
}
71111
}

0 commit comments

Comments
 (0)