diff --git a/.github/actions/free-ubuntu-runner-disk-space/action.yml b/.github/actions/free-ubuntu-runner-disk-space/action.yml
new file mode 100644
index 000000000..116246524
--- /dev/null
+++ b/.github/actions/free-ubuntu-runner-disk-space/action.yml
@@ -0,0 +1,47 @@
+name: Free Disk Space
+description: Remove unused toolchains and packages to free disk space on ubuntu runners
+runs:
+ using: "composite"
+ steps:
+ - name: Free Disk Space
+ shell: bash
+ run: |
+ # Function to measure and remove a directory
+ remove_and_log() {
+ local path=$1
+ local name=$2
+ if [ -e "$path" ]; then
+ local size_kb=$(du -sk "$path" 2>/dev/null | cut -f1)
+ local size_mb=$((size_kb / 1024))
+ sudo rm -rf "$path"
+ echo "Removed $name, freeing ${size_mb}MB"
+ fi
+ }
+
+ # Capture initial disk space
+ initial_avail=$(df / | awk 'NR==2 {print $4}')
+ echo "=== Disk Cleanup Starting ==="
+ echo "Available space before cleanup: $(df -h / | awk 'NR==2 {print $4}')"
+ echo ""
+
+ # Remove directories and track space freed
+ remove_and_log "/usr/share/dotnet" ".NET SDKs"
+ remove_and_log "/usr/share/swift" "Swift toolchain"
+ remove_and_log "/usr/local/.ghcup" "Haskell (ghcup)"
+ remove_and_log "/usr/share/miniconda" "Miniconda"
+ remove_and_log "/usr/local/aws-cli" "AWS CLI v1"
+ remove_and_log "/usr/local/aws-sam-cli" "AWS SAM CLI"
+ remove_and_log "/usr/local/lib/android" "Android SDK"
+ remove_and_log "/usr/lib/google-cloud-sdk" "Google Cloud SDK"
+ remove_and_log "/usr/lib/jvm" "Java JDKs"
+ remove_and_log "/usr/local/share/powershell" "PowerShell"
+ remove_and_log "/opt/hostedtoolcache" "Hosted tool cache"
+
+ # Calculate and display results
+ final_avail=$(df / | awk 'NR==2 {print $4}')
+ space_freed=$((final_avail - initial_avail))
+ space_freed_mb=$((space_freed / 1024))
+ echo ""
+ echo "=== Cleanup Complete ==="
+ echo "Available space after cleanup: $(df -h / | awk 'NR==2 {print $4}')"
+ echo "Total space freed: ${space_freed_mb}MB"
diff --git a/.github/workflows/rust-test.yml b/.github/workflows/rust-test.yml
index 518c4244b..6aa5c9cbd 100644
--- a/.github/workflows/rust-test.yml
+++ b/.github/workflows/rust-test.yml
@@ -46,6 +46,10 @@ jobs:
- name: Cache cargo registry
uses: Swatinem/rust-cache@f0deed1e0edfc6a9be95417288c0e1099b1eeec3 # v2.7.7
+ # Without freeing space we run out of run on disk building for integration tests
+ - name: Free Disk Space
+ if: runner.os == 'Linux'
+ uses: ./.github/actions/free-ubuntu-runner-disk-space
- name: Test
run: cargo test --workspace --all-features
@@ -90,6 +94,10 @@ jobs:
with:
persist-credentials: false
+ # Without freeing space we run out of run on disk building for integration tests
+ - name: Free Disk Space
+ uses: ./.github/actions/free-ubuntu-runner-disk-space
+
- name: Install rust
uses: dtolnay/rust-toolchain@0b1efabc08b657293548b77fb76cc02d26091c7e # stable
with:
diff --git a/crates/bitwarden-uniffi/docs/logging-callback.md b/crates/bitwarden-uniffi/docs/logging-callback.md
new file mode 100644
index 000000000..7f41b23d5
--- /dev/null
+++ b/crates/bitwarden-uniffi/docs/logging-callback.md
@@ -0,0 +1,149 @@
+# SDK Logging Callback Guide
+
+## Overview
+
+The Bitwarden SDK provides an optional logging callback interface that enables mobile applications to receive trace logs from the SDK and forward them to observability systems like Flight Recorder. This document explains the design, integration patterns, and best practices for using the logging callback feature.
+
+## Purpose and Use Case
+
+The logging callback addresses mobile teams' requirements for:
+
+- **Observability**: Collecting SDK trace events in centralized monitoring systems
+- **Debugging**: Capturing SDK behavior for troubleshooting production issues
+- **Flight Recorder Integration**: Feeding SDK logs into mobile observability platforms
+
+The callback is entirely optional. Platform-specific loggers (oslog on iOS, android_logger on Android) continue functioning independently whether or not a callback is registered.
+
+## Architecture
+
+### Design Principles
+
+1. **Non-intrusive**: The SDK operates identically with or without a callback registered
+2. **Thread-safe**: Multiple SDK threads may invoke the callback concurrently
+3. **Error-resilient**: Mobile callback failures do not crash the SDK
+4. **Simple contract**: Mobile teams receive level, target, and message - all other decisions are theirs
+
+### Data Flow
+
+```mermaid
+graph TB
+ A[SDK Code] -->|tracing::info!| B[Tracing Subscriber]
+ B --> C[tracing-subscriber Registry]
+ C --> D[CallbackLayer]
+ C --> E[Platform Loggers]
+
+ D --> F{Callback
Registered?}
+ F -->|Yes| G[UNIFFI Callback]
+ F -->|No| H[No-op]
+
+ G -->|FFI Boundary| I[Mobile Implementation]
+ I --> J[Flight Recorder]
+
+ E --> K[oslog iOS]
+ E --> L[android_logger]
+ E --> M[env_logger Other]
+
+ style D fill:#e1f5ff
+ style G fill:#ffe1e1
+ style I fill:#fff4e1
+ style J fill:#e1ffe1
+```
+
+Platform loggers (oslog/android_logger) operate in parallel, receiving the same events independently.
+
+### Callback Invocation Flow
+
+```mermaid
+sequenceDiagram
+ participant SDK as SDK Code
+ participant Tracing as Tracing Layer
+ participant Callback as CallbackLayer
+ participant UNIFFI as UNIFFI Bridge
+ participant Mobile as Mobile Client
+ participant FR as Flight Recorder
+
+ SDK->>Tracing: tracing::info!("message")
+ Tracing->>Callback: on_event()
+ Callback->>Callback: Extract level, target, message
+ Callback->>UNIFFI: callback.on_log(...)
+ UNIFFI->>Mobile: onLog(...) [FFI]
+ Mobile-->>FR: Queue & Process
+ UNIFFI-->>Callback: Result<(), Error>
+
+ Note over Callback: If error: log to platform logger
+ Note over Mobile: Mobile controls batching, filtering
+```
+
+## LogCallback Interface
+
+### Trait Definition
+
+```rust
+pub trait LogCallback: Send + Sync {
+ /// Called when SDK emits a log entry
+ ///
+ /// # Parameters
+ /// - level: Log level string ("TRACE", "DEBUG", "INFO", "WARN", "ERROR")
+ /// - target: Module that emitted log (e.g., "bitwarden_core::auth")
+ /// - message: The formatted log message
+ ///
+ /// # Returns
+ /// Result<()> - Return errors rather than panicking
+ fn on_log(&self, level: String, target: String, message: String) -> Result<()>;
+}
+```
+
+### Thread Safety Requirements
+
+The `Send + Sync` bounds are mandatory. The SDK invokes callbacks from arbitrary background threads, potentially concurrently. Mobile implementations **must** use thread-safe patterns:
+
+- **Kotlin**: `ConcurrentLinkedQueue`, synchronized blocks, or coroutine channels
+- **Swift**: `DispatchQueue`, `OSAllocatedUnfairLock`, or actor isolation
+
+**Critical**: Callbacks are invoked on SDK background threads, NOT the main/UI thread. Performing UI updates directly in the callback will cause crashes.
+
+## Performance Considerations
+
+### Callback Execution Requirements
+
+Callbacks should return quickly (ideally < 1ms). Blocking operations in the callback delay SDK operations. Follow these patterns:
+
+ā
**Do:**
+- Queue logs to thread-safe data structure immediately
+- Process queued logs asynchronously in background
+- Batch multiple logs per Flight Recorder API call
+- Use timeouts for Flight Recorder network calls
+- Handle errors gracefully (catch exceptions, return errors)
+
+ā **Don't:**
+- Make synchronous network calls in callback
+- Perform expensive computation in callback
+- Access shared state without synchronization
+- Update UI directly (wrong thread!)
+- Throw exceptions without catching
+
+### Filtering Strategy
+
+The SDK sends all INFO+ logs to the callback. Mobile teams filter based on requirements:
+
+```kotlin
+override fun onLog(level: String, target: String, message: String) {
+ // Example: Only forward WARN and ERROR to Flight Recorder
+ if (level == "WARN" || level == "ERROR") {
+ logQueue.offer(LogEntry(level, target, message))
+ }
+ // INFO logs are ignored
+}
+```
+
+## Known Limitations
+
+The current implementation has these characteristics:
+
+1. **Span Support**: Only individual log events are forwarded, not span lifecycle events (enter/exit/close). Mobile teams receive logs without hierarchical operation context.
+
+2. **Structured Fields**: Log metadata (user IDs, request IDs, etc.) is flattened to strings. Mobile teams cannot access structured key-value data without parsing message strings.
+
+3. **Dynamic Filtering**: Mobile teams cannot adjust the SDK's filter level at runtime. The callback receives all INFO+ logs regardless of mobile interest.
+
+4. **Observability**: The callback mechanism itself does not emit metrics (success rate, invocation latency, error frequency). Mobile teams implement monitoring in their callback implementations.
diff --git a/crates/bitwarden-uniffi/examples/callback_demo.rs b/crates/bitwarden-uniffi/examples/callback_demo.rs
new file mode 100644
index 000000000..7194ee2ba
--- /dev/null
+++ b/crates/bitwarden-uniffi/examples/callback_demo.rs
@@ -0,0 +1,73 @@
+//! Demonstration of SDK logging callback mechanism
+//!
+//! This example shows how mobile clients can register a callback to receive
+//! SDK logs and forward them to Flight Recorder or other observability systems.
+
+use std::sync::{Arc, Mutex};
+
+use bitwarden_core::client::internal::ClientManagedTokens;
+use bitwarden_uniffi::{Client, LogCallback};
+
+/// Mock token provider for demo
+#[derive(Debug)]
+struct DemoTokenProvider;
+
+#[async_trait::async_trait]
+impl ClientManagedTokens for DemoTokenProvider {
+ async fn get_access_token(&self) -> Option {
+ Some("demo_token".to_string())
+ }
+}
+
+/// Demo callback that prints logs to stdout
+struct DemoLogCallback {
+ logs: Arc>>,
+}
+
+impl LogCallback for DemoLogCallback {
+ fn on_log(
+ &self,
+ level: String,
+ target: String,
+ message: String,
+ ) -> Result<(), bitwarden_uniffi::error::BitwardenError> {
+ println!("š Callback received: [{}] {} - {}", level, target, message);
+ self.logs
+ .lock()
+ .expect("Failed to lock logs mutex")
+ .push((level, target, message));
+ Ok(())
+ }
+}
+
+fn main() {
+ println!("š SDK Logging Callback Demonstration\n");
+ println!("Creating SDK client with logging callback...\n");
+
+ let logs = Arc::new(Mutex::new(Vec::new()));
+ let callback = Arc::new(DemoLogCallback { logs: logs.clone() });
+
+ // Create client with callback
+ let _client = Client::new(Arc::new(DemoTokenProvider), None, Some(callback));
+
+ println!("ā
Client initialized with callback\n");
+ println!("Emitting SDK logs at different levels...\n");
+
+ // Emit logs that will be captured by callback
+ tracing::info!("User authentication started");
+ tracing::warn!("API rate limit approaching");
+ tracing::error!("Network request failed");
+
+ println!("\nš Summary:");
+ let captured = logs.lock().expect("Failed to lock logs mutex");
+ println!(" Captured {} log events", captured.len());
+ println!(
+ " Levels: {}",
+ captured
+ .iter()
+ .map(|(l, _, _)| l.as_str())
+ .collect::>()
+ .join(", ")
+ );
+ println!("\n⨠Callback successfully forwarded all SDK logs!");
+}
diff --git a/crates/bitwarden-uniffi/src/error.rs b/crates/bitwarden-uniffi/src/error.rs
index 23fe3835d..4d92c3c62 100644
--- a/crates/bitwarden-uniffi/src/error.rs
+++ b/crates/bitwarden-uniffi/src/error.rs
@@ -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 for BitwardenError {
+ fn from(_: uniffi::UnexpectedUniFFICallbackError) -> Self {
+ Self::CallbackError
+ }
+}
diff --git a/crates/bitwarden-uniffi/src/lib.rs b/crates/bitwarden-uniffi/src/lib.rs
index f16325763..7ff9c7821 100644
--- a/crates/bitwarden-uniffi/src/lib.rs
+++ b/crates/bitwarden-uniffi/src/lib.rs
@@ -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)]
@@ -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;
@@ -36,12 +39,13 @@ pub struct Client(pub(crate) bitwarden_pm::PasswordManagerClient);
#[uniffi::export(async_runtime = "tokio")]
impl Client {
/// Initialize a new instance of the SDK client
- #[uniffi::constructor]
+ #[uniffi::constructor(default(log_callback))]
pub fn new(
token_provider: Arc,
settings: Option,
+ log_callback: Option>,
) -> Self {
- init_logger();
+ init_logger(log_callback);
setup_error_converter();
#[cfg(target_os = "android")]
@@ -113,7 +117,7 @@ impl Client {
static INIT: Once = Once::new();
-fn init_logger() {
+fn init_logger(callback: Option>) {
use tracing_subscriber::{EnvFilter, layer::SubscriberExt as _, util::SubscriberInitExt as _};
INIT.call_once(|| {
@@ -137,13 +141,17 @@ 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);
#[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();
}
@@ -151,10 +159,7 @@ fn init_logger() {
#[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"),
@@ -164,10 +169,7 @@ fn init_logger() {
#[cfg(not(any(target_os = "android", target_os = "ios")))]
{
- tracing_subscriber::registry()
- .with(fmtlayer)
- .with(filter)
- .init();
+ registry.init();
}
});
}
@@ -179,3 +181,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 {
+ Some("mock_token".to_string())
+ }
+ }
+ /// Mock LogCallback implementation for testing
+ struct TestLogCallback {
+ logs: Arc>>,
+ }
+ 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"));
+ }
+}
diff --git a/crates/bitwarden-uniffi/src/log_callback.rs b/crates/bitwarden-uniffi/src/log_callback.rs
new file mode 100644
index 000000000..6ea578d0e
--- /dev/null
+++ b/crates/bitwarden-uniffi/src/log_callback.rs
@@ -0,0 +1,107 @@
+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,
+}
+impl CallbackLayer {
+ pub(crate) fn new(callback: Arc) -> Self {
+ Self { callback }
+ }
+}
+impl Layer for CallbackLayer
+where
+ S: tracing::Subscriber,
+{
+ fn on_event(&self, event: &tracing::Event<'_>, _ctx: Context<'_, S>) {
+ let metadata = event.metadata();
+ // 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) {
+ tracing::error!(target: "bitwarden_uniffi::log_callback", "Logging callback failed: {:?}", e);
+ }
+ }
+}
+/// Visitor to extract message from tracing event
+///
+/// **Why only record_debug is implemented:**
+///
+/// The tracing::field::Visit trait provides default implementations for all record
+/// methods (record_str, record_i64, record_bool, etc.) that forward to record_debug.
+/// This means implementing only record_debug captures all field types. The SDK's
+/// logging patterns (including % and ? format specifiers) all route through this
+/// single method 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>>,
+ }
+
+ 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 = 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());
+ }
+}
diff --git a/crates/bitwarden-uniffi/tests/callback_error_handling.rs b/crates/bitwarden-uniffi/tests/callback_error_handling.rs
new file mode 100644
index 000000000..7c8b0be27
--- /dev/null
+++ b/crates/bitwarden-uniffi/tests/callback_error_handling.rs
@@ -0,0 +1,62 @@
+//! 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 = std::result::Result;
+
+/// 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 {
+ 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() {
+ // 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"
+ );
+}
diff --git a/crates/bitwarden-uniffi/tests/callback_field_coverage.rs b/crates/bitwarden-uniffi/tests/callback_field_coverage.rs
new file mode 100644
index 000000000..130126556
--- /dev/null
+++ b/crates/bitwarden-uniffi/tests/callback_field_coverage.rs
@@ -0,0 +1,81 @@
+//! Integration test validating MessageVisitor field extraction.
+//!
+//! Verifies that the MessageVisitor correctly extracts the message field from
+//! tracing events and forwards it through the callback interface.
+
+use std::sync::{Arc, Mutex};
+
+use bitwarden_uniffi::*;
+
+// Type alias to match trait definition
+type Result = std::result::Result;
+
+/// 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 {
+ Some("mock_token".to_string())
+ }
+}
+
+/// Test callback that captures logs
+struct TestCallback {
+ logs: Arc>>,
+}
+
+impl LogCallback for TestCallback {
+ 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(())
+ }
+}
+
+#[test]
+fn test_message_visitor_captures_message_field() {
+ // Validate MessageVisitor captures the message field from trace events
+ // Note: Structured fields (user_id, valid, etc.) are NOT captured
+ // currently. The visitor only extracts the "message" field, not
+ // additional structured metadata.
+ let logs = Arc::new(Mutex::new(Vec::new()));
+ let callback = Arc::new(TestCallback { logs: logs.clone() });
+
+ let _client = Client::new(Arc::new(MockTokenProvider), None, Some(callback));
+
+ // Emit logs at different levels with message text
+ tracing::info!("info message");
+ tracing::warn!("warn message");
+ tracing::error!("error message");
+
+ let captured = logs.lock().expect("Failed to lock logs mutex");
+
+ // Verify all messages captured
+ assert_eq!(captured.len(), 3, "All log entries should be captured");
+
+ // Validate message field extraction
+ assert!(
+ captured[0].2.contains("info message"),
+ "INFO message should be captured, got: {}",
+ captured[0].2
+ );
+ assert!(
+ captured[1].2.contains("warn message"),
+ "WARN message should be captured, got: {}",
+ captured[1].2
+ );
+ assert!(
+ captured[2].2.contains("error message"),
+ "ERROR message should be captured, got: {}",
+ captured[2].2
+ );
+
+ // Validate levels
+ assert_eq!(captured[0].0, "INFO");
+ assert_eq!(captured[1].0, "WARN");
+ assert_eq!(captured[2].0, "ERROR");
+}
diff --git a/crates/bitwarden-uniffi/tests/callback_happy_path.rs b/crates/bitwarden-uniffi/tests/callback_happy_path.rs
new file mode 100644
index 000000000..8e910a857
--- /dev/null
+++ b/crates/bitwarden-uniffi/tests/callback_happy_path.rs
@@ -0,0 +1,63 @@
+//! Integration test validating basic callback functionality.
+//!
+//! Verifies that registered callbacks receive log events with correct data structure
+//! including level, target, and message fields.
+
+use std::sync::{Arc, Mutex};
+
+use bitwarden_uniffi::*;
+
+// Type alias to match trait definition
+type Result = std::result::Result;
+
+/// 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 {
+ Some("mock_token".to_string())
+ }
+}
+
+/// Test callback implementation that captures logs
+struct TestCallback {
+ logs: Arc>>,
+}
+
+impl LogCallback for TestCallback {
+ 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(())
+ }
+}
+
+#[test]
+fn test_callback_happy_path() {
+ // Verify callback receives logs with correct data
+ let logs = Arc::new(Mutex::new(Vec::new()));
+ let callback = Arc::new(TestCallback { logs: logs.clone() });
+
+ // Create client with callback
+ let _client = Client::new(Arc::new(MockTokenProvider), None, Some(callback));
+
+ // Trigger SDK logging
+ tracing::info!("integration test message");
+
+ // Verify callback received the log
+ let captured = logs.lock().expect("Failed to lock logs mutex");
+ assert!(!captured.is_empty(), "Callback should receive logs");
+
+ // Validate log data structure
+ let (level, target, message) = &captured[0];
+ assert_eq!(level, "INFO", "Log level should be INFO");
+ assert!(!target.is_empty(), "Target should not be empty");
+ assert!(
+ message.contains("integration test message"),
+ "Message should contain logged text"
+ );
+}
diff --git a/crates/bitwarden-uniffi/tests/callback_multiple_levels.rs b/crates/bitwarden-uniffi/tests/callback_multiple_levels.rs
new file mode 100644
index 000000000..cd77e65e8
--- /dev/null
+++ b/crates/bitwarden-uniffi/tests/callback_multiple_levels.rs
@@ -0,0 +1,65 @@
+//! Integration test validating callback receives multiple log levels.
+//!
+//! Verifies that the callback mechanism correctly forwards log events at different
+//! severity levels (INFO, WARN, ERROR) to the registered callback implementation.
+
+use std::sync::{Arc, Mutex};
+
+use bitwarden_uniffi::*;
+
+// Type alias to match trait definition
+type Result = std::result::Result;
+
+/// 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 {
+ Some("mock_token".to_string())
+ }
+}
+
+/// Test callback that captures logs
+struct TestCallback {
+ logs: Arc>>,
+}
+
+impl LogCallback for TestCallback {
+ 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(())
+ }
+}
+
+#[test]
+fn test_callback_receives_multiple_log_levels() {
+ // Verify callback receives events at different log levels
+ let logs = Arc::new(Mutex::new(Vec::new()));
+ let callback = Arc::new(TestCallback { logs: logs.clone() });
+
+ let _client = Client::new(Arc::new(MockTokenProvider), None, Some(callback));
+
+ // Emit logs at multiple levels
+ tracing::info!("info message");
+ tracing::warn!("warn message");
+ tracing::error!("error message");
+
+ // Verify all levels captured
+ let captured = logs.lock().expect("Failed to lock logs mutex");
+ assert_eq!(captured.len(), 3, "Should capture all 3 log levels");
+
+ // Validate each level
+ assert_eq!(captured[0].0, "INFO");
+ assert!(captured[0].2.contains("info message"));
+
+ assert_eq!(captured[1].0, "WARN");
+ assert!(captured[1].2.contains("warn message"));
+
+ assert_eq!(captured[2].0, "ERROR");
+ assert!(captured[2].2.contains("error message"));
+}
diff --git a/crates/bitwarden-uniffi/tests/callback_thread_safety.rs b/crates/bitwarden-uniffi/tests/callback_thread_safety.rs
new file mode 100644
index 000000000..920493933
--- /dev/null
+++ b/crates/bitwarden-uniffi/tests/callback_thread_safety.rs
@@ -0,0 +1,74 @@
+//! Integration test validating callback thread safety.
+//!
+//! Verifies that the callback mechanism safely handles concurrent log emissions
+//! from multiple SDK threads without data races or corruption.
+
+use std::{
+ sync::{Arc, Mutex},
+ thread,
+};
+
+use bitwarden_uniffi::*;
+
+// Type alias to match trait definition
+type Result = std::result::Result;
+
+/// 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 {
+ Some("mock_token".to_string())
+ }
+}
+
+/// Thread-safe test callback
+struct TestCallback {
+ logs: Arc>>,
+}
+
+impl LogCallback for TestCallback {
+ 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(())
+ }
+}
+
+#[test]
+fn test_callback_thread_safety() {
+ // Verify callback handles concurrent invocations safely
+ let logs = Arc::new(Mutex::new(Vec::new()));
+ let callback = Arc::new(TestCallback { logs: logs.clone() });
+
+ let _client = Client::new(Arc::new(MockTokenProvider), None, Some(callback));
+
+ // Spawn multiple threads logging simultaneously
+ let handles: Vec<_> = (0..10)
+ .map(|i| {
+ thread::spawn(move || {
+ tracing::info!("thread {} message", i);
+ })
+ })
+ .collect();
+
+ // Wait for all threads to complete
+ for handle in handles {
+ handle.join().expect("Thread should not panic");
+ }
+
+ // Verify all logs captured without data races
+ let captured = logs.lock().expect("Failed to lock logs mutex");
+ assert_eq!(captured.len(), 10, "All threaded logs should be captured");
+
+ // Verify no corrupted entries (all should be INFO level)
+ for (level, _target, message) in captured.iter() {
+ assert_eq!(level, "INFO");
+ assert!(message.contains("thread"));
+ assert!(message.contains("message"));
+ }
+}