[PM-19577] Flight recorder network request and response logging (#1530)

This commit is contained in:
Matt Czech 2025-04-28 15:50:03 -05:00 committed by GitHub
parent 5ea1aad39f
commit 98a8ec8f3c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 269 additions and 69 deletions

View File

@ -0,0 +1,65 @@
import Foundation
import Networking
/// A helper object to build `HTTPService`s with a common set of loggers and request and response handlers.
///
public class HTTPServiceBuilder {
// MARK: Properties
/// The underlying `HTTPClient` that performs the network request.
private let client: HTTPClient
/// A `RequestHandler` that applies default headers (user agent, client type & name, etc) to requests.
private let defaultHeadersRequestHandler: DefaultHeadersRequestHandler
/// The loggers used to log HTTP requests and responses.
private let loggers: [HTTPLogger]
/// A `ResponseHandler` that validates that HTTP responses contain successful (2XX) HTTP status
/// codes or tries to parse the error otherwise.
private let responseValidationHandler = ResponseValidationHandler()
// MARK: Initialization
/// Initialize an `HTTPServiceBuilder`.
///
/// - Parameters:
/// - client: The underlying `HTTPClient` that performs the network request.
/// - defaultHeadersRequestHandler: A `RequestHandler` that applies default headers
/// (user agent, client type & name, etc) to requests.
/// - loggers: The loggers used to log HTTP requests and responses.
///
public init(
client: HTTPClient,
defaultHeadersRequestHandler: DefaultHeadersRequestHandler,
loggers: [HTTPLogger]
) {
self.client = client
self.defaultHeadersRequestHandler = defaultHeadersRequestHandler
self.loggers = loggers
}
// MARK: Methods
/// Builds an `HTTPService`.
///
/// - Parameters:
/// - baseURLGetter: A getter function for dynamically retrieving the base url against which
/// requests are resolved.
/// - tokenProvider: An object used to get an access token and refresh it when necessary.
/// - Returns: An `HTTPService` with common loggers and handlers applied.
///
public func makeService(
baseURLGetter: @escaping @Sendable () -> URL,
tokenProvider: TokenProvider? = nil
) -> HTTPService {
HTTPService(
baseURLGetter: baseURLGetter,
client: client,
loggers: loggers,
requestHandlers: [defaultHeadersRequestHandler],
responseHandlers: [responseValidationHandler],
tokenProvider: tokenProvider
)
}
}

View File

@ -31,15 +31,8 @@ class APIService {
/// necessary.
private let accountTokenProvider: AccountTokenProvider
/// The underlying `HTTPClient` that performs the network request.
private let client: HTTPClient
/// A `RequestHandler` that applies default headers (user agent, client type & name, etc) to requests.
private let defaultHeadersRequestHandler: DefaultHeadersRequestHandler
/// A `ResponseHandler` that validates that HTTP responses contain successful (2XX) HTTP status
/// codes or tries to parse the error otherwise.
private let responseValidationHandler = ResponseValidationHandler()
/// A builder for building an `HTTPService`.
private let httpServiceBuilder: HTTPServiceBuilder
// MARK: Initialization
@ -49,6 +42,7 @@ class APIService {
/// - client: The underlying `HTTPClient` that performs the network request. Defaults
/// to `URLSession.shared`.
/// - environmentService: The service used by the application to retrieve the environment settings.
/// - flightRecorder: The service used by the application for recording temporary debug logs.
/// - stateService: The service used by the application to manage account state.
/// - tokenService: The `TokenService` which manages accessing and updating the active
/// account's tokens.
@ -56,60 +50,47 @@ class APIService {
init(
client: HTTPClient = URLSession.shared,
environmentService: EnvironmentService,
flightRecorder: FlightRecorder,
stateService: StateService,
tokenService: TokenService
) {
self.client = client
self.stateService = stateService
defaultHeadersRequestHandler = DefaultHeadersRequestHandler(
appName: "Bitwarden_Mobile",
appVersion: Bundle.main.appVersion,
buildNumber: Bundle.main.buildNumber,
systemDevice: UIDevice.current
httpServiceBuilder = HTTPServiceBuilder(
client: client,
defaultHeadersRequestHandler: DefaultHeadersRequestHandler(
appName: "Bitwarden_Mobile",
appVersion: Bundle.main.appVersion,
buildNumber: Bundle.main.buildNumber,
systemDevice: UIDevice.current
),
loggers: [
FlightRecorderHTTPLogger(flightRecorder: flightRecorder),
OSLogHTTPLogger(),
]
)
accountTokenProvider = AccountTokenProvider(
httpService: HTTPService(
baseURLGetter: { environmentService.identityURL },
client: client,
requestHandlers: [defaultHeadersRequestHandler],
responseHandlers: [responseValidationHandler]
),
httpService: httpServiceBuilder.makeService(baseURLGetter: { environmentService.identityURL }),
tokenService: tokenService
)
apiService = HTTPService(
apiService = httpServiceBuilder.makeService(
baseURLGetter: { environmentService.apiURL },
client: client,
requestHandlers: [defaultHeadersRequestHandler],
responseHandlers: [responseValidationHandler],
tokenProvider: accountTokenProvider
)
apiUnauthenticatedService = HTTPService(
baseURLGetter: { environmentService.apiURL },
client: client,
requestHandlers: [defaultHeadersRequestHandler],
responseHandlers: [responseValidationHandler]
apiUnauthenticatedService = httpServiceBuilder.makeService(
baseURLGetter: { environmentService.apiURL }
)
eventsService = HTTPService(
eventsService = httpServiceBuilder.makeService(
baseURLGetter: { environmentService.eventsURL },
client: client,
requestHandlers: [defaultHeadersRequestHandler],
responseHandlers: [responseValidationHandler],
tokenProvider: accountTokenProvider
)
hibpService = HTTPService(
baseURL: URL(string: "https://api.pwnedpasswords.com")!,
client: client,
requestHandlers: [defaultHeadersRequestHandler],
responseHandlers: [responseValidationHandler]
hibpService = httpServiceBuilder.makeService(
baseURLGetter: { URL(string: "https://api.pwnedpasswords.com")! }
)
identityService = HTTPService(
baseURLGetter: { environmentService.identityURL },
client: client,
requestHandlers: [defaultHeadersRequestHandler],
responseHandlers: [responseValidationHandler]
identityService = httpServiceBuilder.makeService(
baseURLGetter: { environmentService.identityURL }
)
}
@ -121,11 +102,8 @@ class APIService {
/// - Returns: A `HTTPService` to communicate with the key connector API.
///
func buildKeyConnectorService(baseURL: URL) -> HTTPService {
HTTPService(
baseURL: baseURL,
client: client,
requestHandlers: [defaultHeadersRequestHandler],
responseHandlers: [responseValidationHandler],
httpServiceBuilder.makeService(
baseURLGetter: { baseURL },
tokenProvider: accountTokenProvider
)
}

View File

@ -0,0 +1,36 @@
import Networking
// MARK: FlightRecorderHTTPLogger
/// An `HTTPLogger` that logs HTTP requests and responses to the flight recorder.
///
final class FlightRecorderHTTPLogger: HTTPLogger {
// MARK: Properties
/// The service used by the application for recording temporary debug logs.
private let flightRecorder: FlightRecorder
// MARK: Initialization
/// Initialize a `FlightRecorderHTTPLogger`.
///
/// - Parameter flightRecorder: The service used by the application for recording temporary debug logs.
///
init(flightRecorder: FlightRecorder) {
self.flightRecorder = flightRecorder
}
// MARK: HTTPLogger
func logRequest(_ httpRequest: HTTPRequest) async {
await flightRecorder.log(
"Request \(httpRequest.requestID): \(httpRequest.method.rawValue) \(httpRequest.url)"
)
}
func logResponse(_ httpResponse: HTTPResponse) async {
await flightRecorder.log(
"Response \(httpResponse.requestID): \(httpResponse.url) \(httpResponse.statusCode)"
)
}
}

View File

@ -0,0 +1,89 @@
import Networking
import TestHelpers
import XCTest
@testable import BitwardenShared
class FlightRecorderHTTPLoggerTests: BitwardenTestCase {
// MARK: Properties
var flightRecorder: MockFlightRecorder!
var subject: FlightRecorderHTTPLogger!
let requestID = UUID(uuidString: "773CC135-A878-4851-A28D-180FD7D945FA")!
// MARK: Setup & Teardown
override func setUp() {
super.setUp()
flightRecorder = MockFlightRecorder()
subject = FlightRecorderHTTPLogger(flightRecorder: flightRecorder)
}
override func tearDown() {
super.tearDown()
flightRecorder = nil
subject = nil
}
// MARK: Tests
/// `logRequest(_:)` logs a GET request to the flight recorder.
@MainActor
func test_logRequest_get() async {
let request = HTTPRequest(url: .example, method: .get, requestID: requestID)
await subject.logRequest(request)
XCTAssertEqual(
flightRecorder.logMessages,
["Request 773CC135-A878-4851-A28D-180FD7D945FA: GET https://example.com"]
)
}
/// `logRequest(_:)` logs a POST request to the flight recorder.
@MainActor
func test_logRequest_post() async {
let request = HTTPRequest(url: .example, method: .post, requestID: requestID)
await subject.logRequest(request)
XCTAssertEqual(
flightRecorder.logMessages,
["Request 773CC135-A878-4851-A28D-180FD7D945FA: POST https://example.com"]
)
}
/// `logResponse(_:)` logs a 200 response to the flight recorder.
@MainActor
func test_logResponse_200() async {
let response = HTTPResponse(
url: .example,
statusCode: 200,
headers: [:],
body: Data(),
requestID: requestID
)
await subject.logResponse(response)
XCTAssertEqual(
flightRecorder.logMessages,
["Response 773CC135-A878-4851-A28D-180FD7D945FA: https://example.com 200"]
)
}
/// `logResponse(_:)` logs a 400 response to the flight recorder.
@MainActor
func test_logResponse_400() async {
let response = HTTPResponse(
url: .example,
statusCode: 400,
headers: [:],
body: Data(),
requestID: requestID
)
await subject.logResponse(response)
XCTAssertEqual(
flightRecorder.logMessages,
["Response 773CC135-A878-4851-A28D-180FD7D945FA: https://example.com 400"]
)
}
}

View File

@ -8,11 +8,13 @@ extension APIService {
convenience init(
client: HTTPClient,
environmentService: EnvironmentService = MockEnvironmentService(),
flightRecorder: FlightRecorder = MockFlightRecorder(),
stateService: StateService = MockStateService()
) {
self.init(
client: client,
environmentService: environmentService,
flightRecorder: flightRecorder,
stateService: stateService,
tokenService: MockTokenService()
)

View File

@ -392,6 +392,13 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
keychainRepository: keychainRepository
)
let flightRecorder = DefaultFlightRecorder(
appInfoService: appInfoService,
errorReporter: errorReporter,
stateService: stateService,
timeProvider: timeProvider
)
let rehydrationHelper = DefaultRehydrationHelper(
errorReporter: errorReporter,
stateService: stateService,
@ -404,6 +411,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
let tokenService = DefaultTokenService(keychainRepository: keychainRepository, stateService: stateService)
let apiService = APIService(
environmentService: environmentService,
flightRecorder: flightRecorder,
stateService: stateService,
tokenService: tokenService
)
@ -484,13 +492,6 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
timeProvider: timeProvider
)
let flightRecorder = DefaultFlightRecorder(
appInfoService: appInfoService,
errorReporter: errorReporter,
stateService: stateService,
timeProvider: timeProvider
)
let sendService = DefaultSendService(
fileAPIService: apiService,
sendAPIService: apiService,

View File

@ -1,13 +1,35 @@
import OSLog
/// An object that handles logging HTTP requests and responses.
// MARK: - HTTPLogger
/// A protocol for an object that can log HTTP request and responses.
///
final class HTTPLogger: Sendable {
public protocol HTTPLogger: Sendable {
/// Logs the details of a `HTTPRequest`.
///
/// - Parameter httpRequest: The `HTTPRequest` to log the details of.
///
func logRequest(_ httpRequest: HTTPRequest) {
func logRequest(_ httpRequest: HTTPRequest) async
/// Logs the details of a `HTTPResponse`.
///
/// - Parameter httpResponse: The `HTTPResponse` to log the details of.
///
func logResponse(_ httpResponse: HTTPResponse) async
}
// MARK: - OSLogHTTPLogger
/// An object that handles logging HTTP requests and responses to OSLog.
///
public final class OSLogHTTPLogger: HTTPLogger {
// MARK: Initialization
public init() {}
// MARK: HTTPLogger
public func logRequest(_ httpRequest: HTTPRequest) async {
let formattedBody = formattedBody(httpRequest.body)
let formattedHeaders = formattedHeaders(httpRequest.headers)
Logger.networking.info("""
@ -18,11 +40,7 @@ final class HTTPLogger: Sendable {
)
}
/// Logs the details of a `HTTPResponse`.
///
/// - Parameter httpResponse: The `HTTPResponse` to log the details of.
///
func logResponse(_ httpResponse: HTTPResponse) {
public func logResponse(_ httpResponse: HTTPResponse) async {
let formattedBody = formattedBody(httpResponse.body)
let formattedHeaders = formattedHeaders(httpResponse.headers)
Logger.networking.info("""

View File

@ -2,7 +2,7 @@
///
public struct HTTPMethod: Equatable, Sendable {
/// The string value of the method.
let rawValue: String
public let rawValue: String
}
public extension HTTPMethod {

View File

@ -14,8 +14,8 @@ public final class HTTPService: Sendable {
/// The underlying `HTTPClient` that performs the network request.
let client: HTTPClient
/// A logger used to log HTTP request and responses.
let logger = HTTPLogger()
/// The loggers used to log HTTP request and responses.
let loggers: [HTTPLogger]
/// A list of `RequestHandler`s that have the option to view or modify the request prior to it
/// being sent. Handlers are applied in the order of the items in the handler list.
@ -36,6 +36,7 @@ public final class HTTPService: Sendable {
/// - Parameters:
/// - baseURL: The URL against which requests are resolved.
/// - client: The underlying `HTTPClient` that performs the network request.
/// - loggers: The loggers used to log HTTP request and responses.
/// - requestHandlers: A list of `RequestHandler`s that have the option to view or modify the
/// request prior to it being sent.
/// - responseHandlers: A list of `ResponseHandler`s that have the option to view or modify
@ -45,12 +46,14 @@ public final class HTTPService: Sendable {
public init(
baseURL: URL,
client: HTTPClient = URLSession.shared,
loggers: [HTTPLogger] = [OSLogHTTPLogger()],
requestHandlers: [RequestHandler] = [],
responseHandlers: [ResponseHandler] = [],
tokenProvider: TokenProvider? = nil
) {
baseURLGetter = { baseURL }
self.client = client
self.loggers = loggers
self.requestHandlers = requestHandlers
self.responseHandlers = responseHandlers
self.tokenProvider = tokenProvider
@ -62,6 +65,7 @@ public final class HTTPService: Sendable {
/// - baseURLGetter: A getter function for dynamically retrieving the base url against which
/// requests are resolved.
/// - client: The underlying `HTTPClient` that performs the network request.
/// - loggers: The loggers used to log HTTP request and responses.
/// - requestHandlers: A list of `RequestHandler`s that have the option to view or modify the
/// request prior to it being sent.
/// - responseHandlers: A list of `ResponseHandler`s that have the option to view or modify
@ -71,12 +75,14 @@ public final class HTTPService: Sendable {
public init(
baseURLGetter: @escaping @Sendable () -> URL,
client: HTTPClient = URLSession.shared,
loggers: [HTTPLogger] = [OSLogHTTPLogger()],
requestHandlers: [RequestHandler] = [],
responseHandlers: [ResponseHandler] = [],
tokenProvider: TokenProvider? = nil
) {
self.baseURLGetter = baseURLGetter
self.client = client
self.loggers = loggers
self.requestHandlers = requestHandlers
self.responseHandlers = responseHandlers
self.tokenProvider = tokenProvider
@ -129,10 +135,14 @@ public final class HTTPService: Sendable {
) async throws -> HTTPResponse {
var httpRequest = httpRequest
try await applyRequestHandlers(&httpRequest)
logger.logRequest(httpRequest)
for logger in loggers {
await logger.logRequest(httpRequest)
}
var httpResponse = try await client.send(httpRequest)
logger.logResponse(httpResponse)
for logger in loggers {
await logger.logResponse(httpResponse)
}
if let tokenProvider, httpResponse.statusCode == 401, shouldRetryIfUnauthorized {
try await tokenProvider.refreshToken()

View File

@ -136,6 +136,7 @@ targets:
- "**/Mocks/*"
dependencies:
- target: BitwardenKit
- target: TestHelpers
BitwardenKitTests:
type: bundle.unit-test
platform: iOS