Pulse SDK Integration Tutorial for iOS: Network Logger

Learn how to set up network logger for your app using Pulse SDK. Pulse framework provides you a UI to display the logs in your debug app and also persist the logs that can be exported anytime. By Mark Struzinski.

See course reviews 5 (1) · 1 Review

Download materials
Save for later
Share

Learn how to set up network logger for your app using Pulse SDK. Pulse framework provides you a UI to display the logs in your debug app and also persist the logs that can be exported anytime.

Logging network requests is crucial when debugging and monitoring apps that rely heavily on remote data. In today’s API-driven world, this is true for almost all apps! You’ll come across two types of frameworks to interact with your network APIs while building an app: network proxies and network logger.

Usually, network proxies are the tool you use for monitoring network traffic. Proxies sit between your app and the network, intercepting traffic on the way in and out. They log and present data in real time.

Some good examples of proxy apps are Charles and Proxyman.

Pulse Network Inspector takes a different approach than the previously mentioned proxy apps. Pulse is a network logger. It logs the network activity that an app makes and provides some intuitive UI for later viewing.

Pulse is a framework that you can use to provide any of your app’s testers (QA, engineers, beta testers) access to a history of the network requests the app makes. It means the testers don’t need to know how to set up a proxy for themselves.

Pulse lets you view network activity in supplementary apps or directly inside the app that triggered the network requests. It also provides access to its data store. This approach lets you build your UI around the log data if you choose.

In this tutorial you will:

  • Learn how to integrate Pulse into your app.
  • Add simple data logging and visualise it in the Pulse UI.
  • Hook Pulse up to network requests and inspect all aspects of the request in the Pulse UI.
  • Implement an image downloader that adds Pulse logging to image network requests.

Getting Started

Download the project materials by clicking Download Materials at the top or bottom of this tutorial.

In this tutorial, you’ll work on MovieSearch, an app which lets you search and view movies from The Movie Database via its API. You’ll add the Pulse framework to this app and learn how to use it to log and subsequently inspect the network traffic the app makes.

Before you start, you need to register for an API key at the TMDB website. If you don’t have an account, go to the website’s registration page and sign up.

The MovieDB Registration

After you sign up and log in, go to the account settings page and select the API option from the left panel.

The MovieDB Account Settings

Next, you need to register an app to get an API key. While in API setting’s Overview section, click the link under the Request an API Key subsection. It’ll take you to the Create tab:

The MovieDB create API tab

Select the Developer option. After reading through it, accept the terms on the next page.

Next, fill out the form’s required information, including your app description and address details. You can give any URL, name and description you’d like for the app. Even though this app isn’t for public consumption, you need to get past this step to retrieve the API key on the next screen. After you complete the application form, the next page will display your API key:

TMDB API Key generated

Voila! Copy your API Key as you’ll need it in a moment.

Open the starter app from the downloaded materials. Press Shift-Command-O, search and open APIKey.swift.

Next, insert your API key from above into the value static constant property under the enum APIKey.

That’s it for setup! :] Build and run the project to test the app.

Search screen

You’ll launch to a search screen. Type in any search to get a list of results:

Search results

Finally, tap any result to see a detail view:

Movie detail

Take a minute to review the code. Focus on the networking implementation in NetworkService.swift under the Source ▸ Network ▸ Service folder.

This tutorial will add the Pulse SDK to the app and update the network stack to work with it. Pulse enables targeted logging of all network activity in the app and gives you a view into network performance and any errors.

Before you begin coding it’s time to take a closer look at Pulse.

Introducing Pulse

Pulse is a persistent network logger and inspector. Although it can show you requests via the Pulse Pro experience in real time, its primary function is to log your app’s network activity into a persistent data store. You can then view the data in the custom native UI provided by the PulseUI framework and create a custom UI to display the network data from the logs.

Pulse uses Apple’s open-source SwiftLog framework as a dependency to log data.

Pulse is broken down into three main components: PulseCore, PulseUI and document-based Pulse apps. Here’s an explanation about what each of them does.

PulseCore

PulseCore is the base framework you use to log activity for introspection later. It also provides a network proxy for automatically capturing network requests.

