From 64460e492aeb70b31eb0b6159d0a0b6ad8d1726b Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Fri, 1 Mar 2024 13:23:44 -0700 Subject: [PATCH] Upload crashes to collab directly (#8649) This lets us run rustc_demangle on the backtrace, which helps the Slack view significantly. We're also now uploading files to digital ocean's S3 equivalent (with a 1 month expiry) instead of to Slack. This PR paves the way for (but does not yet implement) sending this data to clickhouse too. Release Notes: - N/A --- Cargo.lock | 1 + Cargo.toml | 1 + crates/collab/.env.toml | 2 + crates/collab/Cargo.toml | 7 +- crates/collab/k8s/collab.template.yml | 5 + crates/collab/src/api.rs | 18 +- crates/collab/src/api/events.rs | 148 ++++++++++- crates/collab/src/api/ips_file.rs | 352 +++++++++++++++++++++++++ crates/collab/src/api/slack.rs | 144 ++++++++++ crates/collab/src/lib.rs | 1 + crates/collab/src/tests/test_server.rs | 1 + crates/zed/src/main.rs | 2 +- script/seed-db | 2 +- 13 files changed, 657 insertions(+), 27 deletions(-) create mode 100644 crates/collab/src/api/ips_file.rs create mode 100644 crates/collab/src/api/slack.rs diff --git a/Cargo.lock b/Cargo.lock index 1333284229..840d7e2f6c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2065,6 +2065,7 @@ dependencies = [ "release_channel", "reqwest", "rpc", + "rustc-demangle", "scrypt", "sea-orm", "semver", diff --git a/Cargo.toml b/Cargo.toml index 5f87027887..5e1eea7414 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -269,6 +269,7 @@ tree-sitter-gomod = { git = "https://github.com/camdencheek/tree-sitter-go-mod" tree-sitter-gowork = { git = "https://github.com/d1y/tree-sitter-go-work" } tree-sitter-haskell = { git = "https://github.com/tree-sitter/tree-sitter-haskell", rev = "8a99848fc734f9c4ea523b3f2a07df133cbbcec2" } tree-sitter-hcl = { git = "https://github.com/MichaHoffmann/tree-sitter-hcl", rev = "v1.1.0" } +rustc-demangle = "0.1.23" tree-sitter-heex = { git = "https://github.com/phoenixframework/tree-sitter-heex", rev = "2e1348c3cf2c9323e87c2744796cf3f3868aa82a" } tree-sitter-html = "0.19.0" tree-sitter-json = { git = "https://github.com/tree-sitter/tree-sitter-json", rev = "40a81c01a40ac48744e0c8ccabbaba1920441199" } diff --git a/crates/collab/.env.toml b/crates/collab/.env.toml index 40c037e053..b244e83eb2 100644 --- a/crates/collab/.env.toml +++ b/crates/collab/.env.toml @@ -19,5 +19,7 @@ ZED_CLIENT_CHECKSUM_SEED = "development-checksum-seed" # CLICKHOUSE_PASSWORD = "" # CLICKHOUSE_DATABASE = "default" +# SLACK_PANICS_WEBHOOK = "" + # RUST_LOG=info # LOG_JSON=true diff --git a/crates/collab/Cargo.toml b/crates/collab/Cargo.toml index e3be776cc2..7cd499513a 100644 --- a/crates/collab/Cargo.toml +++ b/crates/collab/Cargo.toml @@ -7,15 +7,11 @@ version = "0.44.0" publish = false license = "AGPL-3.0-or-later" -[features] -seed-support = ["reqwest"] - [[bin]] name = "collab" [[bin]] name = "seed" -required-features = ["seed-support"] [dependencies] anyhow.workspace = true @@ -40,7 +36,7 @@ parking_lot.workspace = true prometheus = "0.13" prost.workspace = true rand.workspace = true -reqwest = { version = "0.11", features = ["json"], optional = true } +reqwest = { version = "0.11", features = ["json"] } rpc.workspace = true scrypt = "0.7" sea-orm = { version = "0.12.x", features = ["sqlx-postgres", "postgres-array", "runtime-tokio-rustls", "with-uuid"] } @@ -50,6 +46,7 @@ serde_derive.workspace = true serde_json.workspace = true sha2.workspace = true sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "postgres", "json", "time", "uuid", "any"] } +rustc-demangle.workspace = true telemetry_events.workspace = true text.workspace = true time.workspace = true diff --git a/crates/collab/k8s/collab.template.yml b/crates/collab/k8s/collab.template.yml index e3738ddffe..4915c6c97c 100644 --- a/crates/collab/k8s/collab.template.yml +++ b/crates/collab/k8s/collab.template.yml @@ -156,6 +156,11 @@ spec: secretKeyRef: name: clickhouse key: database + - name: SLACK_PANICS_WEBHOOK + valueFrom: + secretKeyRef: + name: slack + key: panics_webhook - name: INVITE_LINK_PREFIX value: ${INVITE_LINK_PREFIX} - name: RUST_BACKTRACE diff --git a/crates/collab/src/api.rs b/crates/collab/src/api.rs index 62dc5f0dfd..2d42beccc8 100644 --- a/crates/collab/src/api.rs +++ b/crates/collab/src/api.rs @@ -1,5 +1,7 @@ pub mod events; pub mod extensions; +pub mod ips_file; +pub mod slack; use crate::{ auth, @@ -21,7 +23,6 @@ use chrono::SecondsFormat; use serde::{Deserialize, Serialize}; use std::sync::Arc; use tower::ServiceBuilder; -use tracing::instrument; pub use extensions::fetch_extensions_from_blob_store_periodically; @@ -29,7 +30,6 @@ pub fn routes(rpc_server: Option>, state: Arc) -> Rou Router::new() .route("/user", get(get_authenticated_user)) .route("/users/:id/access_tokens", post(create_access_token)) - .route("/panic", post(trace_panic)) .route("/rpc_server_snapshot", get(get_rpc_server_snapshot)) .route("/contributors", get(get_contributors).post(add_contributor)) .route("/contributor", get(check_is_contributor)) @@ -120,20 +120,6 @@ struct CreateUserResponse { metrics_id: String, } -#[derive(Debug, Deserialize)] -struct Panic { - version: String, - release_channel: String, - backtrace_hash: String, - text: String, -} - -#[instrument(skip(panic))] -async fn trace_panic(panic: Json) -> Result<()> { - tracing::error!(version = %panic.version, release_channel = %panic.release_channel, backtrace_hash = %panic.backtrace_hash, text = %panic.text, "panic report"); - Ok(()) -} - async fn get_rpc_server_snapshot( Extension(rpc_server): Extension>>, ) -> Result { diff --git a/crates/collab/src/api/events.rs b/crates/collab/src/api/events.rs index f00c2c1927..f15383232a 100644 --- a/crates/collab/src/api/events.rs +++ b/crates/collab/src/api/events.rs @@ -1,21 +1,27 @@ use std::sync::{Arc, OnceLock}; use anyhow::{anyhow, Context}; +use aws_sdk_s3::primitives::ByteStream; use axum::{ body::Bytes, headers::Header, http::HeaderName, routing::post, Extension, Router, TypedHeader, }; -use hyper::StatusCode; +use hyper::{HeaderMap, StatusCode}; use serde::{Serialize, Serializer}; use sha2::{Digest, Sha256}; use telemetry_events::{ ActionEvent, AppEvent, AssistantEvent, CallEvent, CopilotEvent, CpuEvent, EditEvent, EditorEvent, Event, EventRequestBody, EventWrapper, MemoryEvent, SettingEvent, }; +use util::SemanticVersion; -use crate::{AppState, Error, Result}; +use crate::{api::slack, AppState, Error, Result}; + +use super::ips_file::IpsFile; pub fn router() -> Router { - Router::new().route("/telemetry/events", post(post_events)) + Router::new() + .route("/telemetry/events", post(post_events)) + .route("/telemetry/crashes", post(post_crash)) } pub struct ZedChecksumHeader(Vec); @@ -73,6 +79,140 @@ impl Header for CloudflareIpCountryHeader { } } +pub async fn post_crash( + Extension(app): Extension>, + body: Bytes, + headers: HeaderMap, +) -> Result<()> { + static CRASH_REPORTS_BUCKET: &str = "zed-crash-reports"; + + let report = IpsFile::parse(&body)?; + let version_threshold = SemanticVersion::new(0, 123, 0); + + let bundle_id = &report.header.bundle_id; + let app_version = &report.app_version(); + + if bundle_id == "dev.zed.Zed-Dev" { + log::error!("Crash uploads from {} are ignored.", bundle_id); + return Ok(()); + } + + if app_version.is_none() || app_version.unwrap() < version_threshold { + log::error!( + "Crash uploads from {} are ignored.", + report.header.app_version + ); + return Ok(()); + } + let app_version = app_version.unwrap(); + + if let Some(blob_store_client) = app.blob_store_client.as_ref() { + let response = blob_store_client + .head_object() + .bucket(CRASH_REPORTS_BUCKET) + .key(report.header.incident_id.clone() + ".ips") + .send() + .await; + + if response.is_ok() { + log::info!("We've already uploaded this crash"); + return Ok(()); + } + + blob_store_client + .put_object() + .bucket(CRASH_REPORTS_BUCKET) + .key(report.header.incident_id.clone() + ".ips") + .acl(aws_sdk_s3::types::ObjectCannedAcl::PublicRead) + .body(ByteStream::from(body.to_vec())) + .send() + .await + .map_err(|e| log::error!("Failed to upload crash: {}", e)) + .ok(); + } + + let recent_panic_on: Option = headers + .get("x-zed-panicked-on") + .and_then(|h| h.to_str().ok()) + .and_then(|s| s.parse().ok()); + let mut recent_panic = None; + + if let Some(recent_panic_on) = recent_panic_on { + let crashed_at = match report.timestamp() { + Ok(t) => Some(t), + Err(e) => { + log::error!("Can't parse {}: {}", report.header.timestamp, e); + None + } + }; + if crashed_at.is_some_and(|t| (t.timestamp_millis() - recent_panic_on).abs() <= 30000) { + recent_panic = headers.get("x-zed-panic").and_then(|h| h.to_str().ok()); + } + } + + let description = report.description(recent_panic); + let summary = report.backtrace_summary(); + + tracing::error!( + service = "client", + version = %report.header.app_version, + os_version = %report.header.os_version, + bundle_id = %report.header.bundle_id, + incident_id = %report.header.incident_id, + description = %description, + backtrace = %summary, + "crash report"); + + if let Some(slack_panics_webhook) = app.config.slack_panics_webhook.clone() { + let payload = slack::WebhookBody::new(|w| { + w.add_section(|s| s.text(slack::Text::markdown(description))) + .add_section(|s| { + s.add_field(slack::Text::markdown(format!( + "*Version:*\n{} ({})", + bundle_id, app_version + ))) + .add_field({ + let hostname = app.config.blob_store_url.clone().unwrap_or_default(); + let hostname = hostname.strip_prefix("https://").unwrap_or_else(|| { + hostname.strip_prefix("http://").unwrap_or_default() + }); + + slack::Text::markdown(format!( + "*Incident:*\n", + CRASH_REPORTS_BUCKET, + hostname, + report.header.incident_id, + report + .header + .incident_id + .chars() + .take(8) + .collect::(), + )) + }) + }) + .add_rich_text(|r| r.add_preformatted(|p| p.add_text(summary))) + }); + let payload_json = serde_json::to_string(&payload).map_err(|err| { + log::error!("Failed to serialize payload to JSON: {err}"); + Error::Internal(anyhow!(err)) + })?; + + reqwest::Client::new() + .post(slack_panics_webhook) + .header("Content-Type", "application/json") + .body(payload_json) + .send() + .await + .map_err(|err| { + log::error!("Failed to send payload to Slack: {err}"); + Error::Internal(anyhow!(err)) + })?; + } + + Ok(()) +} + pub async fn post_events( Extension(app): Extension>, TypedHeader(ZedChecksumHeader(checksum)): TypedHeader, @@ -98,7 +238,7 @@ pub async fn post_events( summer.update(&body); summer.update(checksum_seed); - if &checksum[..] != &summer.finalize()[..] { + if &checksum != &summer.finalize()[..] { return Err(Error::Http( StatusCode::BAD_REQUEST, "invalid checksum".into(), diff --git a/crates/collab/src/api/ips_file.rs b/crates/collab/src/api/ips_file.rs new file mode 100644 index 0000000000..6f1b4cd8f4 --- /dev/null +++ b/crates/collab/src/api/ips_file.rs @@ -0,0 +1,352 @@ +use collections::HashMap; + +use serde_derive::Deserialize; +use serde_derive::Serialize; +use serde_json::Value; +use util::SemanticVersion; + +#[derive(Debug)] +pub struct IpsFile { + pub header: Header, + pub body: Body, +} + +impl IpsFile { + pub fn parse(bytes: &[u8]) -> anyhow::Result { + let mut split = bytes.splitn(2, |&b| b == b'\n'); + let header_bytes = split + .next() + .ok_or_else(|| anyhow::anyhow!("No header found"))?; + let header: Header = serde_json::from_slice(header_bytes) + .map_err(|e| anyhow::anyhow!("Failed to parse header: {}", e))?; + + let body_bytes = split + .next() + .ok_or_else(|| anyhow::anyhow!("No body found"))?; + + let body: Body = serde_json::from_slice(body_bytes) + .map_err(|e| anyhow::anyhow!("Failed to parse body: {}", e))?; + Ok(IpsFile { header, body }) + } + + pub fn faulting_thread(&self) -> Option<&Thread> { + self.body.threads.get(self.body.faulting_thread? as usize) + } + + pub fn app_version(&self) -> Option { + self.header.app_version.parse().ok() + } + + pub fn timestamp(&self) -> anyhow::Result> { + chrono::DateTime::parse_from_str(&self.header.timestamp, "%Y-%m-%d %H:%M:%S%.f %#z") + .map_err(|e| anyhow::anyhow!(e)) + } + + pub fn description(&self, panic: Option<&str>) -> String { + let mut desc = if self.body.termination.indicator == "Abort trap: 6" { + match panic { + Some(panic_message) => format!("Panic `{}`", panic_message).into(), + None => "Crash `Abort trap: 6` (possible panic)".into(), + } + } else if let Some(msg) = &self.body.exception.message { + format!("Exception `{}`", msg) + } else { + format!("Crash `{}`", self.body.termination.indicator) + }; + if let Some(thread) = self.faulting_thread() { + if let Some(queue) = thread.queue.as_ref() { + desc += &format!( + " on thread {} ({})", + self.body.faulting_thread.unwrap_or_default(), + queue + ); + } else { + desc += &format!( + " on thread {} ({})", + self.body.faulting_thread.unwrap_or_default(), + thread.name.clone().unwrap_or_default() + ); + } + } + desc + } + + pub fn backtrace_summary(&self) -> String { + if let Some(thread) = self.faulting_thread() { + let mut frames = thread + .frames + .iter() + .filter_map(|frame| { + if let Some(name) = &frame.symbol { + if self.is_ignorable_frame(name) { + return None; + } + Some(format!("{:#}", rustc_demangle::demangle(name))) + } else if let Some(image) = self.body.used_images.get(frame.image_index) { + Some(image.name.clone().unwrap_or("".into())) + } else { + Some("".into()) + } + }) + .collect::>(); + + let total = frames.len(); + if total > 21 { + frames = frames.into_iter().take(20).collect(); + frames.push(format!(" and {} more...", total - 20)) + } + frames.join("\n") + } else { + "".into() + } + } + + fn is_ignorable_frame(&self, symbol: &String) -> bool { + [ + "pthread_kill", + "panic", + "backtrace", + "rust_begin_unwind", + "abort", + ] + .iter() + .any(|s| symbol.contains(s)) + } +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct Header { + pub app_name: String, + pub timestamp: String, + pub app_version: String, + pub slice_uuid: String, + pub build_version: String, + pub platform: i64, + #[serde(rename = "bundleID", default)] + pub bundle_id: String, + pub share_with_app_devs: i64, + pub is_first_party: i64, + pub bug_type: String, + pub os_version: String, + pub roots_installed: i64, + pub name: String, + pub incident_id: String, +} +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct Body { + pub uptime: i64, + pub proc_role: String, + pub version: i64, + #[serde(rename = "userID")] + pub user_id: i64, + pub deploy_version: i64, + pub model_code: String, + #[serde(rename = "coalitionID")] + pub coalition_id: i64, + pub os_version: OsVersion, + pub capture_time: String, + pub code_signing_monitor: i64, + pub incident: String, + pub pid: i64, + pub translated: bool, + pub cpu_type: String, + #[serde(rename = "roots_installed")] + pub roots_installed: i64, + #[serde(rename = "bug_type")] + pub bug_type: String, + pub proc_launch: String, + pub proc_start_abs_time: i64, + pub proc_exit_abs_time: i64, + pub proc_name: String, + pub proc_path: String, + pub bundle_info: BundleInfo, + pub store_info: StoreInfo, + pub parent_proc: String, + pub parent_pid: i64, + pub coalition_name: String, + pub crash_reporter_key: String, + #[serde(rename = "codeSigningID")] + pub code_signing_id: String, + #[serde(rename = "codeSigningTeamID")] + pub code_signing_team_id: String, + pub code_signing_flags: i64, + pub code_signing_validation_category: i64, + pub code_signing_trust_level: i64, + pub instruction_byte_stream: InstructionByteStream, + pub sip: String, + pub exception: Exception, + pub termination: Termination, + pub asi: Asi, + pub ext_mods: ExtMods, + pub faulting_thread: Option, + pub threads: Vec, + pub used_images: Vec, + pub shared_cache: SharedCache, + pub vm_summary: String, + pub legacy_info: LegacyInfo, + pub log_writing_signature: String, + pub trial_info: TrialInfo, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct OsVersion { + pub train: String, + pub build: String, + pub release_type: String, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct BundleInfo { + #[serde(rename = "CFBundleShortVersionString")] + pub cfbundle_short_version_string: String, + #[serde(rename = "CFBundleVersion")] + pub cfbundle_version: String, + #[serde(rename = "CFBundleIdentifier")] + pub cfbundle_identifier: String, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct StoreInfo { + pub device_identifier_for_vendor: String, + pub third_party: bool, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct InstructionByteStream { + #[serde(rename = "beforePC")] + pub before_pc: String, + #[serde(rename = "atPC")] + pub at_pc: String, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct Exception { + pub codes: String, + pub raw_codes: Vec, + #[serde(rename = "type")] + pub type_field: String, + pub subtype: Option, + pub signal: String, + pub port: Option, + pub guard_id: Option, + pub message: Option, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct Termination { + pub flags: i64, + pub code: i64, + pub namespace: String, + pub indicator: String, + pub by_proc: String, + pub by_pid: i64, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct Asi { + #[serde(rename = "libsystem_c.dylib")] + pub libsystem_c_dylib: Vec, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct ExtMods { + pub caller: ExtMod, + pub system: ExtMod, + pub targeted: ExtMod, + pub warnings: i64, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct ExtMod { + #[serde(rename = "thread_create")] + pub thread_create: i64, + #[serde(rename = "thread_set_state")] + pub thread_set_state: i64, + #[serde(rename = "task_for_pid")] + pub task_for_pid: i64, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct Thread { + pub thread_state: HashMap, + pub id: i64, + pub triggered: Option, + pub name: Option, + pub queue: Option, + pub frames: Vec, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct Frame { + pub image_offset: i64, + pub symbol: Option, + pub symbol_location: Option, + pub image_index: usize, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct UsedImage { + pub source: String, + pub arch: Option, + pub base: i64, + #[serde(rename = "CFBundleShortVersionString")] + pub cfbundle_short_version_string: Option, + #[serde(rename = "CFBundleIdentifier")] + pub cfbundle_identifier: Option, + pub size: i64, + pub uuid: String, + pub path: Option, + pub name: Option, + #[serde(rename = "CFBundleVersion")] + pub cfbundle_version: Option, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct SharedCache { + pub base: i64, + pub size: i64, + pub uuid: String, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct LegacyInfo { + pub thread_triggered: ThreadTriggered, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct ThreadTriggered { + pub name: String, + pub queue: String, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct TrialInfo { + pub rollouts: Vec, + pub experiments: Vec, +} + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct Rollout { + pub rollout_id: String, + pub factor_pack_ids: HashMap, + pub deployment_id: i64, +} diff --git a/crates/collab/src/api/slack.rs b/crates/collab/src/api/slack.rs new file mode 100644 index 0000000000..2f4234b165 --- /dev/null +++ b/crates/collab/src/api/slack.rs @@ -0,0 +1,144 @@ +use serde::{Deserialize, Serialize}; + +/// https://api.slack.com/reference/messaging/payload +#[derive(Default, Clone, Serialize, Deserialize)] +pub struct WebhookBody { + text: String, + #[serde(skip_serializing_if = "Vec::is_empty")] + blocks: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + thread_ts: Option, + #[serde(skip_serializing_if = "Option::is_none")] + mrkdwn: Option, +} + +impl WebhookBody { + pub fn new(f: impl FnOnce(Self) -> Self) -> Self { + f(Self::default()) + } + + pub fn add_section(mut self, build: impl FnOnce(Section) -> Section) -> Self { + self.blocks.push(Block::Section(build(Section::default()))); + self + } + + pub fn add_rich_text(mut self, build: impl FnOnce(RichText) -> RichText) -> Self { + self.blocks + .push(Block::RichText(build(RichText::default()))); + self + } +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(tag = "type")] +/// https://api.slack.com/reference/block-kit/blocks +pub enum Block { + #[serde(rename = "section")] + Section(Section), + #[serde(rename = "rich_text")] + RichText(RichText), + // .... etc. +} + +/// https://api.slack.com/reference/block-kit/blocks#section +#[derive(Default, Clone, Serialize, Deserialize)] +pub struct Section { + #[serde(skip_serializing_if = "Option::is_none")] + text: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + fields: Vec, + // fields, accessories... +} + +impl Section { + pub fn text(mut self, text: Text) -> Self { + self.text = Some(text); + self + } + + pub fn add_field(mut self, field: Text) -> Self { + self.fields.push(field); + self + } +} + +/// https://api.slack.com/reference/block-kit/composition-objects#text +#[derive(Clone, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum Text { + #[serde(rename = "plain_text")] + PlainText { text: String, emoji: bool }, + #[serde(rename = "mrkdwn")] + Markdown { text: String, verbatim: bool }, +} + +impl Text { + pub fn plain(s: String) -> Self { + Self::PlainText { + text: s, + emoji: true, + } + } + + pub fn markdown(s: String) -> Self { + Self::Markdown { + text: s, + verbatim: false, + } + } +} + +#[derive(Default, Clone, Serialize, Deserialize)] +pub struct RichText { + elements: Vec, +} + +impl RichText { + pub fn new(f: impl FnOnce(Self) -> Self) -> Self { + f(Self::default()) + } + + pub fn add_preformatted( + mut self, + build: impl FnOnce(RichTextPreformatted) -> RichTextPreformatted, + ) -> Self { + self.elements.push(RichTextObject::Preformatted(build( + RichTextPreformatted::default(), + ))); + self + } +} + +/// https://api.slack.com/reference/block-kit/blocks#rich_text +#[derive(Clone, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum RichTextObject { + #[serde(rename = "rich_text_preformatted")] + Preformatted(RichTextPreformatted), + // etc. +} + +/// https://api.slack.com/reference/block-kit/blocks#rich_text_preformatted +#[derive(Clone, Default, Serialize, Deserialize)] +pub struct RichTextPreformatted { + #[serde(skip_serializing_if = "Vec::is_empty")] + elements: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + border: Option, +} + +impl RichTextPreformatted { + pub fn add_text(mut self, text: String) -> Self { + self.elements.push(RichTextElement::Text { text }); + self + } +} + +/// https://api.slack.com/reference/block-kit/blocks#element-types +#[derive(Clone, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum RichTextElement { + #[serde(rename = "text")] + Text { text: String }, + // etc. +} diff --git a/crates/collab/src/lib.rs b/crates/collab/src/lib.rs index 355768a370..f45aba4351 100644 --- a/crates/collab/src/lib.rs +++ b/crates/collab/src/lib.rs @@ -127,6 +127,7 @@ pub struct Config { pub blob_store_bucket: Option, pub zed_environment: Arc, pub zed_client_checksum_seed: Option, + pub slack_panics_webhook: Option, } impl Config { diff --git a/crates/collab/src/tests/test_server.rs b/crates/collab/src/tests/test_server.rs index 35fee85ad2..515fef74c3 100644 --- a/crates/collab/src/tests/test_server.rs +++ b/crates/collab/src/tests/test_server.rs @@ -507,6 +507,7 @@ impl TestServer { clickhouse_password: None, clickhouse_database: None, zed_client_checksum_seed: None, + slack_panics_webhook: None, }, }) } diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 27e3a8ef50..73029b7e3c 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -807,7 +807,7 @@ async fn upload_previous_crashes( .unwrap_or("zed-2024-01-17-221900.ips".to_string()); // don't upload old crash reports from before we had this. let mut uploaded = last_uploaded.clone(); - let crash_report_url = http.build_url("/api/crash"); + let crash_report_url = http.build_zed_api_url("/telemetry/crashes"); for dir in [&*CRASHES_DIR, &*CRASHES_RETIRED_DIR] { let mut children = smol::fs::read_dir(&dir).await?; diff --git a/script/seed-db b/script/seed-db index 5079e01955..d3ee89e4f0 100755 --- a/script/seed-db +++ b/script/seed-db @@ -1,4 +1,4 @@ #!/bin/bash set -e -cargo run --quiet --package=collab --features seed-support --bin seed -- $@ +cargo run --quiet --package=collab --bin seed -- $@