Initial public release of Warp.

Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
David Stern
2026-04-28 08:43:33 -05:00
commit 0dbd3d567a
4982 changed files with 1431549 additions and 0 deletions
@@ -0,0 +1,31 @@
The MIT License (MIT)
Copyright 2019 Segment.io, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
MIT License
Copyright (c) 2021 RudderStack
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+232
View File
@@ -0,0 +1,232 @@
use std::sync::Arc;
use std::{fs::remove_file, time::Duration};
use anyhow::Context;
use chrono::{LocalResult, TimeZone, Utc};
use warp_core::execution_mode::AppExecutionMode;
use warp_core::{report_error, report_if_error};
use warpui::r#async::{FutureExt as _, Timer};
use warpui::{App, Entity, ModelContext, SingletonEntity};
use super::{rudder_event_file_path, RUDDER_TELEMETRY_EVENTS_FILE_NAME};
use crate::auth::AuthStateProvider;
use crate::channel::ChannelState;
use crate::features::FeatureFlag;
use crate::{
server::server_api::ServerApi,
settings::{PrivacySettings, PrivacySettingsChangedEvent},
};
use super::clear_event_queue;
// How often we send Active Usage signals.
const ACTIVE_USAGE_DURATION: Duration = Duration::from_secs(60);
/// Duration to wait before flushing the event queue to Rudderstack.
const TELEMETRY_FLUSH_DURATION: Duration = Duration::from_secs(30);
/// Max telemetry events to write to disk. This is bounded to limit the size of the file as well
/// as latency of writing the file.
const MAX_TELEMETRY_EVENTS_TO_STORE: usize = 20;
/// Maximum time to wait for the telemetry flush network request during shutdown.
/// If the network is unavailable or slow, we don't want the CLI process to hang indefinitely.
const TELEMETRY_SHUTDOWN_FLUSH_TIMEOUT: Duration = Duration::from_secs(5);
/// App singleton responsible for scheduling periodic background tasks for sending batches of
/// telemetry events to Rudderstack. This model respects the user's telemetry enablement setting.
pub struct TelemetryCollector {
server_api: Arc<ServerApi>,
}
impl TelemetryCollector {
pub fn new(server_api: Arc<ServerApi>) -> Self {
Self { server_api }
}
pub fn initialize_telemetry_collection(&self, ctx: &mut ModelContext<TelemetryCollector>) {
// Start a background thread to periodically flush events from the telemetry event queue.
if ChannelState::is_release_bundle() || FeatureFlag::WithSandboxTelemetry.is_enabled() {
// Flush the events to Rudderstack that were persisted into a file the last time the app was
// quit.
self.flush_persisted_events_from_disk(ctx);
}
// Send Active App Usage signals
if FeatureFlag::RecordAppActiveEvents.is_enabled()
&& (ChannelState::is_release_bundle() || FeatureFlag::WithSandboxTelemetry.is_enabled())
{
self.schedule_send_active_usage_event(ctx);
}
// Start a background thread to periodically flush events from the telemetry event queue.
if ChannelState::is_release_bundle()
|| FeatureFlag::WithSandboxTelemetry.is_enabled()
|| FeatureFlag::SendTelemetryToFile.is_enabled()
{
self.schedule_event_queue_flush(ctx);
}
// Clear queued telemetry events when telemetry is enabled or disabled. If telemetry is
// enabled, we will start sending Rudderstack requests when the event queue is periodically
// flushed. The initial request should not contain any events recorded when the user was
// previously opted-out of telemetry. In the case where the user turns the telemetry from
// on to off, we should not send another request with any telemetry, even if the event was
// initially recorded prior to the user turning telemetry off.`
ctx.subscribe_to_model(&PrivacySettings::handle(ctx), |_me, event, _ctx| {
if let PrivacySettingsChangedEvent::UpdateIsTelemetryEnabled { .. } = event {
clear_event_queue();
}
});
}
/// Writes all queued but unsent telemetry telemetry events to disk so that they may be sent
/// on the next app startup.
pub fn write_telemetry_events_to_disk(&self, ctx: &mut ModelContext<TelemetryCollector>) {
match self.server_api.persist_telemetry_events(
MAX_TELEMETRY_EVENTS_TO_STORE,
PrivacySettings::as_ref(ctx).get_snapshot(ctx),
) {
Ok(()) => {
log::info!("Successfully wrote telemetry events to disk")
}
Err(e) => {
log::error!("Failed to write telemetry events to disk {e:#}");
}
}
}
/// Flushes telemetry events when the app is shutting down.
///
/// Depending on the app's execution mode, this will either:
/// * Write events to disk, for sending on the next app startup
/// * Synchronously send events to rudderstack
pub fn flush_telemetry_events_for_shutdown(&self, ctx: &mut ModelContext<TelemetryCollector>) {
let execution_mode = AppExecutionMode::as_ref(ctx);
if execution_mode.send_telemetry_at_shutdown() {
let privacy_settings_snapshot = PrivacySettings::as_ref(ctx).get_snapshot(ctx);
let server_api = self.server_api.clone();
match warpui::r#async::block_on(async move {
server_api
.flush_telemetry_events(privacy_settings_snapshot)
.with_timeout(TELEMETRY_SHUTDOWN_FLUSH_TIMEOUT)
.await
}) {
Ok(Ok(count)) => {
if count > 0 {
log::info!("Successfully flushed telemetry events before shutdown");
}
}
Ok(Err(e)) => {
report_error!(e.context("Error flushing telemetry events before shutdown"));
}
Err(_) => {
log::warn!(
"Telemetry flush timed out after {}s during shutdown, skipping",
TELEMETRY_SHUTDOWN_FLUSH_TIMEOUT.as_secs()
);
}
}
} else {
self.write_telemetry_events_to_disk(ctx);
}
}
/// Sends rudderstack requests containing events persisted to disk (if telemetry is enabled).
/// Events may be written to disk at the end of a session prior to app termination; this
/// function should be called on startup to track events that were recorded at the end of the
/// last session and were not flushed.
fn flush_persisted_events_from_disk(&self, ctx: &mut ModelContext<TelemetryCollector>) {
let privacy_settings_snapshot = PrivacySettings::as_ref(ctx).get_snapshot(ctx);
let server_api = self.server_api.clone();
let _ = ctx.spawn(
async move {
let new_path = rudder_event_file_path();
let old_path =
warp_core::paths::state_dir().join(RUDDER_TELEMETRY_EVENTS_FILE_NAME);
// Try flushing from both new and legacy locations.
for path in [new_path, old_path] {
report_if_error!(server_api
.flush_persisted_events_to_rudder(&path, privacy_settings_snapshot)
.await
.context("Failed to flush rudder events from disk"));
// Remove the file regardless of outcome of flushing the events to avoid the
// case where we accidentally try to re-flush the events on the next app startup.
if let Err(e) = remove_file(&path) {
if e.kind() != std::io::ErrorKind::NotFound {
warp_core::report_error!(
anyhow::anyhow!(e).context("Failed to remove persisted event file")
);
}
}
}
},
|_, _, _| (),
);
}
/// Schedules a background task to send an active usage event in a rudderstack request if
/// telemetry is enabled. The scheduled task once again schedules itself after
/// `ACTIVE_USAGE_DURATION`.
fn schedule_send_active_usage_event(&self, ctx: &mut ModelContext<TelemetryCollector>) {
let auth_state = AuthStateProvider::as_ref(ctx).get().clone();
let is_telemetry_enabled = PrivacySettings::as_ref(ctx).is_telemetry_enabled;
let _ = ctx.spawn(
async move {
// Record app active if there was any activity now or right after the previous check
let last_active_timestamp = App::last_active_timestamp();
if is_telemetry_enabled
&& last_active_timestamp + ACTIVE_USAGE_DURATION.as_secs() as i64
> Utc::now().timestamp()
{
if let LocalResult::Single(timestamp) =
Utc.timestamp_opt(last_active_timestamp, 0)
{
warpui::telemetry::record_app_active_event(
auth_state.user_id().map(|uid| uid.as_string()),
auth_state.anonymous_id(),
timestamp,
);
}
}
Timer::after(ACTIVE_USAGE_DURATION).await;
},
|me, _, ctx| me.schedule_send_active_usage_event(ctx),
);
}
/// Flushes events from the in-memory event queue and schedules a background task to send
/// them in rudderstack request if telemetry is enabled. The scheduled task once again schedules
/// itself after `TELEMETRY_FLUSH_DURATION`.
fn schedule_event_queue_flush(&self, ctx: &mut ModelContext<TelemetryCollector>) {
let server_api = self.server_api.clone();
let privacy_settings_snapshot = PrivacySettings::as_ref(ctx).get_snapshot(ctx);
let _ = ctx.spawn(
async move {
match server_api
.flush_telemetry_events(privacy_settings_snapshot)
.await
{
Ok(count) => {
if count > 0 {
log::debug!("Flushed telemetry events.");
}
}
Err(e) => {
log::info!("Failed to flush events from Telemetry queue: {e}");
}
}
Timer::after(TELEMETRY_FLUSH_DURATION).await;
},
|me, _, ctx| me.schedule_event_queue_flush(ctx),
);
}
}
impl Entity for TelemetryCollector {
type Event = ();
}
impl SingletonEntity for TelemetryCollector {}
+100
View File
@@ -0,0 +1,100 @@
//! Module that builds a static context to attach to each of our events that are sent to Rudderstack.
//! This is needed so we know the backing operating system and version of each telemetry event.
use super::rudder_message::Message as RudderMessage;
use crate::server::OperatingSystemInfo;
use serde::Serialize;
use serde_json::{json, Value};
use std::sync::OnceLock;
#[cfg(target_family = "wasm")]
use warpui::platform::wasm;
static TELEMETRY_CONTEXT: OnceLock<TelemetryContext> = OnceLock::new();
#[derive(Serialize)]
struct TelemetryContextInfo {
/// Info about the operating system of the client.
#[serde(skip_serializing_if = "Option::is_none")]
os: Option<&'static OperatingSystemInfo>,
/// The user agent provided by the browser, if running on Web. If not on
/// Web, this is always `None`.
#[serde(rename = "userAgent", skip_serializing_if = "Option::is_none")]
user_agent: Option<String>,
}
/// Newtype representing a [`Value`] with a serialized version of the context that we send to
/// Rudderstack.
/// See https://www.rudderstack.com/docs/event-spec/standard-events/common-fields/#contextual-fields.
pub struct TelemetryContext(Value);
impl TelemetryContext {
pub fn as_value(&self) -> Value {
self.0.clone()
}
}
impl TelemetryContext {
fn new() -> Self {
let context = TelemetryContextInfo {
os: OperatingSystemInfo::get().ok(),
user_agent: user_agent(),
};
match serde_json::to_value(context) {
Ok(value) => Self(value),
Err(e) => {
log::error!("Failed to serialize telemetry context info to JSON value: {e:?}");
Self(json!({}))
}
}
}
}
/// Extension trait used to attach a telemetry context.
pub(super) trait AttachContext {
/// Attaches a context to the given object.
fn attach_context(&mut self);
}
impl AttachContext for RudderMessage {
/// Attaches the context to the [`RudderMessage`]. Note this is currently last write wins; if a
/// message already has a `context` set it will be overridden.
// TODO(alokedesai): Merge the incoming context with the static `TelemetryContext`, if set.
fn attach_context(&mut self) {
let context = telemetry_context().as_value();
match self {
RudderMessage::Identify(identify) => {
identify.context = Some(context);
}
RudderMessage::Track(track) => track.context = Some(context),
RudderMessage::Page(page) => page.context = Some(context),
RudderMessage::Screen(screen) => screen.context = Some(context),
RudderMessage::Group(group) => group.context = Some(context),
RudderMessage::Alias(alias) => alias.context = Some(context),
RudderMessage::Batch(batch) => batch.context = Some(context),
}
}
}
/// Returns the user agent provided by the browser, if on Web. If not on Web,
/// or if the user agent was not able to be read, returns None.
fn user_agent() -> Option<String> {
cfg_if::cfg_if! {
if #[cfg(target_family = "wasm")] {
wasm::user_agent()
} else {
None
}
}
}
/// Returns the telemetry context
/// that should be attached to all telemetry events associated to this client.
///
/// [Rudderstack](https://www.rudderstack.com/docs/event-spec/standard-events/common-fields/#contextual-fields)
pub fn telemetry_context() -> &'static TelemetryContext {
TELEMETRY_CONTEXT.get_or_init(TelemetryContext::new)
}
@@ -0,0 +1,26 @@
use warp_core::telemetry::{TelemetryContextModel, TelemetryContextProvider};
use warpui::{AppContext, ModelContext, SingletonEntity};
use crate::auth::AuthStateProvider;
pub struct AppTelemetryContextProvider {}
impl AppTelemetryContextProvider {
pub fn new_context_provider(
_ctx: &mut ModelContext<TelemetryContextModel>,
) -> TelemetryContextModel {
Box::new(Self {})
}
}
impl TelemetryContextProvider for AppTelemetryContextProvider {
fn user_id(&self, ctx: &AppContext) -> Option<String> {
let auth_state = AuthStateProvider::as_ref(ctx).get();
auth_state.user_id().map(|uid| uid.as_string())
}
fn anonymous_id(&self, ctx: &AppContext) -> String {
let auth_state = AuthStateProvider::as_ref(ctx).get();
auth_state.anonymous_id()
}
}
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
use warp_core::telemetry::TelemetryEventDesc;
#[derive(Debug)]
enum TelemetryEventPropertyError {
// The variant data is never directly read, but it's used for error formatting if the test
// below fails.
EmptyName(#[expect(dead_code)] Box<dyn TelemetryEventDesc>),
EmptyDescription(#[expect(dead_code)] Box<dyn TelemetryEventDesc>),
}
/// Checks that all telemetry events have a non-empty name and description.
///
/// The name and description are intended to be user-facing and are used to populate
/// our [exhaustive telemetry table](https://docs.warp.dev/support-and-community/privacy-and-security/privacy#exhaustive-telemetry-table).
#[test]
#[cfg(not(target_family = "wasm"))]
fn telemetry_events_have_nonempty_name_and_description() -> Result<(), TelemetryEventPropertyError>
{
for event in warp_core::telemetry::all_events() {
if event.name().is_empty() {
return Err(TelemetryEventPropertyError::EmptyName(event));
}
if event.description().is_empty() {
return Err(TelemetryEventPropertyError::EmptyDescription(event));
}
}
Ok(())
}
+93
View File
@@ -0,0 +1,93 @@
/// Sends a telemetry event to Rudderstack immediately instead of adding it to the event queue that is
/// periodically flushed. This is useful under certain conditions where we want to ensure an event
/// is immediately sent to Rudderstack even if the user quits before the queue is flushed.
#[macro_export]
macro_rules! send_telemetry_sync_from_ctx {
($event:expr, $ctx:expr) => {
#[allow(unused_imports)]
use warp_core::telemetry::TelemetryEvent as _;
let event = $event;
if event.enablement_state().is_enabled() {
let server_api =
<$crate::server::server_api::ServerApiProvider as warpui::SingletonEntity>::handle(
$ctx,
)
.as_ref($ctx)
.get();
let privacy_settings_snapshot =
<$crate::settings::PrivacySettings as warpui::SingletonEntity>::handle($ctx)
.as_ref($ctx)
.get_snapshot($ctx);
let _ = $ctx.spawn(
async move {
if let Err(error) = server_api
.send_telemetry_event(event, privacy_settings_snapshot)
.await
{
log::warn!("Error occurred with sending telemetry event: {}", error);
}
},
|_, _, _| {},
);
}
};
}
/// Sends a telemetry event to Rudderstack immediately. This is the same as [`send_telemetry_sync_from_ctx`],
/// but can be used when the caller only has access to an [`App`] and not a
/// `ViewContext`.
#[macro_export]
macro_rules! send_telemetry_sync_from_app_ctx {
($event:expr, $app_ctx:expr) => {
#[allow(unused_imports)]
use warp_core::telemetry::TelemetryEvent as _;
if $event.enablement_state().is_enabled() {
let server_api =
<$crate::server::server_api::ServerApiProvider as warpui::SingletonEntity>::handle(
$app_ctx,
)
.as_ref($app_ctx)
.get();
let privacy_settings_snapshot =
<$crate::settings::PrivacySettings as warpui::SingletonEntity>::handle($app_ctx)
.as_ref($app_ctx)
.get_snapshot($app_ctx);
$app_ctx
.background_executor()
.spawn(async move {
if let Err(error) = server_api
.send_telemetry_event($event, privacy_settings_snapshot)
.await
{
log::warn!("Error occurred with sending telemetry event: {error}");
}
})
.detach();
}
};
}
/// Sends a telemetry `track` event Rudderstack asynchronously. This is the same as the
/// [`send_telemetry_from_ctx`], except can be called any time you have an Arc<Background>.
/// This should only be called when invoking one of the other macros isn't possible; for example,
/// when you are already on a background thread and thus can't access any app context.
#[macro_export]
macro_rules! send_telemetry_on_executor {
($auth_state: expr, $event:expr, $executor:expr) => {
#[allow(unused_imports)]
use warp_core::telemetry::TelemetryEvent as _;
let event = $event;
if event.enablement_state().is_enabled() {
let user_id = $auth_state.user_id().map(|uid| uid.as_string());
let anonymous_id = $auth_state.anonymous_id();
warpui::record_telemetry_on_executor!(
user_id,
anonymous_id,
event.name().into(),
event.payload(),
event.contains_ugc(),
$executor
);
}
};
}
+410
View File
@@ -0,0 +1,410 @@
mod collector;
mod context;
pub mod context_provider;
mod events;
mod macros;
pub mod rudder_message;
pub mod secret_redaction;
use chrono::Utc;
pub use collector::*;
pub use context::telemetry_context;
pub use events::*;
use crate::auth::UserUid;
use crate::features::FeatureFlag;
use crate::server::telemetry::context::AttachContext;
use crate::server::telemetry_ext::TelemetryExt;
use crate::settings::PrivacySettingsSnapshot;
use crate::ChannelState;
use anyhow::Result;
use futures::FutureExt;
use rudder_message::{
Batch as RudderBatch, BatchMessage as RudderBatchMessageWithMetadata,
BatchMessageItem as RudderBatchMessage, Message as RudderMessage,
};
use std::fs::File;
#[cfg(not(target_family = "wasm"))]
use std::fs::OpenOptions;
use std::future::Future;
use std::path::{Path, PathBuf};
use warp_core::channel::RudderStackDestination;
use warpui::telemetry::Event;
/// Filename for file where telemetry events are written on app quit.
const RUDDER_TELEMETRY_EVENTS_FILE_NAME: &str = "rudder_telemetry_events.json";
/// Filepath where the Rudder events should be written on app quit.
fn rudder_event_file_path() -> PathBuf {
warp_core::paths::secure_state_dir()
.unwrap_or_else(warp_core::paths::state_dir)
.join(RUDDER_TELEMETRY_EVENTS_FILE_NAME)
}
/// Removes all telemetry events from the app telemetry event queue.
pub fn clear_event_queue() {
let _ = warpui::telemetry::flush_events();
}
pub struct TelemetryApi {
pub(super) client: http_client::Client,
}
impl Default for TelemetryApi {
fn default() -> Self {
Self::new()
}
}
impl TelemetryApi {
pub fn new() -> Self {
cfg_if::cfg_if! {
if #[cfg(test)] {
let client = http_client::Client::new_for_test();
} else if #[cfg(target_family = "wasm")] {
let client = http_client::Client::default();
} else {
use std::time::Duration;
let client = http_client::Client::from_client_builder(
// We use our own http client directly instead of the Rudderstack SDK's because using
// our own client gives us the ability to have universal hooks for pre/post
// request/response logic.
reqwest::Client::builder()
// Don't allow insecure connections; they will be rejected by
// the server with a 403 Forbidden.
.https_only(true)
// Keep idle connections in the pool for up to 55s. AWS
// Application Load Balancers will drop idle connections after
// 60s and the default pool idle timeout is 90s; a pool idle
// timeout longer than the server timeout can lead to errors
// upon trying to use an idle connection.
.pool_idle_timeout(Duration::from_secs(55))
.connect_timeout(Duration::from_secs(10)),
).expect("Client should be constructed since we use a compatibility layer to use reqwest::Client");
}
}
Self { client }
}
// Batches up telemetry events from the global queue and sends a Message to the Rudderstack API.
// Returns the number of events that were flushed.
pub async fn flush_events(&self, settings_snapshot: PrivacySettingsSnapshot) -> Result<usize> {
let events = warpui::telemetry::flush_events();
let event_count = events.len();
#[cfg(not(target_family = "wasm"))]
if FeatureFlag::SendTelemetryToFile.is_enabled() {
self.persist_events_to_telemetry_log_file(events.clone())?;
}
if ChannelState::is_release_bundle() || FeatureFlag::WithSandboxTelemetry.is_enabled() {
self.send_batch_messages_to_rudder(
events
.into_iter()
.map(Event::to_rudder_batch_message)
.collect(),
settings_snapshot,
)
.await?;
}
Ok(event_count)
}
/// Flushes events directly to Rudder that were previously written into a file at `path`
/// (likely via a call to `write_events_to_disk`).
pub async fn flush_persisted_events_to_rudder(
&self,
path: &Path,
settings_snapshot: PrivacySettingsSnapshot,
) -> Result<()> {
if path.exists() {
let file = File::open(path)?;
let events: Vec<RudderBatchMessage> = serde_json::from_reader(file)?;
if !events.is_empty() {
let rudder_batch_messages = events
.into_iter()
.map(|message| RudderBatchMessageWithMetadata {
message,
// We don't persist any events that contain sensitive user data.
contains_ugc: false,
})
.collect();
self.send_batch_messages_to_rudder(rudder_batch_messages, settings_snapshot)
.await?;
log::info!("Successfully flushed events to rudder from disk");
}
}
Ok(())
}
/// Writes the last `max_event_count` events into disk. This is useful for persisting events
/// where we can't make a network call to Rudder (such as when the app quits). To flush these
/// events to Rudder, call `flush_events_to_rudder_from_disk`.
pub fn flush_and_persist_events(
&self,
max_event_count: usize,
settings_snapshot: PrivacySettingsSnapshot,
) -> Result<()> {
self.flush_and_persist_events_at_path(
max_event_count,
settings_snapshot,
rudder_event_file_path(),
)
}
fn flush_and_persist_events_at_path(
&self,
max_event_count: usize,
settings_snapshot: PrivacySettingsSnapshot,
path: impl AsRef<Path>,
) -> Result<()> {
if settings_snapshot.should_disable_telemetry() {
log::info!("Not writing queued events to disk because telemetry is disabled.");
return Result::Ok(());
}
log::info!("Writing queued events to disk because telemetry is enabled.");
let file = File::create(path)?;
let events = warpui::telemetry::flush_events();
if events.len() > max_event_count {
log::error!("More telemetry events in queue than the limit to persist")
}
self.persist_events_at_path(&file, max_event_count, events)?;
Ok(())
}
fn persist_events_at_path(
&self,
file: &File,
max_event_count: usize,
events: Vec<Event>,
) -> Result<()> {
let rudder_events_to_persist: Vec<_> = events
.into_iter()
.rev()
.take(max_event_count)
.map(TelemetryExt::to_rudder_batch_message)
.filter_map(|message| (!message.contains_ugc).then_some(message.message))
.collect();
serde_json::to_writer(file, &rudder_events_to_persist)?;
Ok(())
}
#[cfg(not(target_family = "wasm"))]
fn persist_events_to_telemetry_log_file(&self, events: Vec<Event>) -> Result<()> {
let log_directory = warp_logging::log_directory()?;
let telemetry_file_path = log_directory.join(&*ChannelState::telemetry_file_name());
let file = OpenOptions::new()
.create(true)
.append(true)
.open(&telemetry_file_path)?;
self.persist_events_at_path(&file, events.len(), events)
}
/// Sends a `TelemetryEvent` to the Rudderstack API.
pub async fn send_telemetry_event(
&self,
user_id: Option<UserUid>,
anonymous_id: String,
event: impl warp_core::telemetry::TelemetryEvent,
settings_snapshot: PrivacySettingsSnapshot,
) -> Result<()> {
let event = warpui::telemetry::create_event(
user_id.map(|uid| uid.as_string()),
anonymous_id,
event.name().into(),
event.payload(),
event.contains_ugc(),
warpui::time::get_current_time(),
);
self.send_telemetry_event_internal(event, settings_snapshot)
.await
}
/// Internal implementation for sending telemetry events. This reduces code size, since
// we:
// 1. Return a boxed future, so calling `async` functions don't need to inline this one.
// 2. Don't have to monomorphize for each telemetry event implementation.
fn send_telemetry_event_internal(
&self,
event: Event,
settings_snapshot: PrivacySettingsSnapshot,
) -> impl Future<Output = Result<()>> + '_ {
let work = async move {
if settings_snapshot.should_disable_telemetry() {
log::info!("Not sending telemetry event because telemetry is disabled.");
return Result::Ok(());
}
#[cfg(not(target_family = "wasm"))]
if FeatureFlag::SendTelemetryToFile.is_enabled() {
self.persist_events_to_telemetry_log_file(vec![event.clone()])?;
}
if !(ChannelState::is_release_bundle()
|| FeatureFlag::WithSandboxTelemetry.is_enabled())
{
return Result::Ok(());
}
let rudder_batch = vec![event.to_rudder_batch_message()];
let result = self
.send_batch_messages_to_rudder(rudder_batch, settings_snapshot)
.await;
// This is only conditionally compiled because `is_connect` is not
// available on wasm. If additional checks are made against the
// `reqwest::Error`, this condition should be performed specifically
// against `is_connect` and not the whole loop.
#[cfg(not(target_family = "wasm"))]
if let Err(error) = &result {
for cause in error.chain() {
if let Some(err) = cause.downcast_ref::<reqwest::Error>() {
if err.is_connect() {
log::warn!("Failed to send telemetry event: {error}");
return Ok(());
}
}
}
}
result
};
// On WASM, the work future is non-Send, because the HTTP request future contains a reference to a JS
// value (which is fine, since our WASM executor is single-threaded). On all other platforms, we must
// return a Send future in order to use the background executor.
cfg_if::cfg_if! {
if #[cfg(target_family = "wasm")] {
work.boxed_local()
} else {
work.boxed()
}
}
}
/// Send a batch of RudderStack messages to their HTTP API.
/// Note that the rudderanalytics SDK provides a client, but we don't
/// use it for a few reasons:
/// 1. It only supports a blocking HTTP client instead of an async one
/// 2. We want to use our own HTTP client which has before/after request logging hooks
#[cfg_attr(target_family = "wasm", allow(clippy::question_mark))]
async fn send_batch_messages_to_rudder(
&self,
messages: Vec<RudderBatchMessageWithMetadata>,
settings_snapshot: PrivacySettingsSnapshot,
) -> Result<()> {
if messages.is_empty() {
log::debug!("Dropping empty RudderStack telemetry batch");
return Ok(());
}
if settings_snapshot.should_disable_telemetry() {
log::info!("Not sending batched messages because telemetry is disabled.");
return Ok(());
}
log::info!("Start to send telemetry events to RudderStack");
let (mut messages_with_ugc, messages_without_ugc): (Vec<_>, Vec<_>) = messages
.into_iter()
.partition(|message| message.contains_ugc);
// If we shouldn't collect UGC telemetry, forceably clear any messages with UGC before trying to send.
if !settings_snapshot.should_collect_ai_ugc_telemetry() {
messages_with_ugc.clear();
}
for (messages, rudder_stack_destination) in [
(
messages_with_ugc,
ChannelState::rudderstack_ugc_destination(),
),
(
messages_without_ugc,
ChannelState::rudderstack_non_ugc_destination(),
),
] {
if messages.is_empty() {
continue;
}
// Note that timestamp and context are already included in the individual RudderBatchMessages
// and these are the most important ones,
// but we also add them to the RudderMessage::Batch wrapper.
let rudder_message = RudderMessage::Batch(RudderBatch {
batch: messages
.into_iter()
.map(|message| message.message)
.collect(),
original_timestamp: Some(Utc::now()),
..Default::default()
});
if let Err(e) = self
.send_rudder_request(rudder_message, rudder_stack_destination)
.await
{
// Don't treat a connection issue as an error as these are outside of our control.
//
// This is only conditionally compiled because `is_connect` is not
// available on wasm. If additional checks are made against the
// `reqwest::Error`, this condition should be performed specifically
// against `is_connect` and not the whole loop.
#[cfg(not(target_family = "wasm"))]
for cause in e.chain() {
if let Some(err) = cause.downcast_ref::<reqwest::Error>() {
if err.is_connect() {
log::warn!("Failed to send event to RudderStack: {e}");
return Ok(());
}
}
}
return Err(e);
}
}
Ok(())
}
/// Sends a POST request to the RudderStack HTTP API.
async fn send_rudder_request(
&self,
mut msg: RudderMessage,
rudder_stack_destination: RudderStackDestination,
) -> Result<()> {
msg.attach_context();
let path = match msg {
RudderMessage::Identify(_) => "/v1/identify",
RudderMessage::Track(_) => "/v1/track",
RudderMessage::Page(_) => "/v1/page",
RudderMessage::Screen(_) => "/v1/screen",
RudderMessage::Group(_) => "/v1/group",
RudderMessage::Alias(_) => "/v1/alias",
RudderMessage::Batch(_) => "/v1/batch",
};
self.client
.post(&format!("{}{}", rudder_stack_destination.root_url, path))
.basic_auth(rudder_stack_destination.write_key, Some(""))
.json(&msg)
.send()
.await?
.error_for_status()?;
Ok(())
}
}
#[cfg(test)]
#[path = "mod_tests.rs"]
mod tests;
+61
View File
@@ -0,0 +1,61 @@
use rudder_message::Track;
use virtual_fs::VirtualFS;
use super::*;
// Tests that events with UGC are not persisted to desk.
#[test]
fn test_persist_events_doesnt_include_ugc_events() {
let telemetry_api = TelemetryApi::new();
VirtualFS::test(
"test_persist_events_doesnt_include_ugc_events",
|dirs, _sandbox| {
// Add one event without UGC
let user_id = Some("user".into());
let anonymous_id = "anonymous_id".to_owned();
warpui::telemetry::record_event(
user_id.clone(),
anonymous_id.clone(),
"non UGC event name".into(),
None, /* payload */
false, /* contains_ugc */
warpui::time::get_current_time(),
);
warpui::telemetry::record_event(
user_id.clone(),
anonymous_id.clone(),
"UGC event name".into(),
None, /* payload */
true, /* contains_ugc */
warpui::time::get_current_time(),
);
let file_path = dirs.root().join("rudderstack");
telemetry_api
.flush_and_persist_events_at_path(10, PrivacySettingsSnapshot::mock(), &file_path)
.expect("Should be able to persist events");
let file_content: Vec<RudderBatchMessage> =
serde_json::from_reader(File::open(file_path).expect("Failed to open file"))
.expect("Failed to parse file");
assert_eq!(file_content.len(), 1);
let track = file_content[0].unwrap_track();
assert_eq!(track.event, "non UGC event name");
},
);
}
impl RudderBatchMessage {
fn unwrap_track(&self) -> &Track {
match self {
RudderBatchMessage::Track(track) => track,
_ => panic!("Expected a track event"),
}
}
}
+249
View File
@@ -0,0 +1,249 @@
//! Module that contains RudderStack API message types.
//! This is directly copied from the RudderStack Rust SDK: https://github.com/rudderlabs/rudder-sdk-rust/blob/master/src/message.rs
//! We do not use the SDK directly because it unconditionally uses a blocking HTTP client, which we don't want for a few reasons:
//! 1. The blocking HTTP client is not allowed when compiling for WASM, so the crate itself cannot be compiled for WASM
//! 2. An async HTTP client is more efficient
//! 3. We want to use our own HTTP client which has before/after request logging hooks
//! We can consider using the SDK if it adds support for an async HTTP client, tracked by this issue: https://github.com/rudderlabs/rudder-sdk-rust/issues/23
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::auth::UserUid;
/// An enum containing all values which may be sent to RudderStack's API.
#[derive(PartialEq, Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Message {
Identify(Identify),
Track(Track),
Page(Page),
Screen(Screen),
Group(Group),
Alias(Alias),
Batch(Batch),
}
/// An identify event.
#[derive(PartialEq, Debug, Clone, Serialize, Deserialize, Default)]
pub struct Identify {
/// The user id associated with this message.
#[serde(rename = "userId", skip_serializing_if = "Option::is_none")]
pub user_id: Option<UserUid>,
/// The anonymous user id associated with this message.
#[serde(rename = "anonymousId", skip_serializing_if = "Option::is_none")]
pub anonymous_id: Option<String>,
/// The traits to assign to the user.
#[serde(skip_serializing_if = "Option::is_none")]
pub traits: Option<Value>,
/// The timestamp associated with this message.
#[serde(rename = "originalTimestamp", skip_serializing_if = "Option::is_none")]
pub original_timestamp: Option<DateTime<Utc>>,
/// Context associated with this message.
#[serde(skip_serializing_if = "Option::is_none")]
pub context: Option<Value>,
/// Integrations to route this message to.
#[serde(skip_serializing_if = "Option::is_none")]
pub integrations: Option<Value>,
}
/// A track event.
#[derive(PartialEq, Debug, Clone, Serialize, Deserialize, Default)]
pub struct Track {
/// The user id associated with this message.
#[serde(rename = "userId", skip_serializing_if = "Option::is_none")]
pub user_id: Option<UserUid>,
/// The anonymous user id associated with this message.
#[serde(rename = "anonymousId", skip_serializing_if = "Option::is_none")]
pub anonymous_id: Option<String>,
/// The name of the event being tracked.
pub event: String,
/// The properties associated with the event.
#[serde(skip_serializing_if = "Option::is_none")]
pub properties: Option<Value>,
/// The timestamp associated with this message.
#[serde(rename = "originalTimestamp", skip_serializing_if = "Option::is_none")]
pub original_timestamp: Option<DateTime<Utc>>,
/// Context associated with this message.
#[serde(skip_serializing_if = "Option::is_none")]
pub context: Option<Value>,
/// Integrations to route this message to.
#[serde(skip_serializing_if = "Option::is_none")]
pub integrations: Option<Value>,
}
/// A page event.
#[derive(PartialEq, Debug, Clone, Serialize, Deserialize, Default)]
pub struct Page {
/// The user id associated with this message.
#[serde(rename = "userId", skip_serializing_if = "Option::is_none")]
pub user_id: Option<UserUid>,
/// The anonymous user id associated with this message.
#[serde(rename = "anonymousId", skip_serializing_if = "Option::is_none")]
pub anonymous_id: Option<String>,
/// The name of the page being tracked.
pub name: String,
/// The properties associated with the event.
#[serde(skip_serializing_if = "Option::is_none")]
pub properties: Option<Value>,
/// The timestamp associated with this message.
#[serde(rename = "originalTimestamp", skip_serializing_if = "Option::is_none")]
pub original_timestamp: Option<DateTime<Utc>>,
/// Context associated with this message.
#[serde(skip_serializing_if = "Option::is_none")]
pub context: Option<Value>,
/// Integrations to route this message to.
#[serde(skip_serializing_if = "Option::is_none")]
pub integrations: Option<Value>,
}
/// A screen event.
#[derive(PartialEq, Debug, Clone, Serialize, Deserialize, Default)]
pub struct Screen {
/// The user id associated with this message.
#[serde(rename = "userId", skip_serializing_if = "Option::is_none")]
pub user_id: Option<UserUid>,
/// The anonymous user id associated with this message.
#[serde(rename = "anonymousId", skip_serializing_if = "Option::is_none")]
pub anonymous_id: Option<String>,
/// The name of the screen being tracked.
pub name: String,
/// The properties associated with the event.
#[serde(skip_serializing_if = "Option::is_none")]
pub properties: Option<Value>,
/// The timestamp associated with this message.
#[serde(rename = "originalTimestamp", skip_serializing_if = "Option::is_none")]
pub original_timestamp: Option<DateTime<Utc>>,
/// Context associated with this message.
#[serde(skip_serializing_if = "Option::is_none")]
pub context: Option<Value>,
/// Integrations to route this message to.
#[serde(skip_serializing_if = "Option::is_none")]
pub integrations: Option<Value>,
}
/// A group event.
#[derive(PartialEq, Debug, Clone, Serialize, Deserialize, Default)]
pub struct Group {
/// The user id associated with this message.
#[serde(rename = "userId", skip_serializing_if = "Option::is_none")]
pub user_id: Option<UserUid>,
/// The anonymous user id associated with this message.
#[serde(rename = "anonymousId", skip_serializing_if = "Option::is_none")]
pub anonymous_id: Option<String>,
/// The group the user is being associated with.
#[serde(rename = "groupId")]
pub group_id: String,
/// The traits to assign to the group.
#[serde(skip_serializing_if = "Option::is_none")]
pub traits: Option<Value>,
/// The timestamp associated with this message.
#[serde(rename = "originalTimestamp", skip_serializing_if = "Option::is_none")]
pub original_timestamp: Option<DateTime<Utc>>,
/// Context associated with this message.
#[serde(skip_serializing_if = "Option::is_none")]
pub context: Option<Value>,
/// Integrations to route this message to.
#[serde(skip_serializing_if = "Option::is_none")]
pub integrations: Option<Value>,
}
/// An alias event.
#[derive(PartialEq, Debug, Clone, Serialize, Deserialize, Default)]
pub struct Alias {
/// The user id associated with this message.
#[serde(rename = "userId")]
pub user_id: UserUid,
/// The user's previous ID.
#[serde(rename = "previousId")]
pub previous_id: String,
/// The traits to assign to the alias.
#[serde(skip_serializing_if = "Option::is_none")]
pub traits: Option<Value>,
/// The timestamp associated with this message.
#[serde(rename = "originalTimestamp", skip_serializing_if = "Option::is_none")]
pub original_timestamp: Option<DateTime<Utc>>,
/// Context associated with this message.
#[serde(skip_serializing_if = "Option::is_none")]
pub context: Option<Value>,
/// Integrations to route this message to.
#[serde(skip_serializing_if = "Option::is_none")]
pub integrations: Option<Value>,
}
/// A batch of events.
#[derive(PartialEq, Debug, Clone, Serialize, Deserialize, Default)]
pub struct Batch {
/// The batch of messages to send.
pub batch: Vec<BatchMessageItem>,
/// Context associated with this message.
#[serde(skip_serializing_if = "Option::is_none")]
pub context: Option<Value>,
/// Integrations to route this message to.
#[serde(skip_serializing_if = "Option::is_none")]
pub integrations: Option<Value>,
/// The timestamp associated with this message.
#[serde(rename = "originalTimestamp", skip_serializing_if = "Option::is_none")]
pub original_timestamp: Option<DateTime<Utc>>,
}
/// An enum containing all messages which may be placed inside a batch.
#[derive(PartialEq, Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum BatchMessageItem {
#[serde(rename = "identify")]
Identify(Identify),
#[serde(rename = "track")]
Track(Track),
#[serde(rename = "page")]
Page(Page),
#[serde(rename = "screen")]
Screen(Screen),
#[serde(rename = "group")]
Group(Group),
#[serde(rename = "alias")]
Alias(Alias),
}
/// Metadata about a batch sent to Rudderstack and whether it contains user generated content.
pub struct BatchMessage {
pub message: BatchMessageItem,
pub contains_ugc: bool,
}
@@ -0,0 +1,125 @@
//! Best-effort secret redaction for telemetry payloads.
//!
//! Unlike the AI-side secret redaction in `app/src/ai/blocklist/block/secret_redaction.rs`,
//! which is gated on the user's secret-redaction (a.k.a. "safe mode") setting and is used
//! for visual obfuscation in the terminal, the redaction in this module is unconditional:
//! we always do a redaction pass on telemetry payloads that may contain user-generated
//! content, regardless of the user's safe-mode setting. The two settings are deliberately
//! decoupled — visual obfuscation is a UX preference, while telemetry-side redaction is a
//! defence-in-depth measure for data leaving the device.
//!
//! The regex used for redaction always includes the default patterns defined in
//! `crate::terminal::model::secrets::regexes::DEFAULT_REGEXES_WITH_NAMES`. Any custom
//! patterns the user has configured (or that their organization has configured via
//! enterprise secret redaction) are layered on top of those defaults.
//!
//! This module is intentionally lightweight: it does byte-range matching only and does
//! not track `SecretLevel`s or character ranges, since the telemetry path doesn't need
//! either.
use crate::terminal::model::secrets::regexes::DEFAULT_REGEXES_WITH_NAMES;
use lazy_static::lazy_static;
use parking_lot::RwLock;
use regex_automata::meta::Regex;
use serde_json::Value;
use std::collections::HashSet;
use std::ops::Range;
const REDACTION_REPLACEMENT_CHARACTER: &str = "*";
lazy_static! {
/// Regex used to redact secrets from telemetry payloads. Initialized with the
/// default patterns so that redaction works even before the user's privacy
/// settings are loaded (and even for users who have never configured any
/// custom patterns).
static ref TELEMETRY_SECRETS_REGEX: RwLock<Regex> = RwLock::new(build_default_regex());
}
/// Builds a regex containing only the default patterns. Used to seed the static
/// regex before the privacy settings are loaded.
fn build_default_regex() -> Regex {
let patterns: Vec<&str> = DEFAULT_REGEXES_WITH_NAMES
.iter()
.map(|d| d.pattern)
.collect();
Regex::new_many(&patterns).expect("default secret patterns should compile")
}
/// Rebuilds [`TELEMETRY_SECRETS_REGEX`] from the user's and enterprise's secret
/// regex lists, layered on top of the default patterns. The default patterns are
/// always included, so redaction works even when the user has not configured any
/// custom patterns.
pub fn update_telemetry_secrets_regex<'a, U, E>(user_secrets: U, enterprise_secrets: E)
where
U: IntoIterator<Item = &'a regex::Regex>,
E: IntoIterator<Item = &'a regex::Regex>,
{
let patterns = compose_patterns(
user_secrets.into_iter().map(regex::Regex::as_str),
enterprise_secrets.into_iter().map(regex::Regex::as_str),
);
match Regex::new_many(&patterns) {
Ok(regex) => *TELEMETRY_SECRETS_REGEX.write() = regex,
Err(err) => log::error!("Failed to build telemetry secrets regex: {err:?}"),
}
}
/// Composes the full list of patterns to compile into the telemetry regex,
/// ordered enterprise → user → defaults, with later occurrences of an already-
/// seen pattern string deduped out.
fn compose_patterns<'a>(
user: impl Iterator<Item = &'a str>,
enterprise: impl Iterator<Item = &'a str>,
) -> Vec<&'a str> {
let mut seen: HashSet<&str> = HashSet::new();
let mut patterns: Vec<&str> = Vec::new();
let all = enterprise
.chain(user)
.chain(DEFAULT_REGEXES_WITH_NAMES.iter().map(|d| d.pattern));
for pattern in all {
if seen.insert(pattern) {
patterns.push(pattern);
}
}
patterns
}
/// Replaces every detected secret in `input` with a run of asterisks of the same
/// byte length. Overlapping matches (which can occur when multiple patterns match
/// the same region) are merged before replacement, so each character is replaced
/// at most once.
pub fn redact_secrets_in_string(input: &mut String) {
let ranges: Vec<Range<usize>> = {
let regex = TELEMETRY_SECRETS_REGEX.read();
regex.find_iter(input.as_str()).map(|m| m.range()).collect()
};
replace_byte_ranges_with_asterisks(input, ranges);
}
/// Replaces each byte range in `input` with a run of asterisks of the same byte
/// length. Handles overlapping ranges by merging them first, and replaces from
/// the end of the string so earlier byte indices stay valid as we mutate.
fn replace_byte_ranges_with_asterisks(input: &mut String, mut ranges: Vec<Range<usize>>) {
if ranges.is_empty() {
return;
}
// Sort and merge overlapping ranges so we don't double-replace.
ranges.sort_by_key(|r| r.start);
let mut merged: Vec<Range<usize>> = Vec::with_capacity(ranges.len());
for range in ranges {
match merged.last_mut() {
Some(last) if range.start <= last.end => last.end = last.end.max(range.end),
_ => merged.push(range),
}
}
// Replace from the end of the string so earlier byte indices stay valid.
for range in merged.into_iter().rev() {
let len = range.end - range.start;
input.replace_range(range, &REDACTION_REPLACEMENT_CHARACTER.repeat(len));
}
}
/// Walks a [`Value`] and runs [`redact_secrets_in_string`] on every string within
/// it. Non-string scalars (numbers, booleans, nulls) are left untouched.
pub fn redact_secrets_in_value(value: &mut Value) {
match value {
Value::String(s) => redact_secrets_in_string(s),
Value::Array(arr) => arr.iter_mut().for_each(redact_secrets_in_value),
Value::Object(obj) => obj.values_mut().for_each(redact_secrets_in_value),
Value::Null | Value::Bool(_) | Value::Number(_) => {}
}
}
#[cfg(test)]
#[path = "secret_redaction_tests.rs"]
mod tests;
@@ -0,0 +1,222 @@
use serde_json::json;
use super::*;
// AWS-style access keys used in tests; these match `AWS_ACCESS_ID` from
// `DEFAULT_REGEXES_WITH_NAMES`. The example value is the standard one used in
// AWS documentation and is not a real key.
const AWS_KEY_1: &str = "AKIAIOSFODNN7EXAMPLE";
const AWS_KEY_2: &str = "AKIA1234567890123456";
#[test]
fn redact_secrets_in_string_with_no_match_is_noop() {
let mut s = String::from("hello world, no secrets here");
let original = s.clone();
redact_secrets_in_string(&mut s);
assert_eq!(s, original);
}
#[test]
fn redact_secrets_in_string_redacts_single_secret_in_middle() {
let mut s = format!("prefix {AWS_KEY_1} suffix");
redact_secrets_in_string(&mut s);
let expected = format!("prefix {} suffix", "*".repeat(AWS_KEY_1.len()));
assert_eq!(s, expected);
}
#[test]
fn redact_secrets_in_string_redacts_multiple_independent_secrets() {
// This exercises the "replace from the end so earlier byte indices stay
// valid" requirement: replacing the second secret first must not invalidate
// the byte indices of the first secret.
let mut s = format!("a {AWS_KEY_1} b {AWS_KEY_2} c");
redact_secrets_in_string(&mut s);
let expected = format!(
"a {} b {} c",
"*".repeat(AWS_KEY_1.len()),
"*".repeat(AWS_KEY_2.len()),
);
assert_eq!(s, expected);
}
#[test]
fn redact_secrets_in_string_redacts_string_that_is_entirely_a_secret() {
let mut s = AWS_KEY_1.to_string();
redact_secrets_in_string(&mut s);
assert_eq!(s, "*".repeat(AWS_KEY_1.len()));
}
#[test]
fn replace_byte_ranges_with_asterisks_with_empty_ranges_is_noop() {
let mut s = String::from("no changes here");
let original = s.clone();
replace_byte_ranges_with_asterisks(&mut s, vec![]);
assert_eq!(s, original);
}
#[test]
fn replace_byte_ranges_with_asterisks_replaces_independent_ranges() {
// Separate, non-overlapping ranges are replaced independently. This
// exercises the reverse-iteration so earlier byte indices stay valid as
// the string mutates.
let mut s = String::from("0123456789ABCDEF");
let ranges = vec![0..3, 6..9, 12..15];
replace_byte_ranges_with_asterisks(&mut s, ranges);
assert_eq!(s, "***345***9AB***F");
}
#[test]
fn replace_byte_ranges_with_asterisks_merges_overlapping_ranges() {
// Ranges 0..10 and 5..15 overlap; they should merge into 0..15 so we don't
// double-replace the bytes in 5..10.
let mut s = String::from("0123456789ABCDEF");
let ranges = vec![0..10, 5..15];
replace_byte_ranges_with_asterisks(&mut s, ranges);
assert_eq!(s, "***************F");
}
#[test]
fn replace_byte_ranges_with_asterisks_merges_adjacent_ranges() {
// Adjacent (touching) ranges should also merge.
let mut s = String::from("0123456789ABCDEF");
let ranges = vec![0..5, 5..10];
replace_byte_ranges_with_asterisks(&mut s, ranges);
assert_eq!(s, "**********ABCDEF");
}
#[test]
fn replace_byte_ranges_with_asterisks_handles_unsorted_ranges() {
// Input ranges may be in arbitrary order; the function must sort before
// merging or replacing.
let mut s = String::from("0123456789ABCDEF");
let ranges = vec![10..12, 0..2, 4..6];
replace_byte_ranges_with_asterisks(&mut s, ranges);
assert_eq!(s, "**23**6789**CDEF");
}
#[test]
fn replace_byte_ranges_with_asterisks_handles_fully_contained_range() {
// A range fully contained in another should merge to the larger range.
let mut s = String::from("0123456789ABCDEF");
let ranges = vec![2..14, 5..8];
replace_byte_ranges_with_asterisks(&mut s, ranges);
assert_eq!(s, "01************EF");
}
#[test]
fn redact_secrets_in_value_redacts_strings_in_objects() {
let mut value = json!({
"with_secret": format!("contains {AWS_KEY_1} secret"),
"without_secret": "no secret here",
});
redact_secrets_in_value(&mut value);
assert_eq!(
value["with_secret"],
format!("contains {} secret", "*".repeat(AWS_KEY_1.len())),
);
assert_eq!(value["without_secret"], "no secret here");
}
#[test]
fn redact_secrets_in_value_redacts_strings_in_arrays() {
let mut value = json!([
format!("first {AWS_KEY_1}"),
"second clean",
format!("third {AWS_KEY_2}"),
]);
redact_secrets_in_value(&mut value);
assert_eq!(value[0], format!("first {}", "*".repeat(AWS_KEY_1.len())),);
assert_eq!(value[1], "second clean");
assert_eq!(value[2], format!("third {}", "*".repeat(AWS_KEY_2.len())),);
}
#[test]
fn redact_secrets_in_value_recurses_into_nested_structures() {
let mut value = json!({
"outer": {
"inner_array": [
format!("nested {AWS_KEY_1}"),
{"inner_object": format!("deep {AWS_KEY_2}")},
],
"scalar_int": 42,
"scalar_bool": true,
"scalar_null": null,
}
});
redact_secrets_in_value(&mut value);
assert_eq!(
value["outer"]["inner_array"][0],
format!("nested {}", "*".repeat(AWS_KEY_1.len())),
);
assert_eq!(
value["outer"]["inner_array"][1]["inner_object"],
format!("deep {}", "*".repeat(AWS_KEY_2.len())),
);
// Non-string scalars are left untouched.
assert_eq!(value["outer"]["scalar_int"], 42);
assert_eq!(value["outer"]["scalar_bool"], true);
assert!(value["outer"]["scalar_null"].is_null());
}
#[test]
fn redact_secrets_in_value_leaves_non_string_scalars_untouched() {
let mut value = json!({"n": 42, "b": true, "z": null});
let expected = value.clone();
redact_secrets_in_value(&mut value);
assert_eq!(value, expected);
}
#[test]
fn compose_patterns_includes_defaults_when_user_and_enterprise_are_empty() {
let patterns = compose_patterns(std::iter::empty(), std::iter::empty());
assert_eq!(patterns.len(), DEFAULT_REGEXES_WITH_NAMES.len());
for default in DEFAULT_REGEXES_WITH_NAMES {
assert!(
patterns.contains(&default.pattern),
"expected default pattern {} to be present",
default.pattern,
);
}
}
#[test]
fn compose_patterns_layers_user_and_enterprise_on_top_of_defaults() {
let user = [r"\bUSER-\d+\b"];
let enterprise = [r"\bENT-\d+\b"];
let patterns = compose_patterns(user.iter().copied(), enterprise.iter().copied());
// Enterprise comes first, then user, then defaults.
assert_eq!(patterns[0], r"\bENT-\d+\b");
assert_eq!(patterns[1], r"\bUSER-\d+\b");
// Defaults are still all present.
for default in DEFAULT_REGEXES_WITH_NAMES {
assert!(
patterns.contains(&default.pattern),
"expected default pattern {} to be present alongside user/enterprise",
default.pattern,
);
}
}
#[test]
fn compose_patterns_dedups_user_pattern_that_matches_a_default() {
// Pick the first default pattern; passing the same string as a "user"
// pattern should not cause it to appear twice in the composed list.
let duplicated = DEFAULT_REGEXES_WITH_NAMES[0].pattern;
let patterns = compose_patterns(std::iter::once(duplicated), std::iter::empty());
let occurrences = patterns.iter().filter(|p| **p == duplicated).count();
assert_eq!(
occurrences, 1,
"duplicate pattern should appear at most once in composed list",
);
// Total length is the defaults (the user pattern was deduped away).
assert_eq!(patterns.len(), DEFAULT_REGEXES_WITH_NAMES.len());
}
#[test]
fn compose_patterns_dedups_enterprise_pattern_that_matches_a_user_pattern() {
let user = [r"\bSHARED-\d+\b"];
let enterprise = [r"\bSHARED-\d+\b"];
let patterns = compose_patterns(user.iter().copied(), enterprise.iter().copied());
let occurrences = patterns.iter().filter(|p| **p == r"\bSHARED-\d+\b").count();
assert_eq!(occurrences, 1);
}