PulseCore is available on iOS, macOS, watchOS and tvOS. The PulseCore framework provides a PersistentLogHandler struct that logs to a persistent store. You can also use this struct for standard log messages from SwiftLog.

PulseUI

PulseUI provides a UI to parse and display network data from the Pulse logs. This framework’s approach offers a quick way to get up and running with Pulse data. Like PulseCore, it’s also available on iOS, macOS, tvOS and watchOS.

Document-Based Pulse Apps

Document-based Pulse apps are separate from the Pulse framework inside your app. They let you open Pulse documents shared from other devices or apps to view network logs. These apps are available on iOS and macOS. Pulse documents contain the network traffic logged and saved by an app. You can therefore use these to introspect traffic that an app made, perhaps to debug something that a QA analyst or a beta tester experienced.

Now that you’ve got an overview of what Pulse does and its core frameworks, you’ll learn to integrate Pulse into the MovieSearch app.

Pulse Integration

Pulse is distributed via Swift Package Manager, making it easy to integrate into your project via Xcode.

First, open the starter project. In Project navigator on the left, select the MovieSearch project node at the top.

Under Project, select the MovieSearch project item. Then, go to the Package Dependencies tab.

Add Package dependencies in project

Under Packages in the middle pane, click the + button. Next, in the Search or Enter Package URL text field, enter:

https://github.com/kean/Pulse

SPM Add Pulse

Leave the defaults as they are and click Add Package. Then tick all three package products when requested. Finally, click Add Package.

Add Pulse to targets

Here you go! :] At the bottom of Xcode’s Project navigator, you’ll see Pulse and swift-log under the Package Dependencies section.

Packages added

Build and run to ensure everything is working as desired.

Search results

Pulse is designed around networking data however you can also log any arbitrary data. To get started with the framework you’ll first log some basic data.

Logging Basic Data

In addition to network logs, Pulse lets you take advantage of SwiftLog to log any data you need outside of networking-related concerns. You’ll view these logs alongside your network logs.

Setting Up Pulse Logs

First, initialize and configure the Pulse logging backend. Open AppMain.swift. At the top of the file, add an import for Pulse and Logging under the other imports:

import Pulse
import Logging

In init(), add the following code under setupNavbarAppearance():

LoggingSystem.bootstrap(PersistentLogHandler.init)

This code configures SwiftLog to use Pulse’s PersistentLogHandler.

Next, you’ll log some information using SwiftLog, the underlying subsystem Pulse uses.

Introducing SwiftLog

SwiftLog is an open-source logging implementation by Apple. SwiftLog statements have three components: Log Levels, Message and MetadataValue.

Log Levels

SwiftLog provides seven built-in log levels:

  • trace
  • debug
  • info
  • notice
  • warning
  • error
  • critical

You’ll use these levels as part of each log statement based on the severity.

Message

The message is the primary piece of information sent to a log statement. You can make use of interpolated strings in the message to add dynamic data at runtime in your logs.

Metadata Value

This optional parameter helps you attach more data to the log statement. It can be a String, Array or Dictionary. You won’t use this value in your log statements here, but if you ever need to attach more context to your logs, this is the place to do so.

Are you ready to add your first log using SwiftLog API? Here you go! :]

Logging With SwiftLog

Open MovieListViewModel.swift. Then import Logging under the other imports:

import Logging

Under the declaration of the networkService property, add:

let logger = Logger(label: "com.razeware.moviesearch")

This line creates a SwiftLog Logger instance you can use to log events. You’ve given it a label that will separate these log messages from any others.

Next, you’ll log an event when the user performs a search.

Add the following code inside the search() function right before the loading = true:

logger.info("Performing search with term: \(self.searchText)")

So you’ve added a log. But you have no way to view the logs! In the next section, you’ll remedy that.

Using PulseUI to View Logs

Pulse comes with a built-in viewer for logs available on macOS, iOS, iPadOS and tvOS. You can use it anywhere in your UI where you want to see what’s happening in your network stack.

In this tutorial, you’ll set up the viewer as a modal view that displays via a toolbar button. You might want to make this view only available in debug builds or hide it deeper in the UI in a production app. Pulse is an excellent debugging tool, but you wouldn’t want it front and center in your app’s UI!

