[PM-26063] Add Flight Recorder logging for API requests and navigation in Authenticator (#2150)

This commit is contained in:
Matt Czech 2025-11-18 13:53:49 -06:00 committed by GitHub
parent 5bf7e24f97
commit 7c901c2944
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 97 additions and 77 deletions

View File

@ -30,10 +30,12 @@ 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.
///
init(
client: HTTPClient = URLSession.shared,
environmentService: EnvironmentService,
flightRecorder: FlightRecorder,
) {
self.client = client
@ -47,6 +49,9 @@ class APIService {
apiUnauthenticatedService = HTTPService(
baseURLGetter: { environmentService.apiURL },
client: client,
loggers: [
FlightRecorderHTTPLogger(flightRecorder: flightRecorder),
],
requestHandlers: [defaultHeadersRequestHandler],
responseHandlers: [responseValidationHandler],
)

View File

@ -1,4 +1,5 @@
import BitwardenKit
import BitwardenKitMocks
import TestHelpers
import XCTest
@ -31,4 +32,19 @@ class APIServiceTests: BitwardenTestCase {
)
XCTAssertNil(subject.apiUnauthenticatedService.tokenProvider)
}
/// `init(client:)` configures the API service to use `FlightRecorderHTTPLogger` for logging
/// API requests.
func test_init_configuresFlightRecorderLogger() {
let mockFlightRecorder = MockFlightRecorder()
let subject = APIService(
client: MockHTTPClient(),
environmentService: MockEnvironmentService(),
flightRecorder: mockFlightRecorder,
)
XCTAssertTrue(
subject.apiUnauthenticatedService.loggers.contains(where: { $0 is FlightRecorderHTTPLogger }),
)
}
}

View File

@ -7,10 +7,12 @@ import Networking
extension APIService {
convenience init(
client: HTTPClient,
flightRecorder: FlightRecorder = MockFlightRecorder(),
) {
self.init(
client: client,
environmentService: MockEnvironmentService(),
flightRecorder: flightRecorder,
)
}
}

View File

@ -188,6 +188,7 @@ public class ServiceContainer: Services {
let cameraService = DefaultCameraService()
let dataStore = DataStore(errorReporter: errorReporter)
let keychainService = DefaultKeychainService()
let timeProvider = CurrentTime()
let keychainRepository = DefaultKeychainRepository(
appIdService: appIdService,
@ -199,10 +200,19 @@ public class ServiceContainer: Services {
dataStore: dataStore,
)
let flightRecorder = DefaultFlightRecorder(
appInfoService: appInfoService,
errorReporter: errorReporter,
stateService: stateService,
timeProvider: timeProvider,
)
errorReporter.add(logger: flightRecorder)
let environmentService = DefaultEnvironmentService()
let apiService = APIService(
environmentService: environmentService,
flightRecorder: flightRecorder,
)
let errorReportBuilder = DefaultErrorReportBuilder(
@ -210,7 +220,6 @@ public class ServiceContainer: Services {
appInfoService: appInfoService,
)
let timeProvider = CurrentTime()
let totpExpirationManagerFactory = DefaultTOTPExpirationManagerFactory(timeProvider: timeProvider)
let biometricsRepository = DefaultBiometricsRepository(
@ -243,14 +252,6 @@ public class ServiceContainer: Services {
cryptographyKeyService: cryptographyKeyService,
)
let flightRecorder = DefaultFlightRecorder(
appInfoService: appInfoService,
errorReporter: errorReporter,
stateService: stateService,
timeProvider: timeProvider,
)
errorReporter.add(logger: flightRecorder)
let migrationService = DefaultMigrationService(
appSettingsStore: appSettingsStore,
errorReporter: errorReporter,

View File

@ -14,6 +14,7 @@ class AppCoordinator: Coordinator, HasRootNavigator {
typealias Module = AuthModule
& DebugMenuModule
& ItemListModule
& NavigatorBuilderModule
& TabModule
& TutorialModule
@ -107,7 +108,7 @@ class AppCoordinator: Coordinator, HasRootNavigator {
coordinator.navigate(to: authRoute)
} else {
guard let rootNavigator else { return }
let navigationController = UINavigationController()
let navigationController = module.makeNavigationController()
let coordinator = module.makeAuthCoordinator(
delegate: self,
rootNavigator: rootNavigator,
@ -148,7 +149,7 @@ class AppCoordinator: Coordinator, HasRootNavigator {
/// Shows the welcome tutorial.
///
private func showTutorial() {
let navigationController = UINavigationController()
let navigationController = module.makeNavigationController()
let coordinator = module.makeTutorialCoordinator(
stackNavigator: navigationController,
)

View File

@ -1,4 +1,5 @@
import BitwardenKit
import UIKit
// MARK: AppModule
@ -70,3 +71,11 @@ extension DefaultAppModule: FlightRecorderModule {
.asAnyCoordinator()
}
}
// MARK: - DefaultAppModule + NavigatorBuilderModule
extension DefaultAppModule: NavigatorBuilderModule {
public func makeNavigationController() -> UINavigationController {
ViewLoggingNavigationController(logger: services.flightRecorder)
}
}

View File

@ -12,6 +12,7 @@ final class SettingsCoordinator: Coordinator, HasStackNavigator {
/// The module types required by this coordinator for creating child coordinators.
typealias Module = FileSelectionModule
& FlightRecorderModule
& NavigatorBuilderModule
& TutorialModule
typealias Services = HasAppInfoService
@ -133,8 +134,7 @@ final class SettingsCoordinator: Coordinator, HasStackNavigator {
services: services,
)
let view = ExportItemsView(store: Store(processor: processor))
let navController = UINavigationController(rootViewController: UIHostingController(rootView: view))
stackNavigator?.present(navController)
stackNavigator?.present(view)
}
/// Shows a flight recorder view.
@ -156,8 +156,7 @@ final class SettingsCoordinator: Coordinator, HasStackNavigator {
services: services,
)
let view = ImportItemsView(store: Store(processor: processor))
let navController = UINavigationController(rootViewController: UIHostingController(rootView: view))
stackNavigator?.present(navController)
stackNavigator?.present(view)
}
/// Presents an activity controller for importing items.
@ -174,7 +173,7 @@ final class SettingsCoordinator: Coordinator, HasStackNavigator {
}
private func showImportItemsQrCode(delegate: AuthenticatorKeyCaptureDelegate) async {
let navigationController = UINavigationController()
let navigationController = module.makeNavigationController()
let coordinator = AuthenticatorKeyCaptureCoordinator(
delegate: delegate,
services: services,
@ -197,8 +196,7 @@ final class SettingsCoordinator: Coordinator, HasStackNavigator {
state: SelectLanguageState(currentLanguage: currentLanguage),
)
let view = SelectLanguageView(store: Store(processor: processor))
let navController = UINavigationController(rootViewController: UIHostingController(rootView: view))
stackNavigator?.present(navController)
stackNavigator?.present(view)
}
/// Shows the settings screen.
@ -216,7 +214,7 @@ final class SettingsCoordinator: Coordinator, HasStackNavigator {
/// Shows the welcome tutorial.
///
private func showTutorial() {
let navigationController = UINavigationController()
let navigationController = module.makeNavigationController()
let coordinator = module.makeTutorialCoordinator(
stackNavigator: navigationController,
)

View File

@ -64,9 +64,10 @@ class SettingsCoordinatorTests: BitwardenTestCase {
func test_navigateTo_exportVault() throws {
subject.navigate(to: .exportItems)
let navigationController = try XCTUnwrap(stackNavigator.actions.last?.view as? UINavigationController)
XCTAssertTrue(stackNavigator.actions.last?.view is UINavigationController)
XCTAssertTrue(navigationController.viewControllers.first is UIHostingController<ExportItemsView>)
let action = try XCTUnwrap(stackNavigator.actions.last)
XCTAssertEqual(action.embedInNavigationController, true)
XCTAssertEqual(action.type, .presented)
XCTAssertTrue(action.view is ExportItemsView)
}
/// `navigate(to:)` with `.flightRecorder` starts flight recorder coordinator and navigates to
@ -84,9 +85,10 @@ class SettingsCoordinatorTests: BitwardenTestCase {
func test_navigateTo_selectLanguage() throws {
subject.navigate(to: .selectLanguage(currentLanguage: .default))
let navigationController = try XCTUnwrap(stackNavigator.actions.last?.view as? UINavigationController)
XCTAssertTrue(stackNavigator.actions.last?.view is UINavigationController)
XCTAssertTrue(navigationController.viewControllers.first is UIHostingController<SelectLanguageView>)
let action = try XCTUnwrap(stackNavigator.actions.last)
XCTAssertEqual(action.embedInNavigationController, true)
XCTAssertEqual(action.type, .presented)
XCTAssertTrue(action.view is SelectLanguageView)
}
/// `navigate(to:)` with `.settings` pushes the settings view onto the stack navigator.

View File

@ -11,6 +11,7 @@ final class TabCoordinator: Coordinator, HasTabNavigator {
/// The module types required by this coordinator for creating child coordinators.
typealias Module = ItemListModule
& NavigatorBuilderModule
& SettingsModule
// MARK: Properties
@ -85,13 +86,13 @@ final class TabCoordinator: Coordinator, HasTabNavigator {
rootNavigator.show(child: tabNavigator)
let itemListNavigator = UINavigationController()
let itemListNavigator = module.makeNavigationController()
itemListNavigator.navigationBar.prefersLargeTitles = true
itemListCoordinator = module.makeItemListCoordinator(
stackNavigator: itemListNavigator,
)
let settingsNavigator = UINavigationController()
let settingsNavigator = module.makeNavigationController()
settingsNavigator.navigationBar.prefersLargeTitles = true
let settingsCoordinator = module.makeSettingsCoordinator(
stackNavigator: settingsNavigator,

View File

@ -10,6 +10,7 @@ class AuthenticatorItemCoordinator: NSObject, Coordinator, HasStackNavigator {
// MARK: Types
typealias Module = AuthenticatorItemModule
& NavigatorBuilderModule
typealias Services = HasAuthenticatorItemRepository
& HasErrorAlertServices.ErrorAlertServices
@ -81,7 +82,7 @@ class AuthenticatorItemCoordinator: NSObject, Coordinator, HasStackNavigator {
/// - Parameter route: The route to navigate to in the presented coordinator.
///
private func presentChildAuthenticatorItemCoordinator(route: AuthenticatorItemRoute, context: AnyObject?) {
let navigationController = UINavigationController()
let navigationController = module.makeNavigationController()
let coordinator = module.makeAuthenticatorItemCoordinator(stackNavigator: navigationController)
coordinator.navigate(to: route, context: context)
coordinator.start()

View File

@ -11,6 +11,7 @@ final class ItemListCoordinator: Coordinator, HasStackNavigator {
typealias Module = AuthenticatorItemModule
& ItemListModule
& NavigatorBuilderModule
typealias Services = HasTimeProvider
& HasErrorAlertServices.ErrorAlertServices
@ -80,7 +81,7 @@ final class ItemListCoordinator: Coordinator, HasStackNavigator {
/// Shows the totp camera setup screen.
///
private func showCamera(delegate: AuthenticatorKeyCaptureDelegate) async {
let navigationController = UINavigationController()
let navigationController = module.makeNavigationController()
let coordinator = AuthenticatorKeyCaptureCoordinator(
delegate: delegate,
services: services,
@ -95,7 +96,7 @@ final class ItemListCoordinator: Coordinator, HasStackNavigator {
/// Shows the totp manual setup screen.
///
private func showManualTotp(delegate: AuthenticatorKeyCaptureDelegate) {
let navigationController = UINavigationController()
let navigationController = module.makeNavigationController()
let coordinator = AuthenticatorKeyCaptureCoordinator(
delegate: delegate,
services: services,
@ -127,7 +128,7 @@ final class ItemListCoordinator: Coordinator, HasStackNavigator {
/// - Parameter route: The route to navigate to in the coordinator.
///
private func showItem(route: AuthenticatorItemRoute, delegate: AuthenticatorItemOperationDelegate? = nil) {
let navigationController = UINavigationController()
let navigationController = module.makeNavigationController()
let coordinator = module.makeAuthenticatorItemCoordinator(stackNavigator: navigationController)
coordinator.start()
coordinator.navigate(to: route, context: delegate)

View File

@ -12,9 +12,3 @@ public protocol NavigatorBuilderModule: AnyObject {
///
func makeNavigationController() -> UINavigationController
}
extension DefaultAppModule: NavigatorBuilderModule {
public func makeNavigationController() -> UINavigationController {
ViewLoggingNavigationController(logger: services.flightRecorder)
}
}

View File

@ -1,4 +1,3 @@
import BitwardenKit
import UIKit
// MARK: - ViewLoggingNavigationController
@ -10,7 +9,7 @@ import UIKit
/// `UINavigationController` *or* the `delegate` and `presentationController?.delegate` need to be
/// passed from an existing navigation controller to any newly created `UINavigationController`.
///
class ViewLoggingNavigationController: UINavigationController,
public class ViewLoggingNavigationController: UINavigationController,
UINavigationControllerDelegate,
UIAdaptivePresentationControllerDelegate {
// MARK: Properties
@ -24,7 +23,7 @@ class ViewLoggingNavigationController: UINavigationController,
///
/// - Parameter logger: The logger instance used to log when views appear and are dismissed.
///
init(logger: BitwardenLogger) {
public init(logger: BitwardenLogger) {
self.logger = logger
super.init(nibName: nil, bundle: nil)
}
@ -36,7 +35,7 @@ class ViewLoggingNavigationController: UINavigationController,
// MARK: View Lifecycle
override func viewDidLoad() {
override public func viewDidLoad() {
super.viewDidLoad()
delegate = self
presentationController?.delegate = self
@ -44,14 +43,14 @@ class ViewLoggingNavigationController: UINavigationController,
// MARK: UINavigationController
override func dismiss(animated: Bool, completion: (() -> Void)? = nil) {
override public func dismiss(animated: Bool, completion: (() -> Void)? = nil) {
super.dismiss(animated: animated, completion: completion)
logger.log("[Navigation] View dismissed: \(resolveLoggingViewName(for: self))")
}
// MARK: UINavigationControllerDelegate
func navigationController(
public func navigationController(
_ navigationController: UINavigationController,
didShow viewController: UIViewController,
animated: Bool,
@ -61,7 +60,7 @@ class ViewLoggingNavigationController: UINavigationController,
// MARK: UIAdaptivePresentationControllerDelegate
func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
public func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
let viewController = presentationController.presentedViewController
logger.log("[Navigation] View dismissed interactively: \(resolveLoggingViewName(for: viewController))")
}

View File

@ -2,7 +2,7 @@ import BitwardenKitMocks
import SwiftUI
import XCTest
@testable import BitwardenShared
@testable import BitwardenKit
class ViewLoggingNavigationControllerTests: BitwardenTestCase {
// MARK: Properties

View File

@ -1,4 +1,5 @@
import BitwardenKit
import UIKit
// MARK: AppModule
@ -76,3 +77,11 @@ extension DefaultAppModule: FlightRecorderModule {
.asAnyCoordinator()
}
}
// MARK: - DefaultAppModule + NavigatorBuilderModule
extension DefaultAppModule: NavigatorBuilderModule {
public func makeNavigationController() -> UINavigationController {
ViewLoggingNavigationController(logger: services.flightRecorder)
}
}

View File

@ -115,6 +115,13 @@ class AppModuleTests: BitwardenTestCase {
XCTAssertTrue(navigationController.viewControllers[0] is UIHostingController<ImportLoginsView>)
}
/// `makeNavigationController()` builds a navigation controller.
@MainActor
func test_makeNavigationController() {
let navigationController = subject.makeNavigationController()
XCTAssertTrue(navigationController is ViewLoggingNavigationController)
}
/// `makePasswordAutoFillCoordinator` builds the password autofill coordinator.
@MainActor
func test_makePasswordAutoFillCoordinator() {

View File

@ -1,32 +0,0 @@
import XCTest
@testable import BitwardenShared
class NavigatorBuilderModuleTests: BitwardenTestCase {
// MARK: Properties
var subject: DefaultAppModule!
// MARK: Setup & Teardown
override func setUp() {
super.setUp()
subject = DefaultAppModule(services: .withMocks())
}
override func tearDown() {
super.tearDown()
subject = nil
}
// MARK: Tests
/// `makeNavigationController()` builds a navigation controller.
@MainActor
func test_makeNavigationController() {
let navigationController = subject.makeNavigationController()
XCTAssertTrue(navigationController is ViewLoggingNavigationController)
}
}

View File

@ -1,6 +1,7 @@
@testable import AuthenticatorShared
import BitwardenKit
import BitwardenKitMocks
import UIKit
// MARK: - MockAppModule
@ -11,6 +12,7 @@ class MockAppModule:
FileSelectionModule,
FlightRecorderModule,
ItemListModule,
NavigatorBuilderModule,
TutorialModule,
TabModule {
var appCoordinator = MockCoordinator<AppRoute, AppEvent>()
@ -69,6 +71,10 @@ class MockAppModule:
itemListCoordinator.asAnyCoordinator()
}
func makeNavigationController() -> UINavigationController {
UINavigationController()
}
func makeTabCoordinator(
errorReporter _: ErrorReporter,
rootNavigator _: RootNavigator,