diff --git a/rust/.sqlx/query-1bb963ce083f26e4033cf1ad0b8e3aad574562e2c69eb7064ce6588ec7f9d080.json b/rust/.sqlx/query-1bb963ce083f26e4033cf1ad0b8e3aad574562e2c69eb7064ce6588ec7f9d080.json new file mode 100644 index 0000000000000..8ca3a429d03a6 --- /dev/null +++ b/rust/.sqlx/query-1bb963ce083f26e4033cf1ad0b8e3aad574562e2c69eb7064ce6588ec7f9d080.json @@ -0,0 +1,43 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT raw_id, team_id, created_at, symbol_set_id, contents, resolved\n FROM posthog_errortrackingstackframe\n WHERE raw_id = $1 AND team_id = $2\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "raw_id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "team_id", + "type_info": "Int4" + }, + { + "ordinal": 2, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "symbol_set_id", + "type_info": "Uuid" + }, + { + "ordinal": 4, + "name": "contents", + "type_info": "Jsonb" + }, + { + "ordinal": 5, + "name": "resolved", + "type_info": "Bool" + } + ], + "parameters": { + "Left": ["Text", "Int4"] + }, + "nullable": [false, false, false, true, false, false] + }, + "hash": "1bb963ce083f26e4033cf1ad0b8e3aad574562e2c69eb7064ce6588ec7f9d080" +} diff --git a/rust/.sqlx/query-91db325f8239edad65b5c4adfcc219ac954ea792d5958d1bb44011835a518b53.json b/rust/.sqlx/query-91db325f8239edad65b5c4adfcc219ac954ea792d5958d1bb44011835a518b53.json new file mode 100644 index 0000000000000..f06f5d79a0a01 --- /dev/null +++ b/rust/.sqlx/query-91db325f8239edad65b5c4adfcc219ac954ea792d5958d1bb44011835a518b53.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO posthog_errortrackingstackframe (raw_id, team_id, created_at, symbol_set_id, contents, resolved, id)\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n ON CONFLICT (raw_id, team_id) DO UPDATE SET\n created_at = $3,\n symbol_set_id = $4,\n contents = $5,\n resolved = $6\n ", + "describe": { + "columns": [], + "parameters": { + "Left": ["Text", "Int4", "Timestamptz", "Uuid", "Jsonb", "Bool", "Uuid"] + }, + "nullable": [] + }, + "hash": "91db325f8239edad65b5c4adfcc219ac954ea792d5958d1bb44011835a518b53" +} diff --git a/rust/Cargo.lock b/rust/Cargo.lock index b8249e4ef3f53..6ad8c8aa92057 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -1433,6 +1433,7 @@ dependencies = [ "httpmock", "metrics", "mockall", + "moka", "rdkafka", "reqwest 0.12.3", "serde", @@ -3038,6 +3039,26 @@ dependencies = [ "syn 2.0.48", ] +[[package]] +name = "moka" +version = "0.12.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32cf62eb4dd975d2dde76432fb1075c49e3ee2331cf36f1f8fd4b66550d32b6f" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "once_cell", + "parking_lot", + "quanta 0.12.2", + "rustc_version 0.4.1", + "smallvec", + "tagptr", + "thiserror", + "triomphe", + "uuid", +] + [[package]] name = "native-tls" version = "0.2.11" @@ -4904,6 +4925,12 @@ dependencies = [ "libc", ] +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + [[package]] name = "tap" version = "1.0.1" @@ -5302,6 +5329,12 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "triomphe" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "859eb650cfee7434994602c3a68b25d77ad9e68c8a6cd491616ef86661382eb3" + [[package]] name = "try-lock" version = "0.2.5" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 5c2ce029368fb..634906d394ff1 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -96,3 +96,4 @@ ahash = "0.8.11" aws-config = { version = "1.1.7", features = ["behavior-version-latest"] } aws-sdk-s3 = "1.58.0" mockall = "0.13.0" +moka = { version = "0.12.8", features = ["sync"] } diff --git a/rust/cymbal/Cargo.toml b/rust/cymbal/Cargo.toml index a1a1a4cf8b7bb..8e54964fae148 100644 --- a/rust/cymbal/Cargo.toml +++ b/rust/cymbal/Cargo.toml @@ -28,6 +28,7 @@ aws-config = { workspace = true } aws-sdk-s3 = { workspace = true } uuid = { workspace = true } chrono = { workspace = true } +moka = { workspace = true } [dev-dependencies] httpmock = { workspace = true } diff --git a/rust/cymbal/src/app_context.rs b/rust/cymbal/src/app_context.rs index 70a90a3e0a702..77b06145ec00f 100644 --- a/rust/cymbal/src/app_context.rs +++ b/rust/cymbal/src/app_context.rs @@ -12,6 +12,7 @@ use tracing::info; use crate::{ config::Config, error::Error, + frames::resolver::Resolver, symbol_store::{ caching::{Caching, SymbolSetCache}, saving::Saving, @@ -27,6 +28,7 @@ pub struct AppContext { pub kafka_producer: FutureProducer, pub pool: PgPool, pub catalog: Catalog, + pub resolver: Resolver, } impl AppContext { @@ -73,6 +75,7 @@ impl AppContext { ); let catalog = Catalog::new(caching_smp); + let resolver = Resolver::new(config); Ok(Self { health_registry, @@ -81,6 +84,7 @@ impl AppContext { kafka_producer, pool, catalog, + resolver, }) } } diff --git a/rust/cymbal/src/config.rs b/rust/cymbal/src/config.rs index 59abca4fc43ab..9604177cce23d 100644 --- a/rust/cymbal/src/config.rs +++ b/rust/cymbal/src/config.rs @@ -44,6 +44,12 @@ pub struct Config { #[envconfig(default = "sets")] pub ss_prefix: String, + + #[envconfig(default = "100000")] + pub frame_cache_size: u64, + + #[envconfig(default = "600")] + pub frame_cache_ttl_seconds: u64, } impl Config { diff --git a/rust/cymbal/src/fingerprinting.rs b/rust/cymbal/src/fingerprinting.rs index 6420b1677016b..58d9c28204a61 100644 --- a/rust/cymbal/src/fingerprinting.rs +++ b/rust/cymbal/src/fingerprinting.rs @@ -16,7 +16,7 @@ pub fn generate_fingerprint(exception: &[Exception]) -> String { #[cfg(test)] mod test { - use crate::types::{frames::Frame, Stacktrace}; + use crate::{frames::Frame, types::Stacktrace}; use super::*; diff --git a/rust/cymbal/src/types/frames.rs b/rust/cymbal/src/frames/mod.rs similarity index 87% rename from rust/cymbal/src/types/frames.rs rename to rust/cymbal/src/frames/mod.rs index 5a1150c3c3842..09b12ff625b89 100644 --- a/rust/cymbal/src/types/frames.rs +++ b/rust/cymbal/src/frames/mod.rs @@ -5,6 +5,9 @@ use crate::{ error::Error, langs::js::RawJSFrame, metric_consts::PER_FRAME_TIME, symbol_store::Catalog, }; +pub mod records; +pub mod resolver; + // We consume a huge variety of differently shaped stack frames, which we have special-case // transformation for, to produce a single, unified representation of a frame. #[derive(Debug, Deserialize, Serialize, Clone)] @@ -29,14 +32,24 @@ impl RawFrame { res } - pub fn symbol_set_group_key(&self) -> String { + pub fn needs_symbols(&self) -> bool { + // For now, we only support JS, so this is always true + true + } + + pub fn symbol_set_ref(&self) -> String { let RawFrame::JavaScript(raw) = self; raw.source_url().map(String::from).unwrap_or_default() } + + pub fn frame_id(&self) -> String { + let RawFrame::JavaScript(raw) = self; + raw.frame_id() + } } // We emit a single, unified representation of a frame, which is what we pass on to users. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)] pub struct Frame { pub mangled_name: String, // Mangled name of the function pub line: Option, // Line the function is define on, if known diff --git a/rust/cymbal/src/frames/records.rs b/rust/cymbal/src/frames/records.rs new file mode 100644 index 0000000000000..11d6c267b85e2 --- /dev/null +++ b/rust/cymbal/src/frames/records.rs @@ -0,0 +1,102 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sqlx::Executor; +use uuid::Uuid; + +use crate::error::Error; + +use super::Frame; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ErrorTrackingStackFrame { + pub raw_id: String, + pub team_id: i32, + pub created_at: DateTime, + pub symbol_set_id: Option, + pub contents: Frame, + pub resolved: bool, +} + +impl ErrorTrackingStackFrame { + pub fn new( + raw_id: String, + team_id: i32, + symbol_set_id: Option, + contents: Frame, + resolved: bool, + ) -> Self { + Self { + raw_id, + team_id, + symbol_set_id, + contents, + resolved, + created_at: Utc::now(), + } + } + + pub async fn save<'c, E>(&self, e: E) -> Result<(), Error> + where + E: Executor<'c, Database = sqlx::Postgres>, + { + sqlx::query!( + r#" + INSERT INTO posthog_errortrackingstackframe (raw_id, team_id, created_at, symbol_set_id, contents, resolved, id) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (raw_id, team_id) DO UPDATE SET + created_at = $3, + symbol_set_id = $4, + contents = $5, + resolved = $6 + "#, + self.raw_id, + self.team_id, + self.created_at, + self.symbol_set_id, + serde_json::to_value(&self.contents)?, + self.resolved, + Uuid::now_v7() + ).execute(e).await?; + Ok(()) + } + + pub async fn load<'c, E>(e: E, team_id: i32, raw_id: &str) -> Result, Error> + where + E: Executor<'c, Database = sqlx::Postgres>, + { + struct Returned { + raw_id: String, + team_id: i32, + created_at: DateTime, + symbol_set_id: Option, + contents: Value, + resolved: bool, + } + let res = sqlx::query_as!( + Returned, + r#" + SELECT raw_id, team_id, created_at, symbol_set_id, contents, resolved + FROM posthog_errortrackingstackframe + WHERE raw_id = $1 AND team_id = $2 + "#, + raw_id, + team_id + ) + .fetch_optional(e) + .await?; + + let Some(found) = res else { + return Ok(None); + }; + + Ok(Some(Self { + raw_id: found.raw_id, + team_id: found.team_id, + created_at: found.created_at, + symbol_set_id: found.symbol_set_id, + contents: serde_json::from_value(found.contents)?, + resolved: found.resolved, + })) + } +} diff --git a/rust/cymbal/src/frames/resolver.rs b/rust/cymbal/src/frames/resolver.rs new file mode 100644 index 0000000000000..6a10c68c67208 --- /dev/null +++ b/rust/cymbal/src/frames/resolver.rs @@ -0,0 +1,244 @@ +use std::time::Duration; + +use moka::sync::{Cache, CacheBuilder}; +use sqlx::PgPool; + +use crate::{ + config::Config, + error::Error, + symbol_store::{saving::SymbolSetRecord, Catalog}, +}; + +use super::{records::ErrorTrackingStackFrame, Frame, RawFrame}; + +pub struct Resolver { + cache: Cache, +} + +impl Resolver { + pub fn new(config: &Config) -> Self { + let cache = CacheBuilder::new(config.frame_cache_size) + .time_to_live(Duration::from_secs(config.frame_cache_ttl_seconds)) + .build(); + + Self { cache } + } + + pub async fn resolve( + &self, + frame: &RawFrame, + team_id: i32, + pool: &PgPool, + catalog: &Catalog, + ) -> Result { + if let Some(result) = self.cache.get(&frame.frame_id()) { + return Ok(result.contents); + } + + if !frame.needs_symbols() { + return frame.resolve(team_id, catalog).await; + } + + if let Some(result) = + ErrorTrackingStackFrame::load(pool, team_id, &frame.frame_id()).await? + { + self.cache.insert(frame.frame_id(), result.clone()); + return Ok(result.contents); + } + + let resolved = frame.resolve(team_id, catalog).await?; + + let set = SymbolSetRecord::load(pool, team_id, &frame.symbol_set_ref()).await?; + + let record = ErrorTrackingStackFrame::new( + frame.frame_id(), + team_id, + set.map(|s| s.id), + resolved.clone(), + resolved.resolved, + ); + + record.save(pool).await?; + + self.cache.insert(frame.frame_id(), record); + Ok(resolved) + } +} + +#[cfg(test)] +mod test { + + use common_types::ClickHouseEvent; + use httpmock::MockServer; + use mockall::predicate; + use sqlx::PgPool; + + use crate::{ + config::Config, + frames::{records::ErrorTrackingStackFrame, resolver::Resolver, RawFrame}, + symbol_store::{ + saving::{Saving, SymbolSetRecord}, + sourcemap::SourcemapProvider, + Catalog, S3Client, + }, + types::{ErrProps, Stacktrace}, + }; + + const CHUNK_PATH: &str = "/static/chunk-PGUQKT6S.js"; + const MINIFIED: &[u8] = include_bytes!("../../tests/static/chunk-PGUQKT6S.js"); + const MAP: &[u8] = include_bytes!("../../tests/static/chunk-PGUQKT6S.js.map"); + const EXAMPLE_EXCEPTION: &str = include_str!("../../tests/static/raw_ch_exception_list.json"); + + async fn setup_test_context(pool: PgPool, s3_init: S) -> (Config, Catalog, MockServer) + where + S: FnOnce(&Config, S3Client) -> S3Client, + { + let mut config = Config::init_with_defaults().unwrap(); + config.ss_bucket = "test-bucket".to_string(); + config.ss_prefix = "test-prefix".to_string(); + config.allow_internal_ips = true; // Gonna be hitting the sourcemap mocks + + let server = MockServer::start(); + server.mock(|when, then| { + when.method("GET").path(CHUNK_PATH); + then.status(200).body(MINIFIED); + }); + + server.mock(|when, then| { + // Our minified example source uses a relative URL, formatted like this + when.method("GET").path(format!("{}.map", CHUNK_PATH)); + then.status(200).body(MAP); + }); + + let client = S3Client::default(); + + let client = s3_init(&config, client); + + let smp = SourcemapProvider::new(&config); + let saving_smp = Saving::new( + smp, + pool, + client, + config.ss_bucket.clone(), + config.ss_prefix.clone(), + ); + + let catalog = Catalog::new(saving_smp); + + (config, catalog, server) + } + + fn get_test_frame(server: &MockServer) -> RawFrame { + let exception: ClickHouseEvent = serde_json::from_str(EXAMPLE_EXCEPTION).unwrap(); + let props: ErrProps = serde_json::from_str(&exception.properties.unwrap()).unwrap(); + let Stacktrace::Raw { + frames: mut test_stack, + } = props.exception_list.unwrap().swap_remove(0).stack.unwrap() + else { + panic!("Expected a Raw stacktrace") + }; + + // We're going to pretend out stack consists exclusively of JS frames whose source + // we have locally + test_stack.retain(|s| { + let RawFrame::JavaScript(s) = s; + s.source_url.as_ref().unwrap().contains(CHUNK_PATH) + }); + + for frame in test_stack.iter_mut() { + let RawFrame::JavaScript(frame) = frame; + // Our test data contains our /actual/ source urls - we need to swap that to localhost + // When I first wrote this test, I forgot to do this, and it took me a while to figure out + // why the test was passing before I'd even set up the mockserver - which was pretty cool, tbh + frame.source_url = Some(server.url(CHUNK_PATH).to_string()); + } + + test_stack.pop().unwrap() + } + + fn expect_puts_and_gets( + config: &Config, + mut client: S3Client, + puts: usize, + gets: usize, + ) -> S3Client { + client + .expect_put() + .with( + predicate::eq(config.ss_bucket.clone()), + predicate::str::starts_with(config.ss_prefix.clone()), + predicate::eq(Vec::from(MAP)), + ) + .returning(|_, _, _| Ok(())) + .times(puts); + + client + .expect_get() + .with( + predicate::eq(config.ss_bucket.clone()), + predicate::str::starts_with(config.ss_prefix.clone()), + ) + .returning(|_, _| Ok(Vec::from(MAP))) + .times(gets); + + client + } + + #[sqlx::test(migrations = "./tests/test_migrations")] + pub async fn happy_path_test(pool: PgPool) { + // We assert here that s3 receives 1 put and no gets, because we're only resolving + // one frame, twice. Note that we're not using a caching symbol set provider, so if + // the frame is resolved twice, unless the resolver is doing the right thing and fetching the stored + // result from PG, it would have to fetch the sourcemap twice to resolve the frame + let (config, catalog, server) = + setup_test_context(pool.clone(), |c, cl| expect_puts_and_gets(c, cl, 1, 0)).await; + let resolver = Resolver::new(&config); + let frame = get_test_frame(&server); + + let resolved_1 = resolver.resolve(&frame, 0, &pool, &catalog).await.unwrap(); + + // Check there's only 1 symbol set row, and only one frame row + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM posthog_errortrackingsymbolset") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(count, 1); + + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM posthog_errortrackingstackframe") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(count, 1); + + // get the symbol set + let set_ref = frame.symbol_set_ref(); + let set = SymbolSetRecord::load(&pool, 0, &set_ref) + .await + .unwrap() + .unwrap(); + + // get the frame + let frame_id = frame.frame_id(); + let frame = ErrorTrackingStackFrame::load(&pool, 0, &frame_id) + .await + .unwrap() + .unwrap(); + + assert_eq!(frame.symbol_set_id.unwrap(), set.id); + + // Re-do the resolution, which will then hit the in-memory frame cache + let frame = get_test_frame(&server); + let resolved_2 = resolver.resolve(&frame, 0, &pool, &catalog).await.unwrap(); + + resolver.cache.invalidate_all(); + resolver.cache.run_pending_tasks(); + assert_eq!(resolver.cache.entry_count(), 0); + + // Now we should hit PG for the frame + let frame = get_test_frame(&server); + let resolved_3 = resolver.resolve(&frame, 0, &pool, &catalog).await.unwrap(); + + assert_eq!(resolved_1, resolved_2); + assert_eq!(resolved_2, resolved_3); + } +} diff --git a/rust/cymbal/src/langs/js.rs b/rust/cymbal/src/langs/js.rs index 841cabcc42066..35a1076f0e61e 100644 --- a/rust/cymbal/src/langs/js.rs +++ b/rust/cymbal/src/langs/js.rs @@ -1,11 +1,12 @@ use reqwest::Url; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha512}; use sourcemap::{SourceMap, Token}; use crate::{ error::{Error, JsResolveErr, ResolutionError}, + frames::Frame, symbol_store::SymbolCatalog, - types::frames::Frame, }; // A minifed JS stack frame. Just the minimal information needed to lookup some @@ -100,6 +101,20 @@ impl RawJSFrame { Url::parse(&source_url[..useful]) .map_err(|_| JsResolveErr::InvalidSourceUrl(source_url.to_string())) } + + pub fn frame_id(&self) -> String { + let mut hasher = Sha512::new(); + hasher.update(self.fn_name.as_bytes()); + hasher.update(self.line.to_string().as_bytes()); + hasher.update(self.column.to_string().as_bytes()); + hasher.update( + self.source_url + .as_ref() + .unwrap_or(&"".to_string()) + .as_bytes(), + ); + format!("{:x}", hasher.finalize()) + } } impl From<(&RawJSFrame, Token<'_>)> for Frame { diff --git a/rust/cymbal/src/lib.rs b/rust/cymbal/src/lib.rs index 98ef7ff31a2b5..20c273e958c38 100644 --- a/rust/cymbal/src/lib.rs +++ b/rust/cymbal/src/lib.rs @@ -2,6 +2,7 @@ pub mod app_context; pub mod config; pub mod error; pub mod fingerprinting; +pub mod frames; pub mod langs; pub mod metric_consts; pub mod symbol_store; diff --git a/rust/cymbal/src/main.rs b/rust/cymbal/src/main.rs index e0f34ff77acc8..8fca47a17f34b 100644 --- a/rust/cymbal/src/main.rs +++ b/rust/cymbal/src/main.rs @@ -129,7 +129,7 @@ async fn main() -> Result<(), Error> { let mut groups = HashMap::new(); for (i, frame) in frames.into_iter().enumerate() { let group = groups - .entry(frame.symbol_set_group_key()) + .entry(frame.symbol_set_ref()) .or_insert_with(Vec::new); group.push((i, frame.clone())); } @@ -137,7 +137,11 @@ async fn main() -> Result<(), Error> { for (_, frames) in groups.into_iter() { context.worker_liveness.report_healthy().await; // TODO - we shouldn't need to do this, but we do for now. for (i, frame) in frames { - results.push((i, frame.resolve(team_id, &context.catalog).await?)); + let resolved_frame = context + .resolver + .resolve(&frame, team_id, &context.pool, &context.catalog) + .await?; + results.push((i, resolved_frame)); } } diff --git a/rust/cymbal/src/symbol_store/saving.rs b/rust/cymbal/src/symbol_store/saving.rs index f30bff00f6173..c1ae9f08acb16 100644 --- a/rust/cymbal/src/symbol_store/saving.rs +++ b/rust/cymbal/src/symbol_store/saving.rs @@ -22,13 +22,13 @@ pub struct Saving { // A record of an attempt to fetch a symbol set. If it succeeded, it will have a storage pointer #[derive(Debug, sqlx::FromRow)] pub struct SymbolSetRecord { - id: Uuid, - team_id: i32, + pub id: Uuid, + pub team_id: i32, // "ref" is a reserved keyword in Rust, whoops - set_ref: String, - storage_ptr: Option, - failure_reason: Option, - created_at: DateTime, + pub set_ref: String, + pub storage_ptr: Option, + pub failure_reason: Option, + pub created_at: DateTime, } // This is the "intermediate" symbol set data. Rather than a simple `Vec`, the saving layer diff --git a/rust/cymbal/src/types/mod.rs b/rust/cymbal/src/types/mod.rs index 57db29fb813cc..6a329c75572d2 100644 --- a/rust/cymbal/src/types/mod.rs +++ b/rust/cymbal/src/types/mod.rs @@ -1,11 +1,10 @@ use std::collections::HashMap; -use frames::{Frame, RawFrame}; use serde::{Deserialize, Serialize}; use serde_json::Value; use sha2::{digest::Update, Sha512}; -pub mod frames; +use crate::frames::{Frame, RawFrame}; #[derive(Debug, Deserialize, Serialize, Clone)] pub struct Mechanism { @@ -97,7 +96,7 @@ mod test { use common_types::ClickHouseEvent; use serde_json::Error; - use crate::types::{frames::RawFrame, Stacktrace}; + use crate::{frames::RawFrame, types::Stacktrace}; use super::ErrProps; diff --git a/rust/cymbal/tests/resolve.rs b/rust/cymbal/tests/resolve.rs index ecba9bdda9d61..d92eac660615a 100644 --- a/rust/cymbal/tests/resolve.rs +++ b/rust/cymbal/tests/resolve.rs @@ -3,12 +3,13 @@ use std::sync::Arc; use common_types::ClickHouseEvent; use cymbal::{ config::Config, + frames::RawFrame, symbol_store::{ caching::{Caching, SymbolSetCache}, sourcemap::SourcemapProvider, Catalog, }, - types::{frames::RawFrame, ErrProps, Stacktrace}, + types::{ErrProps, Stacktrace}, }; use httpmock::MockServer; use tokio::sync::Mutex; diff --git a/rust/cymbal/tests/test_migrations/20241101134611_test_migration_for_symbol_set_saving_tests.sql b/rust/cymbal/tests/test_migrations/20241101134611_test_migration_for_symbol_set_saving_tests.sql index 46c6d7ca51d49..4de44e0ef4aba 100644 --- a/rust/cymbal/tests/test_migrations/20241101134611_test_migration_for_symbol_set_saving_tests.sql +++ b/rust/cymbal/tests/test_migrations/20241101134611_test_migration_for_symbol_set_saving_tests.sql @@ -10,3 +10,15 @@ CREATE TABLE posthog_errortrackingsymbolset ( -- Create index for team_id and ref combination CREATE INDEX idx_error_tracking_symbol_sets_team_ref ON posthog_errortrackingsymbolset(team_id, ref); + +-- Add migration script here +CREATE TABLE IF NOT EXISTS posthog_errortrackingstackframe ( + id UUID PRIMARY KEY, + raw_id TEXT NOT NULL, + team_id INTEGER NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + symbol_set_id UUID, + contents JSONB NOT NULL, + resolved BOOLEAN NOT NULL, + UNIQUE(raw_id, team_id) +);