All Articles
CategoryTools
Reading Time
9 min read
Published
2026-10-15
Word Count
1,585words

Grab a coffee — this one is a deep dive!

iOSDevelopmentTools 2.0: an in-app network inspector that redacts before it stores

Summary

How iOSDevelopmentTools 2.0 records URLSession traffic with a URLProtocol, redacts secrets before storage, and why its redirect handling had to change.

  • When a tester reports "the checkout screen showed an error", the first question is usually what the app sent and what the server answered.
  • A URLProtocol instead of a proxy or delegate wrapping.
  • Add the package with Swift Package Manager: swift .package(url: "https://github.com/muhittincamdali/iOSDevelopmentTools.git", from: "2.0.0") Then start capture in your app's init and attach the console to the root view.
  • Source code: [github.com/muhittincamdali/iOSDevelopmentTools](https://github.com/muhittincamdali/iOSDevelopmentTools)
iOSDevelopmentTools 2.0: an in-app network inspector that redacts before it stores

Table of Contents


Problem

When a tester reports "the checkout screen showed an error", the first question is usually what the app sent and what the server answered. On a simulator attached to Xcode that is easy to find out. On a test device in someone else's hands it is not: there is no debugger, the console log is gone, and setting up a proxy on the device is a step most testers will not take.

iOSDevelopmentTools puts that information inside the app. Version 2.0.0 records the requests an app makes with URLSession, keeps its log messages and collects a device report, and shows all three in a SwiftUI console that opens with a shake, a button or a window. A request can be opened to see its headers, bodies and timings, or shared as a cURL command or a HAR file.

A tool like this has an obvious risk: it stores exactly the data you would not want in a screenshot attached to a bug report. Sign-in bodies, Authorization headers, cookies and API keys all pass through it. So the second half of the problem is that nothing sensitive should be stored in the first place.

The repository's history is short and honest about this. It was opened in August 2025 as a set of network, storage, analytics and utility modules; v1.0.0 was tagged in February 2026 on top of an Alamofire-based client. In June 2026 those modules were removed and replaced with placeholders, including a network interceptor whose startLoading() never sent the request, so every intercepted request hung (commit 34e9643). The 2.0.0 rebuild in October 2026 removed all of it and started again from the redaction policy (813eb7a, PR #19).

Design decisions and alternatives

A URLProtocol instead of a proxy or delegate wrapping. An external proxy sees all traffic but needs a certificate on the device and a configured network. Wrapping session delegates only works for sessions you create. A URLProtocol subclass registered with URLProtocol.registerClass sees the requests of URLSession.shared, and inserting it into a configuration's protocolClasses covers any other session. NetworkCapture.start() does both: it registers CaptureProtocol and exchanges the default and ephemeral class getters of URLSessionConfiguration with versions that call NetworkCapture.install(into:), so libraries built on URLSession are recorded too. Calling stop() swaps them back.

The protocol copies each request, redacts the copy and stores that, then sends the original on through one internal ForwardingSession. The server receives what the app sent, and the app's session receives the response unchanged. The cost of this design is documented: settings that belong to the app's session rather than to the request, such as its delegate's server trust handling, waitsForConnectivity or a custom URLCache, do not apply to recorded requests. Background sessions and WebSocket tasks are not seen at all.

Redact before storage, not before display. RedactionPolicy runs before a NetworkEntry is created, so the console, HTTPArchive files and curlCommand() only ever see replaced values. Names are normalized before matching (lowercase, percent-decoded up to four times, NFKC, ASCII letters and digits only), so X-API-Key, api_key and API%5FKEY all match the fragment apikey. Matching errs on the side of hiding too much: a JSON key author is hidden because it contains auth.

Withhold what cannot be checked. A JSON body cut off at the capture limit cannot be parsed, so a secret in its first bytes could not be found. The default policy stores such a body's size and marks it withheld instead of keeping its bytes. The same applies to malformed JSON, JSON nested deeper than 64 levels, JSON in which an object repeats a key, and a list of multipart shapes that servers such as Werkzeug read differently from a strict parser.

Bounded memory. NetworkLogger keeps at most 500 entries and 256 KiB of each body by default, clamped to 1–10,000 entries and 0–16 MiB. An early version dropped old entries with Array.removeFirst, which copies the whole history on every request while holding the lock that capture threads wait on; b7a73dc replaced it with a fixed-size ring buffer. Uploads over 4 MiB are streamed in 64 KiB chunks instead of being read into memory.

No dependencies, Swift 6 strict concurrency. Everything outside the console views is Sendable. The 1.0.0 dependencies (Alamofire, SwiftLint, Quick, Nimble) are gone.

A real bug: the redirect that sometimes failed

The most instructive fix in the release is 244d9f1, "stop failing a load right after reporting its redirect".

CaptureProtocol first followed Apple's CustomHTTPProtocol sample. On a redirect it called wasRedirectedTo on the app's session and then failed its own load with CocoaError(.userCancelled). The session normally stops the old load before that failure arrives, but not always. According to the commit, one run in seven of the redirect test on an iOS simulator saw session.data throw error 3072, and a loop of 2,000 redirects failed on macOS in three runs out of three. A session whose delegate refused the redirect always received that error instead of the redirect response.

Experiments with a bare URLProtocol explained why. A session that follows a redirect stops the old load and starts a new one. A delegate that refuses it gives the protocol no signal at all. Anything the protocol sends right after the redirect can reach the task before the switch.

The fix reports the redirect on its own and lets the forwarded task continue to the redirect response, whose disposition is held back. If the app's session stops the load, the held response is cancelled. If it has not done so after half a second (redirectDecisionTime), the redirect counts as refused and the held response, its body and its end go to the app, as they would without capture. The stub server was changed to send the 302 and a body, as a real server does, and two tests were added: "Redirects reach the target every time, not a cancellation error" and "A redirect the app's session refuses returns the redirect response". The commit notes that the new tests fail on the old code.

A second lesson ran through several commits: Swift Characters are the wrong unit for finding separators. A combining mark such as U+0301 right after &, = or a quote joins it into one Character, so a search over Characters does not see the separator while a server does. In e9d0b1e a form body password=<U+0301>hunter2 had been stored unchanged; in bbd81b6 a quote followed by a combining mark escaped curlCommand()'s quoting, so a pasted command could run text from a header value. fac086f found the same issue in LogStore's length limit, where a message of one letter and a million combining marks counted as one Character. All three now work on UTF-8 bytes or Unicode scalars.

Usage

Add the package with Swift Package Manager:

swift
1.package(url: "https://github.com/muhittincamdali/iOSDevelopmentTools.git", from: "2.0.0")

Then start capture in your app's init and attach the console to the root view. This block is copied from the README's quick start; the same code is compiled by the example app and by the test suite through ReadmeSamples.swift:

swift
1@main
2struct MyApp: App {
3 init() {
4 #if DEBUG
5 NetworkCapture.start()
6 #endif
7 }
8 
9 var body: some Scene {
10 WindowGroup {
11 ContentView()
12 #if DEBUG
13 .devToolsConsoleOnShake()
14 #endif
15 }
16 }
17}

devToolsConsoleOnShake() is iOS only. On macOS the README gives the console its own window:

swift
1struct MacDebugScenes: Scene {
2 var body: some Scene {
3 Window("Developer Tools", id: "devtools") {
4 DevToolsConsole()
5 }
6 .keyboardShortcut("d", modifiers: [.command, .option])
7 }
8}

For a bug report, the stored entries can be exported directly:

swift
1func exportForBugReport() throws -> (har: Data, curl: [String], device: String) {
2 let entries = NetworkLogger.shared.entries
3 let har = try HTTPArchive(entries: entries).jsonData()
4 let curl = entries.map { $0.curlCommand() }
5 let device = DeviceSnapshot.current().report()
6 return (har, curl, device)
7}

To add your own sensitive names, extend the default fragments: RedactionPolicy(nameFragments: RedactionPolicy.defaultNameFragments + ["iban", "ssn"]).

The test suite has 148 Swift Testing tests (swift test on macOS at v2.0.0), and CI also runs the suite on an iOS simulator. Network tests send requests from an app session through the capture to a stub server and check what the server received, what the app received and what was stored.

Limits

  • Debug and test builds only. Redaction removes the secrets it knows about, but the console still shows every request. The DocC article "Keeping the tools out of release builds" recommends #if DEBUG or a separate internal target that links the package.
  • Not everything is recorded. Background sessions, WebSocket tasks, schemes other than http and https, and ignored hosts are not captured. Background traffic can be added by hand with NetworkLogger's record(request:response:body:error:startDate:endDate:).
  • Session settings do not carry over. Certificate pinning in your session delegate, a custom cache or cookie storage and waitsForConnectivity do not apply to recorded requests. Ignore that host or stop capture while testing them.
  • Redaction has edges. Plain text, XML and uploaded files are not checked. Path segments are not checked apart from ;name=value parameters. A secret under a name that contains none of the fragments is stored. Log messages are stored as written.
  • A refused redirect arrives late. Because the session gives no signal, the redirect response reaches the app half a second after it arrives.
  • Platforms. iOS 17 and macOS 14 with Swift 6.2; tvOS and visionOS support for the console, persistence across launches and request mocking are listed on the roadmap, not shipped.

Source code: github.com/muhittincamdali/iOSDevelopmentTools

Tags

#swift#ios#urlsession#debugging#swiftui
Muhittin Çamdalı

Muhittin Çamdalı

Senior Mobile Engineer

Senior Mobile Engineer with 12+ years of experience. I build mobile products with iOS, Flutter and React Native, working full-stack and AI-native.

iOS Development News

Weekly Swift tips, SwiftUI tricks and iOS best practices. No spam, only valuable content.

Your subscription starts when you open the link in the confirmation email and press “Confirm my subscription”. The newsletter keeps open/click statistics; you can unsubscribe anytime with one click. Privacy

Share