Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
- Telemetry is now a process-wide singleton internally; the binding API is unchanged, but the `MozAdsTelemetry` callback now receives metrics from every client in the process.
- `request_spoc_or_image_ads` is added to return ads that may ambiguously be spocs or images.
- Added support for relevance scores ([AC-205](https://mozilla-hub.atlassian.net/browse/AC-205)) ([AC-206](https://mozilla-hub.atlassian.net/browse/AC-206)) Accepts a HashMap<String, f64>.
- The `DELETE /v1/delete_user` request for a retired context id is now sent by the ads client right after an ad request, over that request's OHTTP channel, instead of as a direct request from the `context_id` component. When the ad request did not use OHTTP the deletion is skipped and logged, so consumers that do not configure the `ads-client` OHTTP channel no longer send it. No binding API change. ([AC-179](https://mozilla-hub.atlassian.net/browse/AC-179))

### Autofill

Expand Down
4 changes: 2 additions & 2 deletions components/ads-client/docs/usage-javascript.md
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ Options passed when making a single ad request.
* @typedef {Object} MozAdsRequestOptions
* @property {MozAdsRequestCachePolicy|null} cachePolicy - Per-request caching policy.
* @property {Object.<string, boolean>} flags - Request-level flags forwarded as the `flags` object on the wire. An empty object omits it.
* @property {boolean} ohttp - Whether to route this request through OHTTP (default: false).
* @property {boolean} ohttp - Whether to route this request through OHTTP; the context id deletion request that may follow it uses the same setting and is skipped without OHTTP (default: false).
* @property {Object.<string, boolean>} relevance - Per client calculated relevance scores. An empty object omits it. Defaults to `{}`.
*/
```
Expand All @@ -256,7 +256,7 @@ Options passed when making a single ad request.
| ------------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `cachePolicy` | `MozAdsRequestCachePolicy \| null` | Per-request caching policy. If `null`, uses the client's default TTL with a `CacheFirst` mode. |
| `flags` | `Object.<string, boolean>` | Request-level flags forwarded verbatim as the `flags` object on the wire. An empty object omits it. e.g. `{ contextual_placement: true }`. Defaults to `{}`. |
| `ohttp` | `boolean` | Whether to route this request through OHTTP. Defaults to `false`. |
| `ohttp` | `boolean` | Whether to route this request through OHTTP. The context id deletion request that may follow it uses the same setting and is skipped without OHTTP. Defaults to `false`. |
| `relevance` | `Object.<string, number>` | Per client calculated relevance scores. An empty object omits it. Defaults to `{}`.

---
Expand Down
2 changes: 1 addition & 1 deletion components/ads-client/docs/usage-kotlin.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@ data class MozAdsRequestOptions(
| ------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `cachePolicy` | `MozAdsRequestCachePolicy?` | Per-request caching policy. If `null`, uses the client's default TTL with a `CacheFirst` mode. |
| `flags` | `Map<String, Boolean>` | Request-level flags forwarded verbatim as the `flags` object on the wire. An empty map omits it. e.g. `mapOf("contextual_placement" to true)`. Defaults to `emptyMap()`. |
| `ohttp` | `Boolean` | Whether to route this request through OHTTP. Defaults to `false`. |
| `ohttp` | `Boolean` | Whether to route this request through OHTTP. The context id deletion request that may follow it uses the same setting and is skipped without OHTTP. Defaults to `false`. |
| `relevance` | `Map<String, Double>` | Per client calculated relevance scores. An empty object omits it. Defaults to `emptyMap()``. |

---
Expand Down
2 changes: 1 addition & 1 deletion components/ads-client/docs/usage-swift.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@ struct MozAdsRequestOptions {
| ------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `cachePolicy` | `MozAdsRequestCachePolicy?` | Per-request caching policy. If `nil`, uses the client's default TTL with a `cacheFirst` mode. |
| `flags` | `[String: Bool]` | Request-level flags forwarded verbatim as the `flags` object on the wire. An empty dictionary omits it. e.g. `["contextual_placement": true]`. Defaults to `[:]`. |
| `ohttp` | `Bool` | Whether to route this request through OHTTP. Defaults to `false`. |
| `ohttp` | `Bool` | Whether to route this request through OHTTP. The context id deletion request that may follow it uses the same setting and is skipped without OHTTP. Defaults to `false`. |
| `relevance` | `[String: Double]` | Per client calculated relevance scores. An empty object omits it.Defaults to `[:]`. |

---
Expand Down
51 changes: 51 additions & 0 deletions components/ads-client/integration-tests/tests/mars.rs
Original file line number Diff line number Diff line change
Expand Up @@ -292,3 +292,54 @@ fn test_contract_tile_ohttp_prod() {
"OHTTP response should contain mock_tile_1"
);
}

/// Sends the ads client's rotation-time `DELETE /v1/delete_user` through the
/// production OHTTP relay and gateway and expects a 200. Built with viaduct
/// directly because the MARS module is private. CI runs `--ignored`, so it only
/// writes to production when `ADS_CLIENT_PROD_DELETE_USER_CONTRACT` is set.
#[test]
#[ignore = "writes to production MARS: run manually with ADS_CLIENT_PROD_DELETE_USER_CONTRACT=1 -- --ignored --nocapture"]
fn test_contract_delete_user_ohttp_prod() {
if std::env::var_os("ADS_CLIENT_PROD_DELETE_USER_CONTRACT").is_none() {
eprintln!(
"skipping: set ADS_CLIENT_PROD_DELETE_USER_CONTRACT=1 to send a DELETE to production"
);
return;
}
init_backend();
viaduct::ohttp::configure_ohttp_channel(
"ads-client".to_string(),
viaduct::ohttp::OhttpConfig {
gateway_host: "prod.ohttp-gateway.prod.webservices.mozgcp.net".to_string(),
relay_url: "https://mozilla-ohttp.fastly-edge.com/".to_string(),
},
)
.expect("OHTTP channel configuration should succeed");

let settings = viaduct::ClientSettings {
timeout: 5_000,
..viaduct::ClientSettings::default()
};
let client = viaduct::Client::with_ohttp_channel("ads-client", settings)
.expect("ads-client OHTTP channel should be configured");
let request = viaduct::Request::delete(
url::Url::parse("https://ads.mozilla.org/v1/delete_user").unwrap(),
)
.json(&serde_json::json!({
// Synthetic id never issued to a client, so this write touches no real data.
"context_id": "00000000-0000-4000-8000-0000000000ac"
}));

let response = client
.send_sync(request)
.expect("delete_user over OHTTP should get an HTTP response");
eprintln!(
"DELETE /v1/delete_user over OHTTP -> {} {}",
response.status,
response.text()
);
assert_eq!(
response.status, 200,
"delete_user over OHTTP should reach MARS and succeed"
);
}
143 changes: 132 additions & 11 deletions components/ads-client/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ use crate::mars::error::{RecordClickError, RecordImpressionError, ReportAdError}
use crate::mars::{MARSClient, ReportReason};
use crate::telemetry::{self, ClientOperation};
use config::AdsClientConfig;
use context_id::{ContextIDComponent, DefaultContextIdCallback};
use context_id::ContextIDComponent;
use context_id_deletion::ContextIdDeletionQueue;
use error::RequestAdsError;
use sql_support::open_database;
use std::collections::HashMap;
Expand All @@ -25,26 +26,34 @@ use url::Url;
use uuid::Uuid;

pub mod config;
mod context_id_deletion;
pub mod error;

const DEFAULT_TTL_SECONDS: u64 = 300;
const DEFAULT_MAX_CACHE_SIZE_MIB: u64 = 10;
const DEFAULT_ROTATION_DAYS: u8 = 3;

/// Passed as `running_in_test_automation` so `ContextIDComponent` never sends
/// its own `DELETE /delete_user`; `AdsClient` sends it instead. Firefox's
/// `ContextId.sys.mjs` does the same.
const DISABLE_CONTEXT_ID_COMPONENT_DELETION: bool = true;

pub struct AdsClient {
#[cfg(feature = "stateful")]
ads_store: Arc<Option<AdsStore>>,
client: MARSClient,
context_id_component: ContextIDComponent,
context_id_deletion_queue: ContextIdDeletionQueue,
}

impl AdsClient {
pub fn new(client_config: AdsClientConfig) -> Self {
let context_id_deletion_queue = ContextIdDeletionQueue::default();
let context_id_component = ContextIDComponent::new(
&Uuid::new_v4().to_string(),
0,
cfg!(test),
Box::new(DefaultContextIdCallback),
DISABLE_CONTEXT_ID_COMPONENT_DELETION,
Box::new(context_id_deletion_queue.clone()),
);

let environment = client_config.environment;
Expand Down Expand Up @@ -92,6 +101,7 @@ impl AdsClient {
ads_store: Arc::new(ads_store),
client,
context_id_component,
context_id_deletion_queue,
}
}

Expand Down Expand Up @@ -300,6 +310,22 @@ impl AdsClient {
self.client.shutdown_db();
}

/// Best-effort: sends each retired id's deletion over OHTTP, or skips it when
/// the ad request did not use OHTTP. Failures are logged and not retried.
fn flush_context_id_deletions(&self, retired_context_ids: Vec<String>, ohttp: bool) {
for old_context_id in retired_context_ids {
if !ohttp {
error_support::info!(
"Skipping context id deletion request: the ad request did not use OHTTP"
);
continue;
}
if let Err(e) = self.client.delete_user(&old_context_id, true) {
error_support::warn!("Context id deletion request failed: {e}");
}
}
}

fn request_ads<A>(
&self,
placements: Vec<AdPlacementRequest>,
Expand All @@ -317,17 +343,23 @@ impl AdsClient {
} else {
None
};
// Take the retired ids now so they are flushed with this call's OHTTP flag.
// A concurrent call may take them first; nothing is ever sent outside OHTTP.
let retired_context_ids = self.context_id_deletion_queue.take_all();

let cache_policy = options.unwrap_or_default();
let (mut response, request_hash) = self.client.fetch_ads::<A>(
let result = self.client.fetch_ads::<A>(
context_id,
flags,
placements,
cache_policy,
ohttp,
blocks,
relevance,
)?;
);
// Flush even if the fetch failed: the ids have already left the queue.
self.flush_context_id_deletions(retired_context_ids, ohttp);
let (mut response, request_hash) = result?;
response.enrich_callbacks(&request_hash);
Ok(response)
}
Expand All @@ -339,7 +371,7 @@ mod tests {
#[cfg(feature = "stateful")]
use crate::ads_store::builder::AdsStoreBuilder;
use crate::{
mars::Environment,
mars::{error::FetchAdsError, Environment},
test_utils::{
get_example_happy_image_response, get_example_happy_spoc_response,
get_example_happy_uatile_response, make_happy_placement_requests,
Expand All @@ -349,7 +381,17 @@ mod tests {
use super::*;

fn new_with_mars_client(client: MARSClient) -> AdsClient {
AdsClient {
new_with_mars_client_at(client, 0).0
}

/// Builds a client whose context id was created at `creation_timestamp_s`
/// (0 = now) and hands back the deletion queue shared with it.
fn new_with_mars_client_at(
client: MARSClient,
creation_timestamp_s: i64,
) -> (AdsClient, ContextIdDeletionQueue) {
let context_id_deletion_queue = ContextIdDeletionQueue::default();
let ads_client = AdsClient {
#[cfg(feature = "stateful")]
ads_store: Arc::new(Some(
AdsStoreBuilder::new("test_store.db")
Expand All @@ -359,11 +401,90 @@ mod tests {
client,
context_id_component: ContextIDComponent::new(
&Uuid::new_v4().to_string(),
0,
false,
Box::new(DefaultContextIdCallback),
creation_timestamp_s,
DISABLE_CONTEXT_ID_COMPONENT_DELETION,
Box::new(context_id_deletion_queue.clone()),
),
}
context_id_deletion_queue: context_id_deletion_queue.clone(),
};
(ads_client, context_id_deletion_queue)
}

fn thirty_days_ago_s() -> i64 {
(chrono::Utc::now() - chrono::Duration::days(30)).timestamp()
}

#[test]
fn test_rotation_without_ohttp_skips_delete() {
viaduct_dev::init_backend_dev();

let expected_response = get_example_happy_uatile_response();
let ads_mock = mockito::mock("POST", "/ads")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(serde_json::to_string(&expected_response.data).unwrap())
.expect(1)
.create();
let delete_mock = mockito::mock("DELETE", "/delete_user").expect(0).create();

let mars_client = MARSClient::new(Environment::Test, None);
let (ads_client, queue) = new_with_mars_client_at(mars_client, thirty_days_ago_s());

// A 30-day-old id rotates on first use and queues the old id.
ads_client.get_context_id().unwrap();
assert!(!queue.is_empty());

// Without OHTTP the queue is drained and no deletion is sent.
let result = ads_client.request_tile_ads(
make_happy_placement_requests(),
AdRequestFlags::default(),
None,
false,
Default::default(),
AdRequestRelevance::default(),
);
assert!(result.is_ok());
ads_mock.assert();
delete_mock.assert();
assert!(queue.is_empty());
}

#[test]
fn test_rotation_flush_runs_even_when_fetch_fails() {
viaduct_dev::init_backend_dev();

// OHTTP without a configured channel: preflight succeeds, the ad fetch fails,
// and the queue is still drained without sending a deletion.
let preflight_mock = mockito::mock("GET", "/ads-preflight")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"geo_location":"US-CA","normalized_ua":"Firefox/140.0"}"#)
.expect(1)
.create();
let delete_mock = mockito::mock("DELETE", "/delete_user").expect(0).create();

let mars_client = MARSClient::new(Environment::Test, None);
let (ads_client, queue) = new_with_mars_client_at(mars_client, thirty_days_ago_s());
ads_client.get_context_id().unwrap();
assert!(!queue.is_empty());

let result = ads_client.request_tile_ads(
make_happy_placement_requests(),
AdRequestFlags::default(),
None,
true,
Default::default(),
AdRequestRelevance::default(),
);
assert!(matches!(
result,
Err(RequestAdsError::FetchAds(FetchAdsError::Request(
viaduct::ViaductError::OhttpChannelNotConfigured(_)
)))
));
preflight_mock.assert();
delete_mock.assert();
assert!(queue.is_empty());
}

#[test]
Expand Down
Loading
Loading