Repository: PostHog/posthog
Pull request #29860: feat(err): suppress issues
Author: login: oliverb123
Created: 2025-03-13T03:16:33Z | Merged: 2025-03-13T10:35:19Z

Also made it clearer which issues have what statuseseseses when viewing "all"


Diff:
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index 40eb9a860933..0aa791c56276 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -2103,6 +2103,10 @@ const api = {
             return await new ApiRequest().errorTrackingIssueBulk().create({ data: { action: 'resolve', ids } })
         },
 
+        async bulkSuppress(ids: ErrorTrackingIssue['id'][]): Promise<{ content: string }> {
+            return await new ApiRequest().errorTrackingIssueBulk().create({ data: { action: 'suppress', ids } })
+        },
+
         async bulkAssign(
             ids: ErrorTrackingIssue['id'][],
             assignee: ErrorTrackingIssue['assignee']
diff --git a/frontend/src/queries/schema.json b/frontend/src/queries/schema.json
index fb02920f33ad..9fd7beaa57a6 100644
--- a/frontend/src/queries/schema.json
+++ b/frontend/src/queries/schema.json
@@ -6501,7 +6501,7 @@
                     "type": ["string", "null"]
                 },
                 "status": {
-                    "enum": ["archived", "active", "resolved", "pending_release"],
+                    "enum": ["archived", "active", "resolved", "pending_release", "suppressed"],
                     "type": "string"
                 }
             },
@@ -6630,7 +6630,7 @@
                     "type": "string"
                 },
                 "status": {
-                    "enum": ["archived", "active", "resolved", "pending_release", "all"],
+                    "enum": ["archived", "active", "resolved", "pending_release", "suppressed", "all"],
                     "type": "string"
                 }
             },
@@ -6715,7 +6715,7 @@
                     "type": ["string", "null"]
                 },
                 "status": {
-                    "enum": ["archived", "active", "resolved", "pending_release"],
+                    "enum": ["archived", "active", "resolved", "pending_release", "suppressed"],
                     "type": "string"
                 }
             },
diff --git a/frontend/src/queries/schema/schema-general.ts b/frontend/src/queries/schema/schema-general.ts
index 85561af02ec9..b84a69aacf63 100644
--- a/frontend/src/queries/schema/schema-general.ts
+++ b/frontend/src/queries/schema/schema-general.ts
@@ -1758,7 +1758,7 @@ export interface ErrorTrackingRelationalIssue {
     name: string | null
     description: string | null
     assignee: ErrorTrackingIssueAssignee | null
-    status: 'archived' | 'active' | 'resolved' | 'pending_release'
+    status: 'archived' | 'active' | 'resolved' | 'pending_release' | 'suppressed'
     /**  @format date-time */
     first_seen: string
 }
diff --git a/frontend/src/scenes/error-tracking/ErrorTrackingIssueScene.tsx b/frontend/src/scenes/error-tracking/ErrorTrackingIssueScene.tsx
index ac79a648bbaf..6a57df94f964 100644
--- a/frontend/src/scenes/error-tracking/ErrorTrackingIssueScene.tsx
+++ b/frontend/src/scenes/error-tracking/ErrorTrackingIssueScene.tsx
@@ -25,11 +25,12 @@ export const scene: SceneExport = {
     }): (typeof errorTrackingIssueSceneLogic)['props'] => ({ id, fingerprint }),
 }
 
