A modern Swift network-debugging library for iOS — a from-scratch rewrite of Wormholy using SwiftUI, Swift 6 strict concurrency, actors, and URLSession delegate proxying.
It captures HTTP requests, responses, headers, bodies, and metrics; lets you filter, share, and export as cURL / Postman / HAR / plain text; and lives behind a NetworkLoggerView that you present wherever you want.
| Request list | Request detail | Response body |
|---|---|---|
![]() |
![]() |
![]() |
| Appearance | Share / export |
|---|---|
![]() |
![]() |
Wormholy is great. NetworkLogger keeps every feature you actually use and drops two architectural pillars that fight modern apps:
| Wormholy | NetworkLogger |
|---|---|
Walks UIApplication.shared.connectedScenes → keyWindow → rootViewController to push its UI |
You present NetworkLoggerView(logger:) like any SwiftUI view — .sheet, NavigationLink, fullscreen, tab, inline |
Shake-to-trigger NSNotification (wormholy_fire) is hardwired |
No notifications, no shake handler, no global state — wire up whatever trigger you want |
Method-swizzles NSURLSessionConfiguration from an ObjC constructor |
Pure-Swift URLSession delegate proxy you opt into per session |
Singleton Storage.shared, @Published reference types, DispatchQueue.main.async updates |
actor EventStore, Sendable value types, Swift 6 strict concurrency |
Wormholy.limit getter that returns nil synchronously while doing a Task { @MainActor in return ... } |
Real async API |
Swift Package Manager:
.package(url: "https://github.com/olcayertas/network-logger", from: "0.5.0")Add one (or more) of the products to your target:
| Product | Pulls in | When to use |
|---|---|---|
NetworkLogger |
swift-perception, swift-sharing | Always — core library and minimal UI. |
NetworkLoggerDependencies |
the above + swift-dependencies | If you want @Dependency(\.networkLogger) everywhere. |
NetworkLoggerMediaViewers |
the above + WebKit + PDFKit | Inline image / HTML / PDF body previews. |
NetworkLoggerLogHandler |
the above + swift-log | Route swift-log into the Console tab. |
iOS 16+ (uses the Perception library to back-port @Observable). The Swift package targets iOS 16 and macOS 13; UI is iOS-only.
The full DocC catalog is published with the package — open it in Xcode via Product → Build Documentation. Key articles:
- Getting Started — install, three-line quickstart, exclude from Release.
- Capturing network traffic — delegate proxy, URLProtocol, manual
record(_:). - Filtering and searching — structured tokens, date range, pins, recents.
- Persistence and sessions — file-backed mode via swift-sharing.
- Exporting captures — HAR, Postman, cURL, plain text.
- swift-log integration — Console tab + bootstrap.
- JWT viewer — detect, decode, jwt.io-style display.
Add to your Release.xcconfig (or each non-debug target's build settings):
EXCLUDED_SOURCE_FILE_NAMES = NetworkLogger*
No constructor-time side effects, so the library is inert if you just skip wiring it up.
import NetworkLogger
// 1. Create an instance — no singleton.
let logger = NetworkLogger(configuration: .init(
limit: 500,
ignoredHosts: ["analytics.example.com"]
))
// 2. Build a pre-wired session and route requests through it.
let session = logger.makeLoggingURLSession()
let (data, response) = try await session.data(for: request)
// 3. Present the inspector wherever you want.
.sheet(isPresented: $showInspector) {
NetworkLoggerView(logger: logger)
}makeLoggingURLSession() is the shortest path — one line, full body / header / metrics capture, cancellation forwarded. If you need to keep your own URLSession (custom configuration, your own delegate, etc.) use the manual delegate-proxy path described under Recording modes below.
Three complementary ways to feed events into a logger. Pick one or combine them.
let session = logger.makeLoggingURLSession()
let (data, response) = try await session.data(for: request)makeLoggingURLSession() returns a LoggingURLSession actor that:
- Owns a
URLSessionwhose delegate isLoggingURLSessionDelegate(full callback forwarding). - Drives requests via
dataTask(with:).resume()+ aCheckedContinuationglue (TaskResultSink) so the data-delegate callbacks fire reliably — even on iOS 26, whereURLSession.data(for:delegate:)no longer deliversURLSessionDataDelegateevents to the per-task delegate. - Forwards Swift
Taskcancellation to the underlyingURLSessionDataTask. - Bounds memory with a configurable
bodyCaptureLimit(1 MiB default).
Captures everything the inspector needs: request body, response body + headers, metrics, redirects, auth challenges.
If you already have a custom URLSession (your own configuration, pinned transport, existing delegate) and want to keep it, install the logger as a forwardee on your own delegate:
let delegate = await logger.makeSessionDelegate(forwardingTo: myDelegate)
let session = URLSession(configuration: .default, delegate: delegate, delegateQueue: nil)logger.makeSessionDelegate(forwardingTo:) returns a URLSessionDelegate that records every callback then forwards to your real delegate via Objective-C message forwarding (responds(to:) + forwardingTarget(for:)). Works with:
- Upload progress (
didSendBodyData:) - SSL pinning / server-trust (
didReceive challenge:) - HTTP redirects (
willPerformHTTPRedirection:) - Multipart bodies (combined with
BodyStreamTee) URLSessionStreamDelegate/URLSessionWebSocketDelegate— forwarded without any code from us
Limitation: the URLSession async/await convenience APIs (session.data(from:), session.data(for:) without a per-task delegate) consume the data delegate methods internally on iOS 26, so the session delegate only sees task and auth events. Use dataTask(with:).resume() + a delegate-driven completion, or just use makeLoggingURLSession() which already does this for you.
For gRPC, WebSocket, or any traffic that doesn't go through URLSession:
let event = NetworkEvent(
request: .init(
url: URL(string: "grpc://api.example.com/GetUser")!,
httpMethod: "POST",
headers: ["Content-Type": "application/grpc+proto"],
body: BodyData(data: payload)
),
response: .init(statusCode: 0, body: BodyData(data: reply)),
metrics: .init(duration: 0.085),
state: .completed
)
await logger.record(event)When you can't reach the URLSession (a closed-source SDK owns it), opt into the global URLProtocol:
let configuration: URLSessionConfiguration = .default
logger.attach(to: configuration) // installs LoggingURLProtocol on this config
URLProtocol.registerClass(logger.urlProtocolClass) // or globally, if you mustLimitations baked into Apple's URLProtocol design:
- Cannot forward
didSendBodyData:for streamed uploads (Wormholy bug #77 — an Apple-level limitation, not ours). - Background sessions are unsupported.
- The challenge handler always calls its completion handler with
.performDefaultHandling(fixes Wormholy #157's "API misuse" warning by construction).
NetworkLoggerView(logger:) is a plain SwiftUI view that builds its own NavigationStack. Drop it wherever:
// Sheet
.sheet(isPresented: $show) { NetworkLoggerView(logger: logger) }
// Push
NavigationLink("Network Logs") {
NetworkLoggerView(logger: logger)
}
// Full-screen cover
.fullScreenCover(isPresented: $show) { NetworkLoggerView(logger: logger) }
// Tab in a debug menu
TabView { NetworkLoggerView(logger: logger).tabItem { ... } }The library ships no shake-to-present helper, no floating overlay button, and no NSNotification trigger. Wire up your own trigger — see Examples/NetworkLoggerDemo for several patterns.
NetworkLoggerView carries a slider icon in its leading nav-bar position. Tap it to open the Appearance sheet — settings persist to UserDefaults so they survive across launches.
| Setting | What it does |
|---|---|
| Color scheme | Forces System / Light / Dark on the inspector independent of the host app, via .preferredColorScheme(...) at the root. |
| Accent | Picks the inspector's tint (Blue / Indigo / Purple / Teal / Green / Orange / Pink). Applied via .tint(...) so the host app's accent doesn't bleed into chips and buttons. |
| Body font size | Slider 9 – 22 pt (default 12 pt). Live preview in the sheet. Applied to the request and response body screens. |
| Reset to defaults | One-tap restore. |
AppearanceSettings.shared is the singleton the UI reads from. You can pre-seed it at app startup (e.g. to force dark mode on internal builds):
import NetworkLogger
AppearanceSettings.shared.colorScheme = .dark
AppearanceSettings.shared.accent = .indigo
AppearanceSettings.shared.bodyFontSize = 11Pretty-printed JSON bodies are token-coloured (keys, strings, numbers, booleans, null, punctuation) in both the request and response body views. Two palettes ship out of the box and are picked automatically based on the active color scheme:
SyntaxTheme.lightDefault // Xcode-ish, tuned for paper-white surfaces
SyntaxTheme.darkDefault // higher saturation, tuned for dark surfacesBuild a custom palette with the SyntaxTheme(key:string:number:boolean:null:punctuation:) initialiser. Non-JSON bodies (XML, plain text, binary placeholders) skip the highlighter and render unstyled — the scanner never throws on malformed input.
Search highlighting (yellow background from the search field) composes on top of syntax colours, so finding text inside a coloured body does not erase the syntax run for the rest of the response.
The optional NetworkLoggerDependencies product registers NetworkLogger as a swift-dependencies value, so you can configure once at app startup and read the configured instance from anywhere with @Dependency(\.networkLogger) — no prop drilling, no global singleton.
Add the product to your target:
.product(name: "NetworkLoggerDependencies", package: "network-logger")Configure once at app startup:
import NetworkLogger
import NetworkLoggerDependencies
import Dependencies
import SwiftUI
@main
struct MyApp: App {
init() {
prepareDependencies {
$0.networkLogger = NetworkLogger(configuration: .init(
limit: 500,
ignoredHosts: ["analytics.example.com"]
))
}
}
var body: some Scene {
WindowGroup { ContentView() }
}
}Read it anywhere:
struct DebugMenuView: View {
@Dependency(\.networkLogger) var logger
@State private var showLogs = false
var body: some View {
Button("Network logs") { showLogs = true }
.sheet(isPresented: $showLogs) {
NetworkLoggerView(logger: logger)
}
}
}Or use the bundled NetworkLoggerInspector() view, which pulls the dependency for you:
.sheet(isPresented: $showLogs) {
NetworkLoggerInspector()
}Override for tests / previews like any Dependencies-managed value:
withDependencies {
$0.networkLogger = NetworkLogger()
} operation: {
// ... test or preview code that consumes @Dependency(\.networkLogger)
}This module is opt-in. If you don't import NetworkLoggerDependencies, swift-dependencies is never pulled into your dependency graph.
NetworkLoggerConfiguration(
limit: 500, // ring buffer size
ignoredHosts: ["analytics.example.com"],
defaultFilter: "/api/v2", // pre-fill the search box
bodyCaptureLimit: 1_048_576, // 1 MiB body cap with truncation flag
headerRedactor: Redaction.makeHeaderRedactor(), // redacts Authorization, Cookie, ...
responseTransformer: { data, request in
// decrypt, decompress, anything you want
return MyCrypto.decrypt(data)
}
)Runtime mutation:
await logger.setLimit(1000)
await logger.setIgnoredHosts(["new.host"])
await logger.setDefaultFilter("/v3/")
await logger.replaceConfiguration(newConfig)let events = await logger.snapshot()
CurlExporter.string(for: events) // multiline cURL commands
PostmanExporter.collection(name: "MyApp", from: events) // Postman 2.1 JSON
PlainTextExporter.text(for: events) // human-readable text
HARExporter.har(from: events) // HAR 1.2 JSONThe built-in UI wires these into ShareLink automatically (Save to Files, Mail, Messages, etc.).
let combined = CompositeFilter(
URLSubstringFilter("/users"),
MethodFilter("POST"),
StatusCodeRangeFilter.clientError,
HostFilter(blocking: ["staging.example.com"])
)
let problems = (await logger.snapshot()).filtered(by: combined)Built-ins: URLSubstringFilter, MethodFilter, HostFilter, StatusCodeRangeFilter, CompositeFilter (AND), AnyOfFilter (OR), NotFilter. All Sendable. Implement your own by conforming to EventFilter.
| Wormholy issue | NetworkLogger resolution |
|---|---|
| #157 SSL pinning "API MISUSE" | Delegate proxy forwards didReceive challenge: to your delegate; URLProtocol mode always calls the completion handler. |
| #147 Empty request body | BodyStreamTee clones httpBodyStream for the URLProtocol path. The delegate proxy captures from task.originalRequest.httpBody, which URLSession preserves. |
| #146 Custom logs (gRPC) | Public record(_:) API. |
| #144 Debug-only SPM | EXCLUDED_SOURCE_FILE_NAMES = NetworkLogger* per Release config — no library code change needed since there's no constructor side effect. |
| #132 Custom cell content | (Roadmap) cell row override via RequestListView initializer. |
| #130 Decrypt response | Configuration.responseTransformer. |
| #125 Response mocking (Proxyman-style) | Roadmap (stretch goal). |
| #114 Static linking | ObjC constructor + method swizzling removed entirely. Works by construction. |
| #93 Multipart bodies | Same BodyStreamTee path as #147. |
| #77 Alamofire/upload progress | Delegate-proxy mode forwards didSendBodyData: to your delegate. URLProtocol mode preserves Apple's limitation but documents it. |
- All public mutable state is in an
actor(EventStore,ConfigurationStore, per-taskSessionRecorder). NetworkEventand its components arestruct, Sendablevalue types.LoggingURLSessionDelegateisNSObject, @unchecked Sendable(URLSession requires NSObject; isolation is enforced by routing all state mutations through the recorder actor).- UI uses
@Perceptible(Perception library)@MainActorview models subscribed tologger.eventStream(). - No
DispatchQueue.main.async, no@Publishedon reference types, no implicit globals.
Builds clean under -strict-concurrency=complete on Swift 6.
MIT — same as Wormholy.