Press Shift-Command-O and open ContentView.swift. At the top, right under the SwiftUI import, add an import for PulseUI:

import PulseUI

Next, under the declaration of var viewModel, add a state variable to control sheet visibility:

@State
private var showingSheet = false

This state variable controls when the PulseUI log view renders as a modal.

Next, right under the .searchable modifier, add a sheet modifier:

.sheet(isPresented: $showingSheet) {
  MainView()
}

PulseUI provides MainView. It’s an easy-to-use view that displays a lot of information about your network and SwiftLog data.

You added the ability to display the log view in a sheet, but you haven’t created a way to trigger the display yet. You’ll handle that now via a toolbar button.

Under the .onSubmit action, add a toolbar modifier:

// 1
.toolbar {
  // 2
  ToolbarItem(placement: .navigationBarTrailing) {
    // 3
    Button {
      // 4
      showingSheet = true
    } label: {
      // 5
      Image(systemName: "wifi")
    }
  }
}

This code:

  1. Adds a toolbar to the navigation bar.
  2. Creates a ToolbarItem aligned to the right.
  3. Adds a Button as content to the ToolbarItem.
  4. When a user taps the button, sets the showingSheet variable to true.
  5. Uses the SFSymbol for WiFi as an icon for the button.

That’s all you need to display your log UI. Build and run.

When the app launches, you’ll see your new toolbar item on the right side of the navigation bar:

Simulator with wifi icon and search bar

Perform a couple of searches, then click the new toolbar button. You’ll see your search logs:

Log results

You won’t see much on the other tabs yet. You’ll explore them after you start capturing network traffic.

Capturing Network Traffic

Pulse has two methods for logging and capturing network traffic. You can use a combination of URLSessionTaskDelegate and URLSessionDataDelegate, or set up automated logging when your app launches.

The recommended approach is to use delegation to log your network requests. The automated approach uses an Objective-C runtime feature called swizzling to inject Pulse into the depths of URLSession. This allows Pulse to capture all requests regardless of origin. While this may be desirable in some instances, it is something that can be dangerous. So you’ll follow the recommended approach here.

Next, you’ll update the networking stack to use delegation as a way to insert Pulse into the mix.

Adding a Logger Instance

Open NetworkService.swift. Then add an import for PulseCore at the top of the file under the other imports:

import PulseCore

Under the declaration of urlSession, create a logger:

private let logger = NetworkLogger()

NetworkLogger is the class Pulse provides to perform all your logging functions. You’ll use this instance in the next step to log network activity.

Implementing URLSession Delegates

First, you need to change the network interaction to enable Pulse logging. You need to hook up Pulse to various different parts of the network request. This means you will switch from handling the search network request with an inline completion block, to a cached block that will execute as part of one of the URLSession delegate callback.

Update Search to Work with a Delegate

In NetworkService.swift, add the following property underneath the declaration of logger:

var searchCompletion: ((Result<[Movie], NetworkError>) -> Void)?

This property has the same signature as the completion block for search(for:).

Now, update the search(for:) function to remove parsing, error and completion handling. All of that responsibility will shift to the delegate.

Replace the search(for:) function with the following code:

@discardableResult
// 1
func search(for searchTerm: String) -> URLSessionDataTask? {
  // 2
  guard let url = try? url(for: searchTerm) else {
    searchCompletion?(.failure(NetworkError.invalidURL))
    return nil
  }

  // 3
  let task = urlSession.dataTask(with: url)
  // 4
  task.delegate = self
  // 5
  task.resume()
  // 6
  return task
}

This code:

  1. Removes the completion argument and passes the search term entered in the search bar.
  2. Then checks for a valid URL. If the URL is not valid then it calls the completion handler, if it exists.
  3. Creates a data task from the URL.
  4. Then sets the task’s delegate to self.
  5. Calls resume() to start the request.
  6. Finally, returns the task so you can cancel it if needed.

You’ll see a warning, but don’t worry. You’ll fix it soon.

Update NetworkService with URLSessionTaskDelegate

