logiguard fork v3: full patch set on verified 8c74db0 tree
Includes prior-session patches (carry forward so the app compiles): - crates/gpui/build.rs: cross-compile manifest fix - crates/gpui/src/platform.rs: PlatformWindow::activate_with_token trait method - crates/gpui/src/window.rs: Window::activate_with_token public API - crates/gpui_linux/src/linux/wayland/window.rs: WaylandWindow::activate_with_token + activate() keyboard-serial fix Plus the focus-serial fix: - serial.rs: SerialKind::KeyboardEnter - client.rs: store wl_keyboard.enter serial; latest_serial_of() Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
89
crates/client/Cargo.toml
Normal file
89
crates/client/Cargo.toml
Normal file
@@ -0,0 +1,89 @@
|
||||
[package]
|
||||
name = "client"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
license = "GPL-3.0-or-later"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[lib]
|
||||
path = "src/client.rs"
|
||||
doctest = false
|
||||
|
||||
[features]
|
||||
test-support = ["clock/test-support", "collections/test-support", "gpui/test-support", "rpc/test-support"]
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
async-channel.workspace = true
|
||||
async-tungstenite = { workspace = true, features = ["tokio", "tokio-rustls-manual-roots"] }
|
||||
base64.workspace = true
|
||||
chrono = { workspace = true, features = ["serde"] }
|
||||
clock.workspace = true
|
||||
cloud_api_client.workspace = true
|
||||
cloud_api_types.workspace = true
|
||||
cloud_llm_client.workspace = true
|
||||
collections.workspace = true
|
||||
credentials_provider.workspace = true
|
||||
db.workspace = true
|
||||
derive_more.workspace = true
|
||||
feature_flags.workspace = true
|
||||
fs.workspace = true
|
||||
futures.workspace = true
|
||||
gpui.workspace = true
|
||||
gpui_tokio.workspace = true
|
||||
http_client.workspace = true
|
||||
http_client_tls.workspace = true
|
||||
httparse = "1.10"
|
||||
log.workspace = true
|
||||
parking_lot.workspace = true
|
||||
paths.workspace = true
|
||||
postage.workspace = true
|
||||
rand.workspace = true
|
||||
regex.workspace = true
|
||||
release_channel.workspace = true
|
||||
rpc = { workspace = true, features = ["gpui"] }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
serde_urlencoded.workspace = true
|
||||
settings.workspace = true
|
||||
sha2.workspace = true
|
||||
smol.workspace = true
|
||||
telemetry.workspace = true
|
||||
telemetry_events.workspace = true
|
||||
text.workspace = true
|
||||
thiserror.workspace = true
|
||||
time.workspace = true
|
||||
tiny_http.workspace = true
|
||||
tokio-socks.workspace = true
|
||||
tokio.workspace = true
|
||||
url.workspace = true
|
||||
util.workspace = true
|
||||
worktree.workspace = true
|
||||
zed_credentials_provider.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
clock = { workspace = true, features = ["test-support"] }
|
||||
collections = { workspace = true, features = ["test-support"] }
|
||||
fs.workspace = true
|
||||
gpui = { workspace = true, features = ["test-support"] }
|
||||
http_client = { workspace = true, features = ["test-support"] }
|
||||
rpc = { workspace = true, features = ["test-support"] }
|
||||
settings = { workspace = true, features = ["test-support"] }
|
||||
util = { workspace = true, features = ["test-support"] }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
semver.workspace = true
|
||||
windows.workspace = true
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc2-foundation.workspace = true
|
||||
|
||||
[target.'cfg(any(target_os = "windows", target_os = "macos"))'.dependencies]
|
||||
tokio-native-tls = "0.3"
|
||||
|
||||
[target.'cfg(not(any(target_os = "windows", target_os = "macos")))'.dependencies]
|
||||
rustls-pki-types = "1.12"
|
||||
tokio-rustls = { version = "0.26", features = ["tls12", "ring"], default-features = false }
|
||||
1
crates/client/LICENSE-GPL
Symbolic link
1
crates/client/LICENSE-GPL
Symbolic link
@@ -0,0 +1 @@
|
||||
../../LICENSE-GPL
|
||||
2374
crates/client/src/client.rs
Normal file
2374
crates/client/src/client.rs
Normal file
File diff suppressed because it is too large
Load Diff
117
crates/client/src/llm_token.rs
Normal file
117
crates/client/src/llm_token.rs
Normal file
@@ -0,0 +1,117 @@
|
||||
use super::{Client, UserStore};
|
||||
use cloud_api_client::LlmApiToken;
|
||||
use cloud_api_types::websocket_protocol::MessageToClient;
|
||||
use cloud_llm_client::{EXPIRED_LLM_TOKEN_HEADER_NAME, OUTDATED_LLM_TOKEN_HEADER_NAME};
|
||||
use gpui::{
|
||||
App, AppContext as _, Context, Entity, EventEmitter, Global, ReadGlobal as _, Subscription,
|
||||
TaskExt,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub trait NeedsLlmTokenRefresh {
|
||||
/// Returns whether the LLM token needs to be refreshed.
|
||||
fn needs_llm_token_refresh(&self) -> bool;
|
||||
}
|
||||
|
||||
impl NeedsLlmTokenRefresh for http_client::Response<http_client::AsyncBody> {
|
||||
fn needs_llm_token_refresh(&self) -> bool {
|
||||
self.headers().get(EXPIRED_LLM_TOKEN_HEADER_NAME).is_some()
|
||||
|| self.headers().get(OUTDATED_LLM_TOKEN_HEADER_NAME).is_some()
|
||||
}
|
||||
}
|
||||
|
||||
enum TokenRefreshMode {
|
||||
Refresh,
|
||||
ClearAndRefresh,
|
||||
}
|
||||
|
||||
pub fn global_llm_token(cx: &App) -> LlmApiToken {
|
||||
RefreshLlmTokenListener::global(cx)
|
||||
.read(cx)
|
||||
.llm_api_token
|
||||
.clone()
|
||||
}
|
||||
|
||||
struct GlobalRefreshLlmTokenListener(Entity<RefreshLlmTokenListener>);
|
||||
|
||||
impl Global for GlobalRefreshLlmTokenListener {}
|
||||
|
||||
pub struct LlmTokenRefreshedEvent;
|
||||
|
||||
pub struct RefreshLlmTokenListener {
|
||||
client: Arc<Client>,
|
||||
user_store: Entity<UserStore>,
|
||||
llm_api_token: LlmApiToken,
|
||||
_subscription: Subscription,
|
||||
}
|
||||
|
||||
impl EventEmitter<LlmTokenRefreshedEvent> for RefreshLlmTokenListener {}
|
||||
|
||||
impl RefreshLlmTokenListener {
|
||||
pub fn register(client: Arc<Client>, user_store: Entity<UserStore>, cx: &mut App) {
|
||||
let listener = cx.new(|cx| RefreshLlmTokenListener::new(client, user_store, cx));
|
||||
cx.set_global(GlobalRefreshLlmTokenListener(listener));
|
||||
}
|
||||
|
||||
pub fn global(cx: &App) -> Entity<Self> {
|
||||
GlobalRefreshLlmTokenListener::global(cx).0.clone()
|
||||
}
|
||||
|
||||
fn new(client: Arc<Client>, user_store: Entity<UserStore>, cx: &mut Context<Self>) -> Self {
|
||||
client.add_message_to_client_handler({
|
||||
let this = cx.weak_entity();
|
||||
move |message, cx| {
|
||||
if let Some(this) = this.upgrade() {
|
||||
Self::handle_refresh_llm_token(this, message, cx);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let subscription = cx.subscribe(&user_store, |this, _user_store, event, cx| {
|
||||
if matches!(event, super::user::Event::OrganizationChanged) {
|
||||
this.refresh(TokenRefreshMode::ClearAndRefresh, cx);
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
client,
|
||||
user_store,
|
||||
llm_api_token: LlmApiToken::default(),
|
||||
_subscription: subscription,
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh(&self, mode: TokenRefreshMode, cx: &mut Context<Self>) {
|
||||
let client = self.client.clone();
|
||||
let llm_api_token = self.llm_api_token.clone();
|
||||
let organization_id = self
|
||||
.user_store
|
||||
.read(cx)
|
||||
.current_organization()
|
||||
.map(|organization| organization.id.clone());
|
||||
cx.spawn(async move |this, cx| {
|
||||
match mode {
|
||||
TokenRefreshMode::Refresh => {
|
||||
client
|
||||
.refresh_llm_token(&llm_api_token, organization_id)
|
||||
.await?;
|
||||
}
|
||||
TokenRefreshMode::ClearAndRefresh => {
|
||||
client
|
||||
.clear_and_refresh_llm_token(&llm_api_token, organization_id)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
this.update(cx, |_this, cx| cx.emit(LlmTokenRefreshedEvent))
|
||||
})
|
||||
.detach_and_log_err(cx);
|
||||
}
|
||||
|
||||
fn handle_refresh_llm_token(this: Entity<Self>, message: &MessageToClient, cx: &mut App) {
|
||||
match message {
|
||||
MessageToClient::UserUpdated => {
|
||||
this.update(cx, |this, cx| this.refresh(TokenRefreshMode::Refresh, cx));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
66
crates/client/src/proxy.rs
Normal file
66
crates/client/src/proxy.rs
Normal file
@@ -0,0 +1,66 @@
|
||||
//! client proxy
|
||||
|
||||
mod http_proxy;
|
||||
mod socks_proxy;
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
use http_client::Url;
|
||||
use http_proxy::{HttpProxyType, connect_http_proxy_stream, parse_http_proxy};
|
||||
use socks_proxy::{SocksVersion, connect_socks_proxy_stream, parse_socks_proxy};
|
||||
|
||||
pub(crate) async fn connect_proxy_stream(
|
||||
proxy: &Url,
|
||||
rpc_host: (&str, u16),
|
||||
) -> Result<Box<dyn AsyncReadWrite>> {
|
||||
let Some(((proxy_domain, proxy_port), proxy_type)) = parse_proxy_type(proxy) else {
|
||||
// If parsing the proxy URL fails, we must avoid falling back to an insecure connection.
|
||||
// SOCKS proxies are often used in contexts where security and privacy are critical,
|
||||
// so any fallback could expose users to significant risks.
|
||||
anyhow::bail!("Parsing proxy url failed");
|
||||
};
|
||||
|
||||
// Connect to proxy and wrap protocol later
|
||||
let stream = tokio::net::TcpStream::connect((proxy_domain.as_str(), proxy_port))
|
||||
.await
|
||||
.context("Failed to connect to proxy")?;
|
||||
|
||||
let proxy_stream = match proxy_type {
|
||||
ProxyType::SocksProxy(proxy) => connect_socks_proxy_stream(stream, proxy, rpc_host).await?,
|
||||
ProxyType::HttpProxy(proxy) => {
|
||||
connect_http_proxy_stream(stream, proxy, rpc_host, &proxy_domain).await?
|
||||
}
|
||||
};
|
||||
|
||||
Ok(proxy_stream)
|
||||
}
|
||||
|
||||
enum ProxyType<'t> {
|
||||
SocksProxy(SocksVersion<'t>),
|
||||
HttpProxy(HttpProxyType<'t>),
|
||||
}
|
||||
|
||||
fn parse_proxy_type(proxy: &Url) -> Option<((String, u16), ProxyType<'_>)> {
|
||||
let scheme = proxy.scheme();
|
||||
let host = proxy.host()?.to_string();
|
||||
let port = proxy.port_or_known_default()?;
|
||||
let proxy_type = match scheme {
|
||||
scheme if scheme.starts_with("socks") => {
|
||||
Some(ProxyType::SocksProxy(parse_socks_proxy(scheme, proxy)))
|
||||
}
|
||||
scheme if scheme.starts_with("http") => {
|
||||
Some(ProxyType::HttpProxy(parse_http_proxy(scheme, proxy)))
|
||||
}
|
||||
_ => None,
|
||||
}?;
|
||||
|
||||
Some(((host, port), proxy_type))
|
||||
}
|
||||
|
||||
pub(crate) trait AsyncReadWrite:
|
||||
tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static
|
||||
{
|
||||
}
|
||||
impl<T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static> AsyncReadWrite
|
||||
for T
|
||||
{
|
||||
}
|
||||
193
crates/client/src/proxy/http_proxy.rs
Normal file
193
crates/client/src/proxy/http_proxy.rs
Normal file
@@ -0,0 +1,193 @@
|
||||
use anyhow::{Context, Result};
|
||||
use base64::Engine;
|
||||
use httparse::{EMPTY_HEADER, Response};
|
||||
use tokio::{
|
||||
io::{AsyncBufReadExt, AsyncWriteExt, BufStream},
|
||||
net::TcpStream,
|
||||
};
|
||||
#[cfg(any(target_os = "windows", target_os = "macos"))]
|
||||
use tokio_native_tls::{TlsConnector, native_tls};
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||
use tokio_rustls::TlsConnector;
|
||||
use url::Url;
|
||||
|
||||
use super::AsyncReadWrite;
|
||||
|
||||
pub(super) enum HttpProxyType<'t> {
|
||||
HTTP(Option<HttpProxyAuthorization<'t>>),
|
||||
HTTPS(Option<HttpProxyAuthorization<'t>>),
|
||||
}
|
||||
|
||||
pub(super) struct HttpProxyAuthorization<'t> {
|
||||
username: &'t str,
|
||||
password: &'t str,
|
||||
}
|
||||
|
||||
pub(super) fn parse_http_proxy<'t>(scheme: &str, proxy: &'t Url) -> HttpProxyType<'t> {
|
||||
let auth = proxy.password().map(|password| HttpProxyAuthorization {
|
||||
username: proxy.username(),
|
||||
password,
|
||||
});
|
||||
if scheme.starts_with("https") {
|
||||
HttpProxyType::HTTPS(auth)
|
||||
} else {
|
||||
HttpProxyType::HTTP(auth)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn connect_http_proxy_stream(
|
||||
stream: TcpStream,
|
||||
http_proxy: HttpProxyType<'_>,
|
||||
rpc_host: (&str, u16),
|
||||
proxy_domain: &str,
|
||||
) -> Result<Box<dyn AsyncReadWrite>> {
|
||||
match http_proxy {
|
||||
HttpProxyType::HTTP(auth) => http_connect(stream, rpc_host, auth).await,
|
||||
HttpProxyType::HTTPS(auth) => https_connect(stream, rpc_host, auth, proxy_domain).await,
|
||||
}
|
||||
.context("error connecting to http/https proxy")
|
||||
}
|
||||
|
||||
async fn http_connect<T>(
|
||||
stream: T,
|
||||
target: (&str, u16),
|
||||
auth: Option<HttpProxyAuthorization<'_>>,
|
||||
) -> Result<Box<dyn AsyncReadWrite>>
|
||||
where
|
||||
T: AsyncReadWrite,
|
||||
{
|
||||
let mut stream = BufStream::new(stream);
|
||||
let request = make_request(target, auth);
|
||||
stream.write_all(request.as_bytes()).await?;
|
||||
stream.flush().await?;
|
||||
check_response(&mut stream).await?;
|
||||
Ok(Box::new(stream))
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", target_os = "macos"))]
|
||||
async fn https_connect<T>(
|
||||
stream: T,
|
||||
target: (&str, u16),
|
||||
auth: Option<HttpProxyAuthorization<'_>>,
|
||||
proxy_domain: &str,
|
||||
) -> Result<Box<dyn AsyncReadWrite>>
|
||||
where
|
||||
T: AsyncReadWrite,
|
||||
{
|
||||
let tls_connector = TlsConnector::from(native_tls::TlsConnector::new()?);
|
||||
let stream = tls_connector.connect(proxy_domain, stream).await?;
|
||||
http_connect(stream, target, auth).await
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||
async fn https_connect<T>(
|
||||
stream: T,
|
||||
target: (&str, u16),
|
||||
auth: Option<HttpProxyAuthorization<'_>>,
|
||||
proxy_domain: &str,
|
||||
) -> Result<Box<dyn AsyncReadWrite>>
|
||||
where
|
||||
T: AsyncReadWrite,
|
||||
{
|
||||
let proxy_domain = rustls_pki_types::ServerName::try_from(proxy_domain)
|
||||
.context("Address resolution failed")?
|
||||
.to_owned();
|
||||
let tls_connector = TlsConnector::from(std::sync::Arc::new(http_client_tls::tls_config()));
|
||||
let stream = tls_connector.connect(proxy_domain, stream).await?;
|
||||
http_connect(stream, target, auth).await
|
||||
}
|
||||
|
||||
fn make_request(target: (&str, u16), auth: Option<HttpProxyAuthorization<'_>>) -> String {
|
||||
let (host, port) = target;
|
||||
let mut request = format!(
|
||||
"CONNECT {host}:{port} HTTP/1.1\r\nHost: {host}:{port}\r\nProxy-Connection: Keep-Alive\r\n"
|
||||
);
|
||||
if let Some(HttpProxyAuthorization { username, password }) = auth {
|
||||
let auth =
|
||||
base64::prelude::BASE64_STANDARD.encode(format!("{username}:{password}").as_bytes());
|
||||
let auth = format!("Proxy-Authorization: Basic {auth}\r\n");
|
||||
request.push_str(&auth);
|
||||
}
|
||||
request.push_str("\r\n");
|
||||
request
|
||||
}
|
||||
|
||||
async fn check_response<T>(stream: &mut BufStream<T>) -> Result<()>
|
||||
where
|
||||
T: AsyncReadWrite,
|
||||
{
|
||||
let response = recv_response(stream).await?;
|
||||
let mut dummy_headers = [EMPTY_HEADER; MAX_RESPONSE_HEADERS];
|
||||
let mut parser = Response::new(&mut dummy_headers);
|
||||
parser.parse(response.as_bytes())?;
|
||||
|
||||
match parser.code {
|
||||
Some(code) => {
|
||||
if code == 200 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow::anyhow!(
|
||||
"Proxy connection failed with HTTP code: {code}"
|
||||
))
|
||||
}
|
||||
}
|
||||
None => Err(anyhow::anyhow!(
|
||||
"Proxy connection failed with no HTTP code: {}",
|
||||
parser.reason.unwrap_or("Unknown reason")
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_RESPONSE_HEADER_LENGTH: usize = 4096;
|
||||
const MAX_RESPONSE_HEADERS: usize = 16;
|
||||
|
||||
async fn recv_response<T>(stream: &mut BufStream<T>) -> Result<String>
|
||||
where
|
||||
T: AsyncReadWrite,
|
||||
{
|
||||
let mut response = String::new();
|
||||
loop {
|
||||
if stream.read_line(&mut response).await? == 0 {
|
||||
return Err(anyhow::anyhow!("End of stream"));
|
||||
}
|
||||
|
||||
if MAX_RESPONSE_HEADER_LENGTH < response.len() {
|
||||
return Err(anyhow::anyhow!("Maximum response header length exceeded"));
|
||||
}
|
||||
|
||||
if response.ends_with("\r\n\r\n") {
|
||||
return Ok(response);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use url::Url;
|
||||
|
||||
use super::{HttpProxyAuthorization, HttpProxyType, parse_http_proxy};
|
||||
|
||||
#[test]
|
||||
fn test_parse_http_proxy() {
|
||||
let proxy = Url::parse("http://proxy.example.com:1080").unwrap();
|
||||
let scheme = proxy.scheme();
|
||||
|
||||
let version = parse_http_proxy(scheme, &proxy);
|
||||
assert!(matches!(version, HttpProxyType::HTTP(None)))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_http_proxy_with_auth() {
|
||||
let proxy = Url::parse("http://username:password@proxy.example.com:1080").unwrap();
|
||||
let scheme = proxy.scheme();
|
||||
|
||||
let version = parse_http_proxy(scheme, &proxy);
|
||||
assert!(matches!(
|
||||
version,
|
||||
HttpProxyType::HTTP(Some(HttpProxyAuthorization {
|
||||
username: "username",
|
||||
password: "password"
|
||||
}))
|
||||
))
|
||||
}
|
||||
}
|
||||
226
crates/client/src/proxy/socks_proxy.rs
Normal file
226
crates/client/src/proxy/socks_proxy.rs
Normal file
@@ -0,0 +1,226 @@
|
||||
//! socks proxy
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
use http_client::Url;
|
||||
use tokio::net::TcpStream;
|
||||
use tokio_socks::{
|
||||
IntoTargetAddr, TargetAddr,
|
||||
tcp::{Socks4Stream, Socks5Stream},
|
||||
};
|
||||
|
||||
use super::AsyncReadWrite;
|
||||
|
||||
/// Identification to a Socks V4 Proxy
|
||||
pub(super) struct Socks4Identification<'a> {
|
||||
user_id: &'a str,
|
||||
}
|
||||
|
||||
/// Authorization to a Socks V5 Proxy
|
||||
pub(super) struct Socks5Authorization<'a> {
|
||||
username: &'a str,
|
||||
password: &'a str,
|
||||
}
|
||||
|
||||
/// Socks Proxy Protocol Version
|
||||
///
|
||||
/// V4 allows identification using a user_id
|
||||
/// V5 allows authorization using a username and password
|
||||
pub(super) enum SocksVersion<'a> {
|
||||
V4 {
|
||||
local_dns: bool,
|
||||
identification: Option<Socks4Identification<'a>>,
|
||||
},
|
||||
V5 {
|
||||
local_dns: bool,
|
||||
authorization: Option<Socks5Authorization<'a>>,
|
||||
},
|
||||
}
|
||||
|
||||
pub(super) fn parse_socks_proxy<'t>(scheme: &str, proxy: &'t Url) -> SocksVersion<'t> {
|
||||
if scheme.starts_with("socks4") {
|
||||
let identification = match proxy.username() {
|
||||
"" => None,
|
||||
username => Some(Socks4Identification { user_id: username }),
|
||||
};
|
||||
SocksVersion::V4 {
|
||||
local_dns: scheme != "socks4a",
|
||||
identification,
|
||||
}
|
||||
} else {
|
||||
let authorization = proxy.password().map(|password| Socks5Authorization {
|
||||
username: proxy.username(),
|
||||
password,
|
||||
});
|
||||
SocksVersion::V5 {
|
||||
local_dns: scheme != "socks5h",
|
||||
authorization,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn connect_socks_proxy_stream(
|
||||
stream: TcpStream,
|
||||
socks_version: SocksVersion<'_>,
|
||||
rpc_host: (&str, u16),
|
||||
) -> Result<Box<dyn AsyncReadWrite>> {
|
||||
let rpc_host = rpc_host
|
||||
.into_target_addr()
|
||||
.context("Failed to parse target addr")?;
|
||||
|
||||
let local_dns = match &socks_version {
|
||||
SocksVersion::V4 { local_dns, .. } => local_dns,
|
||||
SocksVersion::V5 { local_dns, .. } => local_dns,
|
||||
};
|
||||
let rpc_host = match (rpc_host, local_dns) {
|
||||
(TargetAddr::Domain(domain, port), true) => {
|
||||
let ip_addr = tokio::net::lookup_host((domain.as_ref(), port))
|
||||
.await
|
||||
.with_context(|| format!("Failed to lookup domain {}", domain))?
|
||||
.next()
|
||||
.ok_or_else(|| anyhow::anyhow!("Failed to lookup domain {}", domain))?;
|
||||
TargetAddr::Ip(ip_addr)
|
||||
}
|
||||
(rpc_host, _) => rpc_host,
|
||||
};
|
||||
|
||||
match socks_version {
|
||||
SocksVersion::V4 {
|
||||
identification: None,
|
||||
..
|
||||
} => {
|
||||
let socks = Socks4Stream::connect_with_socket(stream, rpc_host)
|
||||
.await
|
||||
.context("error connecting to socks")?;
|
||||
Ok(Box::new(socks))
|
||||
}
|
||||
SocksVersion::V4 {
|
||||
identification: Some(Socks4Identification { user_id }),
|
||||
..
|
||||
} => {
|
||||
let socks = Socks4Stream::connect_with_userid_and_socket(stream, rpc_host, user_id)
|
||||
.await
|
||||
.context("error connecting to socks")?;
|
||||
Ok(Box::new(socks))
|
||||
}
|
||||
SocksVersion::V5 {
|
||||
authorization: None,
|
||||
..
|
||||
} => {
|
||||
let socks = Socks5Stream::connect_with_socket(stream, rpc_host)
|
||||
.await
|
||||
.context("error connecting to socks")?;
|
||||
Ok(Box::new(socks))
|
||||
}
|
||||
SocksVersion::V5 {
|
||||
authorization: Some(Socks5Authorization { username, password }),
|
||||
..
|
||||
} => {
|
||||
let socks = Socks5Stream::connect_with_password_and_socket(
|
||||
stream, rpc_host, username, password,
|
||||
)
|
||||
.await
|
||||
.context("error connecting to socks")?;
|
||||
Ok(Box::new(socks))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use url::Url;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_socks4() {
|
||||
let proxy = Url::parse("socks4://proxy.example.com:1080").unwrap();
|
||||
let scheme = proxy.scheme();
|
||||
|
||||
let version = parse_socks_proxy(scheme, &proxy);
|
||||
assert!(matches!(
|
||||
version,
|
||||
SocksVersion::V4 {
|
||||
local_dns: true,
|
||||
identification: None
|
||||
}
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_socks4_with_identification() {
|
||||
let proxy = Url::parse("socks4://userid@proxy.example.com:1080").unwrap();
|
||||
let scheme = proxy.scheme();
|
||||
|
||||
let version = parse_socks_proxy(scheme, &proxy);
|
||||
assert!(matches!(
|
||||
version,
|
||||
SocksVersion::V4 {
|
||||
local_dns: true,
|
||||
identification: Some(Socks4Identification { user_id: "userid" })
|
||||
}
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_socks4_with_remote_dns() {
|
||||
let proxy = Url::parse("socks4a://proxy.example.com:1080").unwrap();
|
||||
let scheme = proxy.scheme();
|
||||
|
||||
let version = parse_socks_proxy(scheme, &proxy);
|
||||
assert!(matches!(
|
||||
version,
|
||||
SocksVersion::V4 {
|
||||
local_dns: false,
|
||||
identification: None
|
||||
}
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_socks5() {
|
||||
let proxy = Url::parse("socks5://proxy.example.com:1080").unwrap();
|
||||
let scheme = proxy.scheme();
|
||||
|
||||
let version = parse_socks_proxy(scheme, &proxy);
|
||||
assert!(matches!(
|
||||
version,
|
||||
SocksVersion::V5 {
|
||||
local_dns: true,
|
||||
authorization: None
|
||||
}
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_socks5_with_authorization() {
|
||||
let proxy = Url::parse("socks5://username:password@proxy.example.com:1080").unwrap();
|
||||
let scheme = proxy.scheme();
|
||||
|
||||
let version = parse_socks_proxy(scheme, &proxy);
|
||||
assert!(matches!(
|
||||
version,
|
||||
SocksVersion::V5 {
|
||||
local_dns: true,
|
||||
authorization: Some(Socks5Authorization {
|
||||
username: "username",
|
||||
password: "password"
|
||||
})
|
||||
}
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_socks5_with_remote_dns() {
|
||||
let proxy = Url::parse("socks5h://proxy.example.com:1080").unwrap();
|
||||
let scheme = proxy.scheme();
|
||||
|
||||
let version = parse_socks_proxy(scheme, &proxy);
|
||||
assert!(matches!(
|
||||
version,
|
||||
SocksVersion::V5 {
|
||||
local_dns: false,
|
||||
authorization: None
|
||||
}
|
||||
))
|
||||
}
|
||||
}
|
||||
978
crates/client/src/telemetry.rs
Normal file
978
crates/client/src/telemetry.rs
Normal file
@@ -0,0 +1,978 @@
|
||||
mod event_coalescer;
|
||||
|
||||
use crate::TelemetrySettings;
|
||||
use anyhow::{Context as _, Result};
|
||||
use clock::SystemClock;
|
||||
use fs::Fs;
|
||||
use futures::channel::mpsc;
|
||||
use futures::{Future, StreamExt};
|
||||
use gpui::{App, AppContext as _, BackgroundExecutor, Task};
|
||||
use http_client::{self, AsyncBody, HttpClient, HttpClientWithUrl, Method, Request};
|
||||
use parking_lot::Mutex;
|
||||
use regex::Regex;
|
||||
use release_channel::ReleaseChannel;
|
||||
use settings::{Settings, SettingsStore};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::HashSet;
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::sync::LazyLock;
|
||||
use std::time::Instant;
|
||||
use std::{env, mem, path::PathBuf, sync::Arc, time::Duration};
|
||||
use telemetry_events::{AssistantEventData, AssistantPhase, Event, EventRequestBody, EventWrapper};
|
||||
|
||||
pub struct TelemetrySubscription {
|
||||
pub historical_events: Result<HistoricalEvents>,
|
||||
pub queued_events: Vec<EventWrapper>,
|
||||
pub live_events: mpsc::UnboundedReceiver<EventWrapper>,
|
||||
}
|
||||
|
||||
pub struct HistoricalEvents {
|
||||
pub events: Vec<EventWrapper>,
|
||||
pub parse_error_count: usize,
|
||||
}
|
||||
use util::ResultExt as _;
|
||||
use worktree::{UpdatedEntriesSet, WorktreeId};
|
||||
|
||||
use self::event_coalescer::EventCoalescer;
|
||||
|
||||
pub struct Telemetry {
|
||||
clock: Arc<dyn SystemClock>,
|
||||
http_client: Arc<HttpClientWithUrl>,
|
||||
executor: BackgroundExecutor,
|
||||
state: Arc<Mutex<TelemetryState>>,
|
||||
}
|
||||
|
||||
struct TelemetryState {
|
||||
settings: TelemetrySettings,
|
||||
system_id: Option<Arc<str>>, // Per system
|
||||
installation_id: Option<Arc<str>>, // Per app installation (different for dev, nightly, preview, and stable)
|
||||
session_id: Option<String>, // Per app launch
|
||||
metrics_id: Option<Arc<str>>, // Per logged-in user
|
||||
release_channel: Option<ReleaseChannel>,
|
||||
architecture: &'static str,
|
||||
events_queue: Vec<EventWrapper>,
|
||||
flush_events_task: Option<Task<()>>,
|
||||
|
||||
log_file: Option<File>,
|
||||
is_staff: Option<bool>,
|
||||
first_event_date_time: Option<Instant>,
|
||||
event_coalescer: EventCoalescer,
|
||||
max_queue_size: usize,
|
||||
worktrees_with_project_type_events_sent: HashSet<WorktreeId>,
|
||||
|
||||
os_name: String,
|
||||
app_version: String,
|
||||
os_version: Option<String>,
|
||||
|
||||
subscribers: Vec<mpsc::UnboundedSender<EventWrapper>>,
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
const MAX_QUEUE_LEN: usize = 5;
|
||||
|
||||
#[cfg(not(debug_assertions))]
|
||||
const MAX_QUEUE_LEN: usize = 50;
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
const FLUSH_INTERVAL: Duration = Duration::from_secs(1);
|
||||
|
||||
#[cfg(not(debug_assertions))]
|
||||
const FLUSH_INTERVAL: Duration = Duration::from_secs(60 * 5);
|
||||
static ZED_CLIENT_CHECKSUM_SEED: LazyLock<Option<Vec<u8>>> = LazyLock::new(|| {
|
||||
option_env!("ZED_CLIENT_CHECKSUM_SEED")
|
||||
.map(|s| s.as_bytes().into())
|
||||
.or_else(|| {
|
||||
env::var("ZED_CLIENT_CHECKSUM_SEED")
|
||||
.ok()
|
||||
.map(|s| s.as_bytes().into())
|
||||
})
|
||||
});
|
||||
|
||||
pub static MINIDUMP_ENDPOINT: LazyLock<Option<String>> = LazyLock::new(|| {
|
||||
option_env!("ZED_MINIDUMP_ENDPOINT")
|
||||
.map(str::to_string)
|
||||
.or_else(|| env::var("ZED_MINIDUMP_ENDPOINT").ok())
|
||||
});
|
||||
|
||||
static DOTNET_PROJECT_FILES_REGEX: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"^(global\.json|Directory\.Build\.props|.*\.(csproj|fsproj|vbproj|sln))$").unwrap()
|
||||
});
|
||||
|
||||
pub fn os_name() -> String {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
"macOS".to_string()
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
format!("Linux {}", gpui::guess_compositor())
|
||||
}
|
||||
#[cfg(target_os = "freebsd")]
|
||||
{
|
||||
format!("FreeBSD {}", gpui::guess_compositor())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
"Windows".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Note: This might do blocking IO! Only call from background threads
|
||||
pub fn os_version() -> String {
|
||||
cfg_select! {
|
||||
feature = "test-support" => {
|
||||
// MacOS branch in particular is quite slow, hence we ought to "avoid" it in tests.
|
||||
"test binary".to_owned()
|
||||
}
|
||||
target_os = "macos" => {
|
||||
static MACOS_VERSION_REGEX: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"(\s*\(Build [^)]*[0-9]\))").unwrap()
|
||||
});
|
||||
use objc2_foundation::NSProcessInfo;
|
||||
let process_info = NSProcessInfo::processInfo();
|
||||
let version_nsstring = process_info.operatingSystemVersionString();
|
||||
// "Version 15.6.1 (Build 24G90)" -> "15.6.1 (Build 24G90)"
|
||||
let version_string = version_nsstring.to_string().replace("Version ", "");
|
||||
// "15.6.1 (Build 24G90)" -> "15.6.1"
|
||||
// "26.0.0 (Build 25A5349a)" -> unchanged (Beta or Rapid Security Response; ends with letter)
|
||||
MACOS_VERSION_REGEX
|
||||
.replace_all(&version_string, "")
|
||||
.to_string()
|
||||
}
|
||||
any(target_os = "linux", target_os = "freebsd") => {
|
||||
use std::path::Path;
|
||||
|
||||
let content = if let Ok(file) = std::fs::read_to_string(&Path::new("/etc/os-release")) {
|
||||
file
|
||||
} else if let Ok(file) = std::fs::read_to_string(&Path::new("/usr/lib/os-release")) {
|
||||
file
|
||||
} else if let Ok(file) = std::fs::read_to_string(&Path::new("/var/run/os-release")) {
|
||||
file
|
||||
} else {
|
||||
log::error!(
|
||||
"Failed to load /etc/os-release, /usr/lib/os-release, or /var/run/os-release"
|
||||
);
|
||||
"".to_string()
|
||||
};
|
||||
let mut name = "unknown";
|
||||
let mut version = "unknown";
|
||||
|
||||
for line in content.lines() {
|
||||
match line.split_once('=') {
|
||||
Some(("ID", val)) => name = val.trim_matches('"'),
|
||||
Some(("VERSION_ID", val)) => version = val.trim_matches('"'),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
format!("{} {}", name, version)
|
||||
}
|
||||
target_os = "windows" => {
|
||||
let mut info = unsafe { std::mem::zeroed() };
|
||||
let status = unsafe { windows::Wdk::System::SystemServices::RtlGetVersion(&mut info) };
|
||||
if status.is_ok() {
|
||||
semver::Version::new(
|
||||
info.dwMajorVersion as _,
|
||||
info.dwMinorVersion as _,
|
||||
info.dwBuildNumber as _,
|
||||
)
|
||||
.to_string()
|
||||
} else {
|
||||
"unknown".to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Telemetry {
|
||||
pub fn new(
|
||||
clock: Arc<dyn SystemClock>,
|
||||
client: Arc<HttpClientWithUrl>,
|
||||
cx: &mut App,
|
||||
) -> Arc<Self> {
|
||||
let state = Arc::new(Mutex::new(TelemetryState {
|
||||
settings: *TelemetrySettings::get_global(cx),
|
||||
architecture: env::consts::ARCH,
|
||||
release_channel: ReleaseChannel::try_global(cx),
|
||||
system_id: None,
|
||||
installation_id: None,
|
||||
session_id: None,
|
||||
metrics_id: None,
|
||||
events_queue: Vec::new(),
|
||||
flush_events_task: None,
|
||||
log_file: None,
|
||||
is_staff: None,
|
||||
first_event_date_time: None,
|
||||
event_coalescer: EventCoalescer::new(clock.clone()),
|
||||
max_queue_size: MAX_QUEUE_LEN,
|
||||
worktrees_with_project_type_events_sent: HashSet::new(),
|
||||
|
||||
os_version: None,
|
||||
os_name: os_name(),
|
||||
app_version: release_channel::AppVersion::global(cx).to_string(),
|
||||
subscribers: Vec::new(),
|
||||
}));
|
||||
|
||||
cx.background_spawn({
|
||||
let state = state.clone();
|
||||
let os_version = os_version();
|
||||
state.lock().os_version = Some(os_version);
|
||||
async move {
|
||||
if let Some(tempfile) = File::create(Self::log_file_path()).ok() {
|
||||
state.lock().log_file = Some(tempfile);
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
cx.observe_global::<SettingsStore>({
|
||||
let state = state.clone();
|
||||
|
||||
move |cx| {
|
||||
let mut state = state.lock();
|
||||
state.settings = *TelemetrySettings::get_global(cx);
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
let this = Arc::new(Self {
|
||||
clock,
|
||||
http_client: client,
|
||||
executor: cx.background_executor().clone(),
|
||||
state,
|
||||
});
|
||||
|
||||
let (tx, mut rx) = mpsc::unbounded();
|
||||
::telemetry::init(tx);
|
||||
|
||||
cx.background_spawn({
|
||||
let this = Arc::downgrade(&this);
|
||||
async move {
|
||||
if cfg!(feature = "test-support") {
|
||||
return;
|
||||
}
|
||||
while let Some(event) = rx.next().await {
|
||||
let Some(state) = this.upgrade() else { break };
|
||||
state.report_event(Event::Flexible(event))
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
// We should only ever have one instance of Telemetry, leak the subscription to keep it alive
|
||||
// rather than store in TelemetryState, complicating spawn as subscriptions are not Send
|
||||
std::mem::forget(cx.on_app_quit({
|
||||
let this = this.clone();
|
||||
move |_| this.shutdown_telemetry()
|
||||
}));
|
||||
|
||||
this
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
fn shutdown_telemetry(self: &Arc<Self>) -> impl Future<Output = ()> + use<> {
|
||||
Task::ready(())
|
||||
}
|
||||
|
||||
// Skip calling this function in tests.
|
||||
// TestAppContext ends up calling this function on shutdown and it panics when trying to find the TelemetrySettings
|
||||
#[cfg(not(any(test, feature = "test-support")))]
|
||||
fn shutdown_telemetry(self: &Arc<Self>) -> impl Future<Output = ()> + use<> {
|
||||
telemetry::event!("App Closed");
|
||||
// TODO: close final edit period and make sure it's sent
|
||||
Task::ready(())
|
||||
}
|
||||
|
||||
pub fn log_file_path() -> PathBuf {
|
||||
paths::logs_dir().join("telemetry.log")
|
||||
}
|
||||
|
||||
pub async fn subscribe_with_history(
|
||||
self: &Arc<Self>,
|
||||
fs: Arc<dyn Fs>,
|
||||
) -> TelemetrySubscription {
|
||||
let historical_events = self.read_log_file(fs).await;
|
||||
|
||||
let mut state = self.state.lock();
|
||||
let queued_events: Vec<EventWrapper> = state.events_queue.clone();
|
||||
|
||||
let (tx, rx) = mpsc::unbounded();
|
||||
state.subscribers.push(tx);
|
||||
|
||||
drop(state);
|
||||
|
||||
TelemetrySubscription {
|
||||
historical_events,
|
||||
queued_events,
|
||||
live_events: rx,
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_log_file(self: &Arc<Self>, fs: Arc<dyn Fs>) -> anyhow::Result<HistoricalEvents> {
|
||||
const MAX_LOG_READ: usize = 5 * 1024 * 1024;
|
||||
|
||||
let path = Self::log_file_path();
|
||||
|
||||
let content = fs
|
||||
.load_bytes(&path)
|
||||
.await
|
||||
.with_context(|| format!("failed to load telemetry log from {:?}", path))?;
|
||||
|
||||
let start_offset = if content.len() > MAX_LOG_READ {
|
||||
let skip = content.len() - MAX_LOG_READ;
|
||||
content[skip..]
|
||||
.iter()
|
||||
.position(|&b| b == b'\n')
|
||||
.map(|pos| skip + pos + 1)
|
||||
.unwrap_or(skip)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let content_str = std::str::from_utf8(&content[start_offset..])
|
||||
.context("telemetry log file contains invalid UTF-8")?;
|
||||
|
||||
let mut events = Vec::new();
|
||||
let mut parse_error_count = 0;
|
||||
|
||||
for line in content_str.lines() {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
match serde_json::from_str::<EventWrapper>(line) {
|
||||
Ok(event) => events.push(event),
|
||||
Err(_) => parse_error_count += 1,
|
||||
}
|
||||
}
|
||||
|
||||
Ok(HistoricalEvents {
|
||||
events,
|
||||
parse_error_count,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn has_checksum_seed(&self) -> bool {
|
||||
ZED_CLIENT_CHECKSUM_SEED.is_some()
|
||||
}
|
||||
|
||||
pub fn start(
|
||||
self: &Arc<Self>,
|
||||
system_id: Option<String>,
|
||||
installation_id: Option<String>,
|
||||
session_id: String,
|
||||
cx: &App,
|
||||
) {
|
||||
let mut state = self.state.lock();
|
||||
state.system_id = system_id.map(|id| id.into());
|
||||
state.installation_id = installation_id.map(|id| id.into());
|
||||
state.session_id = Some(session_id);
|
||||
state.app_version = release_channel::AppVersion::global(cx).to_string();
|
||||
state.os_name = os_name();
|
||||
}
|
||||
|
||||
pub fn metrics_enabled(self: &Arc<Self>) -> bool {
|
||||
self.state.lock().settings.metrics
|
||||
}
|
||||
|
||||
pub fn diagnostics_enabled(self: &Arc<Self>) -> bool {
|
||||
self.state.lock().settings.diagnostics
|
||||
}
|
||||
|
||||
pub fn set_authenticated_user_info(
|
||||
self: &Arc<Self>,
|
||||
metrics_id: Option<String>,
|
||||
is_staff: bool,
|
||||
) {
|
||||
let mut state = self.state.lock();
|
||||
|
||||
if !state.settings.metrics {
|
||||
return;
|
||||
}
|
||||
|
||||
let metrics_id: Option<Arc<str>> = metrics_id.map(|id| id.into());
|
||||
state.metrics_id.clone_from(&metrics_id);
|
||||
state.is_staff = Some(is_staff);
|
||||
drop(state);
|
||||
}
|
||||
|
||||
pub fn report_assistant_event(self: &Arc<Self>, event: AssistantEventData) {
|
||||
let event_type = match event.phase {
|
||||
AssistantPhase::Response => "Assistant Responded",
|
||||
AssistantPhase::Invoked => "Assistant Invoked",
|
||||
AssistantPhase::Accepted => "Assistant Response Accepted",
|
||||
AssistantPhase::Rejected => "Assistant Response Rejected",
|
||||
};
|
||||
|
||||
telemetry::event!(
|
||||
event_type,
|
||||
conversation_id = event.conversation_id,
|
||||
kind = event.kind,
|
||||
phase = event.phase,
|
||||
message_id = event.message_id,
|
||||
model = event.model,
|
||||
model_provider = event.model_provider,
|
||||
response_latency = event.response_latency,
|
||||
error_message = event.error_message,
|
||||
language_name = event.language_name,
|
||||
);
|
||||
}
|
||||
|
||||
pub fn log_edit_event(self: &Arc<Self>, environment: &'static str, is_via_ssh: bool) {
|
||||
static LAST_EVENT_TIME: Mutex<Option<Instant>> = Mutex::new(None);
|
||||
|
||||
let mut state = self.state.lock();
|
||||
let period_data = state.event_coalescer.log_event(environment);
|
||||
drop(state);
|
||||
|
||||
if let Some(mut last_event) = LAST_EVENT_TIME.try_lock() {
|
||||
let current_time = std::time::Instant::now();
|
||||
let last_time = last_event.get_or_insert(current_time);
|
||||
|
||||
if current_time.duration_since(*last_time) > Duration::from_secs(60 * 10) {
|
||||
*last_time = current_time;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some((start, end, environment)) = period_data {
|
||||
let duration = end
|
||||
.saturating_duration_since(start)
|
||||
.min(Duration::from_secs(60 * 60 * 24))
|
||||
.as_millis() as i64;
|
||||
|
||||
telemetry::event!(
|
||||
"Editor Edited",
|
||||
duration = duration,
|
||||
environment = environment,
|
||||
is_via_ssh = is_via_ssh
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn report_discovered_project_type_events(
|
||||
self: &Arc<Self>,
|
||||
worktree_id: WorktreeId,
|
||||
updated_entries_set: &UpdatedEntriesSet,
|
||||
) {
|
||||
let Some(project_types) = self.detect_project_types(worktree_id, updated_entries_set)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
for project_type in project_types {
|
||||
telemetry::event!("Project Opened", project_type = project_type);
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_project_types(
|
||||
self: &Arc<Self>,
|
||||
worktree_id: WorktreeId,
|
||||
updated_entries_set: &UpdatedEntriesSet,
|
||||
) -> Option<Vec<String>> {
|
||||
let mut state = self.state.lock();
|
||||
|
||||
if state
|
||||
.worktrees_with_project_type_events_sent
|
||||
.contains(&worktree_id)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut project_types: HashSet<&str> = HashSet::new();
|
||||
|
||||
for (path, _, _) in updated_entries_set.iter() {
|
||||
let Some(file_name) = path.file_name() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let project_type = match file_name {
|
||||
"pnpm-lock.yaml" => Some("pnpm"),
|
||||
"yarn.lock" => Some("yarn"),
|
||||
"package.json" => Some("node"),
|
||||
_ if DOTNET_PROJECT_FILES_REGEX.is_match(file_name) => Some("dotnet"),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if let Some(project_type) = project_type {
|
||||
project_types.insert(project_type);
|
||||
};
|
||||
}
|
||||
|
||||
if !project_types.is_empty() {
|
||||
state
|
||||
.worktrees_with_project_type_events_sent
|
||||
.insert(worktree_id);
|
||||
}
|
||||
|
||||
let mut project_types: Vec<_> = project_types.into_iter().map(String::from).collect();
|
||||
project_types.sort();
|
||||
Some(project_types)
|
||||
}
|
||||
|
||||
fn report_event(self: &Arc<Self>, mut event: Event) {
|
||||
let mut state = self.state.lock();
|
||||
// RUST_LOG=telemetry=trace to debug telemetry events
|
||||
log::trace!(target: "telemetry", "{:?}", event);
|
||||
|
||||
if !state.settings.metrics {
|
||||
return;
|
||||
}
|
||||
|
||||
match &mut event {
|
||||
Event::Flexible(event) => event
|
||||
.event_properties
|
||||
.insert("event_source".into(), "zed".into()),
|
||||
};
|
||||
|
||||
if state.flush_events_task.is_none() {
|
||||
let this = self.clone();
|
||||
state.flush_events_task = Some(self.executor.spawn(async move {
|
||||
this.executor.timer(FLUSH_INTERVAL).await;
|
||||
this.flush_events().detach();
|
||||
}));
|
||||
}
|
||||
|
||||
let date_time = self.clock.utc_now();
|
||||
|
||||
let milliseconds_since_first_event = match state.first_event_date_time {
|
||||
Some(first_event_date_time) => date_time
|
||||
.saturating_duration_since(first_event_date_time)
|
||||
.min(Duration::from_secs(60 * 60 * 24))
|
||||
.as_millis() as i64,
|
||||
None => {
|
||||
state.first_event_date_time = Some(date_time);
|
||||
0
|
||||
}
|
||||
};
|
||||
|
||||
let signed_in = state.metrics_id.is_some();
|
||||
let event_wrapper = EventWrapper {
|
||||
signed_in,
|
||||
milliseconds_since_first_event,
|
||||
event,
|
||||
};
|
||||
|
||||
state
|
||||
.subscribers
|
||||
.retain(|tx| tx.unbounded_send(event_wrapper.clone()).is_ok());
|
||||
|
||||
state.events_queue.push(event_wrapper);
|
||||
|
||||
if state.installation_id.is_some() && state.events_queue.len() >= state.max_queue_size {
|
||||
drop(state);
|
||||
self.flush_events().detach();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn metrics_id(self: &Arc<Self>) -> Option<Arc<str>> {
|
||||
self.state.lock().metrics_id.clone()
|
||||
}
|
||||
|
||||
pub fn system_id(self: &Arc<Self>) -> Option<Arc<str>> {
|
||||
self.state.lock().system_id.clone()
|
||||
}
|
||||
|
||||
pub fn installation_id(self: &Arc<Self>) -> Option<Arc<str>> {
|
||||
self.state.lock().installation_id.clone()
|
||||
}
|
||||
|
||||
pub fn is_staff(self: &Arc<Self>) -> Option<bool> {
|
||||
self.state.lock().is_staff
|
||||
}
|
||||
|
||||
fn build_request(
|
||||
self: &Arc<Self>,
|
||||
// We take in the JSON bytes buffer so we can reuse the existing allocation.
|
||||
mut json_bytes: Vec<u8>,
|
||||
event_request: &EventRequestBody,
|
||||
) -> Result<Request<AsyncBody>> {
|
||||
json_bytes.clear();
|
||||
serde_json::to_writer(&mut json_bytes, event_request)?;
|
||||
|
||||
let checksum = calculate_json_checksum(&json_bytes).unwrap_or_default();
|
||||
|
||||
Ok(Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(
|
||||
self.http_client
|
||||
.build_zed_api_url("/telemetry/events", &[])?
|
||||
.as_ref(),
|
||||
)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("x-zed-checksum", checksum)
|
||||
.body(json_bytes.into())?)
|
||||
}
|
||||
|
||||
pub async fn flush_events_inner(self: &Arc<Self>) -> Result<()> {
|
||||
let (json_bytes, request_body) = {
|
||||
let mut state = self.state.lock();
|
||||
state.first_event_date_time = None;
|
||||
let events = mem::take(&mut state.events_queue);
|
||||
state.flush_events_task.take();
|
||||
if events.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut json_bytes = Vec::new();
|
||||
|
||||
if let Some(file) = &mut state.log_file {
|
||||
for event in &events {
|
||||
json_bytes.clear();
|
||||
serde_json::to_writer(&mut json_bytes, event)?;
|
||||
file.write_all(&json_bytes)?;
|
||||
file.write_all(b"\n")?;
|
||||
}
|
||||
}
|
||||
|
||||
(
|
||||
json_bytes,
|
||||
EventRequestBody {
|
||||
system_id: state.system_id.as_deref().map(Into::into),
|
||||
installation_id: state.installation_id.as_deref().map(Into::into),
|
||||
session_id: state.session_id.clone(),
|
||||
metrics_id: state.metrics_id.as_deref().map(Into::into),
|
||||
is_staff: state.is_staff,
|
||||
app_version: state.app_version.clone(),
|
||||
os_name: state.os_name.clone(),
|
||||
os_version: state.os_version.clone(),
|
||||
architecture: state.architecture.to_string(),
|
||||
|
||||
release_channel: state
|
||||
.release_channel
|
||||
.map(|channel| channel.display_name().to_owned()),
|
||||
events,
|
||||
},
|
||||
)
|
||||
};
|
||||
|
||||
let request = self.build_request(json_bytes, &request_body)?;
|
||||
let response = self.http_client.send(request).await?;
|
||||
if response.status() != 200 {
|
||||
log::error!("Failed to send events: HTTP {:?}", response.status());
|
||||
}
|
||||
|
||||
anyhow::Ok(())
|
||||
}
|
||||
|
||||
pub fn flush_events(self: &Arc<Self>) -> Task<()> {
|
||||
let this = self.clone();
|
||||
self.executor.spawn(async move {
|
||||
this.flush_events_inner().await.log_err();
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn calculate_json_checksum(json: &impl AsRef<[u8]>) -> Option<String> {
|
||||
let checksum_seed = ZED_CLIENT_CHECKSUM_SEED.as_ref()?;
|
||||
|
||||
let mut summer = Sha256::new();
|
||||
summer.update(checksum_seed);
|
||||
summer.update(json);
|
||||
summer.update(checksum_seed);
|
||||
let mut checksum = String::new();
|
||||
for byte in summer.finalize().as_slice() {
|
||||
use std::fmt::Write;
|
||||
write!(&mut checksum, "{:02x}", byte).unwrap();
|
||||
}
|
||||
|
||||
Some(checksum)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use clock::FakeSystemClock;
|
||||
|
||||
use gpui::TestAppContext;
|
||||
use http_client::FakeHttpClient;
|
||||
use std::collections::HashMap;
|
||||
use telemetry_events::FlexibleEvent;
|
||||
use util::rel_path::RelPath;
|
||||
use worktree::{PathChange, ProjectEntryId, WorktreeId};
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_telemetry_flush_on_max_queue_size(
|
||||
executor: BackgroundExecutor,
|
||||
cx: &mut TestAppContext,
|
||||
) {
|
||||
init_test(cx);
|
||||
let clock = Arc::new(FakeSystemClock::new());
|
||||
let http = FakeHttpClient::with_200_response();
|
||||
let system_id = Some("system_id".to_string());
|
||||
let installation_id = Some("installation_id".to_string());
|
||||
let session_id = "session_id".to_string();
|
||||
|
||||
let (telemetry, first_date_time, event) = cx.update(|cx| {
|
||||
let telemetry = Telemetry::new(clock.clone(), http, cx);
|
||||
|
||||
telemetry.state.lock().max_queue_size = 4;
|
||||
telemetry.start(system_id, installation_id, session_id, cx);
|
||||
|
||||
assert!(is_empty_state(&telemetry));
|
||||
|
||||
let first_date_time = clock.utc_now();
|
||||
let event_properties = HashMap::from_iter([(
|
||||
"test_key".to_string(),
|
||||
serde_json::Value::String("test_value".to_string()),
|
||||
)]);
|
||||
|
||||
let event = FlexibleEvent {
|
||||
event_type: "test".to_string(),
|
||||
event_properties,
|
||||
};
|
||||
|
||||
(telemetry, first_date_time, event)
|
||||
});
|
||||
|
||||
cx.update(|_cx| {
|
||||
telemetry.report_event(Event::Flexible(event.clone()));
|
||||
assert_eq!(telemetry.state.lock().events_queue.len(), 1);
|
||||
assert!(telemetry.state.lock().flush_events_task.is_some());
|
||||
assert_eq!(
|
||||
telemetry.state.lock().first_event_date_time,
|
||||
Some(first_date_time)
|
||||
);
|
||||
|
||||
clock.advance(Duration::from_millis(100));
|
||||
|
||||
telemetry.report_event(Event::Flexible(event.clone()));
|
||||
assert_eq!(telemetry.state.lock().events_queue.len(), 2);
|
||||
assert!(telemetry.state.lock().flush_events_task.is_some());
|
||||
assert_eq!(
|
||||
telemetry.state.lock().first_event_date_time,
|
||||
Some(first_date_time)
|
||||
);
|
||||
|
||||
clock.advance(Duration::from_millis(100));
|
||||
|
||||
telemetry.report_event(Event::Flexible(event.clone()));
|
||||
assert_eq!(telemetry.state.lock().events_queue.len(), 3);
|
||||
assert!(telemetry.state.lock().flush_events_task.is_some());
|
||||
assert_eq!(
|
||||
telemetry.state.lock().first_event_date_time,
|
||||
Some(first_date_time)
|
||||
);
|
||||
|
||||
clock.advance(Duration::from_millis(100));
|
||||
|
||||
// Adding a 4th event should cause a flush
|
||||
telemetry.report_event(Event::Flexible(event));
|
||||
});
|
||||
|
||||
// Run the spawned flush task to completion
|
||||
executor.run_until_parked();
|
||||
|
||||
cx.update(|_cx| {
|
||||
assert!(is_empty_state(&telemetry));
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_telemetry_flush_on_flush_interval(
|
||||
executor: BackgroundExecutor,
|
||||
cx: &mut TestAppContext,
|
||||
) {
|
||||
init_test(cx);
|
||||
let clock = Arc::new(FakeSystemClock::new());
|
||||
let http = FakeHttpClient::with_200_response();
|
||||
let system_id = Some("system_id".to_string());
|
||||
let installation_id = Some("installation_id".to_string());
|
||||
let session_id = "session_id".to_string();
|
||||
|
||||
cx.update(|cx| {
|
||||
let telemetry = Telemetry::new(clock.clone(), http, cx);
|
||||
telemetry.state.lock().max_queue_size = 4;
|
||||
telemetry.start(system_id, installation_id, session_id, cx);
|
||||
|
||||
assert!(is_empty_state(&telemetry));
|
||||
let first_date_time = clock.utc_now();
|
||||
|
||||
let event_properties = HashMap::from_iter([(
|
||||
"test_key".to_string(),
|
||||
serde_json::Value::String("test_value".to_string()),
|
||||
)]);
|
||||
|
||||
let event = FlexibleEvent {
|
||||
event_type: "test".to_string(),
|
||||
event_properties,
|
||||
};
|
||||
|
||||
telemetry.report_event(Event::Flexible(event));
|
||||
assert_eq!(telemetry.state.lock().events_queue.len(), 1);
|
||||
assert!(telemetry.state.lock().flush_events_task.is_some());
|
||||
assert_eq!(
|
||||
telemetry.state.lock().first_event_date_time,
|
||||
Some(first_date_time)
|
||||
);
|
||||
|
||||
let duration = Duration::from_millis(1);
|
||||
|
||||
// Test 1 millisecond before the flush interval limit is met
|
||||
executor.advance_clock(FLUSH_INTERVAL - duration);
|
||||
|
||||
assert!(!is_empty_state(&telemetry));
|
||||
|
||||
// Test the exact moment the flush interval limit is met
|
||||
executor.advance_clock(duration);
|
||||
|
||||
assert!(is_empty_state(&telemetry));
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_project_discovery_does_not_double_report(cx: &mut gpui::TestAppContext) {
|
||||
init_test(cx);
|
||||
|
||||
let clock = Arc::new(FakeSystemClock::new());
|
||||
let http = FakeHttpClient::with_200_response();
|
||||
let telemetry = cx.update(|cx| Telemetry::new(clock.clone(), http, cx));
|
||||
let worktree_id = 1;
|
||||
|
||||
// Scan of empty worktree finds nothing
|
||||
test_project_discovery_helper(telemetry.clone(), vec![], Some(vec![]), worktree_id);
|
||||
|
||||
// Files added, second scan of worktree 1 finds project type
|
||||
test_project_discovery_helper(
|
||||
telemetry.clone(),
|
||||
vec!["package.json"],
|
||||
Some(vec!["node"]),
|
||||
worktree_id,
|
||||
);
|
||||
|
||||
// Third scan of worktree does not double report, as we already reported
|
||||
test_project_discovery_helper(telemetry, vec!["package.json"], None, worktree_id);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_pnpm_project_discovery(cx: &mut gpui::TestAppContext) {
|
||||
init_test(cx);
|
||||
|
||||
let clock = Arc::new(FakeSystemClock::new());
|
||||
let http = FakeHttpClient::with_200_response();
|
||||
let telemetry = cx.update(|cx| Telemetry::new(clock.clone(), http, cx));
|
||||
|
||||
test_project_discovery_helper(
|
||||
telemetry,
|
||||
vec!["package.json", "pnpm-lock.yaml"],
|
||||
Some(vec!["node", "pnpm"]),
|
||||
1,
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_yarn_project_discovery(cx: &mut gpui::TestAppContext) {
|
||||
init_test(cx);
|
||||
|
||||
let clock = Arc::new(FakeSystemClock::new());
|
||||
let http = FakeHttpClient::with_200_response();
|
||||
let telemetry = cx.update(|cx| Telemetry::new(clock.clone(), http, cx));
|
||||
|
||||
test_project_discovery_helper(
|
||||
telemetry,
|
||||
vec!["package.json", "yarn.lock"],
|
||||
Some(vec!["node", "yarn"]),
|
||||
1,
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_dotnet_project_discovery(cx: &mut gpui::TestAppContext) {
|
||||
init_test(cx);
|
||||
|
||||
let clock = Arc::new(FakeSystemClock::new());
|
||||
let http = FakeHttpClient::with_200_response();
|
||||
let telemetry = cx.update(|cx| Telemetry::new(clock.clone(), http, cx));
|
||||
|
||||
// Using different worktrees, as production code blocks from reporting a
|
||||
// project type for the same worktree multiple times
|
||||
|
||||
test_project_discovery_helper(
|
||||
telemetry.clone(),
|
||||
vec!["global.json"],
|
||||
Some(vec!["dotnet"]),
|
||||
1,
|
||||
);
|
||||
test_project_discovery_helper(
|
||||
telemetry.clone(),
|
||||
vec!["Directory.Build.props"],
|
||||
Some(vec!["dotnet"]),
|
||||
2,
|
||||
);
|
||||
test_project_discovery_helper(
|
||||
telemetry.clone(),
|
||||
vec!["file.csproj"],
|
||||
Some(vec!["dotnet"]),
|
||||
3,
|
||||
);
|
||||
test_project_discovery_helper(
|
||||
telemetry.clone(),
|
||||
vec!["file.fsproj"],
|
||||
Some(vec!["dotnet"]),
|
||||
4,
|
||||
);
|
||||
test_project_discovery_helper(
|
||||
telemetry.clone(),
|
||||
vec!["file.vbproj"],
|
||||
Some(vec!["dotnet"]),
|
||||
5,
|
||||
);
|
||||
test_project_discovery_helper(telemetry.clone(), vec!["file.sln"], Some(vec!["dotnet"]), 6);
|
||||
|
||||
// Each worktree should only send a single project type event, even when
|
||||
// encountering multiple files associated with that project type
|
||||
test_project_discovery_helper(
|
||||
telemetry,
|
||||
vec!["global.json", "Directory.Build.props"],
|
||||
Some(vec!["dotnet"]),
|
||||
7,
|
||||
);
|
||||
}
|
||||
|
||||
// TODO:
|
||||
// Test settings
|
||||
// Update FakeHTTPClient to keep track of the number of requests and assert on it
|
||||
|
||||
fn init_test(cx: &mut TestAppContext) {
|
||||
cx.update(|cx| {
|
||||
let settings_store = SettingsStore::test(cx);
|
||||
cx.set_global(settings_store);
|
||||
});
|
||||
}
|
||||
|
||||
fn is_empty_state(telemetry: &Telemetry) -> bool {
|
||||
telemetry.state.lock().events_queue.is_empty()
|
||||
&& telemetry.state.lock().flush_events_task.is_none()
|
||||
&& telemetry.state.lock().first_event_date_time.is_none()
|
||||
}
|
||||
|
||||
fn test_project_discovery_helper(
|
||||
telemetry: Arc<Telemetry>,
|
||||
file_paths: Vec<&str>,
|
||||
expected_project_types: Option<Vec<&str>>,
|
||||
worktree_id_num: usize,
|
||||
) {
|
||||
let worktree_id = WorktreeId::from_usize(worktree_id_num);
|
||||
let entries: Vec<_> = file_paths
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, path)| {
|
||||
Some((
|
||||
Arc::from(RelPath::unix(path).ok()?),
|
||||
ProjectEntryId::from_proto(i as u64 + 1),
|
||||
PathChange::Added,
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
let updated_entries: UpdatedEntriesSet = Arc::from(entries.as_slice());
|
||||
|
||||
let detected_project_types = telemetry.detect_project_types(worktree_id, &updated_entries);
|
||||
|
||||
let expected_project_types =
|
||||
expected_project_types.map(|types| types.iter().map(|&t| t.to_string()).collect());
|
||||
|
||||
assert_eq!(detected_project_types, expected_project_types);
|
||||
}
|
||||
}
|
||||
283
crates/client/src/telemetry/event_coalescer.rs
Normal file
283
crates/client/src/telemetry/event_coalescer.rs
Normal file
@@ -0,0 +1,283 @@
|
||||
use std::time;
|
||||
use std::{sync::Arc, time::Instant};
|
||||
|
||||
use clock::SystemClock;
|
||||
|
||||
const COALESCE_TIMEOUT: time::Duration = time::Duration::from_secs(20);
|
||||
const SIMULATED_DURATION_FOR_SINGLE_EVENT: time::Duration = time::Duration::from_millis(1);
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
struct PeriodData {
|
||||
environment: &'static str,
|
||||
start: Instant,
|
||||
end: Option<Instant>,
|
||||
}
|
||||
|
||||
pub struct EventCoalescer {
|
||||
clock: Arc<dyn SystemClock>,
|
||||
state: Option<PeriodData>,
|
||||
}
|
||||
|
||||
impl EventCoalescer {
|
||||
pub fn new(clock: Arc<dyn SystemClock>) -> Self {
|
||||
Self { clock, state: None }
|
||||
}
|
||||
|
||||
pub fn log_event(
|
||||
&mut self,
|
||||
environment: &'static str,
|
||||
) -> Option<(Instant, Instant, &'static str)> {
|
||||
let log_time = self.clock.utc_now();
|
||||
|
||||
let Some(state) = &mut self.state else {
|
||||
self.state = Some(PeriodData {
|
||||
start: log_time,
|
||||
end: None,
|
||||
environment,
|
||||
});
|
||||
return None;
|
||||
};
|
||||
|
||||
let period_end = state
|
||||
.end
|
||||
.unwrap_or(state.start + SIMULATED_DURATION_FOR_SINGLE_EVENT);
|
||||
let within_timeout = log_time - period_end < COALESCE_TIMEOUT;
|
||||
let environment_is_same = state.environment == environment;
|
||||
let should_coaelesce = !within_timeout || !environment_is_same;
|
||||
|
||||
if should_coaelesce {
|
||||
let previous_environment = state.environment;
|
||||
let original_start = state.start;
|
||||
|
||||
state.start = log_time;
|
||||
state.end = None;
|
||||
state.environment = environment;
|
||||
|
||||
return Some((
|
||||
original_start,
|
||||
if within_timeout { log_time } else { period_end },
|
||||
previous_environment,
|
||||
));
|
||||
}
|
||||
|
||||
state.end = Some(log_time);
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use clock::FakeSystemClock;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_same_context_exceeding_timeout() {
|
||||
let clock = Arc::new(FakeSystemClock::new());
|
||||
let environment_1 = "environment_1";
|
||||
let mut event_coalescer = EventCoalescer::new(clock.clone());
|
||||
|
||||
assert_eq!(event_coalescer.state, None);
|
||||
|
||||
let period_start = clock.utc_now();
|
||||
let period_data = event_coalescer.log_event(environment_1);
|
||||
|
||||
assert_eq!(period_data, None);
|
||||
assert_eq!(
|
||||
event_coalescer.state,
|
||||
Some(PeriodData {
|
||||
start: period_start,
|
||||
end: None,
|
||||
environment: environment_1,
|
||||
})
|
||||
);
|
||||
|
||||
let within_timeout_adjustment = COALESCE_TIMEOUT / 2;
|
||||
|
||||
// Ensure that many calls within the timeout don't start a new period
|
||||
for _ in 0..100 {
|
||||
clock.advance(within_timeout_adjustment);
|
||||
let period_data = event_coalescer.log_event(environment_1);
|
||||
let period_end = clock.utc_now();
|
||||
|
||||
assert_eq!(period_data, None);
|
||||
assert_eq!(
|
||||
event_coalescer.state,
|
||||
Some(PeriodData {
|
||||
start: period_start,
|
||||
end: Some(period_end),
|
||||
environment: environment_1,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
let period_end = clock.utc_now();
|
||||
let exceed_timeout_adjustment = COALESCE_TIMEOUT * 2;
|
||||
// Logging an event exceeding the timeout should start a new period
|
||||
clock.advance(exceed_timeout_adjustment);
|
||||
let new_period_start = clock.utc_now();
|
||||
let period_data = event_coalescer.log_event(environment_1);
|
||||
|
||||
assert_eq!(period_data, Some((period_start, period_end, environment_1)));
|
||||
assert_eq!(
|
||||
event_coalescer.state,
|
||||
Some(PeriodData {
|
||||
start: new_period_start,
|
||||
end: None,
|
||||
environment: environment_1,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_different_environment_under_timeout() {
|
||||
let clock = Arc::new(FakeSystemClock::new());
|
||||
let environment_1 = "environment_1";
|
||||
let mut event_coalescer = EventCoalescer::new(clock.clone());
|
||||
|
||||
assert_eq!(event_coalescer.state, None);
|
||||
|
||||
let period_start = clock.utc_now();
|
||||
let period_data = event_coalescer.log_event(environment_1);
|
||||
|
||||
assert_eq!(period_data, None);
|
||||
assert_eq!(
|
||||
event_coalescer.state,
|
||||
Some(PeriodData {
|
||||
start: period_start,
|
||||
end: None,
|
||||
environment: environment_1,
|
||||
})
|
||||
);
|
||||
|
||||
let within_timeout_adjustment = COALESCE_TIMEOUT / 2;
|
||||
clock.advance(within_timeout_adjustment);
|
||||
let period_end = clock.utc_now();
|
||||
let period_data = event_coalescer.log_event(environment_1);
|
||||
|
||||
assert_eq!(period_data, None);
|
||||
assert_eq!(
|
||||
event_coalescer.state,
|
||||
Some(PeriodData {
|
||||
start: period_start,
|
||||
end: Some(period_end),
|
||||
environment: environment_1,
|
||||
})
|
||||
);
|
||||
|
||||
clock.advance(within_timeout_adjustment);
|
||||
|
||||
// Logging an event within the timeout but with a different environment should start a new period
|
||||
let period_end = clock.utc_now();
|
||||
let environment_2 = "environment_2";
|
||||
let period_data = event_coalescer.log_event(environment_2);
|
||||
|
||||
assert_eq!(period_data, Some((period_start, period_end, environment_1)));
|
||||
assert_eq!(
|
||||
event_coalescer.state,
|
||||
Some(PeriodData {
|
||||
start: period_end,
|
||||
end: None,
|
||||
environment: environment_2,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_switching_environment_while_within_timeout() {
|
||||
let clock = Arc::new(FakeSystemClock::new());
|
||||
let environment_1 = "environment_1";
|
||||
let mut event_coalescer = EventCoalescer::new(clock.clone());
|
||||
|
||||
assert_eq!(event_coalescer.state, None);
|
||||
|
||||
let period_start = clock.utc_now();
|
||||
let period_data = event_coalescer.log_event(environment_1);
|
||||
|
||||
assert_eq!(period_data, None);
|
||||
assert_eq!(
|
||||
event_coalescer.state,
|
||||
Some(PeriodData {
|
||||
start: period_start,
|
||||
end: None,
|
||||
environment: environment_1,
|
||||
})
|
||||
);
|
||||
|
||||
let within_timeout_adjustment = COALESCE_TIMEOUT / 2;
|
||||
clock.advance(within_timeout_adjustment);
|
||||
let period_end = clock.utc_now();
|
||||
let environment_2 = "environment_2";
|
||||
let period_data = event_coalescer.log_event(environment_2);
|
||||
|
||||
assert_eq!(period_data, Some((period_start, period_end, environment_1)));
|
||||
assert_eq!(
|
||||
event_coalescer.state,
|
||||
Some(PeriodData {
|
||||
start: period_end,
|
||||
end: None,
|
||||
environment: environment_2,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// 0 20 40 60
|
||||
// |-------------------|-------------------|-------------------|-------------------
|
||||
// |--------|----------env change
|
||||
// |-------------------
|
||||
// |period_start |period_end
|
||||
// |new_period_start
|
||||
|
||||
#[test]
|
||||
fn test_switching_environment_while_exceeding_timeout() {
|
||||
let clock = Arc::new(FakeSystemClock::new());
|
||||
let environment_1 = "environment_1";
|
||||
let mut event_coalescer = EventCoalescer::new(clock.clone());
|
||||
|
||||
assert_eq!(event_coalescer.state, None);
|
||||
|
||||
let period_start = clock.utc_now();
|
||||
let period_data = event_coalescer.log_event(environment_1);
|
||||
|
||||
assert_eq!(period_data, None);
|
||||
assert_eq!(
|
||||
event_coalescer.state,
|
||||
Some(PeriodData {
|
||||
start: period_start,
|
||||
end: None,
|
||||
environment: environment_1,
|
||||
})
|
||||
);
|
||||
|
||||
let exceed_timeout_adjustment = COALESCE_TIMEOUT * 2;
|
||||
clock.advance(exceed_timeout_adjustment);
|
||||
let period_end = clock.utc_now();
|
||||
let environment_2 = "environment_2";
|
||||
let period_data = event_coalescer.log_event(environment_2);
|
||||
|
||||
assert_eq!(
|
||||
period_data,
|
||||
Some((
|
||||
period_start,
|
||||
period_start + SIMULATED_DURATION_FOR_SINGLE_EVENT,
|
||||
environment_1
|
||||
))
|
||||
);
|
||||
assert_eq!(
|
||||
event_coalescer.state,
|
||||
Some(PeriodData {
|
||||
start: period_end,
|
||||
end: None,
|
||||
environment: environment_2,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// 0 20 40 60
|
||||
// |-------------------|-------------------|-------------------|-------------------
|
||||
// |--------|----------------------------------------env change
|
||||
// |-------------------|
|
||||
// |period_start |period_end
|
||||
// |new_period_start
|
||||
}
|
||||
271
crates/client/src/test.rs
Normal file
271
crates/client/src/test.rs
Normal file
@@ -0,0 +1,271 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context as _, Result, anyhow};
|
||||
use cloud_api_client::{
|
||||
AuthenticatedUser, GetAuthenticatedUserResponse, KnownOrUnknown, Plan, PlanInfo,
|
||||
};
|
||||
use cloud_llm_client::{CurrentUsage, UsageData, UsageLimit};
|
||||
use futures::{StreamExt, stream::BoxStream};
|
||||
use gpui::{AppContext as _, TestAppContext};
|
||||
use http_client::{AsyncBody, Method, Request, http};
|
||||
use parking_lot::Mutex;
|
||||
use rpc::{ConnectionId, Peer, Receipt, TypedEnvelope, proto};
|
||||
|
||||
use crate::{Client, Connection, Credentials, EstablishConnectionError};
|
||||
|
||||
pub struct FakeServer {
|
||||
peer: Arc<Peer>,
|
||||
state: Arc<Mutex<FakeServerState>>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeServerState {
|
||||
incoming: Option<BoxStream<'static, Box<dyn proto::AnyTypedEnvelope>>>,
|
||||
connection_id: Option<ConnectionId>,
|
||||
forbid_connections: bool,
|
||||
auth_count: usize,
|
||||
access_token: usize,
|
||||
}
|
||||
|
||||
impl FakeServer {
|
||||
pub async fn for_client(
|
||||
client_user_id: u64,
|
||||
client: &Arc<Client>,
|
||||
cx: &TestAppContext,
|
||||
) -> Self {
|
||||
let server = Self {
|
||||
peer: Peer::new(0),
|
||||
state: Default::default(),
|
||||
};
|
||||
|
||||
client.http_client().as_fake().replace_handler({
|
||||
let state = server.state.clone();
|
||||
move |old_handler, req| {
|
||||
let state = state.clone();
|
||||
let old_handler = old_handler.clone();
|
||||
async move {
|
||||
match (req.method(), req.uri().path()) {
|
||||
(&Method::GET, "/client/users/me") => {
|
||||
let credentials = parse_authorization_header(&req);
|
||||
if credentials
|
||||
!= Some(Credentials {
|
||||
user_id: client_user_id,
|
||||
access_token: state.lock().access_token.to_string(),
|
||||
})
|
||||
{
|
||||
return Ok(http_client::Response::builder()
|
||||
.status(401)
|
||||
.body("Unauthorized".into())
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
Ok(http_client::Response::builder()
|
||||
.status(200)
|
||||
.body(
|
||||
serde_json::to_string(&make_get_authenticated_user_response(
|
||||
client_user_id as i32,
|
||||
format!("user-{client_user_id}"),
|
||||
))
|
||||
.unwrap()
|
||||
.into(),
|
||||
)
|
||||
.unwrap())
|
||||
}
|
||||
_ => old_handler(req).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
client
|
||||
.override_authenticate({
|
||||
let state = Arc::downgrade(&server.state);
|
||||
move |cx| {
|
||||
let state = state.clone();
|
||||
cx.spawn(async move |_| {
|
||||
let state = state.upgrade().context("server dropped")?;
|
||||
let mut state = state.lock();
|
||||
state.auth_count += 1;
|
||||
let access_token = state.access_token.to_string();
|
||||
Ok(Credentials {
|
||||
user_id: client_user_id,
|
||||
access_token,
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
.override_establish_connection({
|
||||
let peer = Arc::downgrade(&server.peer);
|
||||
let state = Arc::downgrade(&server.state);
|
||||
move |credentials, cx| {
|
||||
let peer = peer.clone();
|
||||
let state = state.clone();
|
||||
let credentials = credentials.clone();
|
||||
cx.spawn(async move |cx| {
|
||||
let state = state.upgrade().context("server dropped")?;
|
||||
let peer = peer.upgrade().context("server dropped")?;
|
||||
if state.lock().forbid_connections {
|
||||
Err(EstablishConnectionError::Other(anyhow!(
|
||||
"server is forbidding connections"
|
||||
)))?
|
||||
}
|
||||
|
||||
if credentials
|
||||
!= (Credentials {
|
||||
user_id: client_user_id,
|
||||
access_token: state.lock().access_token.to_string(),
|
||||
})
|
||||
{
|
||||
Err(EstablishConnectionError::Unauthorized)?
|
||||
}
|
||||
|
||||
let (client_conn, server_conn, _) =
|
||||
Connection::in_memory(cx.background_executor().clone());
|
||||
let (connection_id, io, incoming) =
|
||||
peer.add_test_connection(server_conn, cx.background_executor().clone());
|
||||
cx.background_spawn(io).detach();
|
||||
{
|
||||
let mut state = state.lock();
|
||||
state.connection_id = Some(connection_id);
|
||||
state.incoming = Some(incoming);
|
||||
}
|
||||
peer.send(
|
||||
connection_id,
|
||||
proto::Hello {
|
||||
peer_id: Some(connection_id.into()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
Ok(client_conn)
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
client
|
||||
.connect(false, &cx.to_async())
|
||||
.await
|
||||
.into_response()
|
||||
.unwrap();
|
||||
|
||||
server
|
||||
}
|
||||
|
||||
pub fn disconnect(&self) {
|
||||
if self.state.lock().connection_id.is_some() {
|
||||
self.peer.disconnect(self.connection_id());
|
||||
let mut state = self.state.lock();
|
||||
state.connection_id.take();
|
||||
state.incoming.take();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn auth_count(&self) -> usize {
|
||||
self.state.lock().auth_count
|
||||
}
|
||||
|
||||
pub fn roll_access_token(&self) {
|
||||
self.state.lock().access_token += 1;
|
||||
}
|
||||
|
||||
pub fn forbid_connections(&self) {
|
||||
self.state.lock().forbid_connections = true;
|
||||
}
|
||||
|
||||
pub fn allow_connections(&self) {
|
||||
self.state.lock().forbid_connections = false;
|
||||
}
|
||||
|
||||
pub fn send<T: proto::EnvelopedMessage>(&self, message: T) {
|
||||
self.peer.send(self.connection_id(), message).unwrap();
|
||||
}
|
||||
|
||||
#[allow(clippy::await_holding_lock)]
|
||||
pub async fn receive<M: proto::EnvelopedMessage>(&self) -> Result<TypedEnvelope<M>> {
|
||||
let message = self
|
||||
.state
|
||||
.lock()
|
||||
.incoming
|
||||
.as_mut()
|
||||
.expect("not connected")
|
||||
.next()
|
||||
.await
|
||||
.context("other half hung up")?;
|
||||
let type_name = message.payload_type_name();
|
||||
let message = message.into_any();
|
||||
|
||||
if message.is::<TypedEnvelope<M>>() {
|
||||
return Ok(*message.downcast().unwrap());
|
||||
}
|
||||
|
||||
panic!(
|
||||
"fake server received unexpected message type: {:?}",
|
||||
type_name
|
||||
);
|
||||
}
|
||||
|
||||
pub fn respond<T: proto::RequestMessage>(&self, receipt: Receipt<T>, response: T::Response) {
|
||||
self.peer.respond(receipt, response).unwrap()
|
||||
}
|
||||
|
||||
fn connection_id(&self) -> ConnectionId {
|
||||
self.state.lock().connection_id.expect("not connected")
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for FakeServer {
|
||||
fn drop(&mut self) {
|
||||
self.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_authorization_header(req: &Request<AsyncBody>) -> Option<Credentials> {
|
||||
let mut auth_header = req
|
||||
.headers()
|
||||
.get(http::header::AUTHORIZATION)?
|
||||
.to_str()
|
||||
.ok()?
|
||||
.split_whitespace();
|
||||
let user_id = auth_header.next()?.parse().ok()?;
|
||||
let access_token = auth_header.next()?;
|
||||
Some(Credentials {
|
||||
user_id,
|
||||
access_token: access_token.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn make_get_authenticated_user_response(
|
||||
user_id: i32,
|
||||
github_login: String,
|
||||
) -> GetAuthenticatedUserResponse {
|
||||
GetAuthenticatedUserResponse {
|
||||
user: AuthenticatedUser {
|
||||
id: user_id,
|
||||
metrics_id: format!("metrics-id-{user_id}"),
|
||||
avatar_url: "".to_string(),
|
||||
github_login,
|
||||
name: None,
|
||||
is_staff: false,
|
||||
accepted_tos_at: None,
|
||||
has_connected_to_collab_once: false,
|
||||
},
|
||||
feature_flags: vec![],
|
||||
organizations: vec![],
|
||||
default_organization_id: None,
|
||||
plans_by_organization: BTreeMap::new(),
|
||||
configuration_by_organization: BTreeMap::new(),
|
||||
plan: PlanInfo {
|
||||
plan: KnownOrUnknown::Known(Plan::ZedPro),
|
||||
subscription_period: None,
|
||||
usage: CurrentUsage {
|
||||
edit_predictions: UsageData {
|
||||
used: 250,
|
||||
limit: UsageLimit::Unlimited,
|
||||
},
|
||||
},
|
||||
trial_started_at: None,
|
||||
is_account_too_young: false,
|
||||
has_overdue_invoices: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
1101
crates/client/src/user.rs
Normal file
1101
crates/client/src/user.rs
Normal file
File diff suppressed because it is too large
Load Diff
70
crates/client/src/zed_urls.rs
Normal file
70
crates/client/src/zed_urls.rs
Normal file
@@ -0,0 +1,70 @@
|
||||
//! Contains helper functions for constructing URLs to various Zed-related pages.
|
||||
//!
|
||||
//! These URLs will adapt to the configured server URL in order to construct
|
||||
//! links appropriate for the environment (e.g., by linking to a local copy of
|
||||
//! zed.dev in development).
|
||||
|
||||
use gpui::App;
|
||||
use settings::Settings;
|
||||
|
||||
use crate::ClientSettings;
|
||||
|
||||
fn server_url(cx: &App) -> &str {
|
||||
&ClientSettings::get_global(cx).server_url
|
||||
}
|
||||
|
||||
/// Returns the URL to the account page on zed.dev.
|
||||
pub fn account_url(cx: &App) -> String {
|
||||
format!("{server_url}/account", server_url = server_url(cx))
|
||||
}
|
||||
|
||||
/// Returns the URL to the start trial page on zed.dev.
|
||||
pub fn start_trial_url(cx: &App) -> String {
|
||||
format!(
|
||||
"{server_url}/account/start-trial",
|
||||
server_url = server_url(cx)
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns the URL to the upgrade page on zed.dev.
|
||||
pub fn upgrade_to_zed_pro_url(cx: &App) -> String {
|
||||
format!("{server_url}/account/upgrade", server_url = server_url(cx))
|
||||
}
|
||||
|
||||
/// Returns the URL to Zed's terms of service.
|
||||
pub fn terms_of_service(cx: &App) -> String {
|
||||
format!("{server_url}/terms-of-service", server_url = server_url(cx))
|
||||
}
|
||||
|
||||
/// Returns the URL to Zed AI's privacy and security docs.
|
||||
pub fn ai_privacy_and_security(cx: &App) -> String {
|
||||
format!(
|
||||
"{server_url}/docs/ai/privacy-and-security",
|
||||
server_url = server_url(cx)
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns the URL to Zed's edit prediction documentation.
|
||||
pub fn edit_prediction_docs(cx: &App) -> String {
|
||||
format!(
|
||||
"{server_url}/docs/ai/edit-prediction",
|
||||
server_url = server_url(cx)
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns the URL to Zed's ACP registry blog post.
|
||||
pub fn acp_registry_blog(cx: &App) -> String {
|
||||
format!(
|
||||
"{server_url}/blog/acp-registry",
|
||||
server_url = server_url(cx)
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns the URL to Zed's Parallel Agents blog post.
|
||||
pub fn parallel_agents_blog(cx: &App) -> String {
|
||||
format!("{server_url}/blog", server_url = server_url(cx))
|
||||
}
|
||||
|
||||
pub fn shared_agent_thread_url(session_id: &str) -> String {
|
||||
format!("zed://agent/shared/{}", session_id)
|
||||
}
|
||||
Reference in New Issue
Block a user