diff --git a/CHANGELOG.md b/CHANGELOG.md index f99139514f..6d76b8a4c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ [Full Changelog](In progress) +## ✨ What's New ✨ + +### Remote Settings +* Add uptake telemetry support ([#7288](https://github.com/mozilla/application-services/pull/7288)) + # v150.0 (_2026-03-23_) # General diff --git a/components/remote_settings/android/build.gradle b/components/remote_settings/android/build.gradle index cd79ece806..3a7dc8ff2f 100644 --- a/components/remote_settings/android/build.gradle +++ b/components/remote_settings/android/build.gradle @@ -1,10 +1,48 @@ +buildscript { + if (gradle.hasProperty("mozconfig")) { + repositories { + gradle.mozconfig.substs.GRADLE_MAVEN_REPOSITORIES.each { repository -> + maven { + url = repository + if (gradle.mozconfig.substs.ALLOW_INSECURE_GRADLE_REPOSITORIES) { + allowInsecureProtocol = true + } + } + } + } + + dependencies { + classpath libs.mozilla.glean.gradle.plugin + } + } +} + +plugins { + alias libs.plugins.python.envs.plugin +} + apply from: "$appServicesRootDir/build-scripts/component-common.gradle" apply from: "$appServicesRootDir/publish.gradle" +ext { + gleanNamespace = "mozilla.telemetry.glean" + gleanYamlFiles = ["${project.projectDir}/../metrics.yaml"] + if (gradle.hasProperty("mozconfig")) { + gleanPythonEnvDir = gradle.mozconfig.substs.GRADLE_GLEAN_PARSER_VENV + } +} +apply plugin: "org.mozilla.telemetry.glean-gradle-plugin" + android { namespace 'org.mozilla.appservices.remotesettings' } +dependencies { + implementation libs.mozilla.glean + + testImplementation libs.mozilla.glean.forUnitTests +} + ext.configureUniFFIBindgen("remote_settings") ext.dependsOnTheMegazord() ext.configurePublish() diff --git a/components/remote_settings/android/src/main/java/mozilla/appservices/remotesettings/GleanTelemetry.kt b/components/remote_settings/android/src/main/java/mozilla/appservices/remotesettings/GleanTelemetry.kt new file mode 100644 index 0000000000..c7437a4482 --- /dev/null +++ b/components/remote_settings/android/src/main/java/mozilla/appservices/remotesettings/GleanTelemetry.kt @@ -0,0 +1,41 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.appservices.remotesettings + +import android.util.Log +import mozilla.appservices.remotesettings.RemoteSettingsTelemetry +import mozilla.appservices.remotesettings.UptakeEventExtras +import org.mozilla.appservices.remotesettings.GleanMetrics.RemoteSettings as RSMetrics + +/** + * GleanTelemetry is a thin wrapper used to expose + * callbacks used to emit telemetry events to Glean. + */ +class GleanTelemetry : RemoteSettingsTelemetry { + override fun reportUptake(extras: UptakeEventExtras) { + Log.d( + GleanTelemetry::javaClass.name, + "Remote Settings Telemetry Uptake called with: " + + "value=${extras.value}, " + + "source=${extras.source}, " + + "age=${extras.age}, " + + "trigger=${extras.trigger}, " + + "timestamp=${extras.timestamp}, " + + "duration=${extras.duration}, " + + "errorName=${extras.errorName}", + ) + RSMetrics.uptakeRemotesettings.record( + RSMetrics.UptakeRemotesettingsExtra( + value = extras.value, + source = extras.source, + age = extras.age, + trigger = extras.trigger, + timestamp = extras.timestamp, + duration = extras.duration, + errorname = extras.errorName, + ), + ) + } +} diff --git a/components/remote_settings/metrics.yaml b/components/remote_settings/metrics.yaml new file mode 100644 index 0000000000..35e2644c01 --- /dev/null +++ b/components/remote_settings/metrics.yaml @@ -0,0 +1,61 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +# This file defines the metrics that will be gathered for the Remote Settings +# component. Changes to these metrics require data review. +# +# We can't record metrics from Rust directly. To work around that we: +# - Define the metrics in application-services +# - Define API calls in application-services that return the metrics +# alongside the normal results. +# - Record the metrics with Glean in the consumer code + +--- +$schema: moz://mozilla.org/schemas/glean/metrics/2-0-0 + +remote_settings: + uptake_remotesettings: + type: event + disabled: true # To be controlled by server knobs due to expected high volume + description: > + Was the remote content successfully pulled? This uptake telemetry + allows to monitor the behaviour of our clients when it comes to + fetching data from remote servers. + See https://searchfox.org/firefox-main/rev/1427c8863/services/common/metrics.yaml + extra_keys: + value: + description: The synchronization status (success, up_to_date, sync_error, ...) + type: string + source: + description: > + A label to distinguish what is being pulled or updated in the component (eg. recipe id, settings collection name, ...). + type: string + duration: + description: The duration of the synchronization process in milliseconds. + type: string + errorName: + description: An optional string with the error name attribute in case of failure. + type: string + trigger: + description: > + A label to distinguish what triggered the polling/fetching of remote content (eg. "broadcast", "timer", "forced", "manual") + type: string + age: + description: > + The age of pulled data in seconds (ie. difference between publication time and fetch time). + type: string + timestamp: + description: > + The current timestamp, received during synchronization. + type: string + bugs: &uptake_remotecontent_result_uptake_bugs + - https://bugzil.la/1517469 + - https://bugzil.la/1617133 + data_reviews: *uptake_remotecontent_result_uptake_bugs + notification_emails: &uptake_remotecontent_result_uptake_emails + - mleplatre@mozilla.com + - acottner@mozilla.com + data_sensitivity: + - technical + expires: never diff --git a/components/remote_settings/src/lib.rs b/components/remote_settings/src/lib.rs index eb689ee370..a95e271d3e 100644 --- a/components/remote_settings/src/lib.rs +++ b/components/remote_settings/src/lib.rs @@ -16,6 +16,7 @@ pub mod service; #[cfg(feature = "signatures")] pub(crate) mod signatures; pub mod storage; +pub mod telemetry; pub(crate) mod jexl_filter; mod macros; @@ -24,10 +25,12 @@ pub use client::{Attachment, RemoteSettingsRecord, RemoteSettingsResponse, RsJso pub use config::{BaseUrl, RemoteSettingsConfig, RemoteSettingsConfig2, RemoteSettingsServer}; pub use context::RemoteSettingsContext; pub use error::{trace, ApiResult, RemoteSettingsError, Result}; +pub use telemetry::{RemoteSettingsTelemetry, SyncStatus, UptakeEventExtras}; use client::Client; use error::Error; use storage::Storage; +use telemetry::RemoteSettingsTelemetryWrapper; uniffi::setup_scaffolding!("remote_settings"); @@ -60,6 +63,14 @@ impl RemoteSettingsService { } } + /// Set the telemetry implementation used to record Glean metrics. + /// This should be set to a real implementation (eg. Kotlin, Swift). + /// If not set, all metric recording is a no-op. + pub fn set_telemetry(&self, telemetry: Arc) { + self.internal + .set_telemetry(RemoteSettingsTelemetryWrapper::new(telemetry)); + } + /// Create a new Remote Settings client /// /// This method performs no IO or network requests and is safe to run in a main thread that can't be blocked. diff --git a/components/remote_settings/src/service.rs b/components/remote_settings/src/service.rs index 97ef57f643..7f4aa4320c 100644 --- a/components/remote_settings/src/service.rs +++ b/components/remote_settings/src/service.rs @@ -15,8 +15,9 @@ use url::Url; use viaduct::Request; use crate::{ - client::RemoteState, config::BaseUrl, error::Error, storage::Storage, RemoteSettingsClient, - RemoteSettingsConfig2, RemoteSettingsContext, RemoteSettingsServer, Result, + client::RemoteState, config::BaseUrl, error::Error, storage::Storage, + telemetry::RemoteSettingsTelemetryWrapper, RemoteSettingsClient, RemoteSettingsConfig2, + RemoteSettingsContext, RemoteSettingsServer, Result, }; /// Internal Remote settings service API @@ -30,6 +31,7 @@ struct RemoteSettingsServiceInner { bucket_name: String, app_context: Option, remote_state: RemoteState, + telemetry: RemoteSettingsTelemetryWrapper, /// Weakrefs for all clients that we've created. Note: this stores the /// top-level/public `RemoteSettingsClient` structs rather than `client::RemoteSettingsClient`. /// The reason for this is that we return Arcs to the public struct to the foreign code, so we @@ -57,11 +59,16 @@ impl RemoteSettingsService { bucket_name, app_context: config.app_context, remote_state: RemoteState::default(), + telemetry: RemoteSettingsTelemetryWrapper::noop(), clients: vec![], }), } } + pub fn set_telemetry(&self, telemetry: RemoteSettingsTelemetryWrapper) { + self.inner.lock().telemetry = telemetry; + } + pub fn make_client(&self, collection_name: String) -> Arc { let mut inner = self.inner.lock(); // Allow using in-memory databases for testing of external crates. @@ -99,18 +106,27 @@ impl RemoteSettingsService { for client in inner.active_clients() { let client = &client.internal; let collection_name = client.collection_name(); + let cid = format!("{bucket_name}/{collection_name}"); if let Some(client_last_modified) = client.get_last_modified_timestamp()? { if let Some(server_last_modified) = change_map.get(&(collection_name, &bucket_name)) { if client_last_modified == *server_last_modified { trace!("skipping up-to-date collection: {collection_name}"); + inner.telemetry.report_uptake_up_to_date(&cid, None); continue; } } } if synced_collections.insert(collection_name.to_string()) { trace!("syncing collection: {collection_name}"); - client.sync()?; + let start_time = std::time::Instant::now(); + let sync_result = client.sync(); + let duration: u64 = start_time.elapsed().as_millis().try_into().unwrap_or(0); + match &sync_result { + Ok(()) => inner.telemetry.report_uptake_success(&cid, Some(duration)), + Err(e) => inner.telemetry.report_uptake_error(e, &cid), + } + sync_result?; } } Ok(synced_collections.into_iter().collect()) @@ -182,18 +198,24 @@ impl RemoteSettingsServiceInner { trace!("make_request: {url}"); self.remote_state.ensure_no_backoff()?; + let start_time = std::time::Instant::now(); let req = Request::get(url); let resp = req.send()?; self.remote_state.handle_backoff_hint(&resp)?; + const TELEMETRY_SOURCE_POLL: &str = "settings-changes-monitoring"; if resp.is_success() { - Ok(resp.json()?) + let body = resp.json()?; + let duration: u64 = start_time.elapsed().as_millis().try_into().unwrap_or(0); + self.telemetry + .report_uptake_success(TELEMETRY_SOURCE_POLL, Some(duration)); + Ok(body) } else { - Err(Error::response_error( - &resp.url, - format!("status code: {}", resp.status), - )) + let e = Error::response_error(&resp.url, format!("status code: {}", resp.status)); + self.telemetry + .report_uptake_error(&e, TELEMETRY_SOURCE_POLL); + Err(e) } } } @@ -212,3 +234,228 @@ struct ChangesCollection { bucket: String, last_modified: u64, } + +#[cfg(test)] +mod test { + use super::*; + use crate::telemetry::UptakeEventExtras; + use crate::{RemoteSettingsConfig2, RemoteSettingsServer}; + use mockito::{mock, Matcher}; + use std::sync::Arc; + + /// Telemetry implementation that records all events for later assertion. + struct FakeTelemetry { + events: std::sync::Mutex>, + } + + impl FakeTelemetry { + fn new() -> Self { + Self { + events: std::sync::Mutex::new(Vec::new()), + } + } + } + + impl crate::telemetry::RemoteSettingsTelemetry for FakeTelemetry { + fn report_uptake(&self, extras: UptakeEventExtras) { + self.events.lock().unwrap().push(extras); + } + } + + fn make_service(server_url: &str) -> (RemoteSettingsService, Arc) { + let service = RemoteSettingsService::new( + ":memory:".into(), + RemoteSettingsConfig2 { + server: Some(RemoteSettingsServer::Custom { + url: server_url.into(), + }), + ..Default::default() + }, + ); + let telemetry = Arc::new(FakeTelemetry::new()); + service.set_telemetry(RemoteSettingsTelemetryWrapper::new(telemetry.clone())); + (service, telemetry) + } + + fn mock_monitor_changes(collection: &str, timestamp: u64) -> mockito::Mock { + mock("GET", "/v1/buckets/monitor/collections/changes/changeset") + .match_query(Matcher::Any) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(format!( + r#"{{"timestamp": {timestamp}, "changes": [{{"collection": "{collection}", "bucket": "main", "last_modified": {timestamp}}}]}}"# + )) + .create() + } + + fn mock_changeset(collection: &str, timestamp: u64) -> mockito::Mock { + mock( + "GET", + format!("/v1/buckets/main/collections/{collection}/changeset").as_str(), + ) + .match_query(Matcher::Any) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(format!( + r#"{{"changes": [], "timestamp": {timestamp}, "metadata": {{"bucket": "main", "signatures": []}}}}"# + )) + .create() + } + + fn mock_changeset_error(bucket: &str, collection: &str) -> mockito::Mock { + mock( + "GET", + format!("/v1/buckets/{bucket}/collections/{collection}/changeset").as_str(), + ) + .match_query(Matcher::Any) + .with_status(500) + .with_body("server error") + .create() + } + + #[test] + fn test_telemetry_network_error_on_changes_failure() { + viaduct_dev::init_backend_dev(); + mock_changeset_error("monitor", "changes"); + + let (service, telemetry) = make_service(&mockito::server_url()); + let _ = service.sync(); + + let events = telemetry.events.lock().unwrap(); + assert_eq!(events.len(), 1); + assert_eq!( + events[0].source, + Some("settings-changes-monitoring".to_string()) + ); + assert_eq!(events[0].value, Some("network_error".to_string())); + assert_eq!(events[0].errorName, Some("ResponseError".to_string())); + assert!(events[0].errorName.is_some()); + } + + #[test] + fn test_telemetry_on_changes_success() { + viaduct_dev::init_backend_dev(); + let _changes = mock_monitor_changes("cid", 42); + + let (service, telemetry) = make_service(&mockito::server_url()); + let _ = service.sync(); + + let events = telemetry.events.lock().unwrap(); + assert_eq!(events.len(), 1); + assert_eq!( + events[0].source, + Some("settings-changes-monitoring".to_string()) + ); + assert_eq!(events[0].value, Some("success".to_string())); + assert!(events[0].duration.is_some()); + } + + #[cfg(not(feature = "signatures"))] + #[test] + fn test_telemetry_on_collection_success() { + viaduct_dev::init_backend_dev(); + let collection = "cid"; + let timestamp = 1774420582054u64; + let _changes = mock_monitor_changes(collection, timestamp); + let _changeset = mock_changeset(collection, timestamp); + + let (service, telemetry) = make_service(&mockito::server_url()); + let _client = service.make_client(collection.into()); + let _ = service.sync(); + + let events = telemetry.events.lock().unwrap(); + assert_eq!(events.len(), 2); + assert_eq!( + events[0].source, + Some("settings-changes-monitoring".to_string()) + ); + assert_eq!(events[1].source, Some(format!("main/{collection}"))); + assert_eq!(events[1].value, Some("success".to_string())); + assert!(events[1].duration.is_some()); + } + + #[cfg(not(feature = "signatures"))] + #[test] + fn test_telemetry_on_collection_up_to_date() { + viaduct_dev::init_backend_dev(); + let collection = "cid"; + let timestamp = 1774420582054u64; + let _changes = mock_monitor_changes(collection, timestamp); + let _changeset = mock_changeset(collection, timestamp); + + let (service, telemetry) = make_service(&mockito::server_url()); + let _client = service.make_client(collection.into()); + + // First sync: populates local storage with timestamp. + let _ = service.sync(); + let events_before = telemetry.events.lock().unwrap().len(); + // Second sync. + let _ = service.sync(); + + let events = telemetry.events.lock().unwrap(); + assert_eq!(events.len() - events_before, 2); + assert_eq!( + events[events_before].source, + Some("settings-changes-monitoring".to_string()) + ); + assert_eq!( + events[events_before + 1].source, + Some(format!("main/{collection}")) + ); + assert_eq!( + events[events_before + 1].value, + Some("up_to_date".to_string()) + ); + } + + #[test] + fn test_telemetry_on_collection_error() { + viaduct_dev::init_backend_dev(); + let collection = "cid"; + let timestamp = 1774420582054u64; + let _changes = mock_monitor_changes(collection, timestamp); + let _changeset = mock_changeset_error("main", collection); + + let (service, telemetry) = make_service(&mockito::server_url()); + let _client = service.make_client(collection.into()); + let _ = service.sync(); + + let events = telemetry.events.lock().unwrap(); + assert_eq!(events.len(), 2); + assert_eq!( + events[0].source, + Some("settings-changes-monitoring".to_string()) + ); + assert_eq!(events[0].value, Some("success".to_string())); + assert_eq!(events[1].source, Some(format!("main/{collection}"))); + assert_eq!(events[1].value, Some("network_error".to_string())); + assert_eq!(events[1].errorName, Some("ResponseError".to_string())); + } + + #[cfg(feature = "signatures")] + #[test] + fn test_telemetry_on_collection_signature_error() { + viaduct_dev::init_backend_dev(); + let collection = "cid"; + let timestamp = 1774420582054u64; + let _changes = mock_monitor_changes(collection, timestamp); + let _changeset = mock_changeset(collection, timestamp); + + let (service, telemetry) = make_service(&mockito::server_url()); + let _client = service.make_client(collection.into()); + let _ = service.sync(); + + let events = telemetry.events.lock().unwrap(); + assert_eq!(events.len(), 2); + assert_eq!( + events[0].source, + Some("settings-changes-monitoring".to_string()) + ); + assert_eq!(events[1].source, Some(format!("main/{collection}"))); + assert_eq!(events[1].value, Some("signature_error".to_string())); + assert_eq!( + events[1].errorName, + Some("IncompleteSignatureDataError".to_string()) + ); + } +} diff --git a/components/remote_settings/src/telemetry.rs b/components/remote_settings/src/telemetry.rs new file mode 100644 index 0000000000..822dc5a19b --- /dev/null +++ b/components/remote_settings/src/telemetry.rs @@ -0,0 +1,170 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +use std::{fmt, sync::Arc}; + +use crate::error::Error; + +/// Remote Settings sync status. +#[derive(Debug, PartialEq, uniffi::Enum)] +pub enum SyncStatus { + /// Sync completed and new data was stored. + Success, + /// Local data is already up to date, no new data was stored. + UpToDate, + /// A network-level error occurred (connection refused, timeout, bad HTTP status, ...) + NetworkError, + /// The server asked the client to back off. + BackoffError, + /// Content signature verification failed. + SignatureError, + /// Server error (5xx status) + ServerError, + /// An unknown error occurred. + UnknownError, +} + +impl fmt::Display for SyncStatus { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + SyncStatus::Success => "success", + SyncStatus::UpToDate => "up_to_date", + SyncStatus::NetworkError => "network_error", + SyncStatus::BackoffError => "backoff_error", + SyncStatus::SignatureError => "signature_error", + SyncStatus::ServerError => "server_error", + SyncStatus::UnknownError => "unknown_error", + }; + f.write_str(s) + } +} + +#[derive(Debug, PartialEq, uniffi::Record, Default)] +#[allow(non_snake_case)] +pub struct UptakeEventExtras { + /// Main sync status. + pub value: Option, + /// Source of the sync (eg. "settings-changes-monitoring", "main/{collection}", ...) + pub source: Option, + /// Age of the data in milliseconds, if available. + pub age: Option, + /// Trigger that caused the sync (eg. "manual", "startup", "scheduled", ...) if available. + pub trigger: Option, + /// Timestamp received from the server, if available. + pub timestamp: Option, + /// Duration of the sync operation in milliseconds, if available. + pub duration: Option, + /// The name of the error that occurred, if available. + pub errorName: Option, +} + +/// Trait implemented by consumers to record Remote Settings metrics with Glean. +/// +/// Consumers should implement this trait and pass it to +/// [crate::RemoteSettingsService::set_telemetry]. +/// +/// Consumers implement the trait like this (Kotlin example): +/// ```kotlin +/// /* Import the UniFFI-generated bindings */ +/// import mozilla.appservices.remote_settings.RemoteSettingsTelemetry +/// import mozilla.appservices.remote_settings.UptakeEventExtras +/// /* Import the Glean-generated bindings */ +/// import org.mozilla.appservices.remote_settings.GleanMetrics.RemoteSettings as RSMetrics +/// +/// class GleanTelemetry : RemoteSettingsTelemetry { +/// override fun report_uptake(eventExtras: UptakeEventExtras) { +/// RSMetrics.uptakeRemotesettings.record(eventExtras) +/// } +/// } +/// +/// service.setTelemetry(GleanTelemetry()) +/// ``` +#[uniffi::export(with_foreign)] +pub trait RemoteSettingsTelemetry: Send + Sync { + /// Report uptake event. + fn report_uptake(&self, extras: UptakeEventExtras); +} + +struct NoopRemoteSettingsTelemetry; + +impl RemoteSettingsTelemetry for NoopRemoteSettingsTelemetry { + fn report_uptake(&self, _extras: UptakeEventExtras) {} +} + +/// Wrapper around [RemoteSettingsTelemetry] used internally. +#[derive(Clone)] +pub struct RemoteSettingsTelemetryWrapper { + inner: Arc, +} + +impl RemoteSettingsTelemetryWrapper { + pub fn new(inner: Arc) -> Self { + Self { inner } + } + + pub fn noop() -> Self { + Self { + inner: Arc::new(NoopRemoteSettingsTelemetry), + } + } + + pub fn report_uptake_success(&self, source: &str, duration: Option) { + self.inner.report_uptake(UptakeEventExtras { + value: Some(SyncStatus::Success.to_string()), + source: Some(source.to_string()), + age: None, + trigger: None, + timestamp: None, + duration: duration.map(|d| d.to_string()), + errorName: None, + }); + } + + pub fn report_uptake_up_to_date(&self, source: &str, duration: Option) { + self.inner.report_uptake(UptakeEventExtras { + value: Some(SyncStatus::UpToDate.to_string()), + source: Some(source.to_string()), + age: None, + trigger: None, + timestamp: None, + duration: duration.map(|d| d.to_string()), + errorName: None, + }); + } + + pub fn report_uptake_error(&self, error: &Error, source: &str) { + // This is a bit hacky and naive, but it allows us to get the original + // error type without needing to add too much machinery to our error types. + // This mimics what we do in the desktop client: + // https://searchfox.org/firefox-main/rev/26c440c6196eb0b4/services/settings/RemoteSettingsClient.sys.mjs#965 + let error_name = format!("{error:?}") + .split(&['{', '(']) + .next() + .unwrap_or("") + .trim() + .to_string(); + self.inner.report_uptake(UptakeEventExtras { + value: Some(error_to_status(error).to_string()), + source: Some(source.to_string()), + age: None, + trigger: None, + timestamp: None, + duration: None, + errorName: Some(error_name), + }); + } +} + +fn error_to_status(error: &Error) -> SyncStatus { + match error { + Error::RequestError(viaduct::ViaductError::NetworkError(_)) + | Error::ResponseError { .. } => SyncStatus::NetworkError, + Error::BackoffError(_) => SyncStatus::BackoffError, + #[cfg(feature = "signatures")] + Error::IncompleteSignatureDataError(_) => SyncStatus::SignatureError, + #[cfg(feature = "signatures")] + Error::SignatureError(_) => SyncStatus::SignatureError, + _ => SyncStatus::UnknownError, + } +} diff --git a/components/support/rc_crypto/nss/fixtures/profile/logins.db b/components/support/rc_crypto/nss/fixtures/profile/logins.db index ebe1a36f4b..9253f9b3b7 100644 Binary files a/components/support/rc_crypto/nss/fixtures/profile/logins.db and b/components/support/rc_crypto/nss/fixtures/profile/logins.db differ