Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
2277d78
feat(callback): add LogCallback trait for mobile logging integration
addisonbeck Dec 19, 2025
36b8509
feat(callback): implement CallbackLayer and MessageVisitor for tracinโ€ฆ
addisonbeck Dec 19, 2025
00fd9ad
feat(callback): integrate CallbackLayer into init_logger for conditioโ€ฆ
addisonbeck Dec 20, 2025
60f05bd
feat(callback): add log_callback parameter to Client::new() for mobilโ€ฆ
addisonbeck Dec 20, 2025
db1b9dc
feat(callback): add error handling for mobile callback failures
addisonbeck Dec 20, 2025
f70ed06
test(callback): remove flaky test with initialization ordering dependโ€ฆ
addisonbeck Dec 20, 2025
3ef076a
test(callback): replace backward compatibility test with happy path vโ€ฆ
addisonbeck Dec 20, 2025
69f14a8
test(callback): add comprehensive integration test suite for logging โ€ฆ
addisonbeck Dec 20, 2025
3df63fb
docs(callback): add crate documentation and fix formatting
addisonbeck Dec 22, 2025
8a4cc74
fix(callback): add blank line after crate docs in integration tests
addisonbeck Dec 22, 2025
984f317
style(callback): fix import ordering for nightly rustfmt
addisonbeck Dec 22, 2025
8d9ad37
style(callback): fix alphabetical ordering within use statement
addisonbeck Dec 22, 2025
853a6ac
fix(callback): allow missing docs for error module
addisonbeck Dec 22, 2025
8f1f10b
fix(callback): revert error module to private
addisonbeck Dec 22, 2025
9f9fb0e
fix(callback): re-export BitwardenError instead of exposing error module
addisonbeck Dec 22, 2025
69c7d86
style(callback): fix import ordering for pub use statements
addisonbeck Dec 22, 2025
67b49b5
fix(callback): make error module public with allow(missing_docs)
addisonbeck Dec 22, 2025
7fbcfda
fix(callback): replace unwrap() with expect() in tests
addisonbeck Dec 22, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions crates/bitwarden-uniffi/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,16 @@ pub enum BitwardenError {
SshGeneration(#[from] bitwarden_ssh::error::KeyGenerationError),
#[error(transparent)]
SshImport(#[from] bitwarden_ssh::error::SshKeyImportError),
#[error("Callback invocation failed")]
CallbackError,

#[error("A conversion error occurred: {0}")]
Conversion(String),
}
/// Required From implementation for UNIFFI callback error handling
/// Converts unexpected mobile exceptions into BitwardenError
impl From<uniffi::UnexpectedUniFFICallbackError> for BitwardenError {
fn from(_: uniffi::UnexpectedUniFFICallbackError) -> Self {
Self::CallbackError
}
}
85 changes: 70 additions & 15 deletions crates/bitwarden-uniffi/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ use bitwarden_core::{ClientSettings, client::internal::ClientManagedTokens};
pub mod auth;
#[allow(missing_docs)]
pub mod crypto;
mod error;
#[allow(missing_docs)]
pub mod error;
mod log_callback;
#[allow(missing_docs)]
pub mod platform;
#[allow(missing_docs)]
Expand All @@ -25,6 +27,7 @@ mod android_support;

use crypto::CryptoClient;
use error::{Error, Result};
pub use log_callback::LogCallback;
use platform::PlatformClient;
use tool::{ExporterClient, GeneratorClients, SendClient, SshClient};
use vault::VaultClient;
Expand All @@ -40,8 +43,9 @@ impl Client {
pub fn new(
token_provider: Arc<dyn ClientManagedTokens>,
settings: Option<ClientSettings>,
log_callback: Option<Arc<dyn LogCallback>>,
) -> Self {
init_logger();
init_logger(log_callback);
setup_error_converter();

#[cfg(target_os = "android")]
Expand Down Expand Up @@ -113,7 +117,7 @@ impl Client {

static INIT: Once = Once::new();

fn init_logger() {
fn init_logger(callback: Option<Arc<dyn LogCallback>>) {
use tracing_subscriber::{EnvFilter, layer::SubscriberExt as _, util::SubscriberInitExt as _};

INIT.call_once(|| {
Expand All @@ -137,24 +141,27 @@ fn init_logger() {
.with_target(true)
.pretty();

// Build base registry once instead of duplicating per-platform
let registry = tracing_subscriber::registry().with(fmtlayer).with(filter);

// Conditionally add callback layer if provided
// Use Option to avoid type incompatibility between Some/None branches
let callback_layer = callback.map(log_callback::CallbackLayer::new);
let registry = registry.with(callback_layer);

// Platform-specific layers now compose with base registry
#[cfg(target_os = "ios")]
{
const TAG: &str = "com.8bit.bitwarden";

tracing_subscriber::registry()
.with(fmtlayer)
.with(filter)
registry
.with(tracing_oslog::OsLogger::new(TAG, "default"))
.init();
}

#[cfg(target_os = "android")]
{
const TAG: &str = "com.bitwarden.sdk";

tracing_subscriber::registry()
.with(fmtlayer)
.with(filter)
registry
.with(
tracing_android::layer(TAG)
.expect("initialization of android logcat tracing layer"),
Expand All @@ -164,10 +171,7 @@ fn init_logger() {

#[cfg(not(any(target_os = "android", target_os = "ios")))]
{
tracing_subscriber::registry()
.with(fmtlayer)
.with(filter)
.init();
registry.init();
}
});
}
Expand All @@ -179,3 +183,54 @@ fn setup_error_converter() {
crate::error::BitwardenError::Conversion(e.to_string()).into()
});
}
#[cfg(test)]
mod tests {
use std::sync::Mutex;

use super::*;
// Mock token provider for testing
#[derive(Debug)]
struct MockTokenProvider;

#[async_trait::async_trait]
impl ClientManagedTokens for MockTokenProvider {
async fn get_access_token(&self) -> Option<String> {
Some("mock_token".to_string())
}
}
/// Mock LogCallback implementation for testing
struct TestLogCallback {
logs: Arc<Mutex<Vec<(String, String, String)>>>,
}
impl LogCallback for TestLogCallback {
fn on_log(&self, level: String, target: String, message: String) -> Result<()> {
self.logs
.lock()
.expect("Failed to lock logs mutex")
.push((level, target, message));
Ok(())
}
}

// Log callback unit tests only test happy path because running this with
// Once means we get one registered callback per test run. There are
// other tests written as integration tests in the /tests/ folder that
// assert more specific details.
#[test]
fn test_callback_receives_logs() {
let logs = Arc::new(Mutex::new(Vec::new()));
let callback = Arc::new(TestLogCallback { logs: logs.clone() });

// Create client with callback
let _client = Client::new(Arc::new(MockTokenProvider), None, Some(callback));

// Trigger a log
tracing::info!("test message from SDK");

// Verify callback received it
let captured = logs.lock().expect("Failed to lock logs mutex");
assert!(!captured.is_empty(), "Callback should receive logs");
assert_eq!(captured[0].0, "INFO");
assert!(captured[0].2.contains("test message"));
}
}
109 changes: 109 additions & 0 deletions crates/bitwarden-uniffi/src/log_callback.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
use std::sync::Arc;

use tracing_subscriber::{Layer, layer::Context};
/// Callback interface for receiving SDK log events
/// Mobile implementations forward these to Flight Recorder
#[uniffi::export(with_foreign)]
pub trait LogCallback: Send + Sync {
/// Called when SDK emits a log entry
///
/// # Parameters
/// - level: Log level ("TRACE", "DEBUG", "INFO", "WARN", "ERROR")
/// - target: Module that emitted log (e.g., "bitwarden_core::auth")
/// - message: The log message text
///
/// # Returns
/// Result<(), BitwardenError> - mobile implementations should catch exceptions
/// and return errors rather than panicking
fn on_log(&self, level: String, target: String, message: String) -> crate::Result<()>;
}

/// Custom tracing Layer that forwards events to UNIFFI callback
pub(crate) struct CallbackLayer {
callback: Arc<dyn LogCallback>,
}
impl CallbackLayer {
pub(crate) fn new(callback: Arc<dyn LogCallback>) -> Self {
Self { callback }
}
}
impl<S> Layer<S> for CallbackLayer
where
S: tracing::Subscriber,
{
fn on_event(&self, event: &tracing::Event<'_>, _ctx: Context<'_, S>) {
let metadata = event.metadata();
// CRITICAL: Filter out our own error messages to prevent infinite callback loop
if metadata.target() == "bitwarden_uniffi::log_callback" {
return; // Platform loggers still receive this for debugging
}
let level = metadata.level().to_string();
let target = metadata.target().to_string();
// Format event message
let mut visitor = MessageVisitor::default();
event.record(&mut visitor);
let message = visitor.message;
// Forward to UNIFFI callback with error handling
if let Err(e) = self.callback.on_log(level, target, message) {
// Error logged with explicit target for filtering above
// Platform loggers receive this for mobile team debugging
tracing::error!(target: "bitwarden_uniffi::log_callback", "Logging callback failed: {:?}", e);
}
}
}
/// Visitor to extract message from tracing event
///
/// **Why only record_debug is implemented:**
///
/// MessageVisitor implements only record_debug because it's the ONLY required method.
/// The tracing::field::Visit trait provides default implementations for all other
/// record methods (record_str, record_i64, record_u64, record_bool, etc.) that
/// forward to record_debug. The SDK uses % and ? format specifiers which route
/// through record_debug via tracing's default implementations.
#[derive(Default)]
struct MessageVisitor {
message: String,
}
impl tracing::field::Visit for MessageVisitor {
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
if field.name() == "message" {
self.message = format!("{:?}", value);
}
}
}
#[cfg(test)]
mod tests {
use std::sync::{Arc, Mutex};

use super::*;

struct TestLogCallback {
logs: Arc<Mutex<Vec<(String, String, String)>>>,
}

impl LogCallback for TestLogCallback {
fn on_log(&self, level: String, target: String, message: String) -> crate::Result<()> {
self.logs.lock().unwrap().push((level, target, message));
Ok(())
}
}

#[test]
fn test_trait_can_be_implemented() {
let _callback: Arc<dyn LogCallback> = Arc::new(TestLogCallback {
logs: Arc::new(Mutex::new(Vec::new())),
});
}

#[test]
fn test_callback_layer_forwards_events() {
// Verify CallbackLayer correctly extracts and forwards log data
let logs = Arc::new(Mutex::new(Vec::new()));
let callback = Arc::new(TestLogCallback { logs: logs.clone() });
let _layer = CallbackLayer::new(callback);

// Test that layer compiles and can be created
// Full integration test will happen after Client::new() modification
assert!(logs.lock().unwrap().is_empty());
}
}
64 changes: 64 additions & 0 deletions crates/bitwarden-uniffi/tests/callback_error_handling.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
//! Integration test validating SDK resilience to callback errors.
//!
//! Verifies that the SDK continues operating normally when the registered callback
//! returns errors, preventing mobile callback failures from crashing the SDK.

use std::sync::Arc;

use bitwarden_uniffi::*;

// Type alias to match trait definition
type Result<T> = std::result::Result<T, bitwarden_uniffi::error::BitwardenError>;

/// Mock token provider for testing
#[derive(Debug)]
struct MockTokenProvider;

#[async_trait::async_trait]
impl bitwarden_core::client::internal::ClientManagedTokens for MockTokenProvider {
async fn get_access_token(&self) -> Option<String> {
Some("mock_token".to_string())
}
}

/// Failing callback that always returns errors
struct FailingCallback;

impl LogCallback for FailingCallback {
fn on_log(&self, _level: String, _target: String, _message: String) -> Result<()> {
// Simulate mobile callback exception
// Use a simple error that will be converted at FFI boundary
Err(bitwarden_uniffi::error::BitwardenError::Conversion(
"Simulated mobile callback failure".to_string(),
))
}
}

#[test]
fn test_callback_error_does_not_crash_sdk() {
// TC-005: Verify SDK continues operating when callback returns errors

// Create client with failing callback
let client = Client::new(
Arc::new(MockTokenProvider),
None,
Some(Arc::new(FailingCallback)),
);

// SDK should work before triggering callback
assert_eq!(client.echo("test".into()), "test");

// Trigger logs that invoke failing callback
tracing::info!("This log triggers failing callback");
tracing::warn!("Another log that fails");
tracing::error!("Yet another failing log");

// Verify SDK still operational after multiple callback errors
assert_eq!(client.echo("still works".into()), "still works");

// SDK operations continue normally despite callback failures
assert_eq!(
client.echo("definitely still working".into()),
"definitely still working"
);
}
Loading
Loading