diff --git a/CHANGELOG.md b/CHANGELOG.md index a39a1c87b2..a7d9241ce4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ [Full Changelog](In progress) +### Ads Client +- Add agnostic telemetry support (compatible with Glean) + # v147.0 (_2025-12-07_) ### Relay diff --git a/automation/build_ios_artifacts.sh b/automation/build_ios_artifacts.sh index 53a676cb16..f114a3c58c 100755 --- a/automation/build_ios_artifacts.sh +++ b/automation/build_ios_artifacts.sh @@ -11,6 +11,7 @@ export PROJECT=MozillaRustComponentsWrapper ./tools/sdk_generator.sh \ -g Glean \ -o ./megazords/ios-rust/Sources/MozillaRustComponentsWrapper/Generated/Glean \ + "${SOURCE_ROOT}"/components/ads-client/metrics.yaml \ "${SOURCE_ROOT}"/components/nimbus/metrics.yaml \ "${SOURCE_ROOT}"/components/logins/metrics.yaml \ "${SOURCE_ROOT}"/components/sync_manager/metrics.yaml \ diff --git a/components/ads-client/android/build.gradle b/components/ads-client/android/build.gradle index 08a0ad71c3..8974a9c868 100644 --- a/components/ads-client/android/build.gradle +++ b/components/ads-client/android/build.gradle @@ -1,12 +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.ads_client' } dependencies { api project(":httpconfig") + + implementation libs.mozilla.glean + + testImplementation libs.mozilla.glean.forUnitTests } ext.configureUniFFIBindgen("ads_client") diff --git a/components/ads-client/docs/usage.md b/components/ads-client/docs/usage.md index 29bc12c687..cfd34c3a08 100644 --- a/components/ads-client/docs/usage.md +++ b/components/ads-client/docs/usage.md @@ -58,13 +58,93 @@ Configuration for initializing the ads client. pub struct MozAdsClientConfig { pub environment: Environment, pub cache_config: Option, + pub telemetry: Option>, } ``` -| Field | Type | Description | -| -------------- | --------------------------- | ------------------------------------------------------------------------------------------------------ | -| `environment` | `Environment` | Selects which MARS environment to connect to. Unless in a dev build, this value can only ever be Prod. | -| `cache_config` | `Option` | Optional configuration for the internal cache. | +| Field | Type | Description | +| -------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------ | +| `environment` | `Environment` | Selects which MARS environment to connect to. Unless in a dev build, this value can only ever be Prod. | +| `cache_config` | `Option` | Optional configuration for the internal cache. | +| `telemetry` | `Option>` | Optional telemetry instance for recording metrics. If not provided, a no-op implementation is used. | + +--- + +## `MozAdsTelemetry` + +Telemetry interface for recording ads client metrics. You must provide an implementation of this interface to the `MozAdsClientConfig` constructor to enable telemetry collection. If no telemetry instance is provided, a no-op implementation is used and no metrics will be recorded. + +```rust +pub trait MozAdsTelemetry: Send + Sync { + fn record_build_cache_error(&self, label: String, value: String); + fn record_client_error(&self, label: String, value: String); + fn record_client_operation_total(&self, label: String); + fn record_deserialization_error(&self, label: String, value: String); + fn record_http_cache_outcome(&self, label: String, value: String); +} +``` + +### Implementing Telemetry + +To enable telemetry collection, you need to implement the `MozAdsTelemetry` interface and provide an instance to the `MozAdsClientConfig` constructor. The following examples show how to bind Glean metrics to the telemetry interface. + +#### Swift Example + +```swift +import MozillaRustComponents +import Glean + +public final class AdsClientTelemetry: MozAdsTelemetry { + public func recordBuildCacheError(label: String, value: String) { + AdsClientMetrics.buildCacheError[label].set(value) + } + + public func recordClientError(label: String, value: String) { + AdsClientMetrics.clientError[label].set(value) + } + + public func recordClientOperationTotal(label: String) { + AdsClientMetrics.clientOperationTotal[label].add() + } + + public func recordDeserializationError(label: String, value: String) { + AdsClientMetrics.deserializationError[label].set(value) + } + + public func recordHttpCacheOutcome(label: String, value: String) { + AdsClientMetrics.httpCacheOutcome[label].set(value) + } +} +``` + +#### Kotlin Example + +```kotlin +import mozilla.appservices.adsclient.MozAdsTelemetry +import org.mozilla.appservices.ads_client.GleanMetrics.AdsClient + +class AdsClientTelemetry : MozAdsTelemetry { + override fun recordBuildCacheError(label: String, value: String) { + AdsClient.buildCacheError[label].set(value) + } + + override fun recordClientError(label: String, value: String) { + AdsClient.clientError[label].set(value) + } + + override fun recordClientOperationTotal(label: String) { + AdsClient.clientOperationTotal[label].add() + } + + override fun recordDeserializationError(label: String, value: String) { + AdsClient.deserializationError[label].set(value) + } + + override fun recordHttpCacheOutcome(label: String, value: String) { + AdsClient.httpCacheOutcome[label].set(value) + } +} +``` --- @@ -400,21 +480,40 @@ If the effective TTL resolves to 0 seconds, the response is not cached. #### Example Client Configuration -```rust -// Swift / Kotlin pseudocode +```swift +// Swift example let cache = MozAdsCacheConfig( dbPath: "/tmp/ads_cache.sqlite", defaultCacheTtlSeconds: 600, // 10 min maxSizeMib: 20 // 20 MiB ) +let telemetry = AdsClientTelemetry() let clientCfg = MozAdsClientConfig( environment: .prod, - cacheConfig: cache + cacheConfig: cache, + telemetry: telemetry +) + +let client = MozAdsClient(clientConfig: clientCfg) +``` + +```kotlin +// Kotlin example +val cache = MozAdsCacheConfig( + dbPath = "/tmp/ads_cache.sqlite", + defaultCacheTtlSeconds = 600L, // 10 min + maxSizeMib = 20L // 20 MiB ) -let client = MozAdsClient.new(clientConfig: clientCfg) +val telemetry = AdsClientTelemetry() +val clientCfg = MozAdsClientConfig( + environment = MozAdsEnvironment.PROD, + cacheConfig = cache, + telemetry = telemetry +) +val client = MozAdsClient(clientCfg) ``` Where `db_path` represents the location of the SQLite file. This must be a file that the client has permission to write to. diff --git a/components/ads-client/metrics.yaml b/components/ads-client/metrics.yaml new file mode 100644 index 0000000000..a97f35400e --- /dev/null +++ b/components/ads-client/metrics.yaml @@ -0,0 +1,127 @@ +# 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/. + + +--- +$schema: moz://mozilla.org/schemas/glean/metrics/2-0-0 + + +ads_client: + build_cache_error: + type: labeled_string + description: > + Errors encountered when building the HTTP cache, labeled by error type. + The string value contains the error message or error type. + labels: + - builder_error + - database_error + - empty_db_path + - invalid_max_size + - invalid_ttl + bugs: + - https://github.com/mozilla/application-services/pull/7111 + data_reviews: + - https://github.com/mozilla/application-services/pull/7111 + data_sensitivity: + - technical + notification_emails: + - ahanot@mozilla.com + - llisi@mozilla.com + - toast@mozilla.com + expires: never + + client_error: + type: labeled_string + description: > + Errors encountered when using the ads client, labeled by operation type. + The string value contains the error message or error type. Errors are + recorded even if they are propagated to the consumer. + labels: + - record_click + - record_impression + - report_ad + - request_ads + bugs: + - https://github.com/mozilla/application-services/pull/7111 + data_reviews: + - https://github.com/mozilla/application-services/pull/7111 + data_sensitivity: + - interaction + notification_emails: + - ahanot@mozilla.com + - llisi@mozilla.com + - toast@mozilla.com + expires: never + + client_operation_total: + type: labeled_counter + description: > + The total number of operations attempted by the ads client, labeled by + operation type. Used as the denominator for client_operation_success_rate. + labels: + - new + - record_click + - record_impression + - report_ad + - request_ads + bugs: + - https://github.com/mozilla/application-services/pull/7111 + data_reviews: + - https://github.com/mozilla/application-services/pull/7111 + data_sensitivity: + - interaction + notification_emails: + - ahanot@mozilla.com + - llisi@mozilla.com + - toast@mozilla.com + expires: never + + deserialization_error: + type: labeled_string + description: > + Deserialization errors encountered when parsing AdResponse data, + labeled by error type. The string value contains the error message or + details. Invalid ad items are skipped but these errors are tracked for + monitoring data quality issues. + labels: + - invalid_ad_item + - invalid_array + - invalid_structure + bugs: + - https://github.com/mozilla/application-services/pull/7111 + data_reviews: + - https://github.com/mozilla/application-services/pull/7111 + data_sensitivity: + - technical + notification_emails: + - ahanot@mozilla.com + - llisi@mozilla.com + - toast@mozilla.com + expires: never + + http_cache_outcome: + type: labeled_string + description: > + The total number of outcomes encountered during read operations on the + http cache, labeled by type. The string value contains the error message or + error type. + labels: + - cleanup_failed + - hit + - lookup_failed + - miss_not_cacheable + - miss_stored + - no_cache + - store_failed + bugs: + - https://github.com/mozilla/application-services/pull/7111 + data_reviews: + - https://github.com/mozilla/application-services/pull/7111 + data_sensitivity: + - technical + notification_emails: + - ahanot@mozilla.com + - llisi@mozilla.com + - toast@mozilla.com + expires: never \ No newline at end of file diff --git a/components/ads-client/src/client.rs b/components/ads-client/src/client.rs index 21e2ea3dbb..cf27bbba10 100644 --- a/components/ads-client/src/client.rs +++ b/components/ads-client/src/client.rs @@ -13,6 +13,7 @@ use crate::client::config::AdsClientConfig; use crate::error::{RecordClickError, RecordImpressionError, ReportAdError, RequestAdsError}; use crate::http_cache::{HttpCache, RequestCachePolicy}; use crate::mars::MARSClient; +use crate::telemetry::Telemetry; use ad_request::{AdPlacementRequest, AdRequest}; use context_id::{ContextIDComponent, DefaultContextIdCallback}; use url::Url; @@ -27,23 +28,28 @@ pub mod config; const DEFAULT_TTL_SECONDS: u64 = 300; const DEFAULT_MAX_CACHE_SIZE_MIB: u64 = 10; -pub struct AdsClient { - client: MARSClient, +pub struct AdsClient +where + T: Clone + Telemetry, +{ + client: MARSClient, context_id_component: ContextIDComponent, + telemetry: T, } -impl AdsClient { - pub fn new(client_config: Option) -> Self { +impl AdsClient +where + T: Clone + Telemetry, +{ + pub fn new(client_config: AdsClientConfig) -> Self { let context_id = Uuid::new_v4().to_string(); - - let client_config = client_config.unwrap_or_default(); - let context_id_component = ContextIDComponent::new( &context_id, 0, cfg!(test), Box::new(DefaultContextIdCallback), ); + let telemetry = client_config.telemetry; // Configure the cache if a path is provided. // Defaults for ttl and cache size are also set if unspecified. @@ -56,52 +62,51 @@ impl AdsClient { let max_cache_size = ByteSize::mib(cache_cfg.max_size_mib.unwrap_or(DEFAULT_MAX_CACHE_SIZE_MIB)); - let http_cache = HttpCache::builder(cache_cfg.db_path) + let http_cache = match HttpCache::builder(cache_cfg.db_path) .max_size(max_cache_size) .default_ttl(default_cache_ttl) .build() - .ok(); // TODO: handle error with telemetry + { + Ok(cache) => Some(cache), + Err(e) => { + telemetry.record(&e); + None + } + }; - let client = MARSClient::new(client_config.environment, http_cache); - return Self { + let client = MARSClient::new(client_config.environment, http_cache, telemetry.clone()); + let client = Self { context_id_component, client, + telemetry: telemetry.clone(), }; + telemetry.record(&ClientOperationEvent::New); + return client; } - let client = MARSClient::new(client_config.environment, None); - Self { + let client = MARSClient::new(client_config.environment, None, telemetry.clone()); + let client = Self { context_id_component, client, - } - } - - #[cfg(test)] - pub fn new_with_mars_client(client: MARSClient) -> Self { - let context_id_component = ContextIDComponent::new( - &uuid::Uuid::new_v4().to_string(), - 0, - false, - Box::new(DefaultContextIdCallback), - ); - Self { - context_id_component, - client, - } + telemetry: telemetry.clone(), + }; + telemetry.record(&ClientOperationEvent::New); + client } - fn request_ads( + fn request_ads( &self, ad_placement_requests: Vec, options: Option, - ) -> Result, RequestAdsError> + ) -> Result, RequestAdsError> where - T: AdResponseValue, + A: AdResponseValue, { let context_id = self.get_context_id()?; let ad_request = AdRequest::build(context_id, ad_placement_requests)?; let cache_policy = options.unwrap_or_default(); - let (mut response, request_hash) = self.client.fetch_ads(&ad_request, &cache_policy)?; + let (mut response, request_hash) = + self.client.fetch_ads::(&ad_request, &cache_policy)?; response.add_request_hash_to_callbacks(&request_hash); Ok(response) } @@ -111,7 +116,12 @@ impl AdsClient { ad_placement_requests: Vec, options: Option, ) -> Result, RequestAdsError> { - let response = self.request_ads::(ad_placement_requests, options)?; + let response = self + .request_ads::(ad_placement_requests, options) + .inspect_err(|e| { + self.telemetry.record(e); + })?; + self.telemetry.record(&ClientOperationEvent::RequestAds); Ok(response.take_first()) } @@ -120,8 +130,15 @@ impl AdsClient { ad_placement_requests: Vec, options: Option, ) -> Result>, RequestAdsError> { - let response = self.request_ads::(ad_placement_requests, options)?; - Ok(response.data) + let result = self.request_ads::(ad_placement_requests, options); + result + .inspect_err(|e| { + self.telemetry.record(e); + }) + .map(|response| { + self.telemetry.record(&ClientOperationEvent::RequestAds); + response.data + }) } pub fn request_tile_ads( @@ -129,8 +146,15 @@ impl AdsClient { ad_placement_requests: Vec, options: Option, ) -> Result, RequestAdsError> { - let response = self.request_ads::(ad_placement_requests, options)?; - Ok(response.take_first()) + let result = self.request_ads::(ad_placement_requests, options); + result + .inspect_err(|e| { + self.telemetry.record(e); + }) + .map(|response| { + self.telemetry.record(&ClientOperationEvent::RequestAds); + response.take_first() + }) } pub fn record_impression(&self, impression_url: Url) -> Result<(), RecordImpressionError> { @@ -138,7 +162,15 @@ impl AdsClient { if let Some(request_hash) = pop_request_hash_from_url(&mut impression_url) { let _ = self.client.invalidate_cache_by_hash(&request_hash); } - self.client.record_impression(impression_url) + self.client + .record_impression(impression_url) + .inspect_err(|e| { + self.telemetry.record(e); + }) + .inspect(|_| { + self.telemetry + .record(&ClientOperationEvent::RecordImpression); + }) } pub fn record_click(&self, click_url: Url) -> Result<(), RecordClickError> { @@ -146,12 +178,25 @@ impl AdsClient { if let Some(request_hash) = pop_request_hash_from_url(&mut click_url) { let _ = self.client.invalidate_cache_by_hash(&request_hash); } - self.client.record_click(click_url) + self.client + .record_click(click_url) + .inspect_err(|e| { + self.telemetry.record(e); + }) + .inspect(|_| { + self.telemetry.record(&ClientOperationEvent::RecordClick); + }) } pub fn report_ad(&self, report_url: Url) -> Result<(), ReportAdError> { - self.client.report_ad(report_url)?; - Ok(()) + self.client + .report_ad(report_url) + .inspect_err(|e| { + self.telemetry.record(e); + }) + .inspect(|_| { + self.telemetry.record(&ClientOperationEvent::ReportAd); + }) } pub fn get_context_id(&self) -> context_id::ApiResult { @@ -169,10 +214,20 @@ impl AdsClient { } } +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ClientOperationEvent { + New, + RecordClick, + RecordImpression, + ReportAd, + RequestAds, +} + #[cfg(test)] mod tests { use crate::{ client::config::Environment, + ffi::telemetry::MozAdsTelemetryWrapper, test_utils::{ get_example_happy_image_response, get_example_happy_spoc_response, get_example_happy_uatile_response, make_happy_placement_requests, @@ -181,16 +236,42 @@ mod tests { use super::*; + fn new_with_mars_client( + client: MARSClient, + ) -> AdsClient { + let context_id_component = ContextIDComponent::new( + &uuid::Uuid::new_v4().to_string(), + 0, + false, + Box::new(DefaultContextIdCallback), + ); + AdsClient { + context_id_component, + client, + telemetry: MozAdsTelemetryWrapper::noop(), + } + } + #[test] fn test_get_context_id() { - let client = AdsClient::new(None); + let config = AdsClientConfig { + environment: Environment::Test, + cache_config: None, + telemetry: MozAdsTelemetryWrapper::noop(), + }; + let client = AdsClient::new(config); let context_id = client.get_context_id().unwrap(); assert!(!context_id.is_empty()); } #[test] fn test_cycle_context_id() { - let mut client = AdsClient::new(None); + let config = AdsClientConfig { + environment: Environment::Test, + cache_config: None, + telemetry: MozAdsTelemetryWrapper::noop(), + }; + let mut client = AdsClient::new(config); let old_id = client.get_context_id().unwrap(); let previous_id = client.cycle_context_id().unwrap(); assert_eq!(previous_id, old_id); @@ -206,11 +287,12 @@ mod tests { let _m = mockito::mock("POST", "/ads") .with_status(200) .with_header("content-type", "application/json") - .with_body(serde_json::to_string(&expected_response).unwrap()) + .with_body(serde_json::to_string(&expected_response.data).unwrap()) .create(); - let mars_client = MARSClient::new(Environment::Test, None); - let ads_client = AdsClient::new_with_mars_client(mars_client); + let telemetry = MozAdsTelemetryWrapper::noop(); + let mars_client = MARSClient::new(Environment::Test, None, telemetry); + let ads_client = new_with_mars_client(mars_client); let ad_placement_requests = make_happy_placement_requests(); @@ -227,11 +309,12 @@ mod tests { let _m = mockito::mock("POST", "/ads") .with_status(200) .with_header("content-type", "application/json") - .with_body(serde_json::to_string(&expected_response).unwrap()) + .with_body(serde_json::to_string(&expected_response.data).unwrap()) .create(); - let mars_client = MARSClient::new(Environment::Test, None); - let ads_client = AdsClient::new_with_mars_client(mars_client); + let telemetry = MozAdsTelemetryWrapper::noop(); + let mars_client = MARSClient::new(Environment::Test, None, telemetry); + let ads_client = new_with_mars_client(mars_client); let ad_placement_requests = make_happy_placement_requests(); @@ -248,11 +331,12 @@ mod tests { let _m = mockito::mock("POST", "/ads") .with_status(200) .with_header("content-type", "application/json") - .with_body(serde_json::to_string(&expected_response).unwrap()) + .with_body(serde_json::to_string(&expected_response.data).unwrap()) .create(); - let mars_client = MARSClient::new(Environment::Test, None); - let ads_client = AdsClient::new_with_mars_client(mars_client); + let telemetry = MozAdsTelemetryWrapper::noop(); + let mars_client = MARSClient::new(Environment::Test, None, telemetry.clone()); + let ads_client = new_with_mars_client(mars_client); let ad_placement_requests = make_happy_placement_requests(); @@ -267,15 +351,16 @@ mod tests { let cache = HttpCache::builder("test_record_click_invalidates_cache") .build() .unwrap(); - let mars_client = MARSClient::new(Environment::Test, Some(cache)); - let ads_client = AdsClient::new_with_mars_client(mars_client); + let telemetry = MozAdsTelemetryWrapper::noop(); + let mars_client = MARSClient::new(Environment::Test, Some(cache), telemetry.clone()); + let ads_client = new_with_mars_client(mars_client); let response = get_example_happy_image_response(); let _m1 = mockito::mock("POST", "/ads") .with_status(200) .with_header("content-type", "application/json") - .with_body(serde_json::to_string(&response).unwrap()) + .with_body(serde_json::to_string(&response.data).unwrap()) .expect(2) // we expect 2 requests to the server, one for the initial ad request and one after for the cache invalidation request .create(); diff --git a/components/ads-client/src/client/ad_response.rs b/components/ads-client/src/client/ad_response.rs index 2b91f6fddf..24bf6ffd0e 100644 --- a/components/ads-client/src/client/ad_response.rs +++ b/components/ads-client/src/client/ad_response.rs @@ -4,19 +4,45 @@ */ use crate::http_cache::RequestHash; +use crate::telemetry::Telemetry; use serde::de::DeserializeOwned; -use serde::Deserializer; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use url::Url; -#[derive(Debug, Deserialize, PartialEq, Serialize)] -pub struct AdResponse { - #[serde(deserialize_with = "deserialize_ad_response", flatten)] - pub data: HashMap>, +#[derive(Debug, PartialEq, Serialize)] +pub struct AdResponse { + pub data: HashMap>, } -impl AdResponse { +impl AdResponse { + pub fn parse( + data: serde_json::Value, + telemetry: &T, + ) -> Result, serde_json::Error> { + let raw: HashMap = serde_json::from_value(data)?; + let mut result = HashMap::new(); + + for (key, value) in raw { + if let serde_json::Value::Array(arr) = value { + let mut ads: Vec = vec![]; + for item in arr { + match serde_json::from_value::(item.clone()) { + Ok(ad) => ads.push(ad), + Err(e) => { + telemetry.record(&e); + } + } + } + if !ads.is_empty() { + result.insert(key, ads); + } + } + } + + Ok(AdResponse { data: result }) + } + pub fn add_request_hash_to_callbacks(&mut self, request_hash: &RequestHash) { for ads in self.data.values_mut() { for ad in ads.iter_mut() { @@ -34,7 +60,7 @@ impl AdResponse { } } - pub fn take_first(self) -> HashMap { + pub fn take_first(self) -> HashMap { self.data .into_iter() .filter_map(|(k, mut v)| { @@ -69,37 +95,6 @@ pub fn pop_request_hash_from_url(url: &mut Url) -> Option { request_hash } -fn deserialize_ad_response<'de, D, T>(deserializer: D) -> Result>, D::Error> -where - D: Deserializer<'de>, - T: AdResponseValue, -{ - let raw = HashMap::::deserialize(deserializer)?; - let mut result = HashMap::new(); - - for (key, value) in raw { - if let serde_json::Value::Array(arr) = value { - let mut ads: Vec = vec![]; - for item in arr { - if let Ok(ad) = serde_json::from_value::(item.clone()) { - ads.push(ad); - } else { - #[cfg(not(test))] - { - use crate::instrument::{emit_telemetry_event, TelemetryEvent}; - let _ = emit_telemetry_event(Some(TelemetryEvent::InvalidUrlError)); - } - } - } - if !ads.is_empty() { - result.insert(key, ads); - } - } - } - - Ok(result) -} - #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] pub struct AdImage { pub alt_text: Option, @@ -180,6 +175,8 @@ impl AdResponseValue for AdTile { #[cfg(test)] mod tests { + use crate::ffi::telemetry::MozAdsTelemetryWrapper; + use super::*; use serde_json::{from_str, json}; @@ -318,10 +315,10 @@ mod tests { } } ] - }) - .to_string(); + }); - let parsed: AdResponse = from_str(&raw_ad_response).unwrap(); + let parsed = + AdResponse::::parse(raw_ad_response, &MozAdsTelemetryWrapper::noop()).unwrap(); let expected = AdResponse { data: HashMap::from([( @@ -355,10 +352,10 @@ mod tests { let raw_ad_response = json!({ "example_placement_1": [], "example_placement_2": [] - }) - .to_string(); + }); - let parsed: AdResponse = from_str(&raw_ad_response).unwrap(); + let parsed = + AdResponse::::parse(raw_ad_response, &MozAdsTelemetryWrapper::noop()).unwrap(); let expected = AdResponse { data: HashMap::from([]), diff --git a/components/ads-client/src/client/config.rs b/components/ads-client/src/client/config.rs index 75c6e3504a..ad637b4bb5 100644 --- a/components/ads-client/src/client/config.rs +++ b/components/ads-client/src/client/config.rs @@ -6,6 +6,8 @@ use once_cell::sync::Lazy; use url::Url; +use crate::telemetry::Telemetry; + static MARS_API_ENDPOINT_PROD: Lazy = Lazy::new(|| Url::parse("https://ads.mozilla.org/v1/").expect("hardcoded URL must be valid")); @@ -13,10 +15,13 @@ static MARS_API_ENDPOINT_PROD: Lazy = static MARS_API_ENDPOINT_STAGING: Lazy = Lazy::new(|| Url::parse("https://ads.allizom.org/v1/").expect("hardcoded URL must be valid")); -#[derive(Default)] -pub struct AdsClientConfig { +pub struct AdsClientConfig +where + T: Telemetry, +{ pub environment: Environment, pub cache_config: Option, + pub telemetry: T, } #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] diff --git a/components/ads-client/src/error.rs b/components/ads-client/src/error.rs index e8e53ed62d..a041286186 100644 --- a/components/ads-client/src/error.rs +++ b/components/ads-client/src/error.rs @@ -57,21 +57,6 @@ pub enum FetchAdsError { HTTPError(#[from] HTTPError), } -#[derive(Debug, thiserror::Error)] -pub enum EmitTelemetryError { - #[error("URL parse error: {0}")] - UrlParse(#[from] url::ParseError), - - #[error("Error sending request: {0}")] - Request(#[from] viaduct::ViaductError), - - #[error("JSON error: {0}")] - Json(#[from] serde_json::Error), - - #[error("Could not fetch ads, MARS responded with: {0}")] - HTTPError(#[from] HTTPError), -} - #[derive(Debug, thiserror::Error)] pub enum CallbackRequestError { #[error("Could not fetch ads, MARS responded with: {0}")] diff --git a/components/ads-client/src/ffi.rs b/components/ads-client/src/ffi.rs index 051c9f33c5..992a50ab98 100644 --- a/components/ads-client/src/ffi.rs +++ b/components/ads-client/src/ffi.rs @@ -3,18 +3,25 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +pub mod telemetry; + +use std::sync::Arc; + use crate::client::ad_request::{AdContentCategory, AdPlacementRequest, IABContentTaxonomy}; use crate::client::ad_response::{ AdCallbacks, AdImage, AdSpoc, AdTile, SpocFrequencyCaps, SpocRanking, }; use crate::client::config::{AdsCacheConfig, AdsClientConfig, Environment}; use crate::error::ComponentError; +use crate::ffi::telemetry::MozAdsTelemetryWrapper; use crate::http_cache::{CacheMode, RequestCachePolicy}; use error_support::{ErrorHandling, GetErrorHandling}; use url::Url; pub type AdsClientApiResult = std::result::Result; +pub use telemetry::MozAdsTelemetry; + #[derive(Debug, thiserror::Error, uniffi::Error)] pub enum MozAdsClientApiError { #[error("Something unexpected occurred.")] @@ -85,6 +92,21 @@ pub struct MozAdsCallbacks { pub struct MozAdsClientConfig { pub environment: MozAdsEnvironment, pub cache_config: Option, + pub telemetry: Option>, +} + +impl From for AdsClientConfig { + fn from(config: MozAdsClientConfig) -> Self { + let telemetry = config + .telemetry + .map(MozAdsTelemetryWrapper::new) + .unwrap_or_else(MozAdsTelemetryWrapper::noop); + Self { + environment: config.environment.into(), + cache_config: config.cache_config.map(Into::into), + telemetry, + } + } } #[derive(Clone, Copy, Debug, Default, uniffi::Enum, Eq, PartialEq)] @@ -361,15 +383,6 @@ impl From> for RequestCachePolicy { } } -impl From for AdsClientConfig { - fn from(config: MozAdsClientConfig) -> Self { - Self { - environment: config.environment.into(), - cache_config: config.cache_config.map(Into::into), - } - } -} - impl From for AdsCacheConfig { fn from(config: MozAdsCacheConfig) -> Self { Self { diff --git a/components/ads-client/src/ffi/telemetry.rs b/components/ads-client/src/ffi/telemetry.rs new file mode 100644 index 0000000000..cf6b9af6ce --- /dev/null +++ b/components/ads-client/src/ffi/telemetry.rs @@ -0,0 +1,142 @@ +/* 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::any::Any; +use std::sync::Arc; + +use crate::client::ClientOperationEvent; +use crate::error::{RecordClickError, RecordImpressionError, ReportAdError, RequestAdsError}; +use crate::http_cache::{CacheOutcome, HttpCacheBuilderError}; +use crate::telemetry::Telemetry; + +#[uniffi::export(with_foreign)] +pub trait MozAdsTelemetry: Send + Sync { + fn record_build_cache_error(&self, label: String, value: String); + fn record_client_error(&self, label: String, value: String); + fn record_client_operation_total(&self, label: String); + fn record_deserialization_error(&self, label: String, value: String); + fn record_http_cache_outcome(&self, label: String, value: String); +} + +pub struct NoopMozAdsTelemetry; + +impl MozAdsTelemetry for NoopMozAdsTelemetry { + fn record_build_cache_error(&self, _label: String, _value: String) {} + fn record_client_error(&self, _label: String, _value: String) {} + fn record_client_operation_total(&self, _label: String) {} + fn record_deserialization_error(&self, _label: String, _value: String) {} + fn record_http_cache_outcome(&self, _label: String, _value: String) {} +} + +#[derive(Clone)] +pub struct MozAdsTelemetryWrapper { + inner: Arc, +} + +impl MozAdsTelemetryWrapper { + pub fn new(inner: Arc) -> Self { + Self { inner } + } + + pub fn noop() -> Self { + Self { + inner: Arc::new(NoopMozAdsTelemetry), + } + } +} + +impl Telemetry for MozAdsTelemetryWrapper { + fn record(&self, event: &dyn Any) { + if let Some(cache_outcome) = event.downcast_ref::() { + self.inner.record_http_cache_outcome( + match cache_outcome { + CacheOutcome::Hit => "hit".to_string(), + CacheOutcome::LookupFailed(_) => "lookup_failed".to_string(), + CacheOutcome::NoCache => "no_cache".to_string(), + CacheOutcome::MissNotCacheable => "miss_not_cacheable".to_string(), + CacheOutcome::MissStored => "miss_stored".to_string(), + CacheOutcome::StoreFailed(_) => "store_failed".to_string(), + CacheOutcome::CleanupFailed(_) => "cleanup_failed".to_string(), + }, + match cache_outcome { + CacheOutcome::LookupFailed(e) => e.to_string(), + CacheOutcome::StoreFailed(e) => e.to_string(), + CacheOutcome::CleanupFailed(e) => e.to_string(), + _ => "".to_string(), + }, + ); + return; + } + if let Some(client_op) = event.downcast_ref::() { + self.inner.record_client_operation_total(match client_op { + ClientOperationEvent::New => "new".to_string(), + ClientOperationEvent::RecordClick => "record_click".to_string(), + ClientOperationEvent::RecordImpression => "record_impression".to_string(), + ClientOperationEvent::ReportAd => "report_ad".to_string(), + ClientOperationEvent::RequestAds => "request_ads".to_string(), + }); + return; + } + if let Some(cache_builder_error) = event.downcast_ref::() { + self.inner.record_build_cache_error( + match cache_builder_error { + HttpCacheBuilderError::EmptyDbPath => "empty_db_path".to_string(), + HttpCacheBuilderError::Database(_) => "database_error".to_string(), + HttpCacheBuilderError::InvalidMaxSize { .. } => "invalid_max_size".to_string(), + HttpCacheBuilderError::InvalidTtl { .. } => "invalid_ttl".to_string(), + }, + format!("{}", cache_builder_error), + ); + return; + } + if let Some(record_click_error) = event.downcast_ref::() { + self.inner.record_client_error( + "record_click".to_string(), + format!("{}", record_click_error), + ); + return; + } + if let Some(record_impression_error) = event.downcast_ref::() { + self.inner.record_client_error( + "record_impression".to_string(), + format!("{}", record_impression_error), + ); + return; + } + if let Some(report_ad_error) = event.downcast_ref::() { + self.inner + .record_client_error("report_ad".to_string(), format!("{}", report_ad_error)); + return; + } + if let Some(request_ads_error) = event.downcast_ref::() { + self.inner + .record_client_error("request_ads".to_string(), format!("{}", request_ads_error)); + return; + } + if let Some(json_error) = event.downcast_ref::() { + self.inner.record_deserialization_error( + "invalid_ad_item".to_string(), + format!("{}", json_error), + ); + return; + } + eprintln!("Unsupported telemetry event type: {:?}", event.type_id()); + #[cfg(test)] + panic!("Unsupported telemetry event type: {:?}", event.type_id()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + #[should_panic] + fn test_panic_on_unsupported_event() { + struct UnsupportedEvent; + let telemetry = MozAdsTelemetryWrapper::noop(); + telemetry.record(&UnsupportedEvent); + } +} diff --git a/components/ads-client/src/http_cache.rs b/components/ads-client/src/http_cache.rs index 6e3525ac7a..a38bea2db6 100644 --- a/components/ads-client/src/http_cache.rs +++ b/components/ads-client/src/http_cache.rs @@ -14,6 +14,7 @@ use self::{builder::HttpCacheBuilder, cache_control::CacheControl, store::HttpCa use viaduct::{Request, Response}; +pub use self::builder::HttpCacheBuilderError; pub use self::bytesize::ByteSize; pub use self::request_hash::RequestHash; use std::cmp; @@ -83,7 +84,7 @@ impl CacheMode { #[derive(Debug, thiserror::Error)] pub enum HttpCacheError { #[error("Could not build cache: {0}")] - Builder(#[from] builder::Error), + Builder(#[from] builder::HttpCacheBuilderError), #[error("SQLite operation failed: {0}")] Sqlite(#[from] rusqlite::Error), diff --git a/components/ads-client/src/http_cache/builder.rs b/components/ads-client/src/http_cache/builder.rs index 44190dc9dc..9c6c8c9a87 100644 --- a/components/ads-client/src/http_cache/builder.rs +++ b/components/ads-client/src/http_cache/builder.rs @@ -21,7 +21,7 @@ const MIN_TTL: Duration = Duration::from_secs(1); const MAX_TTL: Duration = Duration::from_secs(60 * 60 * 24 * 7); // 7 days #[derive(Debug, thiserror::Error)] -pub enum Error { +pub enum HttpCacheBuilderError { #[error("Database path cannot be empty")] EmptyDbPath, #[error("Database error: {0}")] @@ -77,14 +77,14 @@ impl HttpCacheBuilder { self } - fn validate(&self) -> Result<(), Error> { + fn validate(&self) -> Result<(), HttpCacheBuilderError> { if self.db_path.to_string_lossy().trim().is_empty() { - return Err(Error::EmptyDbPath); + return Err(HttpCacheBuilderError::EmptyDbPath); } if let Some(max_size) = self.max_size { if max_size < MIN_CACHE_SIZE || max_size > MAX_CACHE_SIZE { - return Err(Error::InvalidMaxSize { + return Err(HttpCacheBuilderError::InvalidMaxSize { size_bytes: max_size.as_u64(), min_size: MIN_CACHE_SIZE.to_string(), max_size: MAX_CACHE_SIZE.to_string(), @@ -94,7 +94,7 @@ impl HttpCacheBuilder { if let Some(ttl) = self.default_ttl { if !(MIN_TTL..=MAX_TTL).contains(&ttl) { - return Err(Error::InvalidTtl { + return Err(HttpCacheBuilderError::InvalidTtl { ttl: ttl.as_secs(), min_ttl: format!("{} seconds", MIN_TTL.as_secs()), max_ttl: format!("{} seconds", MAX_TTL.as_secs()), @@ -105,7 +105,7 @@ impl HttpCacheBuilder { Ok(()) } - fn open_connection(&self) -> Result { + fn open_connection(&self) -> Result { let initializer = HttpCacheConnectionInitializer {}; let conn = if cfg!(test) { open_database::open_memory_database(&initializer)? @@ -115,7 +115,7 @@ impl HttpCacheBuilder { Ok(conn) } - pub fn build(&self) -> Result { + pub fn build(&self) -> Result { self.validate()?; let conn = self.open_connection()?; @@ -131,7 +131,7 @@ impl HttpCacheBuilder { } #[cfg(test)] - pub fn build_for_time_dependent_tests(&self) -> Result { + pub fn build_for_time_dependent_tests(&self) -> Result { self.validate()?; let conn = self.open_connection()?; @@ -177,7 +177,7 @@ mod tests { let builder = HttpCacheBuilder::new(" ".to_string()); let result = builder.build(); - assert!(matches!(result, Err(Error::EmptyDbPath))); + assert!(matches!(result, Err(HttpCacheBuilderError::EmptyDbPath))); } #[test] @@ -187,7 +187,7 @@ mod tests { let result = builder.build(); assert!(matches!( result, - Err(Error::InvalidMaxSize { + Err(HttpCacheBuilderError::InvalidMaxSize { size_bytes: 512, min_size: _, max_size: _, @@ -203,7 +203,7 @@ mod tests { let result = builder.build(); assert!(matches!( result, - Err(Error::InvalidMaxSize { + Err(HttpCacheBuilderError::InvalidMaxSize { size_bytes: 2147483648, min_size: _, max_size: _, @@ -228,7 +228,7 @@ mod tests { let result = builder.build(); assert!(matches!( result, - Err(Error::InvalidTtl { + Err(HttpCacheBuilderError::InvalidTtl { ttl: 0, min_ttl: _, max_ttl: _, @@ -244,7 +244,7 @@ mod tests { let result = builder.build(); assert!(matches!( result, - Err(Error::InvalidTtl { + Err(HttpCacheBuilderError::InvalidTtl { ttl: 691200, min_ttl: _, max_ttl: _, diff --git a/components/ads-client/src/instrument.rs b/components/ads-client/src/instrument.rs deleted file mode 100644 index 4695dd5687..0000000000 --- a/components/ads-client/src/instrument.rs +++ /dev/null @@ -1,65 +0,0 @@ -/* 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::sync::LazyLock; - -use crate::error::{ComponentError, EmitTelemetryError}; -use parking_lot::RwLock; -use serde::{Deserialize, Serialize}; -use url::Url; -use viaduct::Request; - -static DEFAULT_TELEMETRY_ENDPOINT: &str = "https://ads.mozilla.org/v1/log"; -static TELEMETRY_ENDPONT: LazyLock> = - LazyLock::new(|| RwLock::new(DEFAULT_TELEMETRY_ENDPOINT.to_string())); - -fn get_telemetry_endpoint() -> String { - TELEMETRY_ENDPONT.read().clone() -} - -#[derive(Debug, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum TelemetryEvent { - Init, - RenderError, - AdLoadError, - FetchError, - InvalidUrlError, -} - -pub trait TrackError { - fn emit_telemetry_if_error(self) -> Self; -} - -impl TrackError for Result { - /// Attempts to emit a telemetry event if the Error type can map to an event type. - fn emit_telemetry_if_error(self) -> Self { - if let Err(ref err) = self { - let error_type = map_error_to_event_type(err); - let _ = emit_telemetry_event(error_type); - } - self - } -} - -fn map_error_to_event_type(err: &ComponentError) -> Option { - match err { - ComponentError::RequestAds(_) => Some(TelemetryEvent::FetchError), - ComponentError::RecordImpression(_) => Some(TelemetryEvent::InvalidUrlError), - ComponentError::RecordClick(_) => Some(TelemetryEvent::InvalidUrlError), - ComponentError::ReportAd(_) => Some(TelemetryEvent::InvalidUrlError), - } -} - -pub fn emit_telemetry_event(event_type: Option) -> Result<(), EmitTelemetryError> { - let endpoint = get_telemetry_endpoint(); - let mut url = Url::parse(&endpoint)?; - if let Some(event) = event_type { - let event_string = serde_json::to_string(&event)?; - url.set_query(Some(&format!("event={}", event_string))); - Request::get(url).send()?; - } - Ok(()) -} diff --git a/components/ads-client/src/lib.rs b/components/ads-client/src/lib.rs index 93c67a39ee..06d5d07b72 100644 --- a/components/ads-client/src/lib.rs +++ b/components/ads-client/src/lib.rs @@ -13,17 +13,19 @@ use url::Url as AdsClientUrl; use client::ad_request::AdPlacementRequest; use client::AdsClient; use http_cache::RequestCachePolicy; -use instrument::TrackError; mod client; mod error; mod ffi; pub mod http_cache; -mod instrument; mod mars; +pub mod telemetry; pub use ffi::*; +use crate::client::config::AdsClientConfig; +use crate::ffi::telemetry::MozAdsTelemetryWrapper; + #[cfg(test)] mod test_utils; @@ -37,15 +39,17 @@ uniffi::custom_type!(AdsClientUrl, String, { #[derive(uniffi::Object)] pub struct MozAdsClient { - inner: Mutex, + inner: Mutex>, } #[uniffi::export] impl MozAdsClient { #[uniffi::constructor] pub fn new(client_config: Option) -> Self { - let config = client_config.map(Into::into); + let client_config = client_config.unwrap_or_default(); + let client_config: AdsClientConfig = client_config.into(); + let client = AdsClient::new(client_config); Self { - inner: Mutex::new(AdsClient::new(config)), + inner: Mutex::new(client), } } @@ -106,7 +110,6 @@ impl MozAdsClient { inner .record_impression(url) .map_err(ComponentError::RecordImpression) - .emit_telemetry_if_error() } #[handle_error(ComponentError)] @@ -114,10 +117,7 @@ impl MozAdsClient { let url = AdsClientUrl::parse(&click_url) .map_err(|e| ComponentError::RecordClick(CallbackRequestError::InvalidUrl(e).into()))?; let inner = self.inner.lock(); - inner - .record_click(url) - .map_err(ComponentError::RecordClick) - .emit_telemetry_if_error() + inner.record_click(url).map_err(ComponentError::RecordClick) } #[handle_error(ComponentError)] @@ -125,10 +125,7 @@ impl MozAdsClient { let url = AdsClientUrl::parse(&report_url) .map_err(|e| ComponentError::ReportAd(CallbackRequestError::InvalidUrl(e).into()))?; let inner = self.inner.lock(); - inner - .report_ad(url) - .map_err(ComponentError::ReportAd) - .emit_telemetry_if_error() + inner.report_ad(url).map_err(ComponentError::ReportAd) } pub fn cycle_context_id(&self) -> AdsClientApiResult { diff --git a/components/ads-client/src/mars.rs b/components/ads-client/src/mars.rs index 3522aeee2d..c05ce033be 100644 --- a/components/ads-client/src/mars.rs +++ b/components/ads-client/src/mars.rs @@ -13,24 +13,33 @@ use crate::{ check_http_status_for_error, CallbackRequestError, FetchAdsError, RecordClickError, RecordImpressionError, ReportAdError, }, - http_cache::{CacheOutcome, HttpCache, HttpCacheError, RequestHash}, + http_cache::{HttpCache, HttpCacheError, RequestHash}, + telemetry::Telemetry, RequestCachePolicy, }; use url::Url; use viaduct::Request; -pub struct MARSClient { +pub struct MARSClient +where + T: Telemetry, +{ endpoint: Url, http_cache: Option, + telemetry: T, } -impl MARSClient { - pub fn new(environment: Environment, http_cache: Option) -> Self { +impl MARSClient +where + T: Telemetry, +{ + pub fn new(environment: Environment, http_cache: Option, telemetry: T) -> Self { let endpoint = environment.into_mars_url().clone(); Self { endpoint, http_cache, + telemetry, } } @@ -44,38 +53,28 @@ impl MARSClient { &self.endpoint } - pub fn fetch_ads( + pub fn fetch_ads( &self, ad_request: &AdRequest, cache_policy: &RequestCachePolicy, - ) -> Result<(AdResponse, RequestHash), FetchAdsError> + ) -> Result<(AdResponse, RequestHash), FetchAdsError> where - T: AdResponseValue, + A: AdResponseValue, { let base = self.get_mars_endpoint(); let url = base.join("ads")?; let request = Request::post(url).json(ad_request); let request_hash = RequestHash::from(&request); - let response: AdResponse = if let Some(cache) = self.http_cache.as_ref() { + let response: AdResponse = if let Some(cache) = self.http_cache.as_ref() { let outcome = cache.send_with_policy(&request, cache_policy)?; - - // TODO: observe cache outcome for metrics/logging. - match &outcome.cache_outcome { - CacheOutcome::Hit => {} - CacheOutcome::LookupFailed(_err) => {} - CacheOutcome::NoCache => {} - CacheOutcome::MissNotCacheable => {} - CacheOutcome::MissStored => {} - CacheOutcome::StoreFailed(_err) => {} - CacheOutcome::CleanupFailed(_err) => {} - } + self.telemetry.record(&outcome.cache_outcome); check_http_status_for_error(&outcome.response)?; - outcome.response.json()? + AdResponse::::parse(outcome.response.json()?, &self.telemetry)? } else { let response = request.send()?; check_http_status_for_error(&response)?; - response.json()? + AdResponse::::parse(response.json()?, &self.telemetry)? }; Ok((response, request_hash)) } @@ -115,6 +114,7 @@ mod tests { use super::*; use crate::client::ad_response::AdImage; + use crate::ffi::telemetry::MozAdsTelemetryWrapper; use crate::test_utils::{get_example_happy_image_response, make_happy_ad_request}; use mockito::mock; @@ -124,7 +124,7 @@ mod tests { let _m = mock("GET", "/impression_callback_url") .with_status(200) .create(); - let client = MARSClient::new(Environment::Test, None); + let client = MARSClient::new(Environment::Test, None, MozAdsTelemetryWrapper::noop()); let url = Url::parse(&format!( "{}/impression_callback_url", &mockito::server_url() @@ -139,7 +139,7 @@ mod tests { viaduct_dev::init_backend_dev(); let _m = mock("GET", "/click_callback_url").with_status(200).create(); - let client = MARSClient::new(Environment::Test, None); + let client = MARSClient::new(Environment::Test, None, MozAdsTelemetryWrapper::noop()); let url = Url::parse(&format!("{}/click_callback_url", &mockito::server_url())).unwrap(); let result = client.record_click(url); assert!(result.is_ok()); @@ -152,7 +152,7 @@ mod tests { .with_status(200) .create(); - let client = MARSClient::new(Environment::Test, None); + let client = MARSClient::new(Environment::Test, None, MozAdsTelemetryWrapper::noop()); let url = Url::parse(&format!( "{}/report_ad_callback_url", &mockito::server_url() @@ -171,10 +171,10 @@ mod tests { .match_header("content-type", "application/json") .with_status(200) .with_header("content-type", "application/json") - .with_body(serde_json::to_string(&expected_response).unwrap()) + .with_body(serde_json::to_string(&expected_response.data).unwrap()) .create(); - let client = MARSClient::new(Environment::Test, None); + let client = MARSClient::new(Environment::Test, None, MozAdsTelemetryWrapper::noop()); let ad_request = make_happy_ad_request(); @@ -191,11 +191,11 @@ mod tests { let _m = mock("POST", "/ads") .with_status(200) .with_header("content-type", "application/json") - .with_body(serde_json::to_string(&expected).unwrap()) + .with_body(serde_json::to_string(&expected.data).unwrap()) .expect(1) // only first request goes to network .create(); - let client = MARSClient::new(Environment::Test, None); + let client = MARSClient::new(Environment::Test, None, MozAdsTelemetryWrapper::noop()); let ad_request = make_happy_ad_request(); // First call should be a miss then warm the cache @@ -213,7 +213,7 @@ mod tests { #[test] fn default_client_uses_prod_url() { - let client = MARSClient::new(Environment::Prod, None); + let client = MARSClient::new(Environment::Prod, None, MozAdsTelemetryWrapper::noop()); assert_eq!( client.get_mars_endpoint().as_str(), "https://ads.mozilla.org/v1/" @@ -229,7 +229,11 @@ mod tests { .build() .unwrap(); - let client = MARSClient::new(Environment::Test, Some(cache)); + let client = MARSClient::new( + Environment::Test, + Some(cache), + MozAdsTelemetryWrapper::noop(), + ); let callback_url = Url::parse(&format!("{}/click", mockito::server_url())).unwrap(); @@ -248,7 +252,11 @@ mod tests { .build() .unwrap(); - let client = MARSClient::new(Environment::Test, Some(cache)); + let client = MARSClient::new( + Environment::Test, + Some(cache), + MozAdsTelemetryWrapper::noop(), + ); let callback_url = Url::parse(&format!("{}/impression", mockito::server_url())).unwrap(); diff --git a/components/ads-client/src/telemetry.rs b/components/ads-client/src/telemetry.rs new file mode 100644 index 0000000000..b4b9d49805 --- /dev/null +++ b/components/ads-client/src/telemetry.rs @@ -0,0 +1,10 @@ +/* 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::any::Any; + +pub trait Telemetry { + fn record(&self, event: &dyn Any); +} diff --git a/taskcluster/scripts/build-and-test-swift.py b/taskcluster/scripts/build-and-test-swift.py index 03e435fa03..9e9b281225 100755 --- a/taskcluster/scripts/build-and-test-swift.py +++ b/taskcluster/scripts/build-and-test-swift.py @@ -105,6 +105,7 @@ def generate_glean_metrics(args): firefox_glean_files = map( str, [ + ROOT_DIR / "components/ads-client/metrics.yaml", ROOT_DIR / "components/nimbus/metrics.yaml", ROOT_DIR / "components/logins/metrics.yaml", ROOT_DIR / "components/sync_manager/metrics.yaml",