-const STATUS_LABEL: Record<ErrorTrackingIssue['status'], string> = {
+export const STATUS_LABEL: Record<ErrorTrackingIssue['status'], string> = {
     active: 'Active',
     archived: 'Archived',
     resolved: 'Resolved',
     pending_release: 'Pending release',
+    suppressed: 'Suppressed',
 }
 
 export function ErrorTrackingIssueScene(): JSX.Element {
@@ -64,6 +65,14 @@ export function ErrorTrackingIssueScene(): JSX.Element {
                                         <LemonButton type="primary" onClick={() => updateIssue({ status: 'resolved' })}>
                                             Resolve
                                         </LemonButton>
+                                        <LemonButton
+                                            type="secondary"
+                                            status="danger"
+                                            tooltip="Stop capturing these errors"
+                                            onClick={() => updateIssue({ status: 'suppressed' })}
+                                        >
+                                            Suppress
+                                        </LemonButton>
                                     </div>
                                 </div>
                             ) : (
diff --git a/frontend/src/scenes/error-tracking/ErrorTrackingListOptions.tsx b/frontend/src/scenes/error-tracking/ErrorTrackingListOptions.tsx
index 049f623b1599..7a445d166687 100644
--- a/frontend/src/scenes/error-tracking/ErrorTrackingListOptions.tsx
+++ b/frontend/src/scenes/error-tracking/ErrorTrackingListOptions.tsx
@@ -15,7 +15,7 @@ export const ErrorTrackingListOptions = (): JSX.Element => {
 
     const { selectedIssueIds } = useValues(errorTrackingSceneLogic)
     const { setSelectedIssueIds } = useActions(errorTrackingSceneLogic)
-    const { mergeIssues, assignIssues, resolveIssues } = useActions(errorTrackingDataNodeLogic)
+    const { mergeIssues, assignIssues, resolveIssues, suppressIssues } = useActions(errorTrackingDataNodeLogic)
 
     return (
         <>
@@ -49,6 +49,18 @@ export const ErrorTrackingListOptions = (): JSX.Element => {
                             >
                                 Resolve
                             </LemonButton>
+                            <LemonButton
+                                type="secondary"
+                                size="small"
+                                status="danger"
+                                tooltip="Stop capturing these errors"
+                                onClick={() => {
+                                    suppressIssues(selectedIssueIds)
+                                    setSelectedIssueIds([])
+                                }}
+                            >
+                                Suppress
+                            </LemonButton>
                             <AssigneeSelect
                                 type="secondary"
                                 size="small"
@@ -90,6 +102,14 @@ export const ErrorTrackingListOptions = (): JSX.Element => {
                                         value: 'resolved',
                                         label: 'Resolved',
                                     },
+                                    {
+                                        value: 'suppressed',
+                                        label: 'Suppressed',
+                                    },
+                                    {
+                                        value: 'resolved',
+                                        label: 'Resolved',
+                                    },
                                 ]}
                                 size="small"
                             />
diff --git a/frontend/src/scenes/error-tracking/ErrorTrackingScene.tsx b/frontend/src/scenes/error-tracking/ErrorTrackingScene.tsx
index 48e740806f7b..1354cfa5a615 100644
--- a/frontend/src/scenes/error-tracking/ErrorTrackingScene.tsx
+++ b/frontend/src/scenes/error-tracking/ErrorTrackingScene.tsx
@@ -6,6 +6,7 @@ import {
     LemonDivider,
     LemonSegmentedButton,
     LemonSkeleton,
+    LemonTag,
     Link,
     Tooltip,
 } from '@posthog/lemon-ui'
@@ -30,6 +31,7 @@ import { InsightLogicProps } from '~/types'
 import { AssigneeSelect } from './AssigneeSelect'
 import { errorTrackingDataNodeLogic } from './errorTrackingDataNodeLogic'
 import { ErrorTrackingFilters } from './ErrorTrackingFilters'
+import { STATUS_LABEL } from './ErrorTrackingIssueScene'
 import { errorTrackingIssueSceneLogic } from './errorTrackingIssueSceneLogic'
 import { ErrorTrackingListOptions } from './ErrorTrackingListOptions'
 import { errorTrackingLogic } from './errorTrackingLogic'
@@ -162,6 +164,10 @@ const CustomGroupTitleColumn: QueryContextColumnComponent = (props) => {
                             ) : (
                                 <LemonSkeleton />
                             )}
+                            <span>|</span>
+                            <span>
+                                <LemonTag>{STATUS_LABEL[record.status]}</LemonTag>
+                            </span>
                         </div>
                     </div>
                 }
diff --git a/frontend/src/scenes/error-tracking/errorTrackingDataNodeLogic.tsx b/frontend/src/scenes/error-tracking/errorTrackingDataNodeLogic.tsx
index 33935facfbd5..e41a3609b213 100644
--- a/frontend/src/scenes/error-tracking/errorTrackingDataNodeLogic.tsx
+++ b/frontend/src/scenes/error-tracking/errorTrackingDataNodeLogic.tsx
@@ -25,6 +25,7 @@ export const errorTrackingDataNodeLogic = kea<errorTrackingDataNodeLogicType>([
     actions({
         mergeIssues: (ids: string[]) => ({ ids }),
         resolveIssues: (ids: string[]) => ({ ids }),
+        suppressIssues: (ids: string[]) => ({ ids }),
         assignIssues: (ids: string[], assignee: ErrorTrackingIssue['assignee']) => ({ ids, assignee }),
         assignIssue: (id: string, assignee: ErrorTrackingIssue['assignee']) => ({ id, assignee }),
     }),
@@ -73,6 +74,20 @@ export const errorTrackingDataNodeLogic = kea<errorTrackingDataNodeLogicType>([
             await api.errorTracking.bulkResolve(ids)
             actions.loadData(true)
         },
+        suppressIssues: async ({ ids }) => {
+            const { results } = values
+
+            // optimistically update local results
+            actions.setResponse({
+                ...values.response,
+                // remove suppressed issues
+                results: results.filter(({ id }) => !ids.includes(id)),
+            })
+            posthog.capture('error_tracking_issue_bulk_suppress')
+            await api.errorTracking.bulkSuppress(ids)
+            actions.loadData(true)
+        },
+
         assignIssues: async ({ ids, assignee }) => {
             const { results } = values
 
diff --git a/posthog/api/error_tracking.py b/posthog/api/error_tracking.py
index 803c78b8d9b5..843260833697 100644
--- a/posthog/api/error_tracking.py
+++ b/posthog/api/error_tracking.py
@@ -177,6 +177,31 @@ def bulk(self, request, **kwargs):
                     assign_issue(
                         issue, assignee, self.organization, request.user, self.team_id, is_impersonated_session(request)
                     )
+            elif action == "suppress":
+                for issue in issues:
+                    log_activity(
+                        organization_id=self.organization.id,
+                        team_id=self.team_id,
+                        user=request.user,
+                        was_impersonated=is_impersonated_session(request),
+                        item_id=issue.id,
+                        scope="ErrorTrackingIssue",
+                        activity="updated",
+                        detail=Detail(
+                            name=issue.name,
+                            changes=[
+                                Change(
+                                    type="ErrorTrackingIssue",
+                                    action="changed",
+                                    field="status",
+                                    before=issue.status,
+                                    after=ErrorTrackingIssue.Status.SUPPRESSED,
+                                )
+                            ],
+                        ),
+                    )
+
+                issues.update(status=ErrorTrackingIssue.Status.SUPPRESSED)
 
         return Response({"success": True})
 
diff --git a/posthog/migrations/0686_alter_errortrackingissue_status.py b/posthog/migrations/0686_alter_errortrackingissue_status.py
new file mode 100644
index 000000000000..3328f29c1033
--- /dev/null
+++ b/posthog/migrations/0686_alter_errortrackingissue_status.py
@@ -0,0 +1,26 @@
+# Generated by Django 4.2.18 on 2025-03-13 01:13
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("posthog", "0685_add_playlist_counted_date"),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name="errortrackingissue",
+            name="status",
+            field=models.TextField(
+                choices=[
+                    ("archived", "Archived"),
+                    ("active", "Active"),
+                    ("resolved", "Resolved"),
+                    ("pending_release", "Pending release"),
+                    ("suppressed", "Suppressed"),
+                ],
+                default="active",
+            ),
+        ),
+    ]
diff --git a/posthog/migrations/max_migration.txt b/posthog/migrations/max_migration.txt
index b08d808a2003..c0b8e7f06365 100644
--- a/posthog/migrations/max_migration.txt
+++ b/posthog/migrations/max_migration.txt
@@ -1 +1 @@
-0685_add_playlist_counted_date
+0686_alter_errortrackingissue_status
diff --git a/posthog/models/error_tracking/error_tracking.py b/posthog/models/error_tracking/error_tracking.py
index 6e542eb45d89..d54bd83fc0e4 100644
--- a/posthog/models/error_tracking/error_tracking.py
+++ b/posthog/models/error_tracking/error_tracking.py
@@ -23,6 +23,7 @@ class Status(models.TextChoices):
         ACTIVE = "active", "Active"
         RESOLVED = "resolved", "Resolved"
         PENDING_RELEASE = "pending_release", "Pending release"
+        SUPPRESSED = "suppressed", "Suppressed"
 
     team = models.ForeignKey(Team, on_delete=models.CASCADE)
     created_at = models.DateTimeField(auto_now_add=True)
diff --git a/posthog/schema.py b/posthog/schema.py
index 5b8774e5cc3b..047cb4d07328 100644
--- a/posthog/schema.py
+++ b/posthog/schema.py
@@ -810,6 +810,7 @@ class Status(StrEnum):
     ACTIVE = "active"
     RESOLVED = "resolved"
     PENDING_RELEASE = "pending_release"
+    SUPPRESSED = "suppressed"
 
 
 class ErrorTrackingIssueAggregations(BaseModel):
@@ -842,6 +843,7 @@ class Status1(StrEnum):
     ACTIVE = "active"
     RESOLVED = "resolved"
     PENDING_RELEASE = "pending_release"
+    SUPPRESSED = "suppressed"
     ALL = "all"
 
 
@@ -850,6 +852,7 @@ class Status2(StrEnum):
     ACTIVE = "active"
     RESOLVED = "resolved"
     PENDING_RELEASE = "pending_release"
+    SUPPRESSED = "suppressed"
 
 
 class Interval(StrEnum):
diff --git a/rust/cymbal/src/issue_resolution.rs b/rust/cymbal/src/issue_resolution.rs
index df770c15134b..368c02f7bc3c 100644
--- a/rust/cymbal/src/issue_resolution.rs
+++ b/rust/cymbal/src/issue_resolution.rs
@@ -1,3 +1,5 @@
+use std::fmt::Display;
+
 use chrono::{DateTime, Utc};
 use common_kafka::kafka_messages::internal_events::{InternalEvent, InternalEventEvent};
 use common_kafka::kafka_producer::send_iter_to_kafka;
@@ -11,6 +13,7 @@ use crate::{
     posthog_utils::{capture_issue_created, capture_issue_reopened},
 };
 
+#[derive(Debug, Clone)]
 pub struct IssueFingerprintOverride {
     pub id: Uuid,
     pub team_id: i32,
@@ -19,20 +22,30 @@ pub struct IssueFingerprintOverride {
     pub version: i64,
 }
 
+#[derive(Debug, Clone)]
 pub struct Issue {
     pub id: Uuid,
     pub team_id: i32,
-    pub status: String,
+    pub status: IssueStatus,
     pub name: Option<String>,
     pub description: Option<String>,
 }
 
+#[derive(Debug, Clone, Copy)]
+pub enum IssueStatus {
+    Archived,
+    Active,
+    Resolved,
+    PendingRelease,
+    Suppressed,
+}
+
 impl Issue {
     pub fn new(team_id: i32, name: String, description: String) -> Self {
         Self {
             id: Uuid::new_v4(),
             team_id,
-            status: "active".to_string(), // TODO - we should at some point use an enum here
+            status: IssueStatus::Active,
             name: Some(name),
             description: Some(description),
         }
@@ -99,7 +112,7 @@ impl Issue {
             "#,
             self.id,
             self.team_id,
-            self.status,
+            self.status.to_string(),
             self.name,
             self.description
         )
@@ -121,13 +134,13 @@ impl Issue {
         &self,
         executor: E,
         context: &AppContext,
-    ) -> Result<bool, UnhandledError>
+    ) -> Result<(), UnhandledError>
     where
         E: sqlx::Executor<'c, Database = sqlx::Postgres>,
     {
-        // If this issue is already active, we don't need to do anything
-        if self.status == "active" {
-            return Ok(false);
+        // If this issue is already active, or permanently suppressed, we don't need to do anything
+        if matches!(self.status, IssueStatus::Active | IssueStatus::Suppressed) {
+            return Ok(());
         }
 
         let res = sqlx::query_scalar!(
@@ -142,14 +155,14 @@ impl Issue {
         .fetch_all(executor)
         .await?;
 
-        let reopened = !res.is_empty();
-        if reopened {
+        // If we actually updated a row
+        if !res.is_empty() {
             metrics::counter!(ISSUE_REOPENED).increment(1);
             capture_issue_reopened(self.team_id, self.id);
             send_issue_reopened_alert(context, self).await?;
         }
 
-        Ok(reopened)
+        Ok(())
     }
 }
 
@@ -213,16 +226,14 @@ pub async fn resolve_issue(
     name: String,
     description: String,
     event_timestamp: DateTime<Utc>,
-) -> Result<Uuid, UnhandledError> {
+) -> Result<Issue, UnhandledError> {
     let mut conn = context.pool.acquire().await?;
 
     // Fast path - just fetch the issue directly, and then reopen it if needed
     let existing_issue = Issue::load_by_fingerprint(&mut *conn, team_id, fingerprint).await?;
     if let Some(issue) = existing_issue {
-        // TODO - we should use the bool here to determine if we need to notify a user
-        // that the issue was reopened
         issue.maybe_reopen(&mut *conn, context).await?;
-        return Ok(issue.id);
+        return Ok(issue);
     }
 
     // Slow path - insert a new issue, and then insert the fingerprint override, rolling
@@ -264,12 +275,10 @@ pub async fn resolve_issue(
     // more efficient to fetch the entire Issue struct above along with the fingerprint, but we're
     // in the slow path anyway, so one extra DB hit is not a big deal.
     if let Some(issue) = Issue::load(&mut *conn, team_id, issue_override.issue_id).await? {
-        // TODO - we should use the bool here to determine if we need to notify a user
-        // that the issue was reopened
         issue.maybe_reopen(&mut *conn, context).await?;
     }
 
-    Ok(issue_override.issue_id)
+    Ok(issue)
 }
 
 async fn send_issue_created_alert(
@@ -315,6 +324,31 @@ async fn send_internal_event(
     Ok(())
 }
 
+impl From<String> for IssueStatus {
+    fn from(s: String) -> Self {
+        match s.as_str() {
+            "archived" => IssueStatus::Archived,
+            "active" => IssueStatus::Active,
+            "resolved" => IssueStatus::Resolved,
+            "pending_release" => IssueStatus::PendingRelease,
+            "suppressed" => IssueStatus::Suppressed,
+            s => unreachable!("Invalid issue status: {}", s),
+        }
+    }
+}
+
+impl Display for IssueStatus {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            IssueStatus::Archived => write!(f, "archived"),
+            IssueStatus::Active => write!(f, "active"),
+            IssueStatus::Resolved => write!(f, "resolved"),
+            IssueStatus::PendingRelease => write!(f, "pending_release"),
+            IssueStatus::Suppressed => write!(f, "suppressed"),
+        }
+    }
+}
+
 #[cfg(test)]
 mod test {
     use crate::sanitize_string;
diff --git a/rust/cymbal/src/lib.rs b/rust/cymbal/src/lib.rs
index b0e851a9f5f2..09945ba41f8f 100644
--- a/rust/cymbal/src/lib.rs
+++ b/rust/cymbal/src/lib.rs
@@ -8,8 +8,9 @@ use chrono::{DateTime, NaiveDateTime, Utc};
 use common_types::ClickHouseEvent;
 use error::{EventError, UnhandledError};
 use fingerprinting::generate_fingerprint;
-use issue_resolution::resolve_issue;
-use metric_consts::FRAME_RESOLUTION;
+use issue_resolution::{resolve_issue, IssueStatus};
+use metric_consts::{FRAME_RESOLUTION, SUPPRESSED_ISSUE_DROPPED_EVENTS};
+use metrics::counter;
 use serde_json::Value;
 use tracing::{error, warn};
 use types::{FingerprintedErrProps, RawErrProps, Stacktrace};
@@ -179,30 +180,50 @@ pub async fn handle_events(
     // Collect the results of issue resolution
     let mut resolved_issues = HashMap::new();
     for (fingerprint, handle) in issue_handles.into_iter() {
-        let issue_id = match handle.await.expect("issue resolution task did not panic") {
-            Ok(id) => id,
+        let issue = match handle.await.expect("issue resolution task did not panic") {
+            Ok(i) => i,
             Err(e) => {
                 let index =
                     find_index_with_matching_fingerprint(&fingerprint, &indexed_fingerprinted);
                 return Err((index, e));
             }
         };
-        resolved_issues.insert(fingerprint, issue_id);
+        resolved_issues.insert(fingerprint, issue);
     }
 
     // Fourth pass, to update the events with the resolved issues
     // Unfreeze the events list, since now we have to modify the events we processed
     let mut events = events;
+    let mut to_drop = Vec::new();
     for (index, fingerprinted) in indexed_fingerprinted.into_iter() {
         let event = &mut events[index];
-        let issue_id = resolved_issues
+        let issue = resolved_issues
             .get(&fingerprinted.fingerprint)
             .cloned()
             .expect("Issue was resolved");
-        let output = fingerprinted.to_output(issue_id);
+
+        let output = fingerprinted.to_output(issue.id);
+
+        if matches!(issue.status, IssueStatus::Suppressed) {
+            to_drop.push(index);
+        }
+
         event.properties = Some(serde_json::to_string(&output).map_err(|e| (index, e.into()))?);
     }
 
+    // Drop the suppressed events. Note we reverse the order of the indices to drop, so that
+    // indices don't shift as we remove items from the list.
+    to_drop.sort_unstable();
+    to_drop.dedup();
+    for index in to_drop.into_iter().rev() {
+        counter!(SUPPRESSED_ISSUE_DROPPED_EVENTS).increment(1);
+        // We can afford the order changes here, because it's the last thing we do, and the order
+        // of the events we emit in this batch doesn't matter - they're sorted by timestamp in
+        // clickhouse anyway, not ingestion order. NOTE: if/when we change this fn to return a
+        // Vec of Results, we should instead replace the items we want to drop with an Err.
+        events.swap_remove(index);
+    }
+
     Ok(events)
 }
 
diff --git a/rust/cymbal/src/metric_consts.rs b/rust/cymbal/src/metric_consts.rs
index 23039ec7713f..7fadce48cf02 100644
--- a/rust/cymbal/src/metric_consts.rs
+++ b/rust/cymbal/src/metric_consts.rs
@@ -39,3 +39,4 @@ pub const CHUNK_ID_NOT_FOUND: &str = "cymbal_chunk_id_not_found";
 pub const CHUNK_ID_PARSE_ERROR: &str = "cymbal_chunk_id_parse_error";
 pub const CHUNK_ID_FAILURE_FETCHED: &str = "cymbal_chunk_id_failure_fetched";
 pub const CHUNK_ID_FAILURE_SAVED: &str = "cymbal_chunk_id_failure_saved";
+pub const SUPPRESSED_ISSUE_DROPPED_EVENTS: &str = "cymbal_suppressed_issue_drop";


Reviews:
user: login: greptile-apps[bot]
body: 
## PR Summary

This PR adds a new 'suppressed' status for error tracking issues, enabling users to permanently ignore specific error types.

Key changes:
- Added new `IssueStatus::Suppressed` state that prevents issues from being reopened and drops associated events
- Implemented bulk suppression functionality in frontend/API with optimistic updates and activity logging
- Fixed issue with duplicate 'resolved' status option in ErrorTrackingListOptions.tsx dropdown menu
- Added metrics tracking via `SUPPRESSED_ISSUE_DROPPED_EVENTS` to monitor dropped events from suppressed issues
- Missing documentation on when to use 'suppressed' vs other statuses and how suppression affects issue merging/splitting

<sub>15 file(s) reviewed, 2 comment(s)</sub>
<sub>[Edit PR Review Bot Settings](https://app.greptile.com/review/github) | [Greptile](https://greptile.com?utm_source=greptile_expert&utm_medium=github&utm_campaign=code_reviews&utm_content=posthog_29860)</sub>
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T03:19:28Z
user: login: rafaeelaudibert
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-13T03:46:03Z
user: login: daibhin
body: Awesome!
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T10:23:50Z

Review comments:
diff_hunk: @@ -325,3 +334,28 @@ mod test {
         assert_eq!(content, "� is not valid JSON");
     }
 }
+
+impl From<String> for IssueStatus {
+    fn from(s: String) -> Self {
+        match s.as_str() {
+            "archived" => IssueStatus::Archived,
+            "active" => IssueStatus::Active,
+            "resolved" => IssueStatus::Resolved,
+            "pending_release" => IssueStatus::PendingRelease,
+            "suppressed" => IssueStatus::Suppressed,
+            s => unreachable!("Invalid issue status: {}", s),
path: rust/cymbal/src/issue_resolution.rs
user: login: greptile-apps[bot]
body: logic: using unreachable! for invalid input is dangerous - should return a Result instead
created_at: 2025-03-13T03:18:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -39,3 +39,4 @@ pub const CHUNK_ID_NOT_FOUND: &str = "cymbal_chunk_id_not_found";
 pub const CHUNK_ID_PARSE_ERROR: &str = "cymbal_chunk_id_parse_error";
 pub const CHUNK_ID_FAILURE_FETCHED: &str = "cymbal_chunk_id_failure_fetched";
 pub const CHUNK_ID_FAILURE_SAVED: &str = "cymbal_chunk_id_failure_saved";
+pub const SUPPRESSED_ISSUE_DROPPED_EVENTS: &str = "cymbal_suppressed_issue_drop";
path: rust/cymbal/src/metric_consts.rs
user: login: greptile-apps[bot]
body: style: Constant suffix 'drop' is inconsistent with other constants that use full words (e.g. 'dropped_events'). Consider using 'cymbal_suppressed_issue_dropped' for consistency.
created_at: 2025-03-13T03:19:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -19,20 +22,30 @@ pub struct IssueFingerprintOverride {
     pub version: i64,
 }
 
+#[derive(Debug, Clone)]
 pub struct Issue {
     pub id: Uuid,
     pub team_id: i32,
-    pub status: String,
+    pub status: IssueStatus,
     pub name: Option<String>,
     pub description: Option<String>,
 }
 
+#[derive(Debug, Clone, Copy)]
+pub enum IssueStatus {
+    Archived,
+    Active,
+    Resolved,
+    PendingRelease,
+    Suppressed,
+}
+
 impl Issue {
     pub fn new(team_id: i32, name: String, description: String) -> Self {
         Self {
             id: Uuid::new_v4(),
             team_id,
-            status: "active".to_string(), // TODO - we should at some point use an enum here
+            status: IssueStatus::Active, // TODO - we should at some point use an enum here
path: rust/cymbal/src/issue_resolution.rs
user: login: rafaeelaudibert
body: ```suggestion
            status: IssueStatus::Active,
```
created_at: 2025-03-13T03:46:03Z
author_association: MEMBER

PR comments:
user: login: posthog-bot
created_at: 2025-03-13T08:54:27Z
body: ## 📸 UI snapshots have been updated

**2** snapshot changes in total. **0** added, **2** modified, **0** deleted:

- `chromium`: **0** added, **0** modified, **0** deleted
- **`webkit`**: **0** added, **2** modified, **0** deleted ([diff for shard 2](https://github.com/PostHog/posthog/pull/29860/commits/5745896e8c25b873a6881c40d2c9868444bd561c))

Triggered by [this commit](https://github.com/PostHog/posthog/pull/29860/commits/21254fe53853ae3c6887409423c64174206b7c13).

👉 **[Review this PR's diff of snapshots.](https://github.com/PostHog/posthog/pull/29860/files#:~:text=frontend/__snapshots__/)**
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-03-13T08:58:45Z
body: **Size Change:** +94 B (0%) 

**Total Size:** 9.93 MB



<details><summary>ℹ️ <strong>View Unchanged</strong></summary>

| Filename | Size | Change |
| :--- | :---: | :---: |
| `frontend/dist/toolbar.js` | 9.93 MB | +94 B (0%) |

</details>



<a href="https://github.com/preactjs/compressed-size-action"><sub>compressed-size-action</sub></a>
author_association: CONTRIBUTOR
user: login: posthog-bot
created_at: 2025-03-13T09:12:24Z
body: ## 📸 UI snapshots have been updated

**2** snapshot changes in total. **0** added, **2** modified, **0** deleted:

- `chromium`: **0** added, **0** modified, **0** deleted
- **`webkit`**: **0** added, **2** modified, **0** deleted ([diff for shard 2](https://github.com/PostHog/posthog/pull/29860/commits/2cbbc2f0d665527300d95b3d6184ee9e2820f0db))

Triggered by [this commit](https://github.com/PostHog/posthog/pull/29860/commits/6b56c6c42d038ede370f0f0851edd708564e3d7c).

👉 **[Review this PR's diff of snapshots.](https://github.com/PostHog/posthog/pull/29860/files#:~:text=frontend/__snapshots__/)**
author_association: CONTRIBUTOR