mirror of
https://github.com/bitwarden/ios.git
synced 2025-12-11 04:34:55 -06:00
[PM-19577] Flight recorder network request and response logging (#1530)
This commit is contained in:
parent
5ea1aad39f
commit
98a8ec8f3c
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@ -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)"
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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"]
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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("""
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -136,6 +136,7 @@ targets:
|
||||
- "**/Mocks/*"
|
||||
dependencies:
|
||||
- target: BitwardenKit
|
||||
- target: TestHelpers
|
||||
BitwardenKitTests:
|
||||
type: bundle.unit-test
|
||||
platform: iOS
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user