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..8744d97dd
--- /dev/null
+++ b/crates/bitwarden-uniffi/docs/logging-callback.md
@@ -0,0 +1,164 @@
+# 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"));
+ }
+}