summaryrefslogtreecommitdiffstats
path: root/third_party/rust/fxa-client/src/telemetry.rs
diff options
context:
space:
mode:
Diffstat (limited to 'third_party/rust/fxa-client/src/telemetry.rs')
-rw-r--r--third_party/rust/fxa-client/src/telemetry.rs360
1 files changed, 360 insertions, 0 deletions
diff --git a/third_party/rust/fxa-client/src/telemetry.rs b/third_party/rust/fxa-client/src/telemetry.rs
new file mode 100644
index 0000000000..711f48bf7c
--- /dev/null
+++ b/third_party/rust/fxa-client/src/telemetry.rs
@@ -0,0 +1,360 @@
+/* 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 crate::{error::*, scopes, FirefoxAccount};
+use jwcrypto::{EncryptionAlgorithm, EncryptionParameters, Jwk};
+use rand_rccrypto::rand::seq::SliceRandom;
+use rc_crypto::rand;
+use serde_derive::*;
+use sync_guid::Guid;
+
+impl FirefoxAccount {
+ /// Get the ecosystem anon id, generating it if necessary.
+ ///
+ /// **💾 This method alters the persisted account state.**
+ pub fn get_ecosystem_anon_id(&mut self) -> Result<String> {
+ self.get_ecosystem_anon_id_helper(true)
+ }
+
+ fn get_ecosystem_anon_id_helper(&mut self, generate_placeholder: bool) -> Result<String> {
+ let profile = self.get_profile(false)?;
+ // Default case: the ecosystem anon ID was generated during login.
+ if let Some(ecosystem_anon_id) = profile.ecosystem_anon_id {
+ return Ok(ecosystem_anon_id);
+ }
+ if !generate_placeholder {
+ return Err(ErrorKind::IllegalState("ecosystem_anon_id should be present").into());
+ }
+ // For older clients, we generate an ecosystem_user_id,
+ // persist it and then return ecosystem_anon_id.
+ let mut ecosystem_user_id = vec![0u8; 32];
+ rand::fill(&mut ecosystem_user_id)?;
+ // Will end up as a len 64 hex string.
+ let ecosystem_user_id = hex::encode(ecosystem_user_id);
+
+ let anon_id_key = self.fetch_random_ecosystem_anon_id_key()?;
+ let ecosystem_anon_id = jwcrypto::encrypt_to_jwe(
+ &ecosystem_user_id.as_bytes(),
+ EncryptionParameters::ECDH_ES {
+ enc: EncryptionAlgorithm::A256GCM,
+ peer_jwk: &anon_id_key,
+ },
+ )?;
+
+ let token = self.get_access_token(scopes::PROFILE_WRITE, None)?.token;
+ if let Err(err) =
+ self.client
+ .set_ecosystem_anon_id(&self.state.config, &token, &ecosystem_anon_id)
+ {
+ if let ErrorKind::RemoteError { code: 412, .. } = err.kind() {
+ // Another client beat us, fetch the new ecosystem_anon_id.
+ return self.get_ecosystem_anon_id_helper(false);
+ }
+ }
+
+ // Persist the unencrypted ecosystem_user_id for possible future use.
+ self.state.ecosystem_user_id = Some(ecosystem_user_id);
+ Ok(ecosystem_anon_id)
+ }
+
+ fn fetch_random_ecosystem_anon_id_key(&self) -> Result<Jwk> {
+ let config = self.client.fxa_client_configuration(&self.state.config)?;
+ let keys = config
+ .ecosystem_anon_id_keys
+ .ok_or_else(|| ErrorKind::NoAnonIdKey)?;
+ let mut rng = rand_rccrypto::RcCryptoRng;
+ Ok(keys
+ .choose(&mut rng)
+ .ok_or_else(|| ErrorKind::NoAnonIdKey)?
+ .clone())
+ }
+
+ /// Gathers and resets telemetry for this account instance.
+ /// This should be considered a short-term solution to telemetry gathering
+ /// and should called whenever consumers expect there might be telemetry,
+ /// and it should submit the telemetry to whatever telemetry system is in
+ /// use (probably glean).
+ ///
+ /// The data is returned as a JSON string, which consumers should parse
+ /// forgivingly (eg, be tolerant of things not existing) to try and avoid
+ /// too many changes as telemetry comes and goes.
+ pub fn gather_telemetry(&mut self) -> Result<String> {
+ let telem = self.telemetry.replace(FxaTelemetry::new());
+ Ok(serde_json::to_string(&telem)?)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::{http_client::*, oauth::AccessTokenInfo, Config};
+ use jwcrypto::{ec::ECKeysParameters, JwkKeyParameters};
+ use std::sync::Arc;
+
+ fn fxa_setup() -> FirefoxAccount {
+ let config = Config::stable_dev("12345678", "https://foo.bar");
+ let mut fxa = FirefoxAccount::with_config(config);
+ fxa.add_cached_token(
+ "profile",
+ AccessTokenInfo {
+ scope: "profile".to_string(),
+ token: "profiletok".to_string(),
+ key: None,
+ expires_at: u64::max_value(),
+ },
+ );
+ fxa.add_cached_token(
+ "profile:write",
+ AccessTokenInfo {
+ scope: "profile".to_string(),
+ token: "profilewritetok".to_string(),
+ key: None,
+ expires_at: u64::max_value(),
+ },
+ );
+ fxa
+ }
+
+ #[test]
+ fn get_ecosystem_anon_id_in_profile() {
+ let mut fxa = fxa_setup();
+
+ let ecosystem_anon_id = "bobo".to_owned();
+
+ let mut client = FxAClientMock::new();
+ client
+ .expect_profile(
+ mockiato::Argument::any,
+ |token| token.partial_eq("profiletok"),
+ mockiato::Argument::any,
+ )
+ .times(1)
+ .returns_once(Ok(Some(ResponseAndETag {
+ response: ProfileResponse {
+ uid: "12345ab".to_string(),
+ email: "foo@bar.com".to_string(),
+ display_name: None,
+ avatar: "https://foo.avatar".to_string(),
+ avatar_default: true,
+ ecosystem_anon_id: Some(ecosystem_anon_id.to_owned()),
+ },
+ etag: None,
+ })));
+ fxa.set_client(Arc::new(client));
+
+ assert_eq!(fxa.get_ecosystem_anon_id().unwrap(), ecosystem_anon_id);
+ }
+
+ #[test]
+ fn get_ecosystem_anon_id_generate_anon_id() {
+ let mut fxa = fxa_setup();
+
+ let mut client = FxAClientMock::new();
+ client
+ .expect_profile(
+ mockiato::Argument::any,
+ |token| token.partial_eq("profiletok"),
+ mockiato::Argument::any,
+ )
+ .times(1)
+ .returns_once(Ok(Some(ResponseAndETag {
+ response: ProfileResponse {
+ uid: "12345ab".to_string(),
+ email: "foo@bar.com".to_string(),
+ display_name: None,
+ avatar: "https://foo.avatar".to_string(),
+ avatar_default: true,
+ ecosystem_anon_id: None,
+ },
+ etag: None,
+ })));
+ client
+ .expect_fxa_client_configuration(mockiato::Argument::any)
+ .times(1)
+ .returns_once(Ok(ClientConfigurationResponse {
+ auth_server_base_url: "https://foo.bar".to_owned(),
+ oauth_server_base_url: "https://foo.bar".to_owned(),
+ profile_server_base_url: "https://foo.bar".to_owned(),
+ sync_tokenserver_base_url: "https://foo.bar".to_owned(),
+ ecosystem_anon_id_keys: Some(vec![Jwk {
+ kid: Some("LlU4keOmhTuq9fCNnpIldYGT9vT9dIDwnu_SBtTgeEQ".to_owned()),
+ key_parameters: JwkKeyParameters::EC(ECKeysParameters {
+ crv: "P-256".to_owned(),
+ x: "i3FM3OFSCZEoqu-jtelXwKt6AL4ODQ75NUdHbcLWQSo".to_owned(),
+ y: "nW-S3QiHDo-9hwfBhKnGKarkt_PVqVyIPUytjutTunY".to_owned(),
+ }),
+ }]),
+ }));
+ client
+ .expect_set_ecosystem_anon_id(
+ mockiato::Argument::any,
+ |token| token.partial_eq("profilewritetok"),
+ mockiato::Argument::any,
+ )
+ .times(1)
+ .returns_once(Ok(()));
+ fxa.set_client(Arc::new(client));
+
+ let ecosystem_anon_id = fxa.get_ecosystem_anon_id().unwrap();
+ // Well, it looks like a jwe folks.
+ assert!(ecosystem_anon_id.chars().filter(|c| c == &'.').count() == 4);
+ assert!(fxa.state.ecosystem_user_id.unwrap().len() == 64);
+ }
+
+ #[test]
+ fn get_ecosystem_anon_id_generate_anon_id_412() {
+ let mut fxa = fxa_setup();
+
+ let ecosystem_anon_id = "bobo".to_owned();
+
+ let mut client = FxAClientMock::new();
+ client
+ .expect_profile(
+ mockiato::Argument::any,
+ |token| token.partial_eq("profiletok"),
+ mockiato::Argument::any,
+ )
+ .returns_once(Ok(Some(ResponseAndETag {
+ response: ProfileResponse {
+ uid: "12345ab".to_string(),
+ email: "foo@bar.com".to_string(),
+ display_name: None,
+ avatar: "https://foo.avatar".to_string(),
+ avatar_default: true,
+ ecosystem_anon_id: None,
+ },
+ etag: None,
+ })));
+ // 2nd profile call after we get the 412.
+ client
+ .expect_profile(
+ mockiato::Argument::any,
+ |token| token.partial_eq("profiletok"),
+ mockiato::Argument::any,
+ )
+ .returns_once(Ok(Some(ResponseAndETag {
+ response: ProfileResponse {
+ uid: "12345ab".to_string(),
+ email: "foo@bar.com".to_string(),
+ display_name: None,
+ avatar: "https://foo.avatar".to_string(),
+ avatar_default: true,
+ ecosystem_anon_id: Some(ecosystem_anon_id.clone()),
+ },
+ etag: None,
+ })));
+ client.expect_profile_calls_in_order();
+ client
+ .expect_fxa_client_configuration(mockiato::Argument::any)
+ .times(1)
+ .returns_once(Ok(ClientConfigurationResponse {
+ auth_server_base_url: "https://foo.bar".to_owned(),
+ oauth_server_base_url: "https://foo.bar".to_owned(),
+ profile_server_base_url: "https://foo.bar".to_owned(),
+ sync_tokenserver_base_url: "https://foo.bar".to_owned(),
+ ecosystem_anon_id_keys: Some(vec![Jwk {
+ kid: Some("LlU4keOmhTuq9fCNnpIldYGT9vT9dIDwnu_SBtTgeEQ".to_owned()),
+ key_parameters: JwkKeyParameters::EC(ECKeysParameters {
+ crv: "P-256".to_owned(),
+ x: "i3FM3OFSCZEoqu-jtelXwKt6AL4ODQ75NUdHbcLWQSo".to_owned(),
+ y: "nW-S3QiHDo-9hwfBhKnGKarkt_PVqVyIPUytjutTunY".to_owned(),
+ }),
+ }]),
+ }));
+ client
+ .expect_set_ecosystem_anon_id(
+ mockiato::Argument::any,
+ |token| token.partial_eq("profilewritetok"),
+ mockiato::Argument::any,
+ )
+ .times(1)
+ .returns_once(Err(ErrorKind::RemoteError {
+ code: 412,
+ errno: 500,
+ error: "precondition failed".to_string(),
+ message: "another user did it".to_string(),
+ info: "".to_string(),
+ }
+ .into()));
+ fxa.set_client(Arc::new(client));
+
+ assert_eq!(fxa.get_ecosystem_anon_id().unwrap(), ecosystem_anon_id);
+ assert!(fxa.state.ecosystem_user_id.is_none());
+ }
+}
+
+// A somewhat mixed-bag of all telemetry we want to collect. The idea is that
+// the app will "pull" telemetry via a new API whenever it thinks there might
+// be something to record.
+// It's considered a temporary solution until either we can record it directly
+// (eg, via glean) or we come up with something better.
+// Note that this means we'll lose telemetry if we crash between gathering it
+// here and the app submitting it, but that should be rare (in practice,
+// apps will submit it directly after an operation that generated telememtry)
+
+/// The reason a tab/command was received.
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "kebab-case")]
+pub enum ReceivedReason {
+ /// A push notification for the command was received.
+ Push,
+ /// Discovered while handling a push notification for a later message.
+ PushMissed,
+ /// Explicit polling for missed commands.
+ Poll,
+}
+
+#[derive(Debug, Serialize)]
+pub struct SentCommand {
+ pub flow_id: String,
+ pub stream_id: String,
+}
+
+impl Default for SentCommand {
+ fn default() -> Self {
+ Self {
+ flow_id: Guid::random().to_string(),
+ stream_id: Guid::random().to_string(),
+ }
+ }
+}
+
+#[derive(Debug, Serialize)]
+pub struct ReceivedCommand {
+ pub flow_id: String,
+ pub stream_id: String,
+ pub reason: ReceivedReason,
+}
+
+// We have a naive strategy to avoid unbounded memory growth - the intention
+// is that if any platform lets things grow to hit these limits, it's probably
+// never going to consume anything - so it doesn't matter what we discard (ie,
+// there's no good reason to have a smarter circular buffer etc)
+const MAX_TAB_EVENTS: usize = 200;
+
+#[derive(Debug, Default, Serialize)]
+pub struct FxaTelemetry {
+ commands_sent: Vec<SentCommand>,
+ commands_received: Vec<ReceivedCommand>,
+}
+
+impl FxaTelemetry {
+ pub fn new() -> Self {
+ FxaTelemetry {
+ ..Default::default()
+ }
+ }
+
+ pub fn record_tab_sent(&mut self, sent: SentCommand) {
+ if self.commands_sent.len() < MAX_TAB_EVENTS {
+ self.commands_sent.push(sent);
+ }
+ }
+
+ pub fn record_tab_received(&mut self, recd: ReceivedCommand) {
+ if self.commands_received.len() < MAX_TAB_EVENTS {
+ self.commands_received.push(recd);
+ }
+ }
+}