diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 3f3c36157e36b..dd2df6cc5b826 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -996,6 +996,7 @@ dependencies = [ "reqwest 0.12.3", "serde", "serde_json", + "sha2", "sourcemap", "sqlx", "thiserror", diff --git a/rust/cymbal/Cargo.toml b/rust/cymbal/Cargo.toml index 532730d12e36a..44ffa877642c0 100644 --- a/rust/cymbal/Cargo.toml +++ b/rust/cymbal/Cargo.toml @@ -23,6 +23,7 @@ serde_json = { workspace = true } serde = { workspace = true } sourcemap = "9.0.0" reqwest = { workspace = true } +sha2 = "0.10.8" [dev-dependencies] httpmock = { workspace = true } diff --git a/rust/cymbal/src/fingerprinting.rs b/rust/cymbal/src/fingerprinting.rs new file mode 100644 index 0000000000000..6420b1677016b --- /dev/null +++ b/rust/cymbal/src/fingerprinting.rs @@ -0,0 +1,268 @@ +use crate::types::Exception; +use sha2::{Digest, Sha512}; + +// Given resolved Frames vector and the original Exception, we can now generate a fingerprint for it +pub fn generate_fingerprint(exception: &[Exception]) -> String { + let mut hasher = Sha512::new(); + + for exc in exception { + exc.include_in_fingerprint(&mut hasher); + } + + let result = hasher.finalize(); + + format!("{:x}", result) +} + +#[cfg(test)] +mod test { + use crate::types::{frames::Frame, Stacktrace}; + + use super::*; + + #[test] + fn test_fingerprint_generation() { + let mut exception = Exception { + exception_type: "TypeError".to_string(), + exception_message: "Cannot read property 'foo' of undefined".to_string(), + mechanism: Default::default(), + module: Default::default(), + thread_id: None, + stack: Default::default(), + }; + + let resolved_frames = vec![ + Frame { + mangled_name: "foo".to_string(), + line: Some(10), + column: Some(5), + source: Some("http://example.com/alpha/foo.js".to_string()), + in_app: true, + resolved_name: Some("bar".to_string()), + resolved: true, + resolve_failure: None, + lang: "javascript".to_string(), + }, + Frame { + mangled_name: "bar".to_string(), + line: Some(20), + column: Some(15), + source: Some("http://example.com/bar.js".to_string()), + in_app: true, + resolved_name: Some("baz".to_string()), + resolved: true, + resolve_failure: None, + lang: "javascript".to_string(), + }, + Frame { + mangled_name: "xyz".to_string(), + line: Some(30), + column: Some(25), + source: None, + in_app: true, + resolved_name: None, + resolved: true, + resolve_failure: None, + lang: "javascript".to_string(), + }, + Frame { + mangled_name: "".to_string(), + line: None, + column: None, + source: None, + in_app: false, + resolved_name: None, + resolved: true, + resolve_failure: None, + lang: "javascript".to_string(), + }, + ]; + + exception.stack = Some(Stacktrace::Resolved { + frames: resolved_frames, + }); + + let fingerprint = super::generate_fingerprint(&[exception]); + assert_eq!( + fingerprint, + "7f5c327cd3941f2da655d852eb4661b411440c080c7ff014feb920afde68beaffe663908d4ab5fb7b7f1e7ab7f1f7cd17949139e8f812b1c3ff0911fc5b68f37" + ); + } + + #[test] + fn test_some_resolved_frames() { + let mut exception = Exception { + exception_type: "TypeError".to_string(), + exception_message: "Cannot read property 'foo' of undefined".to_string(), + mechanism: Default::default(), + module: Default::default(), + thread_id: None, + stack: Default::default(), + }; + + let mut resolved_frames = vec![ + Frame { + mangled_name: "foo".to_string(), + line: Some(10), + column: Some(5), + source: Some("http://example.com/alpha/foo.js".to_string()), + in_app: true, + resolved_name: Some("bar".to_string()), + resolved: true, + resolve_failure: None, + lang: "javascript".to_string(), + }, + Frame { + mangled_name: "bar".to_string(), + line: Some(20), + column: Some(15), + source: Some("http://example.com/bar.js".to_string()), + in_app: true, + resolved_name: Some("baz".to_string()), + resolved: true, + resolve_failure: None, + lang: "javascript".to_string(), + }, + ]; + + let unresolved_frame = Frame { + mangled_name: "xyz".to_string(), + line: Some(30), + column: Some(25), + source: None, + in_app: true, + resolved_name: None, + resolved: false, + resolve_failure: None, + lang: "javascript".to_string(), + }; + + exception.stack = Some(Stacktrace::Resolved { + frames: resolved_frames.clone(), + }); + + let fingerprint_with_all_resolved = super::generate_fingerprint(&[exception.clone()]); + + resolved_frames.push(unresolved_frame); + exception.stack = Some(Stacktrace::Resolved { + frames: resolved_frames, + }); + + let mixed_fingerprint = super::generate_fingerprint(&[exception]); + + // In cases where there are SOME resolved frames, the fingerprint should be identical + // to the case where all frames are resolved (unresolved frames should be ignored) + assert_eq!(fingerprint_with_all_resolved, mixed_fingerprint); + } + + #[test] + fn test_no_resolved_frames() { + let mut exception = Exception { + exception_type: "TypeError".to_string(), + exception_message: "Cannot read property 'foo' of undefined".to_string(), + mechanism: Default::default(), + module: Default::default(), + thread_id: None, + stack: Default::default(), + }; + + let resolved_frames = vec![ + Frame { + mangled_name: "foo".to_string(), + line: Some(10), + column: Some(5), + source: Some("http://example.com/alpha/foo.js".to_string()), + in_app: true, + resolved_name: Some("bar".to_string()), + resolved: false, + resolve_failure: None, + lang: "javascript".to_string(), + }, + Frame { + mangled_name: "bar".to_string(), + line: Some(20), + column: Some(15), + source: Some("http://example.com/bar.js".to_string()), + in_app: true, + resolved_name: Some("baz".to_string()), + resolved: false, + resolve_failure: None, + lang: "javascript".to_string(), + }, + Frame { + mangled_name: "xyz".to_string(), + line: Some(30), + column: Some(25), + source: None, + in_app: true, + resolved_name: None, + resolved: false, + resolve_failure: None, + lang: "javascript".to_string(), + }, + ]; + + let no_stack_fingerprint = super::generate_fingerprint(&[exception.clone()]); + + exception.stack = Some(Stacktrace::Resolved { + frames: resolved_frames, + }); + + let with_stack_fingerprint = super::generate_fingerprint(&[exception]); + + // If there are NO resolved frames, fingerprinting should account for the unresolved frames + assert_ne!(no_stack_fingerprint, with_stack_fingerprint); + } + + #[test] + fn test_no_in_app_frames() { + let mut exception = Exception { + exception_type: "TypeError".to_string(), + exception_message: "Cannot read property 'foo' of undefined".to_string(), + mechanism: Default::default(), + module: Default::default(), + thread_id: None, + stack: Default::default(), + }; + + let mut resolved_frames = vec![Frame { + mangled_name: "foo".to_string(), + line: Some(10), + column: Some(5), + source: Some("http://example.com/alpha/foo.js".to_string()), + in_app: true, + resolved_name: Some("bar".to_string()), + resolved: false, + resolve_failure: None, + lang: "javascript".to_string(), + }]; + + let non_app_frame = Frame { + mangled_name: "bar".to_string(), + line: Some(20), + column: Some(15), + source: Some("http://example.com/bar.js".to_string()), + in_app: false, + resolved_name: Some("baz".to_string()), + resolved: false, + resolve_failure: None, + lang: "javascript".to_string(), + }; + + exception.stack = Some(Stacktrace::Resolved { + frames: resolved_frames.clone(), + }); + + let fingerprint_1 = super::generate_fingerprint(&[exception.clone()]); + + resolved_frames.push(non_app_frame); + exception.stack = Some(Stacktrace::Resolved { + frames: resolved_frames, + }); + + let fingerprint_2 = super::generate_fingerprint(&[exception]); + + // Fingerprinting should ignore non-in-app frames + assert_eq!(fingerprint_1, fingerprint_2); + } +} diff --git a/rust/cymbal/src/langs/js.rs b/rust/cymbal/src/langs/js.rs index e8921d614518d..2b9d8fa00258d 100644 --- a/rust/cymbal/src/langs/js.rs +++ b/rust/cymbal/src/langs/js.rs @@ -127,7 +127,7 @@ impl From<(&RawJSFrame, JsResolveErr)> for Frame { mangled_name: raw_frame.fn_name.clone(), line: Some(raw_frame.line), column: Some(raw_frame.column), - source: raw_frame.source_url.clone(), + source: raw_frame.source_url().map(|u| u.path().to_string()).ok(), in_app: raw_frame.in_app, resolved_name: None, lang: "javascript".to_string(), diff --git a/rust/cymbal/src/lib.rs b/rust/cymbal/src/lib.rs index 2ac9bf03a83ba..98ef7ff31a2b5 100644 --- a/rust/cymbal/src/lib.rs +++ b/rust/cymbal/src/lib.rs @@ -1,6 +1,7 @@ pub mod app_context; pub mod config; pub mod error; +pub mod fingerprinting; 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 8087bff817872..e0f34ff77acc8 100644 --- a/rust/cymbal/src/main.rs +++ b/rust/cymbal/src/main.rs @@ -8,11 +8,9 @@ use cymbal::{ app_context::AppContext, config::Config, error::Error, - metric_consts::{ - ERRORS, EVENT_RECEIVED, MAIN_LOOP_TIME, PER_FRAME_GROUP_TIME, PER_STACK_TIME, - STACK_PROCESSED, - }, - types::{frames::RawFrame, ErrProps}, + fingerprinting, + metric_consts::{ERRORS, EVENT_RECEIVED, MAIN_LOOP_TIME, STACK_PROCESSED}, + types::{ErrProps, Stacktrace}, }; use envconfig::Envconfig; use tokio::task::JoinHandle; @@ -103,7 +101,7 @@ async fn main() -> Result<(), Error> { } }; - let Some(exception_list) = &properties.exception_list else { + let Some(mut exception_list) = properties.exception_list else { // Known issue that $exception_list didn't exist on old clients continue; }; @@ -113,50 +111,46 @@ async fn main() -> Result<(), Error> { continue; } - // TODO - we should resolve all traces - let Some(trace) = exception_list[0].stacktrace.as_ref() else { - metrics::counter!(ERRORS, "cause" => "no_stack_trace").increment(1); - continue; - }; + for exception in exception_list.iter_mut() { + let stack = std::mem::take(&mut exception.stack); + let Some(Stacktrace::Raw { frames }) = stack else { + continue; + }; - let stack_trace: &Vec = &trace.frames; + if frames.is_empty() { + metrics::counter!(ERRORS, "cause" => "no_frames").increment(1); + continue; + } - let per_stack = common_metrics::timing_guard(PER_STACK_TIME, &[]); + let team_id = event.team_id; + let mut results = Vec::with_capacity(frames.len()); - // Cluster the frames by symbol set - let mut groups = HashMap::new(); - for frame in stack_trace { - let group = groups - .entry(frame.symbol_set_group_key()) - .or_insert_with(Vec::new); - group.push(frame.clone()); - } + // Cluster the frames by symbol set + let mut groups = HashMap::new(); + for (i, frame) in frames.into_iter().enumerate() { + let group = groups + .entry(frame.symbol_set_group_key()) + .or_insert_with(Vec::new); + group.push((i, frame.clone())); + } - let team_id = event.team_id; - let mut results = Vec::with_capacity(stack_trace.len()); - 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. - let mut any_success = false; - let per_frame_group = common_metrics::timing_guard(PER_FRAME_GROUP_TIME, &[]); - for frame in frames { - results.push(frame.resolve(team_id, &context.catalog).await); - if results.last().unwrap().is_ok() { - any_success = true; + 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?)); } } - per_frame_group - .label("resolved_any", if any_success { "true" } else { "false" }) - .fin(); + + results.sort_unstable_by_key(|(i, _)| *i); + + exception.stack = Some(Stacktrace::Resolved { + frames: results.into_iter().map(|(_, frame)| frame).collect(), + }); } - per_stack - .label( - "resolved_any", - if results.is_empty() { "true" } else { "false" }, - ) - .fin(); - whole_loop.label("had_frame", "true").fin(); + let _fingerprint = fingerprinting::generate_fingerprint(&exception_list); metrics::counter!(STACK_PROCESSED).increment(1); + whole_loop.label("had_frame", "true").fin(); } } diff --git a/rust/cymbal/src/types/frames.rs b/rust/cymbal/src/types/frames.rs index f14840cfb0073..5a1150c3c3842 100644 --- a/rust/cymbal/src/types/frames.rs +++ b/rust/cymbal/src/types/frames.rs @@ -1,4 +1,5 @@ use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha512}; use crate::{ error::Error, langs::js::RawJSFrame, metric_consts::PER_FRAME_TIME, symbol_store::Catalog, @@ -35,7 +36,7 @@ impl RawFrame { } // We emit a single, unified representation of a frame, which is what we pass on to users. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct Frame { pub mangled_name: String, // Mangled name of the function pub line: Option, // Line the function is define on, if known @@ -47,3 +48,31 @@ pub struct Frame { pub resolved: bool, // Did we manage to resolve the frame? pub resolve_failure: Option, // If we failed to resolve the frame, why? } + +impl Frame { + pub fn include_in_fingerprint(&self, h: &mut Sha512) { + if let Some(resolved) = &self.resolved_name { + h.update(resolved.as_bytes()); + if let Some(s) = self.source.as_ref() { + h.update(s.as_bytes()) + } + return; + } + + h.update(self.mangled_name.as_bytes()); + + if let Some(source) = &self.source { + h.update(source.as_bytes()); + } + + if let Some(line) = self.line { + h.update(line.to_string().as_bytes()); + } + + if let Some(column) = self.column { + h.update(column.to_string().as_bytes()); + } + + h.update(self.lang.as_bytes()); + } +} diff --git a/rust/cymbal/src/types/mod.rs b/rust/cymbal/src/types/mod.rs index 3a420e6410601..57db29fb813cc 100644 --- a/rust/cymbal/src/types/mod.rs +++ b/rust/cymbal/src/types/mod.rs @@ -1,7 +1,9 @@ use std::collections::HashMap; +use frames::{Frame, RawFrame}; use serde::{Deserialize, Serialize}; use serde_json::Value; +use sha2::{digest::Update, Sha512}; pub mod frames; @@ -19,8 +21,10 @@ pub struct Mechanism { } #[derive(Debug, Deserialize, Serialize, Clone)] -pub struct Stacktrace { - pub frames: Vec, +#[serde(tag = "type", rename_all = "lowercase")] +pub enum Stacktrace { + Raw { frames: Vec }, + Resolved { frames: Vec }, } #[derive(Debug, Deserialize, Serialize, Clone)] @@ -34,8 +38,8 @@ pub struct Exception { pub module: Option, #[serde(skip_serializing_if = "Option::is_none")] pub thread_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub stacktrace: Option, + #[serde(skip_serializing_if = "Option::is_none", rename = "stacktrace")] + pub stack: Option, } // Given a Clickhouse Event's properties, we care about the contents @@ -60,12 +64,40 @@ pub struct ErrProps { pub other: HashMap, } +impl Exception { + pub fn include_in_fingerprint(&self, h: &mut Sha512) { + h.update(self.exception_type.as_bytes()); + h.update(self.exception_message.as_bytes()); + let Some(Stacktrace::Resolved { frames }) = &self.stack else { + return; + }; + + let has_no_resolved = !frames.iter().any(|f| f.resolved); + let has_no_in_app = !frames.iter().any(|f| f.in_app); + + if has_no_in_app { + // TODO: we should try to be smarter about handling the case when + // there are no in-app frames + if let Some(f) = frames.first() { + f.include_in_fingerprint(h) + } + return; + } + + for frame in frames { + if (has_no_resolved || frame.resolved) && frame.in_app { + frame.include_in_fingerprint(h) + } + } + } +} + #[cfg(test)] mod test { use common_types::ClickHouseEvent; use serde_json::Error; - use crate::types::frames::RawFrame; + use crate::types::{frames::RawFrame, Stacktrace}; use super::ErrProps; @@ -93,9 +125,11 @@ mod test { assert_eq!(mechanism.source, None); assert_eq!(mechanism.synthetic, Some(false)); - let stacktrace = exception_list[0].stacktrace.as_ref().unwrap(); - assert_eq!(stacktrace.frames.len(), 2); - let RawFrame::JavaScript(frame) = &stacktrace.frames[0]; + let Stacktrace::Raw { frames } = exception_list[0].stack.as_ref().unwrap() else { + panic!("Expected a Raw stacktrace") + }; + assert_eq!(frames.len(), 2); + let RawFrame::JavaScript(frame) = &frames[0]; assert_eq!( frame.source_url, @@ -106,7 +140,7 @@ mod test { assert_eq!(frame.line, 64); assert_eq!(frame.column, 25112); - let RawFrame::JavaScript(frame) = &stacktrace.frames[1]; + let RawFrame::JavaScript(frame) = &frames[1]; assert_eq!( frame.source_url, Some("https://app-static.eu.posthog.com/static/chunk-PGUQKT6S.js".to_string()) diff --git a/rust/cymbal/tests/resolve.rs b/rust/cymbal/tests/resolve.rs index 1be56aeb90536..ec71862e55793 100644 --- a/rust/cymbal/tests/resolve.rs +++ b/rust/cymbal/tests/resolve.rs @@ -2,7 +2,7 @@ use common_types::ClickHouseEvent; use cymbal::{ config::Config, symbol_store::{sourcemap::SourcemapProvider, Catalog}, - types::{frames::RawFrame, ErrProps}, + types::{frames::RawFrame, ErrProps, Stacktrace}, }; use httpmock::MockServer; @@ -28,12 +28,12 @@ async fn end_to_end_resolver_test() { let exception: ClickHouseEvent = serde_json::from_str(EXAMPLE_EXCEPTION).unwrap(); let props: ErrProps = serde_json::from_str(&exception.properties.unwrap()).unwrap(); - let mut test_stack: Vec = props.exception_list.unwrap()[0] - .stacktrace - .as_ref() - .unwrap() - .frames - .clone(); + 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 @@ -42,7 +42,7 @@ async fn end_to_end_resolver_test() { s.source_url.as_ref().unwrap().contains(CHUNK_PATH) }); - for frame in &mut test_stack { + 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 diff --git a/rust/cymbal/tests/static/raw_ch_exception_list.json b/rust/cymbal/tests/static/raw_ch_exception_list.json index 7e7080c8fcf12..1c8eebbdc97e8 100644 --- a/rust/cymbal/tests/static/raw_ch_exception_list.json +++ b/rust/cymbal/tests/static/raw_ch_exception_list.json @@ -1,7 +1,7 @@ { "uuid": "019295b1-519f-71a6-aacf-c97b5db73696", "event": "$exception", - "properties": "{\"$os\":\"Mac OS X\",\"$os_version\":\"10.15.7\",\"$browser\":\"Chrome\",\"$device_type\":\"Desktop\",\"$current_url\":\"https://eu.posthog.com/project/12557/feature_flags/31624\",\"$host\":\"eu.posthog.com\",\"$pathname\":\"/project/12557/feature_flags/31624\",\"$raw_user_agent\":\"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36\",\"$browser_version\":129,\"$browser_language\":\"en-GB\",\"$screen_height\":1080,\"$screen_width\":1920,\"$viewport_height\":934,\"$viewport_width\":1920,\"$lib\":\"web\",\"$lib_version\":\"1.170.1\",\"$insert_id\":\"xjfjg606eo2x7n4x\",\"$time\":1729088278.943,\"distinct_id\":\"pQC9X9Fe7BPzJXVxpY0fx37UwFOCd1vXHzh8rjUPv1G\",\"$device_id\":\"018ccedb-d598-79bb-94e0-4751a3b956f4\",\"$console_log_recording_enabled_server_side\":true,\"$autocapture_disabled_server_side\":false,\"$web_vitals_enabled_server_side\":true,\"$exception_capture_enabled_server_side\":true,\"$exception_capture_endpoint\":\"/e/\",\"realm\":\"cloud\",\"email_service_available\":true,\"slack_service_available\":true,\"commit_sha\":\"bafa32953e\",\"$user_id\":\"pQC9X9Fe7BPzJXVxpY0fx37UwFOCd1vXHzh8rjUPv1G\",\"is_demo_project\":false,\"$groups\":{\"project\":\"018c1057-288d-0000-93bb-3bd44c845f22\",\"organization\":\"018afaa6-8b2e-0000-2311-d58d2df832ad\",\"customer\":\"cus_P5B9QmoUKLAUlx\",\"instance\":\"https://eu.posthog.com\"},\"has_billing_plan\":true,\"$referrer\":\"$direct\",\"$referring_domain\":\"$direct\",\"$session_recording_start_reason\":\"session_id_changed\",\"$exception_list\":[{\"type\":\"UnhandledRejection\",\"value\":\"Unexpected usage\",\"stacktrace\":{\"frames\":[{\"filename\":\"https://app-static.eu.posthog.com/static/chunk-PGUQKT6S.js\",\"function\":\"?\",\"in_app\":true,\"lineno\":64,\"colno\":25112},{\"filename\":\"https://app-static.eu.posthog.com/static/chunk-PGUQKT6S.js\",\"function\":\"n.loadForeignModule\",\"in_app\":true,\"lineno\":64,\"colno\":15003}]},\"mechanism\":{\"handled\":false,\"synthetic\":false}}],\"$exception_level\":\"error\",\"$exception_personURL\":\"https://us.posthog.com/project/sTMFPsFhdP1Ssg/person/pQC9X9Fe7BPzJXVxpY0fx37UwFOCd1vXHzh8rjUPv1G\",\"token\":\"sTMFPsFhdP1Ssg\",\"$session_id\":\"019295b0-db2b-7e02-8010-0a1c4db680df\",\"$window_id\":\"019295b0-db2b-7e02-8010-0a1dee88e5f5\",\"$lib_custom_api_host\":\"https://internal-t.posthog.com\",\"$is_identified\":true,\"$lib_rate_limit_remaining_tokens\":97.28999999999999,\"$sent_at\":\"2024-10-16T14:17:59.543000+00:00\",\"$geoip_city_name\":\"Lisbon\",\"$geoip_city_confidence\":null,\"$geoip_country_name\":\"Portugal\",\"$geoip_country_code\":\"PT\",\"$geoip_country_confidence\":null,\"$geoip_continent_name\":\"Europe\",\"$geoip_continent_code\":\"EU\",\"$geoip_postal_code\":\"1269-001\",\"$geoip_postal_code_confidence\":null,\"$geoip_latitude\":38.731,\"$geoip_longitude\":-9.1373,\"$geoip_accuracy_radius\":100,\"$geoip_time_zone\":\"Europe/Lisbon\",\"$geoip_subdivision_1_code\":\"11\",\"$geoip_subdivision_1_name\":\"Lisbon\",\"$geoip_subdivision_1_confidence\":null,\"$lib_version__major\":1,\"$lib_version__minor\":170,\"$lib_version__patch\":1,\"$group_2\":\"018c1057-288d-0000-93bb-3bd44c845f22\",\"$group_0\":\"018afaa6-8b2e-0000-2311-d58d2df832ad\",\"$group_3\":\"cus_P5B9QmoUKLAUlx\",\"$group_1\":\"https://eu.posthog.com\"}", + "properties": "{\"$os\":\"Mac OS X\",\"$os_version\":\"10.15.7\",\"$browser\":\"Chrome\",\"$device_type\":\"Desktop\",\"$current_url\":\"https://eu.posthog.com/project/12557/feature_flags/31624\",\"$host\":\"eu.posthog.com\",\"$pathname\":\"/project/12557/feature_flags/31624\",\"$raw_user_agent\":\"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36\",\"$browser_version\":129,\"$browser_language\":\"en-GB\",\"$screen_height\":1080,\"$screen_width\":1920,\"$viewport_height\":934,\"$viewport_width\":1920,\"$lib\":\"web\",\"$lib_version\":\"1.170.1\",\"$insert_id\":\"xjfjg606eo2x7n4x\",\"$time\":1729088278.943,\"distinct_id\":\"pQC9X9Fe7BPzJXVxpY0fx37UwFOCd1vXHzh8rjUPv1G\",\"$device_id\":\"018ccedb-d598-79bb-94e0-4751a3b956f4\",\"$console_log_recording_enabled_server_side\":true,\"$autocapture_disabled_server_side\":false,\"$web_vitals_enabled_server_side\":true,\"$exception_capture_enabled_server_side\":true,\"$exception_capture_endpoint\":\"/e/\",\"realm\":\"cloud\",\"email_service_available\":true,\"slack_service_available\":true,\"commit_sha\":\"bafa32953e\",\"$user_id\":\"pQC9X9Fe7BPzJXVxpY0fx37UwFOCd1vXHzh8rjUPv1G\",\"is_demo_project\":false,\"$groups\":{\"project\":\"018c1057-288d-0000-93bb-3bd44c845f22\",\"organization\":\"018afaa6-8b2e-0000-2311-d58d2df832ad\",\"customer\":\"cus_P5B9QmoUKLAUlx\",\"instance\":\"https://eu.posthog.com\"},\"has_billing_plan\":true,\"$referrer\":\"$direct\",\"$referring_domain\":\"$direct\",\"$session_recording_start_reason\":\"session_id_changed\",\"$exception_list\":[{\"type\":\"UnhandledRejection\",\"value\":\"Unexpected usage\",\"stacktrace\":{\"type\": \"raw\", \"frames\":[{\"filename\":\"https://app-static.eu.posthog.com/static/chunk-PGUQKT6S.js\",\"function\":\"?\",\"in_app\":true,\"lineno\":64,\"colno\":25112},{\"filename\":\"https://app-static.eu.posthog.com/static/chunk-PGUQKT6S.js\",\"function\":\"n.loadForeignModule\",\"in_app\":true,\"lineno\":64,\"colno\":15003}]},\"mechanism\":{\"handled\":false,\"synthetic\":false}}],\"$exception_level\":\"error\",\"$exception_personURL\":\"https://us.posthog.com/project/sTMFPsFhdP1Ssg/person/pQC9X9Fe7BPzJXVxpY0fx37UwFOCd1vXHzh8rjUPv1G\",\"token\":\"sTMFPsFhdP1Ssg\",\"$session_id\":\"019295b0-db2b-7e02-8010-0a1c4db680df\",\"$window_id\":\"019295b0-db2b-7e02-8010-0a1dee88e5f5\",\"$lib_custom_api_host\":\"https://internal-t.posthog.com\",\"$is_identified\":true,\"$lib_rate_limit_remaining_tokens\":97.28999999999999,\"$sent_at\":\"2024-10-16T14:17:59.543000+00:00\",\"$geoip_city_name\":\"Lisbon\",\"$geoip_city_confidence\":null,\"$geoip_country_name\":\"Portugal\",\"$geoip_country_code\":\"PT\",\"$geoip_country_confidence\":null,\"$geoip_continent_name\":\"Europe\",\"$geoip_continent_code\":\"EU\",\"$geoip_postal_code\":\"1269-001\",\"$geoip_postal_code_confidence\":null,\"$geoip_latitude\":38.731,\"$geoip_longitude\":-9.1373,\"$geoip_accuracy_radius\":100,\"$geoip_time_zone\":\"Europe/Lisbon\",\"$geoip_subdivision_1_code\":\"11\",\"$geoip_subdivision_1_name\":\"Lisbon\",\"$geoip_subdivision_1_confidence\":null,\"$lib_version__major\":1,\"$lib_version__minor\":170,\"$lib_version__patch\":1,\"$group_2\":\"018c1057-288d-0000-93bb-3bd44c845f22\",\"$group_0\":\"018afaa6-8b2e-0000-2311-d58d2df832ad\",\"$group_3\":\"cus_P5B9QmoUKLAUlx\",\"$group_1\":\"https://eu.posthog.com\"}", "timestamp": "2024-10-16T07:17:59.088000-07:00", "team_id": 2, "distinct_id": "pQC9X9Fe7BPzJXVxpY0fx37UwFOCd1vXHzh8rjUPv1G",