use crate::api::CloudflareIpCountryHeader; use crate::{AppState, Error, Result}; use anyhow::anyhow; use axum::{ Extension, Router, TypedHeader, body::Bytes, headers::Header, http::{HeaderName, StatusCode}, routing::post, }; use chrono::Duration; use serde::{Deserialize, Serialize}; use serde_json::json; use sha2::{Digest, Sha256}; use std::sync::{Arc, OnceLock}; use telemetry_events::{Event, EventRequestBody}; use util::ResultExt; use uuid::Uuid; pub fn router() -> Router { Router::new() .route("/telemetry/events", post(post_events)) .route("/telemetry/crashes", post(post_panic)) .route("/telemetry/panics", post(post_panic)) .route("/telemetry/hangs", post(post_panic)) } pub struct ZedChecksumHeader(Vec); impl Header for ZedChecksumHeader { fn name() -> &'static HeaderName { static ZED_CHECKSUM_HEADER: OnceLock = OnceLock::new(); ZED_CHECKSUM_HEADER.get_or_init(|| HeaderName::from_static("x-zed-checksum")) } fn decode<'i, I>(values: &mut I) -> Result where Self: Sized, I: Iterator, { let checksum = values .next() .ok_or_else(axum::headers::Error::invalid)? .to_str() .map_err(|_| axum::headers::Error::invalid())?; let bytes = hex::decode(checksum).map_err(|_| axum::headers::Error::invalid())?; Ok(Self(bytes)) } fn encode>(&self, _values: &mut E) { unimplemented!() } } pub async fn post_panic() -> Result<()> { // as of v0.201.x crash/panic reporting is now done via Sentry. // The endpoint returns OK to avoid spurious errors for old clients. Ok(()) } pub async fn post_events( Extension(app): Extension>, TypedHeader(ZedChecksumHeader(checksum)): TypedHeader, country_code_header: Option>, body: Bytes, ) -> Result<()> { let Some(expected) = calculate_json_checksum(app.clone(), &body) else { return Err(Error::http( StatusCode::INTERNAL_SERVER_ERROR, "events not enabled".into(), ))?; }; let checksum_matched = checksum == expected; let request_body: telemetry_events::EventRequestBody = serde_json::from_slice(&body).map_err(|err| { log::error!("can't parse event json: {err}"); Error::Internal(anyhow!(err)) })?; let Some(last_event) = request_body.events.last() else { return Err(Error::http(StatusCode::BAD_REQUEST, "no events".into()))?; }; let country_code = country_code_header.map(|h| h.to_string()); let first_event_at = chrono::Utc::now() - chrono::Duration::milliseconds(last_event.milliseconds_since_first_event); if let Some(kinesis_client) = app.kinesis_client.clone() && let Some(stream) = app.config.kinesis_stream.clone() { let mut request = kinesis_client.put_records().stream_name(stream); let mut has_records = false; for row in for_snowflake( request_body.clone(), first_event_at, country_code.clone(), checksum_matched, ) { if let Some(data) = serde_json::to_vec(&row).log_err() { request = request.records( aws_sdk_kinesis::types::PutRecordsRequestEntry::builder() .partition_key(request_body.system_id.clone().unwrap_or_default()) .data(data.into()) .build() .unwrap(), ); has_records = true; } } if has_records { request.send().await.log_err(); } }; Ok(()) } pub fn calculate_json_checksum(app: Arc, json: &impl AsRef<[u8]>) -> Option> { let checksum_seed = app.config.zed_client_checksum_seed.as_ref()?; let mut summer = Sha256::new(); summer.update(checksum_seed); summer.update(json); summer.update(checksum_seed); Some(summer.finalize().into_iter().collect()) } fn for_snowflake( body: EventRequestBody, first_event_at: chrono::DateTime, country_code: Option, checksum_matched: bool, ) -> impl Iterator { body.events.into_iter().map(move |event| { let timestamp = first_event_at + Duration::milliseconds(event.milliseconds_since_first_event); let (event_type, mut event_properties) = match &event.event { Event::Flexible(e) => ( e.event_type.clone(), serde_json::to_value(&e.event_properties).unwrap(), ), }; if let serde_json::Value::Object(ref mut map) = event_properties { map.insert("app_version".to_string(), body.app_version.clone().into()); map.insert("os_name".to_string(), body.os_name.clone().into()); map.insert("os_version".to_string(), body.os_version.clone().into()); map.insert("architecture".to_string(), body.architecture.clone().into()); map.insert( "release_channel".to_string(), body.release_channel.clone().into(), ); map.insert("signed_in".to_string(), event.signed_in.into()); map.insert("checksum_matched".to_string(), checksum_matched.into()); if let Some(country_code) = country_code.as_ref() { map.insert("country".to_string(), country_code.clone().into()); } } // NOTE: most amplitude user properties are read out of our event_properties // dictionary. See https://app.amplitude.com/data/zed/Zed/sources/detail/production/falcon%3A159998 // for how that is configured. let user_properties = body.is_staff.map(|is_staff| { serde_json::json!({ "is_staff": is_staff, }) }); SnowflakeRow { time: timestamp, user_id: body.metrics_id.clone(), device_id: body.system_id.clone(), event_type, event_properties, user_properties, insert_id: Some(Uuid::new_v4().to_string()), } }) } #[derive(Serialize, Deserialize, Debug)] pub struct SnowflakeRow { pub time: chrono::DateTime, pub user_id: Option, pub device_id: Option, pub event_type: String, pub event_properties: serde_json::Value, pub user_properties: Option, pub insert_id: Option, } impl SnowflakeRow { pub fn new( event_type: impl Into, metrics_id: Option, is_staff: bool, system_id: Option, event_properties: serde_json::Value, ) -> Self { Self { time: chrono::Utc::now(), event_type: event_type.into(), device_id: system_id, user_id: metrics_id.map(|id| id.to_string()), insert_id: Some(uuid::Uuid::new_v4().to_string()), event_properties, user_properties: Some(json!({"is_staff": is_staff})), } } pub async fn write( self, client: &Option, stream: &Option, ) -> anyhow::Result<()> { let Some((client, stream)) = client.as_ref().zip(stream.as_ref()) else { return Ok(()); }; let row = serde_json::to_vec(&self)?; client .put_record() .stream_name(stream) .partition_key(&self.user_id.unwrap_or_default()) .data(row.into()) .send() .await?; Ok(()) } }