While in NetworkService.swift, update NetworkService to make it a class instead of a struct and have it inherit from NSObject.

Update the declaration as follows:

class NetworkService: NSObject {
  ...
}

This change is required so that the class can become a URLSession delegate.

Add an extension to NetworkService and declare conformance to URLSessionTaskDelegate and URLSessionDataDelegate and add the first delegate method by adding the following code at the bottom of the file:

extension NetworkService: URLSessionTaskDelegate, URLSessionDataDelegate {
  func urlSession(
    _ session: URLSession, 
    dataTask: URLSessionDataTask, 
    didReceive response: URLResponse, 
    completionHandler: @escaping (URLSession.ResponseDisposition) -> Void
  ) {
    // 1
    logger.logDataTask(dataTask, didReceive: response)
    // 2
    if let response = response as? HTTPURLResponse,
      response.statusCode != 200 {
      searchCompletion?(.failure(.invalidResponseType))
    }

    // 3
    completionHandler(.allow)
  }
}

This delegate function fires when urlSession receives a response. Here’s a code breakdown:

  1. This is your first Pulse network logging statement! It tells Pulse to log that the data task received a response.
  2. If the response isn’t a 200 status code, or success, you call searchCompletion with a failure result.
  3. However if the request was successful, you call the delegate call’s completion handler to allow the data task to continue.

Next, add two more delegate methods:

// 1
func urlSession(
  _ session: URLSession, 
  task: URLSessionTask, 
  didCompleteWithError error: Error?
) {
  logger.logTask(task, didCompleteWithError: error)
}

// 2
func urlSession(
  _ session: URLSession, 
  task: URLSessionTask, 
  didFinishCollecting metrics: URLSessionTaskMetrics
) {
  logger.logTask(task, didFinishCollecting: metrics)
}

The above functions add more Pulse log points for the data task at the following points:

  1. Successful completion of the task.
  2. When metrics have been collected.

You have one final delegate protocol to implement.

Update NetworkService with URLSessionDataDelegate

Because you’ve switched to using URLSession delegates, you still need a way to hook into the response when data is received, parse it and update the backing array when you have updated results.

At the bottom of the extension, add your final delegate function:

func urlSession(
  _ session: URLSession, 
  dataTask: URLSessionDataTask, 
  didReceive data: Data
) {
  // 1
  logger.logDataTask(dataTask, didReceive: data)
  
  do {
    // 2
    let decoder = JSONDecoder()
    decoder.keyDecodingStrategy = .convertFromSnakeCase

    let movieResponse = try decoder.decode(MovieResponse.self, from: data)
    // 3
    searchCompletion?(.success(movieResponse.list))
  } catch {
    // 4
    searchCompletion?(.failure(NetworkError.invalidParse))
  }
}

This function:

  1. Sends a log statement to Pulse for the receipt of data.
  2. Attempts to decode the response.
  3. If successful, calls searchCompletion with a success result.
  4. If parsing fails, calls completion with an appropriate error and a failure result.

Next, you need to update MovieListViewModel to incorporate the changes to the search request.

Update the List View Model

Open MovieListViewModel.swift. Add a function under search() to handle the search response:

private func processSearchResponse(result: Result<[Movie], NetworkError>) {
  // 1
  DispatchQueue.main.async {
    // 2
    self.loading = false
    // 3
    guard let list = try? result.get() else {
      return
    }
    
    // 4
    self.movieList = list
  }
}

The above code:

  1. Moves to the main queue because it’ll trigger a UI update.
  2. Sets the loading property to false. Updating this property re-renders the UI and causes the progress spinner to disappear.
  3. If there are no results, returns early so as not to replace any existing list with an empty one.
  4. Sets the published property movieList to the search results. This will trigger an update to the search results UI.

Now, update the search() function to:

func search() {
  // 1
  if let task = currentTask {
    task.cancel()
    currentTask = nil
  }
  // 2
  DispatchQueue.main.async {
    guard !self.searchText.isEmpty else {
      self.movieList = []
      return
    }
  }

  // 3
  logger.info("Performing search with term: \(self.searchText)")
  loading = true
  
  // 4
  networkService.searchCompletion = processSearchResponse(result:)
  
  // 5
  let task = networkService.search(for: searchText)
  // 6
  currentTask = task
}

Here’s a code breakdown:

  1. If currentTask isn’t nil, that means a request is in progress. Cancel it to perform the new one.
  2. If searchText is empty, clear the list.
  3. Then, log the search and set the loading state to true.
  4. Set the new searchCompletion property to the processSearchResponse(result:).
  5. Create the task and update the currentTask property.

That’s it! Build and run. You’ll see the app behave in the same way. But now, when you perform a few searches and tap the network button, you’ll get logs of the network activity!

Make a few different searches and then go into the Pulse UI. Take some time to inspect the Pulse UI now that you have some log data to explore.

The first view you’ll see is the Console tab:

Pulse console log results

This view mixes all log items: network and standard SwiftLog entries. There is textual search and filter UI as well. You can filter by date and log level:

Filter UI

Tapping an item gives you a detail summary:

Detail log summary

Pin detail items by tapping the pin icon at the top. The Share button lets you share your Pulse document for others to view in one of the dedicated iOS or macOS apps.

Pin an item now to save it.

Tapping View on the Response Body section shows you a formatted JSON response body:

Detail response body

Back in the main request view, tap the Headers segment at the top and you’ll see a dedicated view of request and response headers:

Detail log headers

Select the Metrics segment and you’ll see network performance stats:

Metrics display

Back in the main Pulse UI console, if you select the network tab, you’ll only see network logs and exclude the SwiftLog standard entries:

Network tab

Select the Pins tab to see the item you pinned as a favorite:

Pins tab

Then select Settings to see some options available to you:

Settings tab

Browse Files lets you browse the Files app for stored Pulse logs from outside this app. Unfortunately, an apparent bug in this UI doesn’t let this modal experience dismiss. So if you go into this then you’ll need to kill the app and relaunch it to get back to the app.

Remote Logging pairs with Pulse Pro to allow livestreaming of network logs via the macOS app.

You have one more area to update for Pulse logging.

Capturing Image Requests

The list and detail views both use NetworkService to download poster images. You’ll add logging for image requests as well. It makes sense to split this functionality into a helper class to add the logging.

Set up ImageDownloader

In the Xcode Project Navigator, open MovieSearch ▸ Source ▸ Network ▸ Service. Right-click the Service group and select New File ….

Then select Swift File and click Next. Name the new file ImageDownloader.swift and click Create.

Remove import Foundation and replace with this code:

// 1
import UIKit
import PulseCore

// 2
class ImageDownloader: NSObject {
  private let imageBaseURLString = "https://image.tmdb.org"

  let urlSession = URLSession(configuration: URLSessionConfiguration.default, delegate: nil, delegateQueue: nil)
  // 3
  let logger = NetworkLogger()

  // 4
  var imageDownloadCompletion: ((Result<UIImage, NetworkError>) -> Void)?

  // 5
  func downloadImage(for imageType: ImageType, at path: String) {
    guard let url = try? url(for: imageType, at: path) else {
      return
    }

    let task = urlSession.dataTask(with: url)
    task.delegate = self
    task.resume()
  }

  // 6
  private func url(for imageType: ImageType, at path: String) throws -> URL {
    let imagePathParam = imageType.pathParameter()
    guard let baseURL = URL(string: imageBaseURLString),
      var urlComponents = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else {
        throw NetworkError.invalidURL
      }

    urlComponents.path = "/t/p/\(imagePathParam)\(path)"

    let queryItems: [URLQueryItem] = [
      URLQueryItem(name: "api_key", value: APIKey.value)
    ]

    urlComponents.queryItems = queryItems

    guard let url = urlComponents.url else {
      throw NetworkError.invalidURL
    }

    return url
  }
}

This code is an implementation of a simple download utility for images. As a quick summary, this code:

  1. Imports UIKit instead of Foundation because you’ll work with UIImage. It also imports PulseCore for logging.
  2. Makes the implementation a class. You’ll implement the same delegation pattern as in NetworkService, so ImageDownloader needs to be a class that inherits from NSObject.
  3. Creates a NetworkLogger instance just like you did before.
  4. Uses a property to hold a completion handler like you did before.
  5. Creates a function to trigger image downloads.
  6. Generates an image URL from the image type, detail or list, and the poster path retrieved from the API response for each movie.

Next, implement Pulse logging in an extension in ImageDownloader.swift. Under the closing brace of ImageDownloader, add:

extension ImageDownloader: URLSessionTaskDelegate, URLSessionDataDelegate {
  func urlSession(
    _ session: URLSession, 
    dataTask: URLSessionDataTask, 
    didReceive response: URLResponse, 
    completionHandler: @escaping (URLSession.ResponseDisposition) -> Void
  ) {
    logger.logDataTask(dataTask, didReceive: response)

    if let response = response as? HTTPURLResponse,
      response.statusCode != 200 {
      imageDownloadCompletion?(.failure(.invalidResponseType))
    }

    completionHandler(.allow)
  }

  func urlSession(
    _ session: URLSession, 
    task: URLSessionTask, 
    didCompleteWithError error: Error?
    ) {
    logger.logTask(task, didCompleteWithError: error)
    imageDownloadCompletion?(.failure(NetworkError.invalidResponseType))
  }

  func urlSession(
    _ session: URLSession, 
    task: URLSessionTask, 
    didFinishCollecting metrics: URLSessionTaskMetrics
    ) {
    logger.logTask(task, didFinishCollecting: metrics)
  }

  func urlSession(
    _ session: URLSession, 
    dataTask: URLSessionDataTask, 
    didReceive data: Data
    ) {
    logger.logDataTask(dataTask, didReceive: data)
    guard let image = UIImage(data: data) else {
      imageDownloadCompletion?(.failure(.invalidParse))
      return
    }
    imageDownloadCompletion?(.success(image))
  }
}

Much of this is like the NetworkService extension. Note the transformation of Data to UIImage in the urlSession(_:dataTask:didReceive:) function. Aside from that key difference, all the patterns and log statements are like NetworkService.

Update MovieDetailViewModel

Now, you’ll switch to ImageDownloader for all image downloads. Open MovieDetailViewModel.swift. At the top of the class declaration, replace the networkService property with an ImageDownloader:

private let imageDownloader = ImageDownloader()

Next, under fetchImage(for:), add this helper function:

private func processImageResponse(result: Result<UIImage, NetworkError>) {
  // 1
  guard let image = try? result.get() else {
    return
  }

  // 2
  DispatchQueue.main.async {
    self.posterImage = image
  }
}

This helper:

  1. Checks whether the network request returned an image. Otherwise, it returns early.
  2. Sets the posterImage on the main queue, triggering a UI update.

Now, replace fetchImage(for:) with:

func fetchImage(for movie: Movie, imageType: ImageType) {
  guard let posterPath = movie.posterPath else {
    return
  }

  imageDownloader.imageDownloadCompletion = processImageResponse(result:)
  imageDownloader.downloadImage(for: .list, at: posterPath)
}

This code switches the download image implementation to the new class. It follows the delegation pattern required to use Pulse logging.

Next, open NetworkService.swift and remove the downloadImage(for:at:completion:) and url(for:at:) functions because you no longer need them. That completes your swap of the image download implementation.

Build and run to check your work. Perform a search and you’ll see image request results showing in your logs alongside the search results:

Image results

Tap an image request. Then tap again into Response Body and you’ll see the image as part of the response:

Image results response body

And that’s it! You updated your network implementation to add Pulse logging.

Pulse gives you some excellent functionality for in-app debugging out of the box. You’ll find the visually-oriented interface, ability to review results in-app and ability to share Pulse files helpful when debugging tricky network conditions.

Where to Go From Here?

Download the completed project by clicking Download Materials at the top or bottom of the tutorial.

Pulse has many more features you can explore. Pulse Pro adds the ability to livestream logs.

If you have specific needs, you can develop your own viewer front-end that works with the Pulse data and is implemented via a Core Data based API, so iOS and macOS developers should feel at home.

Finally, if you want to explore more about network proxy tools, check out our tutorials on Charles and Proxyman, which offer a different take on viewing and debugging network conditions.

If you have any comments or questions, please join the discussion below.