From 0841c9797cb9ea4b9c3f7bf2e62df59fd1f529e5 Mon Sep 17 00:00:00 2001 From: Abhay Sood Date: Wed, 5 Nov 2025 07:37:41 +0530 Subject: [PATCH 01/98] feat(frontend): add basic structure for data filters page --- .../app/[teamId]/data_filters/page.tsx | 137 ++++++++++++++++++ frontend/dashboard/app/[teamId]/layout.tsx | 6 + frontend/dashboard/app/api/api_calls.ts | 61 ++++++++ 3 files changed, 204 insertions(+) create mode 100644 frontend/dashboard/app/[teamId]/data_filters/page.tsx diff --git a/frontend/dashboard/app/[teamId]/data_filters/page.tsx b/frontend/dashboard/app/[teamId]/data_filters/page.tsx new file mode 100644 index 000000000..2a5619195 --- /dev/null +++ b/frontend/dashboard/app/[teamId]/data_filters/page.tsx @@ -0,0 +1,137 @@ +"use client" + +import { DataFiltersApiStatus, DataFiltersResponse, fetchDataFiltersFromServer, FilterSource } from '@/app/api/api_calls' +import Filters, { AppVersionsInitialSelectionType, defaultFilters } from '@/app/components/filters' +import LoadingBar from '@/app/components/loading_bar' +import { Table, TableBody, TableHead, TableHeader, TableRow } from '@/app/components/table' + +import { useRouter, useSearchParams } from 'next/navigation' +import { useEffect, useState } from 'react' + +interface PageState { + dataFiltersApiStatus: DataFiltersApiStatus + filters: typeof defaultFilters + dataFilters: DataFiltersResponse | null +} + +export default function DataFilters({ params }: { params: { teamId: string } }) { + const router = useRouter() + const searchParams = useSearchParams() + + const initialState: PageState = { + dataFiltersApiStatus: DataFiltersApiStatus.Loading, + filters: defaultFilters, + dataFilters: null, + } + + const [pageState, setPageState] = useState(initialState) + + const updatePageState = (newState: Partial) => { + setPageState(prevState => { + const updatedState = { ...prevState, ...newState } + return updatedState + }) + } + + const getDataFilters = async () => { + updatePageState({ dataFiltersApiStatus: DataFiltersApiStatus.Loading }) + + const result = await fetchDataFiltersFromServer(pageState.filters.app!.id) + + switch (result.status) { + case DataFiltersApiStatus.Error: + updatePageState({ dataFiltersApiStatus: DataFiltersApiStatus.Error }) + break + case DataFiltersApiStatus.NoFilters: + updatePageState({ dataFiltersApiStatus: DataFiltersApiStatus.NoFilters }) + break + case DataFiltersApiStatus.Success: + updatePageState({ + dataFiltersApiStatus: DataFiltersApiStatus.Success, + dataFilters: result.data + }) + break + } + } + + const handleFiltersChanged = (updatedFilters: typeof defaultFilters) => { + // update filters only if they have changed + if (pageState.filters.ready !== updatedFilters.ready || pageState.filters.serialisedFilters !== updatedFilters.serialisedFilters) { + updatePageState({ + filters: updatedFilters, + dataFilters: null, + }) + } + } + + useEffect(() => { + if (!pageState.filters.ready) { + return + } + + // update url + router.replace(`?${pageState.filters.serialisedFilters!}`, { scroll: false }) + + getDataFilters() + }, [pageState.filters]) + + return ( +
+

Data Filters

+
+ + +
+ + {/* Error state for bug reports fetch */} + {pageState.filters.ready + && pageState.dataFiltersApiStatus === DataFiltersApiStatus.Error + &&

Error fetching data filters, please change filters, refresh page or select a different app to try again

} + + {/* Main bug reports list UI */} + {pageState.filters.ready + && (pageState.dataFiltersApiStatus === DataFiltersApiStatus.Success || pageState.dataFiltersApiStatus === DataFiltersApiStatus.Loading) && +
+
+ +
+
+ + + + Filter + Created At + Created By + Last Updated + Updated At + + + + +
+
} +
+ ) +} diff --git a/frontend/dashboard/app/[teamId]/layout.tsx b/frontend/dashboard/app/[teamId]/layout.tsx index 22efc1edb..ce5230f6e 100644 --- a/frontend/dashboard/app/[teamId]/layout.tsx +++ b/frontend/dashboard/app/[teamId]/layout.tsx @@ -91,6 +91,12 @@ const initNavData = { { title: "Settings", items: [ + { + title: "Data Filters", + url: "data_filters", + isActive: false, + external: false, + }, { title: "Apps", url: "apps", diff --git a/frontend/dashboard/app/api/api_calls.ts b/frontend/dashboard/app/api/api_calls.ts index 4ec1205c7..5d7e3b98d 100644 --- a/frontend/dashboard/app/api/api_calls.ts +++ b/frontend/dashboard/app/api/api_calls.ts @@ -353,6 +353,15 @@ export enum AlertsOverviewApiStatus { Cancelled, } +export enum DataFiltersApiStatus { + Loading, + Success, + Error, + NoFilters, + Cancelled, +} + + export enum SessionType { All = "All Sessions", Crashes = "Crash Sessions", @@ -1022,6 +1031,34 @@ export const emptyAlertsOverviewResponse = { }[], } +export type DataFiltersResponse = { + meta: { + next: false, + previous: false, + }, + results: DataFilter[], +} + +export type DataFilter = { + id: string, + type: DataFilterType, + rule: string, + collection_config: DataFilterCollectionConfig, + attachment_config: DataFilterAttachmentConfig | null, + created_at: string, + created_by: string, + updated_at: string, + updated_by: string, +} + +export type DataFilterType = "event" | "trace" +export type DataFilterCollectionConfig = + | { mode: 'sample_rate'; sample_rate: number } + | { mode: 'timeline_only' } + | { mode: 'disable' }; + +export type DataFilterAttachmentConfig = 'layout_snapshot' | 'screenshot' | 'none'; + export class AppVersion { name: string code: string @@ -2356,3 +2393,27 @@ export const fetchAlertsOverviewFromServer = async ( return { status: AlertsOverviewApiStatus.Cancelled, data: null } } } + +export const fetchDataFiltersFromServer = async ( + appId: String, +) => { + const url = `/api/apps/${appId}/data_filters` + + try { + const res = await measureAuth.fetchMeasure(url) + + if (!res.ok) { + return { status: DataFiltersApiStatus.Error, data: null } + } + + const data = await res.json() + + if (data.results === null) { + return { status: DataFiltersApiStatus.NoFilters, data: null } + } else { + return { status: DataFiltersApiStatus.Success, data: data } + } + } catch { + return { status: DataFiltersApiStatus.Cancelled, data: null } + } +} \ No newline at end of file From d07fb2ce6495dd75790c0d3af704a57c80d3ae0b Mon Sep 17 00:00:00 2001 From: Abhay Sood Date: Wed, 5 Nov 2025 07:53:51 +0530 Subject: [PATCH 02/98] feat(frontend): add table with dummy data --- .../app/[teamId]/data_filters/page.tsx | 70 +++++++++++++++---- frontend/dashboard/app/api/api_calls.ts | 66 ++++++++++++++++- 2 files changed, 123 insertions(+), 13 deletions(-) diff --git a/frontend/dashboard/app/[teamId]/data_filters/page.tsx b/frontend/dashboard/app/[teamId]/data_filters/page.tsx index 2a5619195..1b4c1e8d4 100644 --- a/frontend/dashboard/app/[teamId]/data_filters/page.tsx +++ b/frontend/dashboard/app/[teamId]/data_filters/page.tsx @@ -1,17 +1,43 @@ "use client" -import { DataFiltersApiStatus, DataFiltersResponse, fetchDataFiltersFromServer, FilterSource } from '@/app/api/api_calls' +import { DataFiltersApiStatus, DataFiltersResponse, emptyDataFiltersResponse, fetchDataFiltersFromServer, FilterSource } from '@/app/api/api_calls' import Filters, { AppVersionsInitialSelectionType, defaultFilters } from '@/app/components/filters' import LoadingBar from '@/app/components/loading_bar' -import { Table, TableBody, TableHead, TableHeader, TableRow } from '@/app/components/table' +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/app/components/table' +import { DataFilterAttachmentConfig, DataFilterCollectionConfig } from '@/app/api/api_calls' +import { formatDateToHumanReadableDate, formatDateToHumanReadableTime } from '@/app/utils/time_utils' import { useRouter, useSearchParams } from 'next/navigation' import { useEffect, useState } from 'react' interface PageState { dataFiltersApiStatus: DataFiltersApiStatus filters: typeof defaultFilters - dataFilters: DataFiltersResponse | null + dataFilters: DataFiltersResponse +} + +const getCollectionConfigDisplay = (collectionConfig: DataFilterCollectionConfig): string => { + switch (collectionConfig.mode) { + case 'sample_rate': + return `Sample Rate: ${collectionConfig.sample_rate * 100}%` + case 'timeline_only': + return 'Timeline Only' + case 'disable': + return 'Do not collect' + default: + return 'Unknown' + } +} + +const getAttachmentConfigDisplay = (attachmentConfig: DataFilterAttachmentConfig | null): string => { + if (!attachmentConfig || attachmentConfig === 'none') { + return 'With no attachments' + } else if (attachmentConfig === 'layout_snapshot') { + return 'With layout snapshot' + } else if (attachmentConfig === 'screenshot') { + return 'With screenshot' + } + return attachmentConfig } export default function DataFilters({ params }: { params: { teamId: string } }) { @@ -19,9 +45,9 @@ export default function DataFilters({ params }: { params: { teamId: string } }) const searchParams = useSearchParams() const initialState: PageState = { - dataFiltersApiStatus: DataFiltersApiStatus.Loading, + dataFiltersApiStatus: DataFiltersApiStatus.Success, filters: defaultFilters, - dataFilters: null, + dataFilters: emptyDataFiltersResponse, } const [pageState, setPageState] = useState(initialState) @@ -59,7 +85,7 @@ export default function DataFilters({ params }: { params: { teamId: string } }) if (pageState.filters.ready !== updatedFilters.ready || pageState.filters.serialisedFilters !== updatedFilters.serialisedFilters) { updatePageState({ filters: updatedFilters, - dataFilters: null, + dataFilters: emptyDataFiltersResponse, }) } } @@ -72,7 +98,8 @@ export default function DataFilters({ params }: { params: { teamId: string } }) // update url router.replace(`?${pageState.filters.serialisedFilters!}`, { scroll: false }) - getDataFilters() + // TODO: Re-enable API call when ready + // getDataFilters() }, [pageState.filters]) return ( @@ -121,14 +148,33 @@ export default function DataFilters({ params }: { params: { teamId: string } }) - Filter - Created At - Created By - Last Updated - Updated At + Filter + Updated At + Updated By + {pageState.dataFilters.results.map((dataFilter, idx) => ( + + +

{dataFilter.filter}

+
+

{getCollectionConfigDisplay(dataFilter.collection_config)}

+

{getAttachmentConfigDisplay(dataFilter.attachment_config)}

+ + +

{formatDateToHumanReadableDate(dataFilter.updated_at)}

+
+

{formatDateToHumanReadableTime(dataFilter.updated_at)}

+ + +

{dataFilter.updated_by}

+
+ + ))}
} diff --git a/frontend/dashboard/app/api/api_calls.ts b/frontend/dashboard/app/api/api_calls.ts index 5d7e3b98d..e9cee2955 100644 --- a/frontend/dashboard/app/api/api_calls.ts +++ b/frontend/dashboard/app/api/api_calls.ts @@ -1042,7 +1042,7 @@ export type DataFiltersResponse = { export type DataFilter = { id: string, type: DataFilterType, - rule: string, + filter: string, collection_config: DataFilterCollectionConfig, attachment_config: DataFilterAttachmentConfig | null, created_at: string, @@ -1059,6 +1059,70 @@ export type DataFilterCollectionConfig = export type DataFilterAttachmentConfig = 'layout_snapshot' | 'screenshot' | 'none'; +export const emptyDataFiltersResponse: DataFiltersResponse = { + meta: { + next: false, + previous: false, + }, + results: [ + { + id: "df-001", + type: "event", + filter: "event.type == 'click' && event.target == 'checkout_button'", + collection_config: { mode: 'sample_rate', sample_rate: 0.5 }, + attachment_config: 'screenshot', + created_at: "2024-01-15T10:30:00Z", + created_by: "user1@example.com", + updated_at: "2024-02-20T14:45:00Z", + updated_by: "user2@example.com", + }, + { + id: "df-002", + type: "trace", + filter: "trace.duration > 5000 && trace.status == 'error'", + collection_config: { mode: 'timeline_only' }, + attachment_config: 'layout_snapshot', + created_at: "2024-01-20T08:15:00Z", + created_by: "admin@example.com", + updated_at: "2024-01-20T08:15:00Z", + updated_by: "admin@example.com", + }, + { + id: "df-003", + type: "event", + filter: "event.name == 'app_background' && session.is_crash == true", + collection_config: { mode: 'disable' }, + attachment_config: null, + created_at: "2024-02-01T12:00:00Z", + created_by: "developer@example.com", + updated_at: "2024-03-10T09:30:00Z", + updated_by: "lead@example.com", + }, + { + id: "df-004", + type: "trace", + filter: "trace.name == 'network_request' && trace.http.status_code >= 400", + collection_config: { mode: 'sample_rate', sample_rate: 0.25 }, + attachment_config: 'none', + created_at: "2024-02-10T16:20:00Z", + created_by: "qa@example.com", + updated_at: "2024-02-28T11:15:00Z", + updated_by: "qa@example.com", + }, + { + id: "df-005", + type: "event", + filter: "event.type == 'gesture' && device.manufacturer == 'Samsung'", + collection_config: { mode: 'sample_rate', sample_rate: 1.0 }, + attachment_config: 'screenshot', + created_at: "2024-03-05T13:45:00Z", + created_by: "user3@example.com", + updated_at: "2024-03-05T13:45:00Z", + updated_by: "user3@example.com", + }, + ], +} + export class AppVersion { name: string code: string From 8825783f0170485051f3cc371f2f964cb29f82aa Mon Sep 17 00:00:00 2001 From: Abhay Sood Date: Wed, 5 Nov 2025 08:15:18 +0530 Subject: [PATCH 03/98] feat(frontend): handle default filters --- .../app/[teamId]/data_filters/page.tsx | 148 +++++++++++++----- frontend/dashboard/app/api/api_calls.ts | 24 ++- 2 files changed, 132 insertions(+), 40 deletions(-) diff --git a/frontend/dashboard/app/[teamId]/data_filters/page.tsx b/frontend/dashboard/app/[teamId]/data_filters/page.tsx index 1b4c1e8d4..e9ddec415 100644 --- a/frontend/dashboard/app/[teamId]/data_filters/page.tsx +++ b/frontend/dashboard/app/[teamId]/data_filters/page.tsx @@ -5,7 +5,7 @@ import Filters, { AppVersionsInitialSelectionType, defaultFilters } from '@/app/ import LoadingBar from '@/app/components/loading_bar' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/app/components/table' -import { DataFilterAttachmentConfig, DataFilterCollectionConfig } from '@/app/api/api_calls' +import { DataFilter, DataFilterAttachmentConfig, DataFilterCollectionConfig, DataFilterType } from '@/app/api/api_calls' import { formatDateToHumanReadableDate, formatDateToHumanReadableTime } from '@/app/utils/time_utils' import { useRouter, useSearchParams } from 'next/navigation' import { useEffect, useState } from 'react' @@ -16,12 +16,27 @@ interface PageState { dataFilters: DataFiltersResponse } +const isGlobalRule = (type: DataFilterType): boolean => { + return type === 'all_events' || type === 'all_traces' +} + +const getFilterDisplayText = (type: DataFilterType, filter: string): string => { + switch (type) { + case 'all_events': + return 'All Events' + case 'all_traces': + return 'All Traces' + default: + return filter + } +} + const getCollectionConfigDisplay = (collectionConfig: DataFilterCollectionConfig): string => { switch (collectionConfig.mode) { case 'sample_rate': - return `Sample Rate: ${collectionConfig.sample_rate * 100}%` + return `Collect at ${collectionConfig.sample_rate}% sample rate` case 'timeline_only': - return 'Timeline Only' + return 'Collect with session timeline only' case 'disable': return 'Do not collect' default: @@ -102,6 +117,9 @@ export default function DataFilters({ params }: { params: { teamId: string } }) // getDataFilters() }, [pageState.filters]) + const globalRules = pageState.dataFilters.results.filter(df => isGlobalRule(df.type)) + const overrideRules = pageState.dataFilters.results.filter(df => !isGlobalRule(df.type)) + return (

Data Filters

@@ -132,51 +150,103 @@ export default function DataFilters({ params }: { params: { teamId: string } }) onFiltersChanged={handleFiltersChanged} />
- {/* Error state for bug reports fetch */} + {/* Error state for data filters fetch */} {pageState.filters.ready && pageState.dataFiltersApiStatus === DataFiltersApiStatus.Error &&

Error fetching data filters, please change filters, refresh page or select a different app to try again

} - {/* Main bug reports list UI */} + {/* Main data filters UI */} {pageState.filters.ready && (pageState.dataFiltersApiStatus === DataFiltersApiStatus.Success || pageState.dataFiltersApiStatus === DataFiltersApiStatus.Loading) && -
+
-
- - - - Filter - Updated At - Updated By - - - - {pageState.dataFilters.results.map((dataFilter, idx) => ( - - -

{dataFilter.filter}

-
-

{getCollectionConfigDisplay(dataFilter.collection_config)}

-

{getAttachmentConfigDisplay(dataFilter.attachment_config)}

- - -

{formatDateToHumanReadableDate(dataFilter.updated_at)}

-
-

{formatDateToHumanReadableTime(dataFilter.updated_at)}

- - -

{dataFilter.updated_by}

-
- - ))} - -
+ + {/* Global Rules Section */} + {globalRules.length > 0 && ( +
+

Default Filters

+ +
+ + + + + Filter + Updated At + Updated By + + + + {globalRules.map((dataFilter, idx) => ( + + +

{getFilterDisplayText(dataFilter.type, dataFilter.filter)}

+

{getCollectionConfigDisplay(dataFilter.collection_config)}

+

{getAttachmentConfigDisplay(dataFilter.attachment_config)}

+
+ +

{formatDateToHumanReadableDate(dataFilter.updated_at)}

+
+

{formatDateToHumanReadableTime(dataFilter.updated_at)}

+ + +

{dataFilter.updated_by}

+
+ + ))} + +
+
+ )} + +
+ + {/* Override Rules Table */} + {overrideRules.length > 0 && ( +
+

Overrides

+ +
+ + + + + Filter + Updated At + Updated By + + + + {overrideRules.map((dataFilter, idx) => ( + + +

{getFilterDisplayText(dataFilter.type, dataFilter.filter)}

+
+

{getCollectionConfigDisplay(dataFilter.collection_config)}

+

{getAttachmentConfigDisplay(dataFilter.attachment_config)}

+ + +

{formatDateToHumanReadableDate(dataFilter.updated_at)}

+
+

{formatDateToHumanReadableTime(dataFilter.updated_at)}

+ + +

{dataFilter.updated_by}

+
+ + ))} + +
+
+ )}
}
) diff --git a/frontend/dashboard/app/api/api_calls.ts b/frontend/dashboard/app/api/api_calls.ts index e9cee2955..f42758a46 100644 --- a/frontend/dashboard/app/api/api_calls.ts +++ b/frontend/dashboard/app/api/api_calls.ts @@ -1051,7 +1051,7 @@ export type DataFilter = { updated_by: string, } -export type DataFilterType = "event" | "trace" +export type DataFilterType = "event" | "trace" | "all_events" | "all_traces"; export type DataFilterCollectionConfig = | { mode: 'sample_rate'; sample_rate: number } | { mode: 'timeline_only' } @@ -1065,6 +1065,28 @@ export const emptyDataFiltersResponse: DataFiltersResponse = { previous: false, }, results: [ + { + id: "df-global-001", + type: "all_events", + filter: 'event_type == "*"', + collection_config: { mode: 'timeline_only'}, + attachment_config: 'none', + created_at: "2024-01-01T00:00:00Z", + created_by: "system@example.com", + updated_at: "2024-01-01T00:00:00Z", + updated_by: "system@example.com", + }, + { + id: "df-global-002", + type: "all_traces", + filter: 'span.name == "*"', + collection_config: { mode: 'sample_rate', sample_rate: 1 }, + attachment_config: 'none', + created_at: "2024-01-01T00:00:00Z", + created_by: "system@example.com", + updated_at: "2024-01-01T00:00:00Z", + updated_by: "system@example.com", + }, { id: "df-001", type: "event", From 91508aade566a61af1a076f7f77bc67c98b6d6f4 Mon Sep 17 00:00:00 2001 From: Abhay Sood Date: Wed, 5 Nov 2025 08:26:59 +0530 Subject: [PATCH 04/98] feat(frontend): rename rules to filters --- .../app/[teamId]/data_filters/page.tsx | 41 ++++++++++++------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/frontend/dashboard/app/[teamId]/data_filters/page.tsx b/frontend/dashboard/app/[teamId]/data_filters/page.tsx index e9ddec415..1552edd23 100644 --- a/frontend/dashboard/app/[teamId]/data_filters/page.tsx +++ b/frontend/dashboard/app/[teamId]/data_filters/page.tsx @@ -5,10 +5,12 @@ import Filters, { AppVersionsInitialSelectionType, defaultFilters } from '@/app/ import LoadingBar from '@/app/components/loading_bar' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/app/components/table' -import { DataFilter, DataFilterAttachmentConfig, DataFilterCollectionConfig, DataFilterType } from '@/app/api/api_calls' +import { DataFilterAttachmentConfig, DataFilterCollectionConfig, DataFilterType } from '@/app/api/api_calls' import { formatDateToHumanReadableDate, formatDateToHumanReadableTime } from '@/app/utils/time_utils' import { useRouter, useSearchParams } from 'next/navigation' import { useEffect, useState } from 'react' +import { Button } from '@/app/components/button' +import { Plus } from 'lucide-react' interface PageState { dataFiltersApiStatus: DataFiltersApiStatus @@ -16,7 +18,7 @@ interface PageState { dataFilters: DataFiltersResponse } -const isGlobalRule = (type: DataFilterType): boolean => { +const isGlobalFilter = (type: DataFilterType): boolean => { return type === 'all_events' || type === 'all_traces' } @@ -46,7 +48,7 @@ const getCollectionConfigDisplay = (collectionConfig: DataFilterCollectionConfig const getAttachmentConfigDisplay = (attachmentConfig: DataFilterAttachmentConfig | null): string => { if (!attachmentConfig || attachmentConfig === 'none') { - return 'With no attachments' + return '' } else if (attachmentConfig === 'layout_snapshot') { return 'With layout snapshot' } else if (attachmentConfig === 'screenshot') { @@ -117,12 +119,23 @@ export default function DataFilters({ params }: { params: { teamId: string } }) // getDataFilters() }, [pageState.filters]) - const globalRules = pageState.dataFilters.results.filter(df => isGlobalRule(df.type)) - const overrideRules = pageState.dataFilters.results.filter(df => !isGlobalRule(df.type)) + const globalFilters = pageState.dataFilters.results.filter(df => isGlobalFilter(df.type)) + const overrideFilters = pageState.dataFilters.results.filter(df => !isGlobalFilter(df.type)) return (
-

Data Filters

+ +
+

Data Filters

+ +
- {/* Global Rules Section */} - {globalRules.length > 0 && ( + {/* Global Filters Section */} + {globalFilters.length > 0 && (
-

Default Filters

+

Global Filters

@@ -179,7 +192,7 @@ export default function DataFilters({ params }: { params: { teamId: string } }) - {globalRules.map((dataFilter, idx) => ( + {globalFilters.map((dataFilter, idx) => ( - {/* Override Rules Table */} - {overrideRules.length > 0 && ( + {/* Override Filters Table */} + {overrideFilters.length > 0 && (

Overrides

- + @@ -222,7 +235,7 @@ export default function DataFilters({ params }: { params: { teamId: string } }) - {overrideRules.map((dataFilter, idx) => ( + {overrideFilters.map((dataFilter, idx) => ( Date: Wed, 5 Nov 2025 08:35:22 +0530 Subject: [PATCH 05/98] feat(frontend): show dropdown to create filter and update state --- .../app/[teamId]/data_filters/page.tsx | 30 ++++++++++++++----- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/frontend/dashboard/app/[teamId]/data_filters/page.tsx b/frontend/dashboard/app/[teamId]/data_filters/page.tsx index 1552edd23..17652c99c 100644 --- a/frontend/dashboard/app/[teamId]/data_filters/page.tsx +++ b/frontend/dashboard/app/[teamId]/data_filters/page.tsx @@ -11,11 +11,13 @@ import { useRouter, useSearchParams } from 'next/navigation' import { useEffect, useState } from 'react' import { Button } from '@/app/components/button' import { Plus } from 'lucide-react' +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/app/components/dropdown_menu' interface PageState { dataFiltersApiStatus: DataFiltersApiStatus filters: typeof defaultFilters dataFilters: DataFiltersResponse + editingFilterType: 'event' | 'trace' | null } const isGlobalFilter = (type: DataFilterType): boolean => { @@ -65,6 +67,7 @@ export default function DataFilters({ params }: { params: { teamId: string } }) dataFiltersApiStatus: DataFiltersApiStatus.Success, filters: defaultFilters, dataFilters: emptyDataFiltersResponse, + editingFilterType: null, } const [pageState, setPageState] = useState(initialState) @@ -127,14 +130,25 @@ export default function DataFilters({ params }: { params: { teamId: string } })

Data Filters

- + + + + + + updatePageState({ editingFilterType: 'event' })}> + Event Filter + + updatePageState({ editingFilterType: 'trace' })}> + Trace Filter + + +
From a4f3c97f200221f35eb78b6a177df24df7e10d11 Mon Sep 17 00:00:00 2001 From: Abhay Sood Date: Wed, 5 Nov 2025 08:41:32 +0530 Subject: [PATCH 06/98] feat(frontend): show a create/edit filter card and manage edit state --- .../app/[teamId]/data_filters/page.tsx | 41 +++++- .../session_targeting/rule_builder_card.tsx | 135 ++++++++++++++++++ 2 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 frontend/dashboard/app/components/session_targeting/rule_builder_card.tsx diff --git a/frontend/dashboard/app/[teamId]/data_filters/page.tsx b/frontend/dashboard/app/[teamId]/data_filters/page.tsx index 17652c99c..b47ac3df0 100644 --- a/frontend/dashboard/app/[teamId]/data_filters/page.tsx +++ b/frontend/dashboard/app/[teamId]/data_filters/page.tsx @@ -12,6 +12,7 @@ import { useEffect, useState } from 'react' import { Button } from '@/app/components/button' import { Plus } from 'lucide-react' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/app/components/dropdown_menu' +import { Card, CardContent, CardFooter } from '@/app/components/card' interface PageState { dataFiltersApiStatus: DataFiltersApiStatus @@ -125,6 +126,14 @@ export default function DataFilters({ params }: { params: { teamId: string } }) const globalFilters = pageState.dataFilters.results.filter(df => isGlobalFilter(df.type)) const overrideFilters = pageState.dataFilters.results.filter(df => !isGlobalFilter(df.type)) + const handleCancel = () => { + updatePageState({ editingFilterType: null }) + } + + const handleCreateFilter = () => { + updatePageState({ editingFilterType: null }) + } + return (
@@ -135,7 +144,7 @@ export default function DataFilters({ params }: { params: { teamId: string } }) @@ -177,6 +186,36 @@ export default function DataFilters({ params }: { params: { teamId: string } }) onFiltersChanged={handleFiltersChanged} />
+ {/* Filter creation card */} + {pageState.editingFilterType && ( + <> + + +
+
+
+ + + + + +
+
+ + )} + {/* Error state for data filters fetch */} {pageState.filters.ready && pageState.dataFiltersApiStatus === DataFiltersApiStatus.Error diff --git a/frontend/dashboard/app/components/session_targeting/rule_builder_card.tsx b/frontend/dashboard/app/components/session_targeting/rule_builder_card.tsx new file mode 100644 index 000000000..3d6e56181 --- /dev/null +++ b/frontend/dashboard/app/components/session_targeting/rule_builder_card.tsx @@ -0,0 +1,135 @@ +"use client" + +import { useState } from "react" +import { Card, CardContent, CardFooter } from "@/app/components/card" +import { Button } from "@/app/components/button" +import { Input } from "@/app/components/input" +import DropdownSelect, { DropdownSelectType } from "@/app/components/dropdown_select" +import { Plus } from "lucide-react" +import RuleBuilderAttributeRow from "@/app/components/session_targeting/rule_builder_attribute_row" + +interface RuleBuilderCardProps { + type: 'event' | 'trace' | 'session_attr' + onCancel: () => void + onSave: (rule: any) => void + initialData?: any | null +} + +interface EventFilter { + id: string + key: string + type: string + value: string | boolean | number + operator: string + hasError?: boolean + errorMessage?: string + hint?: string +} + +// Sample data - replace with actual data from your API +const eventTypes = ["click", "view", "error", "custom"] +const attributeKeys = ["user_id", "session_id", "device_type", "app_version"] +const operators = ["equals", "not equals", "contains", "greater than", "less than"] + +// Operator types mapping for different attribute types +const operatorTypesMapping = { + string: ['eq', 'neq', 'contains', 'ncontains'], + number: ['eq', 'neq', 'gt', 'gte', 'lt', 'lte'], + int64: ['eq', 'neq', 'gt', 'gte', 'lt', 'lte'], + float64: ['eq', 'neq', 'gt', 'gte', 'lt', 'lte'], + bool: ['eq', 'neq'] +} + +const getOperatorsForType = (mapping: any, type: string): string[] => { + return mapping[type] || mapping.string +} + +export default function RuleBuilderCard({ type, onCancel, onSave, initialData }: RuleBuilderCardProps) { + const [selectedEventType, setSelectedEventType] = useState(initialData?.eventType || eventTypes[0]) + const [eventFilters, setEventFilters] = useState(initialData?.eventFilters || []) + + // For session_attr type + const [sessionAttr, setSessionAttr] = useState(initialData?.attribute || { + id: 'session-attr', + key: attributeKeys[0], + type: 'string', + operator: 'eq', + value: "" + }) + + const [collectionMode, setCollectionMode] = useState<'none' | 'sample' | 'timeline'>(initialData?.collectionMode || 'sample') + const [eventTraceSamplingRate, setEventTraceSamplingRate] = useState(initialData?.eventTraceSamplingRate?.toString() || "100") + const [snapshotType, setSnapshotType] = useState<'none' | 'screenshot' | 'layout'>(initialData?.snapshotType || 'none') + + const addEventFilter = () => { + const newFilter: EventFilter = { + id: `filter-${Date.now()}`, + key: attributeKeys[0], + type: 'string', + operator: 'eq', + value: "" + } + setEventFilters([...eventFilters, newFilter]) + } + + const removeEventFilter = (conditionId: string, attrId: string) => { + setEventFilters(eventFilters.filter(filter => filter.id !== attrId)) + } + + const updateEventFilter = (conditionId: string, attrId: string, field: 'key' | 'type' | 'value' | 'operator', value: any) => { + setEventFilters(eventFilters.map(filter => + filter.id === attrId ? { ...filter, [field]: value } : filter + )) + } + + const updateSessionAttr = (conditionId: string, attrId: string, field: 'key' | 'type' | 'value' | 'operator', value: any) => { + setSessionAttr(prev => ({ ...prev, [field]: value })) + } + + const handleSave = () => { + let rule: any = { + type: type, + collectEvent: collectionMode === 'sample', + collectTimeline: collectionMode === 'timeline', + eventTraceSamplingRate: collectionMode === 'sample' ? parseFloat(eventTraceSamplingRate) : null, + snapshotType: collectionMode === 'none' ? null : snapshotType + } + + if (type === 'event' || type === 'trace') { + rule.eventType = selectedEventType + rule.eventFilters = eventFilters + } else if (type === 'session_attr') { + rule.attribute = sessionAttr + } + + onSave(rule) + } + + const checkboxStyle = "appearance-none border-black rounded-xs font-display checked:bg-neutral-950 checked:hover:bg-neutral-950 focus:ring-offset-yellow-200 focus:ring-0 checked:focus:bg-neutral-950" + const radioStyle = "appearance-none border-black rounded-full font-display checked:bg-neutral-950 checked:hover:bg-neutral-950 focus:ring-offset-yellow-200 focus:ring-0 checked:focus:bg-neutral-950" + + return ( + + +
+ + + + + + + + ) +} From 2eebbf12b10d4a0fa9caf0822bbdc6326e7d9ff3 Mon Sep 17 00:00:00 2001 From: Abhay Sood Date: Wed, 5 Nov 2025 09:01:41 +0530 Subject: [PATCH 07/98] feat(frontend): add a basic flow for editing global filters --- .../app/[teamId]/data_filters/page.tsx | 202 ++++++++++++++---- 1 file changed, 163 insertions(+), 39 deletions(-) diff --git a/frontend/dashboard/app/[teamId]/data_filters/page.tsx b/frontend/dashboard/app/[teamId]/data_filters/page.tsx index b47ac3df0..693c13c7b 100644 --- a/frontend/dashboard/app/[teamId]/data_filters/page.tsx +++ b/frontend/dashboard/app/[teamId]/data_filters/page.tsx @@ -10,15 +10,22 @@ import { formatDateToHumanReadableDate, formatDateToHumanReadableTime } from '@/ import { useRouter, useSearchParams } from 'next/navigation' import { useEffect, useState } from 'react' import { Button } from '@/app/components/button' -import { Plus } from 'lucide-react' +import { Plus, Pencil } from 'lucide-react' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/app/components/dropdown_menu' import { Card, CardContent, CardFooter } from '@/app/components/card' +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/app/components/dialog' interface PageState { dataFiltersApiStatus: DataFiltersApiStatus filters: typeof defaultFilters dataFilters: DataFiltersResponse editingFilterType: 'event' | 'trace' | null + editingGlobalFilter: { + id: string + type: DataFilterType + collectionMode: DataFilterCollectionConfig['mode'] + sampleRate?: number + } | null } const isGlobalFilter = (type: DataFilterType): boolean => { @@ -69,6 +76,7 @@ export default function DataFilters({ params }: { params: { teamId: string } }) filters: defaultFilters, dataFilters: emptyDataFiltersResponse, editingFilterType: null, + editingGlobalFilter: null, } const [pageState, setPageState] = useState(initialState) @@ -134,6 +142,27 @@ export default function DataFilters({ params }: { params: { teamId: string } }) updatePageState({ editingFilterType: null }) } + const handleEditGlobalFilter = (dataFilter: typeof globalFilters[0], e: React.MouseEvent) => { + e.stopPropagation() + updatePageState({ + editingGlobalFilter: { + id: dataFilter.id, + type: dataFilter.type, + collectionMode: dataFilter.collection_config.mode, + sampleRate: dataFilter.collection_config.mode === 'sample_rate' ? dataFilter.collection_config.sample_rate : undefined + } + }) + } + + const handleSaveGlobalFilter = () => { + // TODO: Implement save logic + updatePageState({ editingGlobalFilter: null }) + } + + const handleCancelGlobalFilter = () => { + updatePageState({ editingGlobalFilter: null }) + } + return (
@@ -231,42 +260,35 @@ export default function DataFilters({ params }: { params: { teamId: string } }) {/* Global Filters Section */} {globalFilters.length > 0 && ( -
-

Global Filters

- -
- -
- - - Filter - Updated At - Updated By - - - - {globalFilters.map((dataFilter, idx) => ( - - -

{getFilterDisplayText(dataFilter.type, dataFilter.filter)}

-

{getCollectionConfigDisplay(dataFilter.collection_config)}

-

{getAttachmentConfigDisplay(dataFilter.attachment_config)}

-
- -

{formatDateToHumanReadableDate(dataFilter.updated_at)}

-
-

{formatDateToHumanReadableTime(dataFilter.updated_at)}

- - -

{dataFilter.updated_by}

-
- - ))} - -
+
+ {globalFilters.map((dataFilter, idx) => ( + + +
+
+

{getFilterDisplayText(dataFilter.type, dataFilter.filter)}

+

{getCollectionConfigDisplay(dataFilter.collection_config)}{getAttachmentConfigDisplay(dataFilter.attachment_config) && ` • ${getAttachmentConfigDisplay(dataFilter.attachment_config)}`}

+
+
+
+ {formatDateToHumanReadableDate(dataFilter.updated_at)} at {formatDateToHumanReadableTime(dataFilter.updated_at)} + + {dataFilter.updated_by} +
+ +
+
+
+
+ ))}
)} @@ -275,9 +297,12 @@ export default function DataFilters({ params }: { params: { teamId: string } }) {/* Override Filters Table */} {overrideFilters.length > 0 && (
-

Overrides

+
+

Custom Filters

+

Filters for specific events and traces that override global settings

+
-
+
@@ -314,6 +339,105 @@ export default function DataFilters({ params }: { params: { teamId: string } }) )} } + + {/* Global Filter Edit Dialog */} + !open && handleCancelGlobalFilter()}> + + + + Edit {pageState.editingGlobalFilter?.type === 'all_events' ? 'All Events' : 'All Traces'} Filter + + + +
+
+ +
+ + {pageState.editingGlobalFilter?.collectionMode === 'sample_rate' && ( + updatePageState({ + editingGlobalFilter: pageState.editingGlobalFilter ? { + ...pageState.editingGlobalFilter, + sampleRate: parseInt(e.target.value) + } : null + })} + className="ml-6 w-24 px-2 py-1 border rounded text-sm" + /> + )} + + + + +
+
+
+ + + + + +
+
) } From ba17a2519fa2f74195105ef9c5ad4c7852c2aa85 Mon Sep 17 00:00:00 2001 From: Abhay Sood Date: Wed, 5 Nov 2025 11:35:31 +0530 Subject: [PATCH 08/98] feat(frontend): improve sampling rate input --- .../app/[teamId]/data_filters/page.tsx | 169 +++++++++--------- .../rule_builder_card.tsx | 7 +- .../data_filters/sampling_rate_input.tsx | 60 +++++++ 3 files changed, 147 insertions(+), 89 deletions(-) rename frontend/dashboard/app/components/{session_targeting => data_filters}/rule_builder_card.tsx (93%) create mode 100644 frontend/dashboard/app/components/data_filters/sampling_rate_input.tsx diff --git a/frontend/dashboard/app/[teamId]/data_filters/page.tsx b/frontend/dashboard/app/[teamId]/data_filters/page.tsx index 693c13c7b..b95629dd9 100644 --- a/frontend/dashboard/app/[teamId]/data_filters/page.tsx +++ b/frontend/dashboard/app/[teamId]/data_filters/page.tsx @@ -14,6 +14,7 @@ import { Plus, Pencil } from 'lucide-react' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/app/components/dropdown_menu' import { Card, CardContent, CardFooter } from '@/app/components/card' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/app/components/dialog' +import SamplingRateInput from '@/app/components/data_filters/sampling_rate_input' interface PageState { dataFiltersApiStatus: DataFiltersApiStatus @@ -264,7 +265,7 @@ export default function DataFilters({ params }: { params: { teamId: string } }) {globalFilters.map((dataFilter, idx) => (
@@ -280,7 +281,7 @@ export default function DataFilters({ params }: { params: { teamId: string } })
@@ -294,15 +295,23 @@ export default function DataFilters({ params }: { params: { teamId: string } })
- {/* Override Filters Table */} - {overrideFilters.length > 0 && ( -
-
-

Custom Filters

-

Filters for specific events and traces that override global settings

-
+ {/* Override Filters Section */} +
+
+

Filters

+

Add filters for events and traces that override global settings

+
-
+
+ + {overrideFilters.length === 0 ? ( +
+

+ Click "Create Filter" to override the global filter settings for any event or trace +

+
+ ) : ( +
@@ -336,88 +345,82 @@ export default function DataFilters({ params }: { params: { teamId: string } }) ))}
-
- )} +
+ )} +
} {/* Global Filter Edit Dialog */} !open && handleCancelGlobalFilter()}> - - Edit {pageState.editingGlobalFilter?.type === 'all_events' ? 'All Events' : 'All Traces'} Filter + + Edit {pageState.editingGlobalFilter?.type === 'all_events' ? 'Events' : 'Traces'} Filter -
-
- -
- - {pageState.editingGlobalFilter?.collectionMode === 'sample_rate' && ( - updatePageState({ - editingGlobalFilter: pageState.editingGlobalFilter ? { - ...pageState.editingGlobalFilter, - sampleRate: parseInt(e.target.value) - } : null - })} - className="ml-6 w-24 px-2 py-1 border rounded text-sm" - /> - )} - - - - -
-
+
+ + + + +
diff --git a/frontend/dashboard/app/components/session_targeting/rule_builder_card.tsx b/frontend/dashboard/app/components/data_filters/rule_builder_card.tsx similarity index 93% rename from frontend/dashboard/app/components/session_targeting/rule_builder_card.tsx rename to frontend/dashboard/app/components/data_filters/rule_builder_card.tsx index 3d6e56181..53f46007d 100644 --- a/frontend/dashboard/app/components/session_targeting/rule_builder_card.tsx +++ b/frontend/dashboard/app/components/data_filters/rule_builder_card.tsx @@ -3,10 +3,6 @@ import { useState } from "react" import { Card, CardContent, CardFooter } from "@/app/components/card" import { Button } from "@/app/components/button" -import { Input } from "@/app/components/input" -import DropdownSelect, { DropdownSelectType } from "@/app/components/dropdown_select" -import { Plus } from "lucide-react" -import RuleBuilderAttributeRow from "@/app/components/session_targeting/rule_builder_attribute_row" interface RuleBuilderCardProps { type: 'event' | 'trace' | 'session_attr' @@ -33,7 +29,7 @@ const operators = ["equals", "not equals", "contains", "greater than", "less tha // Operator types mapping for different attribute types const operatorTypesMapping = { - string: ['eq', 'neq', 'contains', 'ncontains'], + string: ['eq', 'neq', 'contains'], number: ['eq', 'neq', 'gt', 'gte', 'lt', 'lte'], int64: ['eq', 'neq', 'gt', 'gte', 'lt', 'lte'], float64: ['eq', 'neq', 'gt', 'gte', 'lt', 'lte'], @@ -111,7 +107,6 @@ export default function RuleBuilderCard({ type, onCancel, onSave, initialData }: return ( -
diff --git a/frontend/dashboard/app/components/data_filters/sampling_rate_input.tsx b/frontend/dashboard/app/components/data_filters/sampling_rate_input.tsx new file mode 100644 index 000000000..7c1d79c72 --- /dev/null +++ b/frontend/dashboard/app/components/data_filters/sampling_rate_input.tsx @@ -0,0 +1,60 @@ +import { useState, useEffect } from 'react' + +interface SamplingRateInputProps { + value: number + onChange: (value: number) => void + disabled?: boolean +} + +const formatSamplingRate = (value: string): string => { + if (value === '') return '' + const numValue = parseFloat(value) + if (isNaN(numValue)) return '' + const clampedValue = Math.max(0, Math.min(100, numValue)) + const formattedValue = Math.round(clampedValue * 1000000) / 1000000 + return formattedValue.toString() +} + +export default function SamplingRateInput({ value, onChange, disabled = false }: SamplingRateInputProps) { + const [inputValue, setInputValue] = useState(value.toString()) + + // Sync with prop value when it changes externally + useEffect(() => { + setInputValue(value.toString()) + }, [value]) + + const handleBlur = () => { + const formatted = formatSamplingRate(inputValue) + + if (formatted !== '') { + const numValue = parseFloat(formatted) + onChange(numValue) + setInputValue(numValue.toString()) + } else { + // Reset to current value if invalid + setInputValue(value.toString()) + } + } + + const handleChange = (e: React.ChangeEvent) => { + setInputValue(e.target.value) + } + + return ( +
+ Collect at + + % sampling rate +
+ ) +} From 4f16178ac11b0fd74dad6f874f0d52a72ba72c90 Mon Sep 17 00:00:00 2001 From: Abhay Sood Date: Wed, 5 Nov 2025 11:37:50 +0530 Subject: [PATCH 09/98] feat(frontend): fix empty state --- .../app/[teamId]/data_filters/page.tsx | 28 ++--- frontend/dashboard/app/api/api_calls.ts | 110 +++++++++--------- 2 files changed, 69 insertions(+), 69 deletions(-) diff --git a/frontend/dashboard/app/[teamId]/data_filters/page.tsx b/frontend/dashboard/app/[teamId]/data_filters/page.tsx index b95629dd9..104ac7fc1 100644 --- a/frontend/dashboard/app/[teamId]/data_filters/page.tsx +++ b/frontend/dashboard/app/[teamId]/data_filters/page.tsx @@ -296,21 +296,21 @@ export default function DataFilters({ params }: { params: { teamId: string } })
{/* Override Filters Section */} -
-
-

Filters

-

Add filters for events and traces that override global settings

+ {overrideFilters.length === 0 ? ( +
+

+ Click "Create Filter" to override the global filter settings for any event or trace +

+ ) : ( +
+
+

Filters

+

Add filters for events and traces that override global settings

+
-
+
- {overrideFilters.length === 0 ? ( -
-

- Click "Create Filter" to override the global filter settings for any event or trace -

-
- ) : (
@@ -346,8 +346,8 @@ export default function DataFilters({ params }: { params: { teamId: string } })
- )} -
+
+ )}
} {/* Global Filter Edit Dialog */} diff --git a/frontend/dashboard/app/api/api_calls.ts b/frontend/dashboard/app/api/api_calls.ts index f42758a46..72e617e60 100644 --- a/frontend/dashboard/app/api/api_calls.ts +++ b/frontend/dashboard/app/api/api_calls.ts @@ -1087,61 +1087,61 @@ export const emptyDataFiltersResponse: DataFiltersResponse = { updated_at: "2024-01-01T00:00:00Z", updated_by: "system@example.com", }, - { - id: "df-001", - type: "event", - filter: "event.type == 'click' && event.target == 'checkout_button'", - collection_config: { mode: 'sample_rate', sample_rate: 0.5 }, - attachment_config: 'screenshot', - created_at: "2024-01-15T10:30:00Z", - created_by: "user1@example.com", - updated_at: "2024-02-20T14:45:00Z", - updated_by: "user2@example.com", - }, - { - id: "df-002", - type: "trace", - filter: "trace.duration > 5000 && trace.status == 'error'", - collection_config: { mode: 'timeline_only' }, - attachment_config: 'layout_snapshot', - created_at: "2024-01-20T08:15:00Z", - created_by: "admin@example.com", - updated_at: "2024-01-20T08:15:00Z", - updated_by: "admin@example.com", - }, - { - id: "df-003", - type: "event", - filter: "event.name == 'app_background' && session.is_crash == true", - collection_config: { mode: 'disable' }, - attachment_config: null, - created_at: "2024-02-01T12:00:00Z", - created_by: "developer@example.com", - updated_at: "2024-03-10T09:30:00Z", - updated_by: "lead@example.com", - }, - { - id: "df-004", - type: "trace", - filter: "trace.name == 'network_request' && trace.http.status_code >= 400", - collection_config: { mode: 'sample_rate', sample_rate: 0.25 }, - attachment_config: 'none', - created_at: "2024-02-10T16:20:00Z", - created_by: "qa@example.com", - updated_at: "2024-02-28T11:15:00Z", - updated_by: "qa@example.com", - }, - { - id: "df-005", - type: "event", - filter: "event.type == 'gesture' && device.manufacturer == 'Samsung'", - collection_config: { mode: 'sample_rate', sample_rate: 1.0 }, - attachment_config: 'screenshot', - created_at: "2024-03-05T13:45:00Z", - created_by: "user3@example.com", - updated_at: "2024-03-05T13:45:00Z", - updated_by: "user3@example.com", - }, + // { + // id: "df-001", + // type: "event", + // filter: "event.type == 'click' && event.target == 'checkout_button'", + // collection_config: { mode: 'sample_rate', sample_rate: 0.5 }, + // attachment_config: 'screenshot', + // created_at: "2024-01-15T10:30:00Z", + // created_by: "user1@example.com", + // updated_at: "2024-02-20T14:45:00Z", + // updated_by: "user2@example.com", + // }, + // { + // id: "df-002", + // type: "trace", + // filter: "trace.duration > 5000 && trace.status == 'error'", + // collection_config: { mode: 'timeline_only' }, + // attachment_config: 'layout_snapshot', + // created_at: "2024-01-20T08:15:00Z", + // created_by: "admin@example.com", + // updated_at: "2024-01-20T08:15:00Z", + // updated_by: "admin@example.com", + // }, + // { + // id: "df-003", + // type: "event", + // filter: "event.name == 'app_background' && session.is_crash == true", + // collection_config: { mode: 'disable' }, + // attachment_config: null, + // created_at: "2024-02-01T12:00:00Z", + // created_by: "developer@example.com", + // updated_at: "2024-03-10T09:30:00Z", + // updated_by: "lead@example.com", + // }, + // { + // id: "df-004", + // type: "trace", + // filter: "trace.name == 'network_request' && trace.http.status_code >= 400", + // collection_config: { mode: 'sample_rate', sample_rate: 0.25 }, + // attachment_config: 'none', + // created_at: "2024-02-10T16:20:00Z", + // created_by: "qa@example.com", + // updated_at: "2024-02-28T11:15:00Z", + // updated_by: "qa@example.com", + // }, + // { + // id: "df-005", + // type: "event", + // filter: "event.type == 'gesture' && device.manufacturer == 'Samsung'", + // collection_config: { mode: 'sample_rate', sample_rate: 1.0 }, + // attachment_config: 'screenshot', + // created_at: "2024-03-05T13:45:00Z", + // created_by: "user3@example.com", + // updated_at: "2024-03-05T13:45:00Z", + // updated_by: "user3@example.com", + // }, ], } From 1c52a3dfac64aff3a08e343040644192b3d5bff7 Mon Sep 17 00:00:00 2001 From: Abhay Sood Date: Wed, 5 Nov 2025 11:41:26 +0530 Subject: [PATCH 10/98] feat(frontend): hide content ni edit mode --- .../app/[teamId]/data_filters/page.tsx | 104 +++++++++--------- frontend/dashboard/app/api/api_calls.ts | 22 ++-- 2 files changed, 64 insertions(+), 62 deletions(-) diff --git a/frontend/dashboard/app/[teamId]/data_filters/page.tsx b/frontend/dashboard/app/[teamId]/data_filters/page.tsx index 104ac7fc1..4ecccee19 100644 --- a/frontend/dashboard/app/[teamId]/data_filters/page.tsx +++ b/frontend/dashboard/app/[teamId]/data_filters/page.tsx @@ -259,8 +259,8 @@ export default function DataFilters({ params }: { params: { teamId: string } })
- {/* Global Filters Section */} - {globalFilters.length > 0 && ( + {/* Global Filters Section - Hidden during edit mode */} + {pageState.editingFilterType === null && globalFilters.length > 0 && (
{globalFilters.map((dataFilter, idx) => ( - {/* Override Filters Section */} - {overrideFilters.length === 0 ? ( -
-

- Click "Create Filter" to override the global filter settings for any event or trace -

-
- ) : ( -
-
-

Filters

-

Add filters for events and traces that override global settings

+ {/* Override Filters Section - Hidden during edit mode */} + {pageState.editingFilterType === null && ( + overrideFilters.length === 0 ? ( +
+

+ Click "Create Filter" to override the global filter settings for any event or trace +

- -
- -
- - - - - Filter - Updated At - Updated By - - - - {overrideFilters.map((dataFilter, idx) => ( - - -

{getFilterDisplayText(dataFilter.type, dataFilter.filter)}

-
-

{getCollectionConfigDisplay(dataFilter.collection_config)}

-

{getAttachmentConfigDisplay(dataFilter.attachment_config)}

- - -

{formatDateToHumanReadableDate(dataFilter.updated_at)}

-
-

{formatDateToHumanReadableTime(dataFilter.updated_at)}

- - -

{dataFilter.updated_by}

-
+ ) : ( +
+
+

Filters

+

Add filters for events and traces that override global settings

+
+ +
+ +
+ +
+ + + Filter + Updated At + Updated By - ))} - -
+ + + {overrideFilters.map((dataFilter, idx) => ( + + +

{getFilterDisplayText(dataFilter.type, dataFilter.filter)}

+
+

{getCollectionConfigDisplay(dataFilter.collection_config)}

+

{getAttachmentConfigDisplay(dataFilter.attachment_config)}

+ + +

{formatDateToHumanReadableDate(dataFilter.updated_at)}

+
+

{formatDateToHumanReadableTime(dataFilter.updated_at)}

+ + +

{dataFilter.updated_by}

+
+ + ))} + + +
-
+ ) )}
} diff --git a/frontend/dashboard/app/api/api_calls.ts b/frontend/dashboard/app/api/api_calls.ts index 72e617e60..507212209 100644 --- a/frontend/dashboard/app/api/api_calls.ts +++ b/frontend/dashboard/app/api/api_calls.ts @@ -1087,17 +1087,17 @@ export const emptyDataFiltersResponse: DataFiltersResponse = { updated_at: "2024-01-01T00:00:00Z", updated_by: "system@example.com", }, - // { - // id: "df-001", - // type: "event", - // filter: "event.type == 'click' && event.target == 'checkout_button'", - // collection_config: { mode: 'sample_rate', sample_rate: 0.5 }, - // attachment_config: 'screenshot', - // created_at: "2024-01-15T10:30:00Z", - // created_by: "user1@example.com", - // updated_at: "2024-02-20T14:45:00Z", - // updated_by: "user2@example.com", - // }, + { + id: "df-001", + type: "event", + filter: "event.type == 'click' && event.target == 'checkout_button'", + collection_config: { mode: 'sample_rate', sample_rate: 0.5 }, + attachment_config: 'screenshot', + created_at: "2024-01-15T10:30:00Z", + created_by: "user1@example.com", + updated_at: "2024-02-20T14:45:00Z", + updated_by: "user2@example.com", + }, // { // id: "df-002", // type: "trace", From e2bcf7a8610205e0a6c23fe51af6df59714c9178 Mon Sep 17 00:00:00 2001 From: Abhay Sood Date: Wed, 5 Nov 2025 11:52:13 +0530 Subject: [PATCH 11/98] feat(frontend): implement clicks for filters --- .../app/[teamId]/data_filters/page.tsx | 48 +++++++++++++++---- 1 file changed, 39 insertions(+), 9 deletions(-) diff --git a/frontend/dashboard/app/[teamId]/data_filters/page.tsx b/frontend/dashboard/app/[teamId]/data_filters/page.tsx index 4ecccee19..b795159d7 100644 --- a/frontend/dashboard/app/[teamId]/data_filters/page.tsx +++ b/frontend/dashboard/app/[teamId]/data_filters/page.tsx @@ -27,6 +27,13 @@ interface PageState { collectionMode: DataFilterCollectionConfig['mode'] sampleRate?: number } | null + editingFilter: { + id: string + type: DataFilterType + filter: string + collectionMode: DataFilterCollectionConfig['mode'] + sampleRate?: number + } | null } const isGlobalFilter = (type: DataFilterType): boolean => { @@ -78,6 +85,7 @@ export default function DataFilters({ params }: { params: { teamId: string } }) dataFilters: emptyDataFiltersResponse, editingFilterType: null, editingGlobalFilter: null, + editingFilter: null, } const [pageState, setPageState] = useState(initialState) @@ -164,6 +172,27 @@ export default function DataFilters({ params }: { params: { teamId: string } }) updatePageState({ editingGlobalFilter: null }) } + const handleEditFilter = (dataFilter: typeof overrideFilters[0]) => { + updatePageState({ + editingFilter: { + id: dataFilter.id, + type: dataFilter.type, + filter: dataFilter.filter, + collectionMode: dataFilter.collection_config.mode, + sampleRate: dataFilter.collection_config.mode === 'sample_rate' ? dataFilter.collection_config.sample_rate : undefined + } + }) + } + + const handleSaveFilter = () => { + // TODO: Implement save logic + updatePageState({ editingFilter: null }) + } + + const handleCancelFilter = () => { + updatePageState({ editingFilter: null }) + } + return (
@@ -174,7 +203,7 @@ export default function DataFilters({ params }: { params: { teamId: string } }) @@ -216,8 +245,8 @@ export default function DataFilters({ params }: { params: { teamId: string } }) onFiltersChanged={handleFiltersChanged} />
- {/* Filter creation card */} - {pageState.editingFilterType && ( + {/* Filter creation/edit card */} + {(pageState.editingFilterType || pageState.editingFilter) && ( <> @@ -228,17 +257,17 @@ export default function DataFilters({ params }: { params: { teamId: string } }) @@ -260,7 +289,7 @@ export default function DataFilters({ params }: { params: { teamId: string } })
{/* Global Filters Section - Hidden during edit mode */} - {pageState.editingFilterType === null && globalFilters.length > 0 && ( + {pageState.editingFilterType === null && pageState.editingFilter === null && globalFilters.length > 0 && (
{globalFilters.map((dataFilter, idx) => ( {/* Override Filters Section - Hidden during edit mode */} - {pageState.editingFilterType === null && ( + {pageState.editingFilterType === null && pageState.editingFilter === null && ( overrideFilters.length === 0 ? (

@@ -326,7 +355,8 @@ export default function DataFilters({ params }: { params: { teamId: string } }) {overrideFilters.map((dataFilter, idx) => ( handleEditFilter(dataFilter)} >

{getFilterDisplayText(dataFilter.type, dataFilter.filter)}

From c580c0457655e82f3ffdaf96da4ce2a9ed9ec1ff Mon Sep 17 00:00:00 2001 From: Abhay Sood Date: Wed, 5 Nov 2025 12:00:34 +0530 Subject: [PATCH 12/98] feat(frontend): use routes for edit/create mode --- .../event/[filterId]/edit/page.tsx | 50 +++++++++++ .../data_filters/event/create/page.tsx | 50 +++++++++++ .../app/[teamId]/data_filters/page.tsx | 88 +++---------------- .../trace/[filterId]/edit/page.tsx | 50 +++++++++++ .../data_filters/trace/create/page.tsx | 50 +++++++++++ frontend/dashboard/app/api/api_calls.ts | 17 ++-- 6 files changed, 221 insertions(+), 84 deletions(-) create mode 100644 frontend/dashboard/app/[teamId]/data_filters/event/[filterId]/edit/page.tsx create mode 100644 frontend/dashboard/app/[teamId]/data_filters/event/create/page.tsx create mode 100644 frontend/dashboard/app/[teamId]/data_filters/trace/[filterId]/edit/page.tsx create mode 100644 frontend/dashboard/app/[teamId]/data_filters/trace/create/page.tsx diff --git a/frontend/dashboard/app/[teamId]/data_filters/event/[filterId]/edit/page.tsx b/frontend/dashboard/app/[teamId]/data_filters/event/[filterId]/edit/page.tsx new file mode 100644 index 000000000..6bd9e5b36 --- /dev/null +++ b/frontend/dashboard/app/[teamId]/data_filters/event/[filterId]/edit/page.tsx @@ -0,0 +1,50 @@ +"use client" + +import { useRouter } from 'next/navigation' +import { Button } from '@/app/components/button' +import { Card, CardContent, CardFooter } from '@/app/components/card' + +export default function EditEventFilter({ params }: { params: { teamId: string, filterId: string } }) { + const router = useRouter() + + const handleCancel = () => { + router.push(`/${params.teamId}/data_filters`) + } + + const handleSave = () => { + // TODO: Implement save logic + router.push(`/${params.teamId}/data_filters`) + } + + return ( +
+

Edit Event Filter

+
+ + + +
+ {/* TODO: Add form fields */} +
+
+ + + + + +
+
+ ) +} diff --git a/frontend/dashboard/app/[teamId]/data_filters/event/create/page.tsx b/frontend/dashboard/app/[teamId]/data_filters/event/create/page.tsx new file mode 100644 index 000000000..fe3c264bd --- /dev/null +++ b/frontend/dashboard/app/[teamId]/data_filters/event/create/page.tsx @@ -0,0 +1,50 @@ +"use client" + +import { useRouter } from 'next/navigation' +import { Button } from '@/app/components/button' +import { Card, CardContent, CardFooter } from '@/app/components/card' + +export default function CreateEventFilter({ params }: { params: { teamId: string } }) { + const router = useRouter() + + const handleCancel = () => { + router.push(`/${params.teamId}/data_filters`) + } + + const handleCreate = () => { + // TODO: Implement create logic + router.push(`/${params.teamId}/data_filters`) + } + + return ( +
+

Create Event Filter

+
+ + + +
+ {/* TODO: Add form fields */} +
+
+ + + + + +
+
+ ) +} diff --git a/frontend/dashboard/app/[teamId]/data_filters/page.tsx b/frontend/dashboard/app/[teamId]/data_filters/page.tsx index b795159d7..ddcca3a3d 100644 --- a/frontend/dashboard/app/[teamId]/data_filters/page.tsx +++ b/frontend/dashboard/app/[teamId]/data_filters/page.tsx @@ -11,29 +11,21 @@ import { useRouter, useSearchParams } from 'next/navigation' import { useEffect, useState } from 'react' import { Button } from '@/app/components/button' import { Plus, Pencil } from 'lucide-react' -import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/app/components/dropdown_menu' -import { Card, CardContent, CardFooter } from '@/app/components/card' +import { Card, CardContent } from '@/app/components/card' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/app/components/dialog' +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/app/components/dropdown_menu' import SamplingRateInput from '@/app/components/data_filters/sampling_rate_input' interface PageState { dataFiltersApiStatus: DataFiltersApiStatus filters: typeof defaultFilters dataFilters: DataFiltersResponse - editingFilterType: 'event' | 'trace' | null editingGlobalFilter: { id: string type: DataFilterType collectionMode: DataFilterCollectionConfig['mode'] sampleRate?: number } | null - editingFilter: { - id: string - type: DataFilterType - filter: string - collectionMode: DataFilterCollectionConfig['mode'] - sampleRate?: number - } | null } const isGlobalFilter = (type: DataFilterType): boolean => { @@ -83,9 +75,7 @@ export default function DataFilters({ params }: { params: { teamId: string } }) dataFiltersApiStatus: DataFiltersApiStatus.Success, filters: defaultFilters, dataFilters: emptyDataFiltersResponse, - editingFilterType: null, editingGlobalFilter: null, - editingFilter: null, } const [pageState, setPageState] = useState(initialState) @@ -143,14 +133,6 @@ export default function DataFilters({ params }: { params: { teamId: string } }) const globalFilters = pageState.dataFilters.results.filter(df => isGlobalFilter(df.type)) const overrideFilters = pageState.dataFilters.results.filter(df => !isGlobalFilter(df.type)) - const handleCancel = () => { - updatePageState({ editingFilterType: null }) - } - - const handleCreateFilter = () => { - updatePageState({ editingFilterType: null }) - } - const handleEditGlobalFilter = (dataFilter: typeof globalFilters[0], e: React.MouseEvent) => { e.stopPropagation() updatePageState({ @@ -173,24 +155,8 @@ export default function DataFilters({ params }: { params: { teamId: string } }) } const handleEditFilter = (dataFilter: typeof overrideFilters[0]) => { - updatePageState({ - editingFilter: { - id: dataFilter.id, - type: dataFilter.type, - filter: dataFilter.filter, - collectionMode: dataFilter.collection_config.mode, - sampleRate: dataFilter.collection_config.mode === 'sample_rate' ? dataFilter.collection_config.sample_rate : undefined - } - }) - } - - const handleSaveFilter = () => { - // TODO: Implement save logic - updatePageState({ editingFilter: null }) - } - - const handleCancelFilter = () => { - updatePageState({ editingFilter: null }) + const filterType = dataFilter.type === 'event' ? 'event' : 'trace' + router.push(`/${params.teamId}/data_filters/${filterType}/${dataFilter.id}/edit`) } return ( @@ -203,16 +169,16 @@ export default function DataFilters({ params }: { params: { teamId: string } }) - updatePageState({ editingFilterType: 'event' })}> + router.push(`/${params.teamId}/data_filters/event/create`)}> Event Filter - updatePageState({ editingFilterType: 'trace' })}> + router.push(`/${params.teamId}/data_filters/trace/create`)}> Trace Filter @@ -245,36 +211,6 @@ export default function DataFilters({ params }: { params: { teamId: string } }) onFiltersChanged={handleFiltersChanged} />
- {/* Filter creation/edit card */} - {(pageState.editingFilterType || pageState.editingFilter) && ( - <> - - -
-
-
- - - - - -
-
- - )} - {/* Error state for data filters fetch */} {pageState.filters.ready && pageState.dataFiltersApiStatus === DataFiltersApiStatus.Error @@ -288,8 +224,8 @@ export default function DataFilters({ params }: { params: { teamId: string } })
- {/* Global Filters Section - Hidden during edit mode */} - {pageState.editingFilterType === null && pageState.editingFilter === null && globalFilters.length > 0 && ( + {/* Global Filters Section */} + {globalFilters.length > 0 && (
{globalFilters.map((dataFilter, idx) => ( - {/* Override Filters Section - Hidden during edit mode */} - {pageState.editingFilterType === null && pageState.editingFilter === null && ( + {/* Override Filters Section */} + {( overrideFilters.length === 0 ? (

@@ -355,7 +291,7 @@ export default function DataFilters({ params }: { params: { teamId: string } }) {overrideFilters.map((dataFilter, idx) => ( handleEditFilter(dataFilter)} > diff --git a/frontend/dashboard/app/[teamId]/data_filters/trace/[filterId]/edit/page.tsx b/frontend/dashboard/app/[teamId]/data_filters/trace/[filterId]/edit/page.tsx new file mode 100644 index 000000000..e6e64ae04 --- /dev/null +++ b/frontend/dashboard/app/[teamId]/data_filters/trace/[filterId]/edit/page.tsx @@ -0,0 +1,50 @@ +"use client" + +import { useRouter } from 'next/navigation' +import { Button } from '@/app/components/button' +import { Card, CardContent, CardFooter } from '@/app/components/card' + +export default function EditTraceFilter({ params }: { params: { teamId: string, filterId: string } }) { + const router = useRouter() + + const handleCancel = () => { + router.push(`/${params.teamId}/data_filters`) + } + + const handleSave = () => { + // TODO: Implement save logic + router.push(`/${params.teamId}/data_filters`) + } + + return ( +

+

Edit Trace Filter

+
+ + + +
+ {/* TODO: Add form fields */} +
+
+ + + + + +
+
+ ) +} diff --git a/frontend/dashboard/app/[teamId]/data_filters/trace/create/page.tsx b/frontend/dashboard/app/[teamId]/data_filters/trace/create/page.tsx new file mode 100644 index 000000000..663ffb23a --- /dev/null +++ b/frontend/dashboard/app/[teamId]/data_filters/trace/create/page.tsx @@ -0,0 +1,50 @@ +"use client" + +import { useRouter } from 'next/navigation' +import { Button } from '@/app/components/button' +import { Card, CardContent, CardFooter } from '@/app/components/card' + +export default function CreateTraceFilter({ params }: { params: { teamId: string } }) { + const router = useRouter() + + const handleCancel = () => { + router.push(`/${params.teamId}/data_filters`) + } + + const handleCreate = () => { + // TODO: Implement create logic + router.push(`/${params.teamId}/data_filters`) + } + + return ( +
+

Create Trace Filter

+
+ + + +
+ {/* TODO: Add form fields */} +
+
+ + + + + +
+
+ ) +} diff --git a/frontend/dashboard/app/api/api_calls.ts b/frontend/dashboard/app/api/api_calls.ts index 507212209..b2290f67f 100644 --- a/frontend/dashboard/app/api/api_calls.ts +++ b/frontend/dashboard/app/api/api_calls.ts @@ -1039,6 +1039,15 @@ export type DataFiltersResponse = { results: DataFilter[], } +export type DataFilterType = "event" | "trace" | "all_events" | "all_traces"; + +export type DataFilterCollectionConfig = + | { mode: 'sample_rate'; sample_rate: number } + | { mode: 'timeline_only' } + | { mode: 'disable' }; + +export type DataFilterAttachmentConfig = 'layout_snapshot' | 'screenshot' | 'none'; + export type DataFilter = { id: string, type: DataFilterType, @@ -1051,14 +1060,6 @@ export type DataFilter = { updated_by: string, } -export type DataFilterType = "event" | "trace" | "all_events" | "all_traces"; -export type DataFilterCollectionConfig = - | { mode: 'sample_rate'; sample_rate: number } - | { mode: 'timeline_only' } - | { mode: 'disable' }; - -export type DataFilterAttachmentConfig = 'layout_snapshot' | 'screenshot' | 'none'; - export const emptyDataFiltersResponse: DataFiltersResponse = { meta: { next: false, From ce526759e767176d439403f7ae527742a05b44ab Mon Sep 17 00:00:00 2001 From: Abhay Sood Date: Wed, 5 Nov 2025 13:44:58 +0530 Subject: [PATCH 13/98] feat(frontend): revamp UI to match rest of the app --- .../event/[filterId]/edit/page.tsx | 0 .../event/create/page.tsx | 0 .../[teamId]/{data_filters => data}/page.tsx | 245 +++++++++++------- .../trace/[filterId]/edit/page.tsx | 0 .../trace/create/page.tsx | 0 frontend/dashboard/app/[teamId]/layout.tsx | 4 +- frontend/dashboard/app/api/api_calls.ts | 94 +++---- 7 files changed, 196 insertions(+), 147 deletions(-) rename frontend/dashboard/app/[teamId]/{data_filters => data}/event/[filterId]/edit/page.tsx (100%) rename frontend/dashboard/app/[teamId]/{data_filters => data}/event/create/page.tsx (100%) rename frontend/dashboard/app/[teamId]/{data_filters => data}/page.tsx (61%) rename frontend/dashboard/app/[teamId]/{data_filters => data}/trace/[filterId]/edit/page.tsx (100%) rename frontend/dashboard/app/[teamId]/{data_filters => data}/trace/create/page.tsx (100%) diff --git a/frontend/dashboard/app/[teamId]/data_filters/event/[filterId]/edit/page.tsx b/frontend/dashboard/app/[teamId]/data/event/[filterId]/edit/page.tsx similarity index 100% rename from frontend/dashboard/app/[teamId]/data_filters/event/[filterId]/edit/page.tsx rename to frontend/dashboard/app/[teamId]/data/event/[filterId]/edit/page.tsx diff --git a/frontend/dashboard/app/[teamId]/data_filters/event/create/page.tsx b/frontend/dashboard/app/[teamId]/data/event/create/page.tsx similarity index 100% rename from frontend/dashboard/app/[teamId]/data_filters/event/create/page.tsx rename to frontend/dashboard/app/[teamId]/data/event/create/page.tsx diff --git a/frontend/dashboard/app/[teamId]/data_filters/page.tsx b/frontend/dashboard/app/[teamId]/data/page.tsx similarity index 61% rename from frontend/dashboard/app/[teamId]/data_filters/page.tsx rename to frontend/dashboard/app/[teamId]/data/page.tsx index ddcca3a3d..488231eaa 100644 --- a/frontend/dashboard/app/[teamId]/data_filters/page.tsx +++ b/frontend/dashboard/app/[teamId]/data/page.tsx @@ -11,9 +11,8 @@ import { useRouter, useSearchParams } from 'next/navigation' import { useEffect, useState } from 'react' import { Button } from '@/app/components/button' import { Plus, Pencil } from 'lucide-react' -import { Card, CardContent } from '@/app/components/card' -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/app/components/dialog' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/app/components/dropdown_menu' +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/app/components/dialog' import SamplingRateInput from '@/app/components/data_filters/sampling_rate_input' interface PageState { @@ -46,7 +45,7 @@ const getFilterDisplayText = (type: DataFilterType, filter: string): string => { const getCollectionConfigDisplay = (collectionConfig: DataFilterCollectionConfig): string => { switch (collectionConfig.mode) { case 'sample_rate': - return `Collect at ${collectionConfig.sample_rate}% sample rate` + return `Collect all at ${collectionConfig.sample_rate}% sample rate` case 'timeline_only': return 'Collect with session timeline only' case 'disable': @@ -131,10 +130,18 @@ export default function DataFilters({ params }: { params: { teamId: string } }) }, [pageState.filters]) const globalFilters = pageState.dataFilters.results.filter(df => isGlobalFilter(df.type)) + const allEventsFilter = globalFilters.find(df => df.type === 'all_events') + const allTracesFilter = globalFilters.find(df => df.type === 'all_traces') const overrideFilters = pageState.dataFilters.results.filter(df => !isGlobalFilter(df.type)) + const eventFilters = overrideFilters.filter(df => df.type === 'event') + const traceFilters = overrideFilters.filter(df => df.type === 'trace') + + const handleEditFilter = (dataFilter: typeof overrideFilters[0]) => { + const filterType = dataFilter.type === 'event' ? 'event' : 'trace' + router.push(`/${params.teamId}/data/${filterType}/${dataFilter.id}/edit`) + } - const handleEditGlobalFilter = (dataFilter: typeof globalFilters[0], e: React.MouseEvent) => { - e.stopPropagation() + const handleEditGlobalFilter = (dataFilter: typeof globalFilters[0]) => { updatePageState({ editingGlobalFilter: { id: dataFilter.id, @@ -154,16 +161,11 @@ export default function DataFilters({ params }: { params: { teamId: string } }) updatePageState({ editingGlobalFilter: null }) } - const handleEditFilter = (dataFilter: typeof overrideFilters[0]) => { - const filterType = dataFilter.type === 'event' ? 'event' : 'trace' - router.push(`/${params.teamId}/data_filters/${filterType}/${dataFilter.id}/edit`) - } - return (
-

Data Filters

+

Data Control

- router.push(`/${params.teamId}/data_filters/event/create`)}> - Event Filter + router.push(`/${params.teamId}/data/event/create`)}> + Event Rule - router.push(`/${params.teamId}/data_filters/trace/create`)}> - Trace Filter + router.push(`/${params.teamId}/data/trace/create`)}> + Trace Rule @@ -224,98 +226,141 @@ export default function DataFilters({ params }: { params: { teamId: string } })
- {/* Global Filters Section */} - {globalFilters.length > 0 && ( -
- {globalFilters.map((dataFilter, idx) => ( - +

Event Rules

+
+ + {/* Default Event Filter */} +
+

Default Rule

+ {allEventsFilter && ( + -
-
- -
- ))} + + + )}
- )} - -
- - {/* Override Filters Section */} - {( - overrideFilters.length === 0 ? ( -
-

- Click "Create Filter" to override the global filter settings for any event or trace -

+
+ {allEventsFilter && ( +
+ {getCollectionConfigDisplay(allEventsFilter.collection_config)}
- ) : ( -
-
-

Filters

-

Add filters for events and traces that override global settings

-
+ )} + {eventFilters.length > 0 && ( + <> +
+ {/* Event Overrides */} +

Overrides

+ + + + Rule + Updated At + Updated By + + + + {eventFilters.map((dataFilter, idx) => ( + handleEditFilter(dataFilter)} + > + +

{getFilterDisplayText(dataFilter.type, dataFilter.filter)}

+
+

{getCollectionConfigDisplay(dataFilter.collection_config)}

+

{getAttachmentConfigDisplay(dataFilter.attachment_config)}

+ + +

{formatDateToHumanReadableDate(dataFilter.updated_at)}

+
+

{formatDateToHumanReadableTime(dataFilter.updated_at)}

+ + +

{dataFilter.updated_by}

+
+ + ))} + +
+ + )} +
+ +
+ + {/* Trace Filters Section */} +
+

Trace Rules

+
-
+ {/* Default Trace Filter */} +
+

Default Rule

+ {allTracesFilter && ( + + )} +
+
+ {allTracesFilter && ( +
+ {getCollectionConfigDisplay(allTracesFilter.collection_config)} +
+ )} + {traceFilters.length > 0 && ( + <> +
+ {/* Trace Overrides */} +

Overrides

+
- - - Filter - Updated At - Updated By + + + Rule + Updated At + Updated By + + + + {traceFilters.map((dataFilter, idx) => ( + handleEditFilter(dataFilter)} + > + +

{getFilterDisplayText(dataFilter.type, dataFilter.filter)}

+
+

{getCollectionConfigDisplay(dataFilter.collection_config)}

+

{getAttachmentConfigDisplay(dataFilter.attachment_config)}

+ + +

{formatDateToHumanReadableDate(dataFilter.updated_at)}

+
+

{formatDateToHumanReadableTime(dataFilter.updated_at)}

+ + +

{dataFilter.updated_by}

+
- - - {overrideFilters.map((dataFilter, idx) => ( - handleEditFilter(dataFilter)} - > - -

{getFilterDisplayText(dataFilter.type, dataFilter.filter)}

-
-

{getCollectionConfigDisplay(dataFilter.collection_config)}

-

{getAttachmentConfigDisplay(dataFilter.attachment_config)}

- - -

{formatDateToHumanReadableDate(dataFilter.updated_at)}

-
-

{formatDateToHumanReadableTime(dataFilter.updated_at)}

- - -

{dataFilter.updated_by}

-
- - ))} - + ))} +
-
-
- ) - )} + + )} +
} {/* Global Filter Edit Dialog */} @@ -323,7 +368,7 @@ export default function DataFilters({ params }: { params: { teamId: string } }) - Edit {pageState.editingGlobalFilter?.type === 'all_events' ? 'Events' : 'Traces'} Filter + Edit Default {pageState.editingGlobalFilter?.type === 'all_events' ? 'Events' : 'Traces'} Rule @@ -387,7 +432,7 @@ export default function DataFilters({ params }: { params: { teamId: string } }) })} className="appearance-none w-4 h-4 border border-gray-400 rounded-full checked:bg-black checked:border-black cursor-pointer outline-none focus:outline-none focus:ring-0 focus-visible:ring-0 flex-shrink-0" /> - Collect no {pageState.editingGlobalFilter?.type === 'all_events' ? 'events' : 'traces'} + Collect no {pageState.editingGlobalFilter?.type === 'all_events' ? 'events' : 'traces'} by default
diff --git a/frontend/dashboard/app/[teamId]/data_filters/trace/[filterId]/edit/page.tsx b/frontend/dashboard/app/[teamId]/data/trace/[filterId]/edit/page.tsx similarity index 100% rename from frontend/dashboard/app/[teamId]/data_filters/trace/[filterId]/edit/page.tsx rename to frontend/dashboard/app/[teamId]/data/trace/[filterId]/edit/page.tsx diff --git a/frontend/dashboard/app/[teamId]/data_filters/trace/create/page.tsx b/frontend/dashboard/app/[teamId]/data/trace/create/page.tsx similarity index 100% rename from frontend/dashboard/app/[teamId]/data_filters/trace/create/page.tsx rename to frontend/dashboard/app/[teamId]/data/trace/create/page.tsx diff --git a/frontend/dashboard/app/[teamId]/layout.tsx b/frontend/dashboard/app/[teamId]/layout.tsx index ce5230f6e..cc8eb59ce 100644 --- a/frontend/dashboard/app/[teamId]/layout.tsx +++ b/frontend/dashboard/app/[teamId]/layout.tsx @@ -92,8 +92,8 @@ const initNavData = { title: "Settings", items: [ { - title: "Data Filters", - url: "data_filters", + title: "Data", + url: "data", isActive: false, external: false, }, diff --git a/frontend/dashboard/app/api/api_calls.ts b/frontend/dashboard/app/api/api_calls.ts index b2290f67f..3c10e0a69 100644 --- a/frontend/dashboard/app/api/api_calls.ts +++ b/frontend/dashboard/app/api/api_calls.ts @@ -1099,50 +1099,50 @@ export const emptyDataFiltersResponse: DataFiltersResponse = { updated_at: "2024-02-20T14:45:00Z", updated_by: "user2@example.com", }, - // { - // id: "df-002", - // type: "trace", - // filter: "trace.duration > 5000 && trace.status == 'error'", - // collection_config: { mode: 'timeline_only' }, - // attachment_config: 'layout_snapshot', - // created_at: "2024-01-20T08:15:00Z", - // created_by: "admin@example.com", - // updated_at: "2024-01-20T08:15:00Z", - // updated_by: "admin@example.com", - // }, - // { - // id: "df-003", - // type: "event", - // filter: "event.name == 'app_background' && session.is_crash == true", - // collection_config: { mode: 'disable' }, - // attachment_config: null, - // created_at: "2024-02-01T12:00:00Z", - // created_by: "developer@example.com", - // updated_at: "2024-03-10T09:30:00Z", - // updated_by: "lead@example.com", - // }, - // { - // id: "df-004", - // type: "trace", - // filter: "trace.name == 'network_request' && trace.http.status_code >= 400", - // collection_config: { mode: 'sample_rate', sample_rate: 0.25 }, - // attachment_config: 'none', - // created_at: "2024-02-10T16:20:00Z", - // created_by: "qa@example.com", - // updated_at: "2024-02-28T11:15:00Z", - // updated_by: "qa@example.com", - // }, - // { - // id: "df-005", - // type: "event", - // filter: "event.type == 'gesture' && device.manufacturer == 'Samsung'", - // collection_config: { mode: 'sample_rate', sample_rate: 1.0 }, - // attachment_config: 'screenshot', - // created_at: "2024-03-05T13:45:00Z", - // created_by: "user3@example.com", - // updated_at: "2024-03-05T13:45:00Z", - // updated_by: "user3@example.com", - // }, + { + id: "df-002", + type: "trace", + filter: "trace.duration > 5000 && trace.status == 'error'", + collection_config: { mode: 'timeline_only' }, + attachment_config: 'layout_snapshot', + created_at: "2024-01-20T08:15:00Z", + created_by: "admin@example.com", + updated_at: "2024-01-20T08:15:00Z", + updated_by: "admin@example.com", + }, + { + id: "df-003", + type: "event", + filter: "event.name == 'app_background' && session.is_crash == true", + collection_config: { mode: 'disable' }, + attachment_config: null, + created_at: "2024-02-01T12:00:00Z", + created_by: "developer@example.com", + updated_at: "2024-03-10T09:30:00Z", + updated_by: "lead@example.com", + }, + { + id: "df-004", + type: "trace", + filter: "trace.name == 'network_request' && trace.http.status_code >= 400", + collection_config: { mode: 'sample_rate', sample_rate: 0.25 }, + attachment_config: 'none', + created_at: "2024-02-10T16:20:00Z", + created_by: "qa@example.com", + updated_at: "2024-02-28T11:15:00Z", + updated_by: "qa@example.com", + }, + { + id: "df-005", + type: "event", + filter: "event.type == 'gesture' && device.manufacturer == 'Samsung'", + collection_config: { mode: 'sample_rate', sample_rate: 1.0 }, + attachment_config: 'screenshot', + created_at: "2024-03-05T13:45:00Z", + created_by: "user3@example.com", + updated_at: "2024-03-05T13:45:00Z", + updated_by: "user3@example.com", + }, ], } @@ -2483,8 +2483,12 @@ export const fetchAlertsOverviewFromServer = async ( export const fetchDataFiltersFromServer = async ( appId: String, + type?: string, ) => { - const url = `/api/apps/${appId}/data_filters` + let url = `/api/apps/${appId}/dataFilters` + if (type) { + url += `?type=${type}` + } try { const res = await measureAuth.fetchMeasure(url) From 71777b176b8148d7454ead0907b53759bff7d622 Mon Sep 17 00:00:00 2001 From: Abhay Sood Date: Wed, 5 Nov 2025 13:58:39 +0530 Subject: [PATCH 14/98] feat(frontend): refactor default rule dialog --- frontend/dashboard/app/[teamId]/data/page.tsx | 157 ++++-------------- .../data/edit_default_rule_dialog.tsx | 114 +++++++++++++ .../rule_builder_card.tsx | 0 .../sampling_rate_input.tsx | 0 4 files changed, 147 insertions(+), 124 deletions(-) create mode 100644 frontend/dashboard/app/components/data/edit_default_rule_dialog.tsx rename frontend/dashboard/app/components/{data_filters => data}/rule_builder_card.tsx (100%) rename frontend/dashboard/app/components/{data_filters => data}/sampling_rate_input.tsx (100%) diff --git a/frontend/dashboard/app/[teamId]/data/page.tsx b/frontend/dashboard/app/[teamId]/data/page.tsx index 488231eaa..a2e36c57f 100644 --- a/frontend/dashboard/app/[teamId]/data/page.tsx +++ b/frontend/dashboard/app/[teamId]/data/page.tsx @@ -12,22 +12,16 @@ import { useEffect, useState } from 'react' import { Button } from '@/app/components/button' import { Plus, Pencil } from 'lucide-react' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/app/components/dropdown_menu' -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/app/components/dialog' -import SamplingRateInput from '@/app/components/data_filters/sampling_rate_input' +import EditDefaultRuleDialog, { DefaultRuleState } from '@/app/components/data/edit_default_rule_dialog' interface PageState { dataFiltersApiStatus: DataFiltersApiStatus filters: typeof defaultFilters dataFilters: DataFiltersResponse - editingGlobalFilter: { - id: string - type: DataFilterType - collectionMode: DataFilterCollectionConfig['mode'] - sampleRate?: number - } | null + defaultRuleEditState: DefaultRuleState | null } -const isGlobalFilter = (type: DataFilterType): boolean => { +const isDefaultRule = (type: DataFilterType): boolean => { return type === 'all_events' || type === 'all_traces' } @@ -68,13 +62,12 @@ const getAttachmentConfigDisplay = (attachmentConfig: DataFilterAttachmentConfig export default function DataFilters({ params }: { params: { teamId: string } }) { const router = useRouter() - const searchParams = useSearchParams() const initialState: PageState = { dataFiltersApiStatus: DataFiltersApiStatus.Success, filters: defaultFilters, dataFilters: emptyDataFiltersResponse, - editingGlobalFilter: null, + defaultRuleEditState: null, } const [pageState, setPageState] = useState(initialState) @@ -129,10 +122,10 @@ export default function DataFilters({ params }: { params: { teamId: string } }) // getDataFilters() }, [pageState.filters]) - const globalFilters = pageState.dataFilters.results.filter(df => isGlobalFilter(df.type)) - const allEventsFilter = globalFilters.find(df => df.type === 'all_events') - const allTracesFilter = globalFilters.find(df => df.type === 'all_traces') - const overrideFilters = pageState.dataFilters.results.filter(df => !isGlobalFilter(df.type)) + const defaultRules = pageState.dataFilters.results.filter(df => isDefaultRule(df.type)) + const allEventsFilter = defaultRules.find(df => df.type === 'all_events') + const allTracesFilter = defaultRules.find(df => df.type === 'all_traces') + const overrideFilters = pageState.dataFilters.results.filter(df => !isDefaultRule(df.type)) const eventFilters = overrideFilters.filter(df => df.type === 'event') const traceFilters = overrideFilters.filter(df => df.type === 'trace') @@ -141,9 +134,9 @@ export default function DataFilters({ params }: { params: { teamId: string } }) router.push(`/${params.teamId}/data/${filterType}/${dataFilter.id}/edit`) } - const handleEditGlobalFilter = (dataFilter: typeof globalFilters[0]) => { + const handleEditDefaultRule = (dataFilter: typeof defaultRules[0]) => { updatePageState({ - editingGlobalFilter: { + defaultRuleEditState: { id: dataFilter.id, type: dataFilter.type, collectionMode: dataFilter.collection_config.mode, @@ -152,18 +145,16 @@ export default function DataFilters({ params }: { params: { teamId: string } }) }) } - const handleSaveGlobalFilter = () => { - // TODO: Implement save logic - updatePageState({ editingGlobalFilter: null }) + const handleSaveDefaultRule = () => { + updatePageState({ defaultRuleEditState: null }) } - const handleCancelGlobalFilter = () => { - updatePageState({ editingGlobalFilter: null }) + const handleCancelDefaultRule = () => { + updatePageState({ defaultRuleEditState: null }) } return (
-

Data Control

@@ -211,14 +202,15 @@ export default function DataFilters({ params }: { params: { teamId: string } }) showFreeText={false} freeTextPlaceholder={""} onFiltersChanged={handleFiltersChanged} /> -
- {/* Error state for data filters fetch */} +
+ + {/* Error state for data rules fetch */} {pageState.filters.ready && pageState.dataFiltersApiStatus === DataFiltersApiStatus.Error &&

Error fetching data filters, please change filters, refresh page or select a different app to try again

} - {/* Main data filters UI */} + {/* Main data rules UI */} {pageState.filters.ready && (pageState.dataFiltersApiStatus === DataFiltersApiStatus.Success || pageState.dataFiltersApiStatus === DataFiltersApiStatus.Loading) &&
@@ -226,17 +218,17 @@ export default function DataFilters({ params }: { params: { teamId: string } })
- {/* Event Filters Section */} + {/* Event Rules Section */}

Event Rules

- {/* Default Event Filter */} + {/* Default Event Rule */}

Default Rule

{allEventsFilter && (
-
+
- {/* Trace Filters Section */} + {/* Trace Rules Section */}

Trace Rules

- {/* Default Trace Filter */} + {/* Default Trace Rule */}

Default Rule

{allTracesFilter && (
} - {/* Global Filter Edit Dialog */} - !open && handleCancelGlobalFilter()}> - - - - Edit Default {pageState.editingGlobalFilter?.type === 'all_events' ? 'Events' : 'Traces'} Rule - - - -
- - - - - -
- - - - - -
-
+ {/* Default Rule Edit Dialog */} + updatePageState({ defaultRuleEditState: updatedRule })} + />
) } diff --git a/frontend/dashboard/app/components/data/edit_default_rule_dialog.tsx b/frontend/dashboard/app/components/data/edit_default_rule_dialog.tsx new file mode 100644 index 000000000..464fa9e11 --- /dev/null +++ b/frontend/dashboard/app/components/data/edit_default_rule_dialog.tsx @@ -0,0 +1,114 @@ +"use client" + +import { DataFilterCollectionConfig, DataFilterType } from '@/app/api/api_calls' +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/app/components/dialog' +import { Button } from '@/app/components/button' +import SamplingRateInput from '@/app/components/data/sampling_rate_input' + +export interface DefaultRuleState { + id: string + type: DataFilterType + collectionMode: DataFilterCollectionConfig['mode'] + sampleRate?: number +} + +interface EditDefaultRuleDialogProps { + isOpen: boolean + defaultRule: DefaultRuleState | null + onClose: () => void + onSave: () => void + onUpdate: (updatedRule: DefaultRuleState) => void +} + +export default function EditDefaultRuleDialog({ + isOpen, + defaultRule, + onClose, + onSave, + onUpdate +}: EditDefaultRuleDialogProps) { + return ( + !open && onClose()}> + + + + Edit Default {defaultRule?.type === 'all_events' ? 'Events' : 'Traces'} Rule + + + +
+ + + + + +
+ + + + + +
+
+ ) +} diff --git a/frontend/dashboard/app/components/data_filters/rule_builder_card.tsx b/frontend/dashboard/app/components/data/rule_builder_card.tsx similarity index 100% rename from frontend/dashboard/app/components/data_filters/rule_builder_card.tsx rename to frontend/dashboard/app/components/data/rule_builder_card.tsx diff --git a/frontend/dashboard/app/components/data_filters/sampling_rate_input.tsx b/frontend/dashboard/app/components/data/sampling_rate_input.tsx similarity index 100% rename from frontend/dashboard/app/components/data_filters/sampling_rate_input.tsx rename to frontend/dashboard/app/components/data/sampling_rate_input.tsx From 54e7b2ad858c21b67a6ab9779a46bc497247c8cb Mon Sep 17 00:00:00 2001 From: Abhay Sood Date: Wed, 5 Nov 2025 14:03:31 +0530 Subject: [PATCH 15/98] feat(frontend): refactor tables into reusable component --- frontend/dashboard/app/[teamId]/data/page.tsx | 114 +----------------- frontend/dashboard/app/api/api_calls.ts | 16 +-- .../components/data/rule_overrides_table.tsx | 94 +++++++++++++++ 3 files changed, 107 insertions(+), 117 deletions(-) create mode 100644 frontend/dashboard/app/components/data/rule_overrides_table.tsx diff --git a/frontend/dashboard/app/[teamId]/data/page.tsx b/frontend/dashboard/app/[teamId]/data/page.tsx index a2e36c57f..7b7d4f94f 100644 --- a/frontend/dashboard/app/[teamId]/data/page.tsx +++ b/frontend/dashboard/app/[teamId]/data/page.tsx @@ -3,16 +3,14 @@ import { DataFiltersApiStatus, DataFiltersResponse, emptyDataFiltersResponse, fetchDataFiltersFromServer, FilterSource } from '@/app/api/api_calls' import Filters, { AppVersionsInitialSelectionType, defaultFilters } from '@/app/components/filters' import LoadingBar from '@/app/components/loading_bar' -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/app/components/table' - -import { DataFilterAttachmentConfig, DataFilterCollectionConfig, DataFilterType } from '@/app/api/api_calls' -import { formatDateToHumanReadableDate, formatDateToHumanReadableTime } from '@/app/utils/time_utils' -import { useRouter, useSearchParams } from 'next/navigation' +import { DataFilterCollectionConfig, DataFilterType } from '@/app/api/api_calls' +import { useRouter } from 'next/navigation' import { useEffect, useState } from 'react' import { Button } from '@/app/components/button' import { Plus, Pencil } from 'lucide-react' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/app/components/dropdown_menu' import EditDefaultRuleDialog, { DefaultRuleState } from '@/app/components/data/edit_default_rule_dialog' +import RulesTable from '@/app/components/data/rule_overrides_table' interface PageState { dataFiltersApiStatus: DataFiltersApiStatus @@ -25,17 +23,6 @@ const isDefaultRule = (type: DataFilterType): boolean => { return type === 'all_events' || type === 'all_traces' } -const getFilterDisplayText = (type: DataFilterType, filter: string): string => { - switch (type) { - case 'all_events': - return 'All Events' - case 'all_traces': - return 'All Traces' - default: - return filter - } -} - const getCollectionConfigDisplay = (collectionConfig: DataFilterCollectionConfig): string => { switch (collectionConfig.mode) { case 'sample_rate': @@ -49,17 +36,6 @@ const getCollectionConfigDisplay = (collectionConfig: DataFilterCollectionConfig } } -const getAttachmentConfigDisplay = (attachmentConfig: DataFilterAttachmentConfig | null): string => { - if (!attachmentConfig || attachmentConfig === 'none') { - return '' - } else if (attachmentConfig === 'layout_snapshot') { - return 'With layout snapshot' - } else if (attachmentConfig === 'screenshot') { - return 'With screenshot' - } - return attachmentConfig -} - export default function DataFilters({ params }: { params: { teamId: string } }) { const router = useRouter() @@ -242,47 +218,7 @@ export default function DataFilters({ params }: { params: { teamId: string } })
)} - {eventFilters.length > 0 && ( - <> -
- {/* Event Overrides */} -

Overrides

-
- - - - Rule - Updated At - Updated By - - - - {eventFilters.map((dataFilter, idx) => ( - handleEditFilter(dataFilter)} - > - -

{getFilterDisplayText(dataFilter.type, dataFilter.filter)}

-
-

{getCollectionConfigDisplay(dataFilter.collection_config)}

-

{getAttachmentConfigDisplay(dataFilter.attachment_config)}

- - -

{formatDateToHumanReadableDate(dataFilter.updated_at)}

-
-

{formatDateToHumanReadableTime(dataFilter.updated_at)}

- - -

{dataFilter.updated_by}

-
- - ))} - -
- - )} +
@@ -311,47 +247,7 @@ export default function DataFilters({ params }: { params: { teamId: string } })
)} - {traceFilters.length > 0 && ( - <> -
- {/* Trace Overrides */} -

Overrides

-
- - - - Rule - Updated At - Updated By - - - - {traceFilters.map((dataFilter, idx) => ( - handleEditFilter(dataFilter)} - > - -

{getFilterDisplayText(dataFilter.type, dataFilter.filter)}

-
-

{getCollectionConfigDisplay(dataFilter.collection_config)}

-

{getAttachmentConfigDisplay(dataFilter.attachment_config)}

- - -

{formatDateToHumanReadableDate(dataFilter.updated_at)}

-
-

{formatDateToHumanReadableTime(dataFilter.updated_at)}

- - -

{dataFilter.updated_by}

-
- - ))} - -
- - )} +
} diff --git a/frontend/dashboard/app/api/api_calls.ts b/frontend/dashboard/app/api/api_calls.ts index 3c10e0a69..253fbe360 100644 --- a/frontend/dashboard/app/api/api_calls.ts +++ b/frontend/dashboard/app/api/api_calls.ts @@ -1051,7 +1051,7 @@ export type DataFilterAttachmentConfig = 'layout_snapshot' | 'screenshot' | 'non export type DataFilter = { id: string, type: DataFilterType, - filter: string, + rule: string, collection_config: DataFilterCollectionConfig, attachment_config: DataFilterAttachmentConfig | null, created_at: string, @@ -1069,7 +1069,7 @@ export const emptyDataFiltersResponse: DataFiltersResponse = { { id: "df-global-001", type: "all_events", - filter: 'event_type == "*"', + rule: 'event_type == "*"', collection_config: { mode: 'timeline_only'}, attachment_config: 'none', created_at: "2024-01-01T00:00:00Z", @@ -1080,7 +1080,7 @@ export const emptyDataFiltersResponse: DataFiltersResponse = { { id: "df-global-002", type: "all_traces", - filter: 'span.name == "*"', + rule: 'span.name == "*"', collection_config: { mode: 'sample_rate', sample_rate: 1 }, attachment_config: 'none', created_at: "2024-01-01T00:00:00Z", @@ -1091,7 +1091,7 @@ export const emptyDataFiltersResponse: DataFiltersResponse = { { id: "df-001", type: "event", - filter: "event.type == 'click' && event.target == 'checkout_button'", + rule: "event.type == 'click' && event.target == 'checkout_button'", collection_config: { mode: 'sample_rate', sample_rate: 0.5 }, attachment_config: 'screenshot', created_at: "2024-01-15T10:30:00Z", @@ -1102,7 +1102,7 @@ export const emptyDataFiltersResponse: DataFiltersResponse = { { id: "df-002", type: "trace", - filter: "trace.duration > 5000 && trace.status == 'error'", + rule: "trace.duration > 5000 && trace.status == 'error'", collection_config: { mode: 'timeline_only' }, attachment_config: 'layout_snapshot', created_at: "2024-01-20T08:15:00Z", @@ -1113,7 +1113,7 @@ export const emptyDataFiltersResponse: DataFiltersResponse = { { id: "df-003", type: "event", - filter: "event.name == 'app_background' && session.is_crash == true", + rule: "event.name == 'app_background' && session.is_crash == true", collection_config: { mode: 'disable' }, attachment_config: null, created_at: "2024-02-01T12:00:00Z", @@ -1124,7 +1124,7 @@ export const emptyDataFiltersResponse: DataFiltersResponse = { { id: "df-004", type: "trace", - filter: "trace.name == 'network_request' && trace.http.status_code >= 400", + rule: "trace.name == 'network_request' && trace.http.status_code >= 400", collection_config: { mode: 'sample_rate', sample_rate: 0.25 }, attachment_config: 'none', created_at: "2024-02-10T16:20:00Z", @@ -1135,7 +1135,7 @@ export const emptyDataFiltersResponse: DataFiltersResponse = { { id: "df-005", type: "event", - filter: "event.type == 'gesture' && device.manufacturer == 'Samsung'", + rule: "event.type == 'gesture' && device.manufacturer == 'Samsung'", collection_config: { mode: 'sample_rate', sample_rate: 1.0 }, attachment_config: 'screenshot', created_at: "2024-03-05T13:45:00Z", diff --git a/frontend/dashboard/app/components/data/rule_overrides_table.tsx b/frontend/dashboard/app/components/data/rule_overrides_table.tsx new file mode 100644 index 000000000..feb5482d0 --- /dev/null +++ b/frontend/dashboard/app/components/data/rule_overrides_table.tsx @@ -0,0 +1,94 @@ +"use client" + +import { DataFiltersResponse, DataFilterCollectionConfig, DataFilterAttachmentConfig, DataFilterType } from '@/app/api/api_calls' +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/app/components/table' +import { formatDateToHumanReadableDate, formatDateToHumanReadableTime } from '@/app/utils/time_utils' + +const getFilterDisplayText = (type: DataFilterType, filter: string): string => { + switch (type) { + case 'all_events': + return 'All Events' + case 'all_traces': + return 'All Traces' + default: + return filter + } +} + +const getCollectionConfigDisplay = (collectionConfig: DataFilterCollectionConfig): string => { + switch (collectionConfig.mode) { + case 'sample_rate': + return `Collect all at ${collectionConfig.sample_rate}% sample rate` + case 'timeline_only': + return 'Collect with session timeline only' + case 'disable': + return 'Do not collect' + default: + return 'Unknown' + } +} + +const getAttachmentConfigDisplay = (attachmentConfig: DataFilterAttachmentConfig | null): string => { + if (!attachmentConfig || attachmentConfig === 'none') { + return '' + } else if (attachmentConfig === 'layout_snapshot') { + return 'With layout snapshot' + } else if (attachmentConfig === 'screenshot') { + return 'With screenshot' + } + return attachmentConfig +} + +type RuleFilter = DataFiltersResponse['results'][0] + +interface RulesTableProps { + rules: RuleFilter[] + onRuleClick: (rule: RuleFilter) => void +} + +export default function RulesTable({ rules, onRuleClick }: RulesTableProps) { + if (rules.length === 0) { + return null + } + + return ( + <> +
+

Overrides

+
+ + + + Rule + Updated At + Updated By + + + + {rules.map((dataFilter, idx) => ( + onRuleClick(dataFilter)} + > + +

{getFilterDisplayText(dataFilter.type, dataFilter.rule)}

+
+

{getCollectionConfigDisplay(dataFilter.collection_config)}

+

{getAttachmentConfigDisplay(dataFilter.attachment_config)}

+ + +

{formatDateToHumanReadableDate(dataFilter.updated_at)}

+
+

{formatDateToHumanReadableTime(dataFilter.updated_at)}

+ + +

{dataFilter.updated_by}

+
+ + ))} + +
+ + ) +} From e2f21372aa0959d0f97d5cfd9b05f235b82e16de Mon Sep 17 00:00:00 2001 From: Abhay Sood Date: Wed, 5 Nov 2025 14:20:01 +0530 Subject: [PATCH 16/98] feat(frontend): add client side pagination for override rule tables --- frontend/dashboard/app/api/api_calls.ts | 114 +++++++++++++++++- .../components/data/rule_overrides_table.tsx | 43 ++++++- 2 files changed, 150 insertions(+), 7 deletions(-) diff --git a/frontend/dashboard/app/api/api_calls.ts b/frontend/dashboard/app/api/api_calls.ts index 253fbe360..176b5532a 100644 --- a/frontend/dashboard/app/api/api_calls.ts +++ b/frontend/dashboard/app/api/api_calls.ts @@ -1053,7 +1053,7 @@ export type DataFilter = { type: DataFilterType, rule: string, collection_config: DataFilterCollectionConfig, - attachment_config: DataFilterAttachmentConfig | null, + attachment_config: DataFilterAttachmentConfig, created_at: string, created_by: string, updated_at: string, @@ -1115,7 +1115,7 @@ export const emptyDataFiltersResponse: DataFiltersResponse = { type: "event", rule: "event.name == 'app_background' && session.is_crash == true", collection_config: { mode: 'disable' }, - attachment_config: null, + attachment_config: 'none', created_at: "2024-02-01T12:00:00Z", created_by: "developer@example.com", updated_at: "2024-03-10T09:30:00Z", @@ -1143,6 +1143,116 @@ export const emptyDataFiltersResponse: DataFiltersResponse = { updated_at: "2024-03-05T13:45:00Z", updated_by: "user3@example.com", }, + { + id: "df-006", + type: "trace", + rule: "trace.name == 'database_query' && trace.duration > 1000", + collection_config: { mode: 'sample_rate', sample_rate: 0.75 }, + attachment_config: 'none', + created_at: "2024-03-10T09:20:00Z", + created_by: "developer@example.com", + updated_at: "2024-03-15T14:30:00Z", + updated_by: "developer@example.com", + }, + { + id: "df-007", + type: "event", + rule: "event.type == 'navigation' && event.screen == 'profile'", + collection_config: { mode: 'timeline_only' }, + attachment_config: 'layout_snapshot', + created_at: "2024-03-12T11:15:00Z", + created_by: "user1@example.com", + updated_at: "2024-03-12T11:15:00Z", + updated_by: "user1@example.com", + }, + { + id: "df-008", + type: "trace", + rule: "trace.name == 'api_call' && trace.http.method == 'POST'", + collection_config: { mode: 'sample_rate', sample_rate: 0.3 }, + attachment_config: 'none', + created_at: "2024-03-14T16:40:00Z", + created_by: "qa@example.com", + updated_at: "2024-03-20T10:25:00Z", + updated_by: "lead@example.com", + }, + { + id: "df-009", + type: "event", + rule: "event.type == 'error' && event.severity == 'high'", + collection_config: { mode: 'sample_rate', sample_rate: 1.0 }, + attachment_config: 'screenshot', + created_at: "2024-03-18T08:30:00Z", + created_by: "admin@example.com", + updated_at: "2024-03-18T08:30:00Z", + updated_by: "admin@example.com", + }, + { + id: "df-010", + type: "trace", + rule: "trace.name == 'image_upload' && trace.file_size > 5000000", + collection_config: { mode: 'disable' }, + attachment_config: 'none', + created_at: "2024-03-22T14:55:00Z", + created_by: "developer@example.com", + updated_at: "2024-03-25T09:10:00Z", + updated_by: "admin@example.com", + }, + { + id: "df-011", + type: "event", + rule: "event.type == 'payment' && event.amount > 100", + collection_config: { mode: 'sample_rate', sample_rate: 1.0 }, + attachment_config: 'screenshot', + created_at: "2024-03-26T10:20:00Z", + created_by: "user2@example.com", + updated_at: "2024-03-26T10:20:00Z", + updated_by: "user2@example.com", + }, + { + id: "df-012", + type: "trace", + rule: "trace.name == 'video_processing' && trace.duration > 3000", + collection_config: { mode: 'timeline_only' }, + attachment_config: 'layout_snapshot', + created_at: "2024-03-28T15:45:00Z", + created_by: "qa@example.com", + updated_at: "2024-03-28T15:45:00Z", + updated_by: "qa@example.com", + }, + { + id: "df-013", + type: "event", + rule: "event.type == 'search' && event.query_length > 50", + collection_config: { mode: 'sample_rate', sample_rate: 0.5 }, + attachment_config: 'none', + created_at: "2024-04-01T12:00:00Z", + created_by: "user3@example.com", + updated_at: "2024-04-05T14:20:00Z", + updated_by: "lead@example.com", + }, + { + id: "df-014", + type: "trace", + rule: "trace.name == 'cache_miss' && trace.status == 'ok'", + collection_config: { mode: 'sample_rate', sample_rate: 0.1 }, + attachment_config: 'none', + created_at: "2024-04-03T09:30:00Z", + created_by: "developer@example.com", + updated_at: "2024-04-03T09:30:00Z", + updated_by: "developer@example.com", + }, + { + id: "df-015", + type: "event", + rule: "event.type == 'form_submit' && event.field_count > 10", + collection_config: { mode: 'sample_rate', sample_rate: 0.8 }, + attachment_config: 'layout_snapshot', + created_at: "2024-04-07T11:40:00Z", + created_by: "user1@example.com", + updated_at: "2024-04-10T16:15:00Z", + updated_by: "admin@example.com", + }, ], } diff --git a/frontend/dashboard/app/components/data/rule_overrides_table.tsx b/frontend/dashboard/app/components/data/rule_overrides_table.tsx index feb5482d0..38be777d6 100644 --- a/frontend/dashboard/app/components/data/rule_overrides_table.tsx +++ b/frontend/dashboard/app/components/data/rule_overrides_table.tsx @@ -3,6 +3,8 @@ import { DataFiltersResponse, DataFilterCollectionConfig, DataFilterAttachmentConfig, DataFilterType } from '@/app/api/api_calls' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/app/components/table' import { formatDateToHumanReadableDate, formatDateToHumanReadableTime } from '@/app/utils/time_utils' +import Paginator from '@/app/components/paginator' +import { useState, useEffect } from 'react' const getFilterDisplayText = (type: DataFilterType, filter: string): string => { switch (type) { @@ -28,8 +30,8 @@ const getCollectionConfigDisplay = (collectionConfig: DataFilterCollectionConfig } } -const getAttachmentConfigDisplay = (attachmentConfig: DataFilterAttachmentConfig | null): string => { - if (!attachmentConfig || attachmentConfig === 'none') { +const getAttachmentConfigDisplay = (attachmentConfig: DataFilterAttachmentConfig): string => { + if (attachmentConfig === 'none') { return '' } else if (attachmentConfig === 'layout_snapshot') { return 'With layout snapshot' @@ -46,16 +48,47 @@ interface RulesTableProps { onRuleClick: (rule: RuleFilter) => void } +const paginationLimit = 5 + export default function RulesTable({ rules, onRuleClick }: RulesTableProps) { + const [paginationOffset, setPaginationOffset] = useState(0) + + useEffect(() => { + setPaginationOffset(0) + }, [rules]) + if (rules.length === 0) { return null } + const handleNextPage = () => { + setPaginationOffset(paginationOffset + paginationLimit) + } + + const handlePrevPage = () => { + setPaginationOffset(Math.max(0, paginationOffset - paginationLimit)) + } + + const prevEnabled = paginationOffset > 0 + const nextEnabled = paginationOffset + paginationLimit < rules.length + const paginatedRules = rules.slice(paginationOffset, paginationOffset + paginationLimit) + return ( <>
-

Overrides

-
+
+

Overrides

+ {rules.length > paginationLimit && ( + + )} +
+
@@ -65,7 +98,7 @@ export default function RulesTable({ rules, onRuleClick }: RulesTableProps) { - {rules.map((dataFilter, idx) => ( + {paginatedRules.map((dataFilter, idx) => ( Date: Wed, 5 Nov 2025 14:28:04 +0530 Subject: [PATCH 17/98] feat(frontend): rename filter to rule --- frontend/dashboard/app/[teamId]/data/page.tsx | 40 ++--- frontend/dashboard/app/api/api_calls.ts | 140 ++---------------- .../edit_default_rule_dialog.tsx | 8 +- .../{data => data_rule}/rule_builder_card.tsx | 0 .../rule_overrides_table.tsx | 10 +- .../sampling_rate_input.tsx | 0 6 files changed, 44 insertions(+), 154 deletions(-) rename frontend/dashboard/app/components/{data => data_rule}/edit_default_rule_dialog.tsx (95%) rename frontend/dashboard/app/components/{data => data_rule}/rule_builder_card.tsx (100%) rename frontend/dashboard/app/components/{data => data_rule}/rule_overrides_table.tsx (91%) rename frontend/dashboard/app/components/{data => data_rule}/sampling_rate_input.tsx (100%) diff --git a/frontend/dashboard/app/[teamId]/data/page.tsx b/frontend/dashboard/app/[teamId]/data/page.tsx index 7b7d4f94f..e2ff801a2 100644 --- a/frontend/dashboard/app/[teamId]/data/page.tsx +++ b/frontend/dashboard/app/[teamId]/data/page.tsx @@ -1,29 +1,29 @@ "use client" -import { DataFiltersApiStatus, DataFiltersResponse, emptyDataFiltersResponse, fetchDataFiltersFromServer, FilterSource } from '@/app/api/api_calls' +import { DataRulesApiStatus, DataRulesResponse, emptyDataFiltersResponse, fetchDataFiltersFromServer, FilterSource } from '@/app/api/api_calls' import Filters, { AppVersionsInitialSelectionType, defaultFilters } from '@/app/components/filters' import LoadingBar from '@/app/components/loading_bar' -import { DataFilterCollectionConfig, DataFilterType } from '@/app/api/api_calls' +import { DataRuleCollectionConfig, DataRuleType } from '@/app/api/api_calls' import { useRouter } from 'next/navigation' import { useEffect, useState } from 'react' import { Button } from '@/app/components/button' import { Plus, Pencil } from 'lucide-react' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/app/components/dropdown_menu' -import EditDefaultRuleDialog, { DefaultRuleState } from '@/app/components/data/edit_default_rule_dialog' -import RulesTable from '@/app/components/data/rule_overrides_table' +import EditDefaultRuleDialog, { DefaultRuleState } from '@/app/components/data_rule/edit_default_rule_dialog' +import RulesTable from '@/app/components/data_rule/rule_overrides_table' interface PageState { - dataFiltersApiStatus: DataFiltersApiStatus + dataFiltersApiStatus: DataRulesApiStatus filters: typeof defaultFilters - dataFilters: DataFiltersResponse + dataFilters: DataRulesResponse defaultRuleEditState: DefaultRuleState | null } -const isDefaultRule = (type: DataFilterType): boolean => { +const isDefaultRule = (type: DataRuleType): boolean => { return type === 'all_events' || type === 'all_traces' } -const getCollectionConfigDisplay = (collectionConfig: DataFilterCollectionConfig): string => { +const getCollectionConfigDisplay = (collectionConfig: DataRuleCollectionConfig): string => { switch (collectionConfig.mode) { case 'sample_rate': return `Collect all at ${collectionConfig.sample_rate}% sample rate` @@ -40,7 +40,7 @@ export default function DataFilters({ params }: { params: { teamId: string } }) const router = useRouter() const initialState: PageState = { - dataFiltersApiStatus: DataFiltersApiStatus.Success, + dataFiltersApiStatus: DataRulesApiStatus.Success, filters: defaultFilters, dataFilters: emptyDataFiltersResponse, defaultRuleEditState: null, @@ -56,20 +56,20 @@ export default function DataFilters({ params }: { params: { teamId: string } }) } const getDataFilters = async () => { - updatePageState({ dataFiltersApiStatus: DataFiltersApiStatus.Loading }) + updatePageState({ dataFiltersApiStatus: DataRulesApiStatus.Loading }) const result = await fetchDataFiltersFromServer(pageState.filters.app!.id) switch (result.status) { - case DataFiltersApiStatus.Error: - updatePageState({ dataFiltersApiStatus: DataFiltersApiStatus.Error }) + case DataRulesApiStatus.Error: + updatePageState({ dataFiltersApiStatus: DataRulesApiStatus.Error }) break - case DataFiltersApiStatus.NoFilters: - updatePageState({ dataFiltersApiStatus: DataFiltersApiStatus.NoFilters }) + case DataRulesApiStatus.NoFilters: + updatePageState({ dataFiltersApiStatus: DataRulesApiStatus.NoFilters }) break - case DataFiltersApiStatus.Success: + case DataRulesApiStatus.Success: updatePageState({ - dataFiltersApiStatus: DataFiltersApiStatus.Success, + dataFiltersApiStatus: DataRulesApiStatus.Success, dataFilters: result.data }) break @@ -138,7 +138,7 @@ export default function DataFilters({ params }: { params: { teamId: string } }) @@ -183,14 +183,14 @@ export default function DataFilters({ params }: { params: { teamId: string } }) {/* Error state for data rules fetch */} {pageState.filters.ready - && pageState.dataFiltersApiStatus === DataFiltersApiStatus.Error + && pageState.dataFiltersApiStatus === DataRulesApiStatus.Error &&

Error fetching data filters, please change filters, refresh page or select a different app to try again

} {/* Main data rules UI */} {pageState.filters.ready - && (pageState.dataFiltersApiStatus === DataFiltersApiStatus.Success || pageState.dataFiltersApiStatus === DataFiltersApiStatus.Loading) && + && (pageState.dataFiltersApiStatus === DataRulesApiStatus.Success || pageState.dataFiltersApiStatus === DataRulesApiStatus.Loading) &&
-
+
diff --git a/frontend/dashboard/app/api/api_calls.ts b/frontend/dashboard/app/api/api_calls.ts index 176b5532a..084032008 100644 --- a/frontend/dashboard/app/api/api_calls.ts +++ b/frontend/dashboard/app/api/api_calls.ts @@ -353,7 +353,7 @@ export enum AlertsOverviewApiStatus { Cancelled, } -export enum DataFiltersApiStatus { +export enum DataRulesApiStatus { Loading, Success, Error, @@ -1031,36 +1031,36 @@ export const emptyAlertsOverviewResponse = { }[], } -export type DataFiltersResponse = { +export type DataRulesResponse = { meta: { next: false, previous: false, }, - results: DataFilter[], + results: DataRule[], } -export type DataFilterType = "event" | "trace" | "all_events" | "all_traces"; +export type DataRuleType = "event" | "trace" | "all_events" | "all_traces"; -export type DataFilterCollectionConfig = +export type DataRuleCollectionConfig = | { mode: 'sample_rate'; sample_rate: number } | { mode: 'timeline_only' } | { mode: 'disable' }; -export type DataFilterAttachmentConfig = 'layout_snapshot' | 'screenshot' | 'none'; +export type DataRuleAttachmentConfig = 'layout_snapshot' | 'screenshot' | 'none'; -export type DataFilter = { +export type DataRule = { id: string, - type: DataFilterType, + type: DataRuleType, rule: string, - collection_config: DataFilterCollectionConfig, - attachment_config: DataFilterAttachmentConfig, + collection_config: DataRuleCollectionConfig, + attachment_config: DataRuleAttachmentConfig, created_at: string, created_by: string, updated_at: string, updated_by: string, } -export const emptyDataFiltersResponse: DataFiltersResponse = { +export const emptyDataFiltersResponse: DataRulesResponse = { meta: { next: false, previous: false, @@ -1143,116 +1143,6 @@ export const emptyDataFiltersResponse: DataFiltersResponse = { updated_at: "2024-03-05T13:45:00Z", updated_by: "user3@example.com", }, - { - id: "df-006", - type: "trace", - rule: "trace.name == 'database_query' && trace.duration > 1000", - collection_config: { mode: 'sample_rate', sample_rate: 0.75 }, - attachment_config: 'none', - created_at: "2024-03-10T09:20:00Z", - created_by: "developer@example.com", - updated_at: "2024-03-15T14:30:00Z", - updated_by: "developer@example.com", - }, - { - id: "df-007", - type: "event", - rule: "event.type == 'navigation' && event.screen == 'profile'", - collection_config: { mode: 'timeline_only' }, - attachment_config: 'layout_snapshot', - created_at: "2024-03-12T11:15:00Z", - created_by: "user1@example.com", - updated_at: "2024-03-12T11:15:00Z", - updated_by: "user1@example.com", - }, - { - id: "df-008", - type: "trace", - rule: "trace.name == 'api_call' && trace.http.method == 'POST'", - collection_config: { mode: 'sample_rate', sample_rate: 0.3 }, - attachment_config: 'none', - created_at: "2024-03-14T16:40:00Z", - created_by: "qa@example.com", - updated_at: "2024-03-20T10:25:00Z", - updated_by: "lead@example.com", - }, - { - id: "df-009", - type: "event", - rule: "event.type == 'error' && event.severity == 'high'", - collection_config: { mode: 'sample_rate', sample_rate: 1.0 }, - attachment_config: 'screenshot', - created_at: "2024-03-18T08:30:00Z", - created_by: "admin@example.com", - updated_at: "2024-03-18T08:30:00Z", - updated_by: "admin@example.com", - }, - { - id: "df-010", - type: "trace", - rule: "trace.name == 'image_upload' && trace.file_size > 5000000", - collection_config: { mode: 'disable' }, - attachment_config: 'none', - created_at: "2024-03-22T14:55:00Z", - created_by: "developer@example.com", - updated_at: "2024-03-25T09:10:00Z", - updated_by: "admin@example.com", - }, - { - id: "df-011", - type: "event", - rule: "event.type == 'payment' && event.amount > 100", - collection_config: { mode: 'sample_rate', sample_rate: 1.0 }, - attachment_config: 'screenshot', - created_at: "2024-03-26T10:20:00Z", - created_by: "user2@example.com", - updated_at: "2024-03-26T10:20:00Z", - updated_by: "user2@example.com", - }, - { - id: "df-012", - type: "trace", - rule: "trace.name == 'video_processing' && trace.duration > 3000", - collection_config: { mode: 'timeline_only' }, - attachment_config: 'layout_snapshot', - created_at: "2024-03-28T15:45:00Z", - created_by: "qa@example.com", - updated_at: "2024-03-28T15:45:00Z", - updated_by: "qa@example.com", - }, - { - id: "df-013", - type: "event", - rule: "event.type == 'search' && event.query_length > 50", - collection_config: { mode: 'sample_rate', sample_rate: 0.5 }, - attachment_config: 'none', - created_at: "2024-04-01T12:00:00Z", - created_by: "user3@example.com", - updated_at: "2024-04-05T14:20:00Z", - updated_by: "lead@example.com", - }, - { - id: "df-014", - type: "trace", - rule: "trace.name == 'cache_miss' && trace.status == 'ok'", - collection_config: { mode: 'sample_rate', sample_rate: 0.1 }, - attachment_config: 'none', - created_at: "2024-04-03T09:30:00Z", - created_by: "developer@example.com", - updated_at: "2024-04-03T09:30:00Z", - updated_by: "developer@example.com", - }, - { - id: "df-015", - type: "event", - rule: "event.type == 'form_submit' && event.field_count > 10", - collection_config: { mode: 'sample_rate', sample_rate: 0.8 }, - attachment_config: 'layout_snapshot', - created_at: "2024-04-07T11:40:00Z", - created_by: "user1@example.com", - updated_at: "2024-04-10T16:15:00Z", - updated_by: "admin@example.com", - }, ], } @@ -2604,17 +2494,17 @@ export const fetchDataFiltersFromServer = async ( const res = await measureAuth.fetchMeasure(url) if (!res.ok) { - return { status: DataFiltersApiStatus.Error, data: null } + return { status: DataRulesApiStatus.Error, data: null } } const data = await res.json() if (data.results === null) { - return { status: DataFiltersApiStatus.NoFilters, data: null } + return { status: DataRulesApiStatus.NoFilters, data: null } } else { - return { status: DataFiltersApiStatus.Success, data: data } + return { status: DataRulesApiStatus.Success, data: data } } } catch { - return { status: DataFiltersApiStatus.Cancelled, data: null } + return { status: DataRulesApiStatus.Cancelled, data: null } } } \ No newline at end of file diff --git a/frontend/dashboard/app/components/data/edit_default_rule_dialog.tsx b/frontend/dashboard/app/components/data_rule/edit_default_rule_dialog.tsx similarity index 95% rename from frontend/dashboard/app/components/data/edit_default_rule_dialog.tsx rename to frontend/dashboard/app/components/data_rule/edit_default_rule_dialog.tsx index 464fa9e11..feab0f717 100644 --- a/frontend/dashboard/app/components/data/edit_default_rule_dialog.tsx +++ b/frontend/dashboard/app/components/data_rule/edit_default_rule_dialog.tsx @@ -1,14 +1,14 @@ "use client" -import { DataFilterCollectionConfig, DataFilterType } from '@/app/api/api_calls' +import { DataRuleCollectionConfig, DataRuleType } from '@/app/api/api_calls' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/app/components/dialog' import { Button } from '@/app/components/button' -import SamplingRateInput from '@/app/components/data/sampling_rate_input' +import SamplingRateInput from '@/app/components/data_rule/sampling_rate_input' export interface DefaultRuleState { id: string - type: DataFilterType - collectionMode: DataFilterCollectionConfig['mode'] + type: DataRuleType + collectionMode: DataRuleCollectionConfig['mode'] sampleRate?: number } diff --git a/frontend/dashboard/app/components/data/rule_builder_card.tsx b/frontend/dashboard/app/components/data_rule/rule_builder_card.tsx similarity index 100% rename from frontend/dashboard/app/components/data/rule_builder_card.tsx rename to frontend/dashboard/app/components/data_rule/rule_builder_card.tsx diff --git a/frontend/dashboard/app/components/data/rule_overrides_table.tsx b/frontend/dashboard/app/components/data_rule/rule_overrides_table.tsx similarity index 91% rename from frontend/dashboard/app/components/data/rule_overrides_table.tsx rename to frontend/dashboard/app/components/data_rule/rule_overrides_table.tsx index 38be777d6..72d7e13de 100644 --- a/frontend/dashboard/app/components/data/rule_overrides_table.tsx +++ b/frontend/dashboard/app/components/data_rule/rule_overrides_table.tsx @@ -1,12 +1,12 @@ "use client" -import { DataFiltersResponse, DataFilterCollectionConfig, DataFilterAttachmentConfig, DataFilterType } from '@/app/api/api_calls' +import { DataRulesResponse, DataRuleCollectionConfig, DataRuleAttachmentConfig, DataRuleType } from '@/app/api/api_calls' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/app/components/table' import { formatDateToHumanReadableDate, formatDateToHumanReadableTime } from '@/app/utils/time_utils' import Paginator from '@/app/components/paginator' import { useState, useEffect } from 'react' -const getFilterDisplayText = (type: DataFilterType, filter: string): string => { +const getFilterDisplayText = (type: DataRuleType, filter: string): string => { switch (type) { case 'all_events': return 'All Events' @@ -17,7 +17,7 @@ const getFilterDisplayText = (type: DataFilterType, filter: string): string => { } } -const getCollectionConfigDisplay = (collectionConfig: DataFilterCollectionConfig): string => { +const getCollectionConfigDisplay = (collectionConfig: DataRuleCollectionConfig): string => { switch (collectionConfig.mode) { case 'sample_rate': return `Collect all at ${collectionConfig.sample_rate}% sample rate` @@ -30,7 +30,7 @@ const getCollectionConfigDisplay = (collectionConfig: DataFilterCollectionConfig } } -const getAttachmentConfigDisplay = (attachmentConfig: DataFilterAttachmentConfig): string => { +const getAttachmentConfigDisplay = (attachmentConfig: DataRuleAttachmentConfig): string => { if (attachmentConfig === 'none') { return '' } else if (attachmentConfig === 'layout_snapshot') { @@ -41,7 +41,7 @@ const getAttachmentConfigDisplay = (attachmentConfig: DataFilterAttachmentConfig return attachmentConfig } -type RuleFilter = DataFiltersResponse['results'][0] +type RuleFilter = DataRulesResponse['results'][0] interface RulesTableProps { rules: RuleFilter[] diff --git a/frontend/dashboard/app/components/data/sampling_rate_input.tsx b/frontend/dashboard/app/components/data_rule/sampling_rate_input.tsx similarity index 100% rename from frontend/dashboard/app/components/data/sampling_rate_input.tsx rename to frontend/dashboard/app/components/data_rule/sampling_rate_input.tsx From 961a548674cf17d191ae0b3df9ac73daee5398c3 Mon Sep 17 00:00:00 2001 From: Abhay Sood Date: Wed, 5 Nov 2025 20:45:03 +0530 Subject: [PATCH 18/98] feat(frontend): add cel parser and generator --- .../utils/cel/cel_conversion_test.ts | 162 ++++ .../__tests__/utils/cel/cel_generator_test.ts | 765 ++++++++++++++++++ .../__tests__/utils/cel/cel_parser_test.ts | 335 ++++++++ .../{[filterId] => [ruleId]}/edit/page.tsx | 0 frontend/dashboard/app/[teamId]/data/page.tsx | 36 +- .../dashboard/app/utils/cel/cel_generator.ts | 321 ++++++++ .../dashboard/app/utils/cel/cel_parser.ts | 618 ++++++++++++++ .../dashboard/app/utils/cel/cel_tokenizer.ts | 314 +++++++ .../dashboard/app/utils/cel/conditions.ts | 41 + 9 files changed, 2574 insertions(+), 18 deletions(-) create mode 100644 frontend/dashboard/__tests__/utils/cel/cel_conversion_test.ts create mode 100644 frontend/dashboard/__tests__/utils/cel/cel_generator_test.ts create mode 100644 frontend/dashboard/__tests__/utils/cel/cel_parser_test.ts rename frontend/dashboard/app/[teamId]/data/event/{[filterId] => [ruleId]}/edit/page.tsx (100%) create mode 100644 frontend/dashboard/app/utils/cel/cel_generator.ts create mode 100644 frontend/dashboard/app/utils/cel/cel_parser.ts create mode 100644 frontend/dashboard/app/utils/cel/cel_tokenizer.ts create mode 100644 frontend/dashboard/app/utils/cel/conditions.ts diff --git a/frontend/dashboard/__tests__/utils/cel/cel_conversion_test.ts b/frontend/dashboard/__tests__/utils/cel/cel_conversion_test.ts new file mode 100644 index 000000000..f3f4e7052 --- /dev/null +++ b/frontend/dashboard/__tests__/utils/cel/cel_conversion_test.ts @@ -0,0 +1,162 @@ +import { conditionsToCel } from "@/app/utils/cel/cel_generator"; +import { celToConditions } from "@/app/utils/cel/cel_parser"; + +describe('CEL to conditions and back to CEL', () => { + + test('processes a single event condition', () => { + const cel = '(event_type == "anr")'; + const conditions = celToConditions(cel); + const outputCel = conditionsToCel({ event: conditions.event, trace: undefined, session: undefined }); + expect(outputCel).toBe(cel); + }); + + test('processes multiple event conditions with AND operator', () => { + const cel = '((event_type == "anr") && (event_type == "exception"))'; + const conditions = celToConditions(cel); + const outputCel = conditionsToCel({ event: conditions.event, trace: undefined, session: undefined }); + expect(outputCel).toBe(cel); + }); + + test('processes multiple event conditions with OR operator', () => { + const cel = '((event_type == "anr") || (event_type == "exception"))'; + const conditions = celToConditions(cel); + const outputCel = conditionsToCel({ event: conditions.event, trace: undefined, session: undefined }); + expect(outputCel).toBe(cel); + }); + + test('processes event with attribute', () => { + const cel = '(event_type == "exception" && exception.handled == false)'; + const conditions = celToConditions(cel); + const outputCel = conditionsToCel({ event: conditions.event, trace: undefined, session: undefined }); + expect(outputCel).toBe(cel); + }); + + test('processes event with attribute and ud-attribute', () => { + const cel = '(event_type == "custom" && custom.name == "login" && event.user_defined_attrs.is_premium_user == true)'; + const conditions = celToConditions(cel); + const outputCel = conditionsToCel({ event: conditions.event, trace: undefined, session: undefined }); + expect(outputCel).toBe(cel); + }); + + test('processes event with ud-attribute', () => { + const cel = '(event_type == "custom" && event.user_defined_attrs.is_premium_user == true)'; + const conditions = celToConditions(cel); + const outputCel = conditionsToCel({ event: conditions.event, trace: undefined, session: undefined }); + expect(outputCel).toBe(cel); + }); + + test('processes session attribute only', () => { + const cel = '(attribute.is_device_foldable == true)'; + const conditions = celToConditions(cel); + const outputCel = conditionsToCel({ event: undefined, trace: undefined, session: conditions.session, }); + expect(outputCel).toBe(cel); + }); + + test('processes multiple session attributes with AND operator', () => { + const cel = '((attribute.is_device_foldable == true) && (attribute.app_version == "1.0.0"))'; + const conditions = celToConditions(cel); + const outputCel = conditionsToCel({ event: undefined, trace: undefined, session: conditions.session, }); + expect(outputCel).toBe(cel); + }); + + test('processes multiple session attributes with OR operator', () => { + const cel = '((attribute.is_device_foldable == true) || (attribute.app_version == "1.0.0"))'; + const conditions = celToConditions(cel); + const outputCel = conditionsToCel({ event: undefined, trace: undefined, session: conditions.session, }); + expect(outputCel).toBe(cel); + }); + + test('processes combined event and session conditions', () => { + const cel = '(event_type == "anr") && (attribute.is_device_foldable == true)'; + const conditions = celToConditions(cel); + const outputCel = conditionsToCel({ event: conditions.event, trace: undefined, session: conditions.session }); + expect(outputCel).toBe(cel); + }); + + test('processes condition with contains operator', () => { + const cel = '(event_type == "custom" && custom.name.contains("log"))'; + const conditions = celToConditions(cel); + const outputCel = conditionsToCel({ event: conditions.event, trace: undefined, session: undefined }); + expect(outputCel).toBe(cel); + }); + + test('processes condition with startsWith operator', () => { + const cel = '(event_type == "custom" && custom.name.startsWith("log"))'; + const conditions = celToConditions(cel); + const outputCel = conditionsToCel({ event: conditions.event, trace: undefined, session: undefined }); + expect(outputCel).toBe(cel); + }); + + test('processes condition with gte operator', () => { + const cel = '(event_type == "custom" && custom.retry_cound >= 1)'; + const conditions = celToConditions(cel); + const outputCel = conditionsToCel({ event: conditions.event, trace: undefined, session: undefined }); + expect(outputCel).toBe(cel); + }); + + test('processes condition with lte operator', () => { + const cel = '(event_type == "custom" && custom.retry_cound <= 1)'; + const conditions = celToConditions(cel); + const outputCel = conditionsToCel({ event: conditions.event, trace: undefined, session: undefined }); + expect(outputCel).toBe(cel); + }); + + test('processes condition with lt operator', () => { + const cel = '(event_type == "custom" && custom.retry_cound < 1)'; + const conditions = celToConditions(cel); + const outputCel = conditionsToCel({ event: conditions.event, trace: undefined, session: undefined }); + expect(outputCel).toBe(cel); + }); + + test('processes condition with gt operator', () => { + const cel = '(event_type == "custom" && custom.retry_cound > 1)'; + const conditions = celToConditions(cel); + const outputCel = conditionsToCel({ event: conditions.event, trace: undefined, session: undefined }); + expect(outputCel).toBe(cel); + }); + + test('processes a span condition', () => { + const cel = '(span_name == "Activity TTID")'; + const conditions = celToConditions(cel); + const outputCel = conditionsToCel({ event: undefined, trace: conditions.trace , session: undefined }); + expect(outputCel).toBe(cel); + }); + + test('processes multiple span conditions with AND operator', () => { + const cel = '((span_name == "Activity TTID") && (span_name.startsWith("HTTP GET /config")))'; + const conditions = celToConditions(cel); + const outputCel = conditionsToCel({ event: undefined, trace: conditions.trace , session: undefined }); + expect(outputCel).toBe(cel); + }); + + test('processes multiple span conditions with OR operator', () => { + const cel = '((span_name == "Activity TTID") || (span_name.startsWith("HTTP GET /config")))'; + const conditions = celToConditions(cel); + const outputCel = conditionsToCel({ event: undefined, trace: conditions.trace , session: undefined }); + expect(outputCel).toBe(cel); + }); + + test('processes a span condition with user defined attributes', () => { + const cel = '(span_name == "Activity TTID" && trace.user_defined_attrs.api_level >= 21)'; + const conditions = celToConditions(cel); + + expect(conditions.trace).toBeDefined(); + + const outputCel = conditionsToCel({ event: undefined, trace: conditions.trace , session: undefined }); + expect(outputCel).toBe(cel); + }); + + test('processes a span condition with session conditions', () => { + const cel = '(span_name == "Activity TTID") && (attribute.is_device_foldable == true)'; + const conditions = celToConditions(cel); + const outputCel = conditionsToCel({ event: undefined, trace: conditions.trace, session: conditions.session }); + expect(outputCel).toBe(cel); + }); + + test('processes complex combined conditions', () => { + const cel = '((event_type == "anr") && (event_type == "exception")) && (span_name == "Activity TTID" && trace.user_defined_attrs.api_level >= 21) && (attribute.is_device_foldable == true)'; + const conditions = celToConditions(cel); + const outputCel = conditionsToCel({ event: conditions.event, trace: conditions.trace, session: conditions.session }); + expect(outputCel).toBe(cel); + }); +}); diff --git a/frontend/dashboard/__tests__/utils/cel/cel_generator_test.ts b/frontend/dashboard/__tests__/utils/cel/cel_generator_test.ts new file mode 100644 index 000000000..3f7c2a505 --- /dev/null +++ b/frontend/dashboard/__tests__/utils/cel/cel_generator_test.ts @@ -0,0 +1,765 @@ +import { conditionsToCel } from "@/app/utils/cel/cel_generator"; +import { ParsedConditions } from "@/app/utils/cel/cel_parser"; + +describe('CEL Generator', () => { + describe('CEL from event conditions', () => { + test('generates CEL for single event type condition', () => { + const conditions: ParsedConditions = { + event: { + conditions: [{ + id: 'cond1', + type: 'anr', + attrs: [], + ud_attrs: [], + session_attrs: [] + }], + operators: [] + } + }; + + + const result = conditionsToCel(conditions); + expect(result).toBe('(event_type == "anr")'); + }); + + test('generates CEL for event with standard attribute', () => { + const conditions: ParsedConditions = { + event: { + conditions: [{ + id: 'cond1', + type: 'exception', + attrs: [{ + id: 'attr1', + key: 'handled', + type: 'boolean', + value: false, + operator: 'eq' + }], + ud_attrs: [], + session_attrs: [] + }], + operators: [] + } + }; + + const result = conditionsToCel(conditions); + expect(result).toBe('(event_type == "exception" && exception.handled == false)'); + }); + + test('generates CEL for event with user-defined attribute', () => { + const conditions: ParsedConditions = { + event: { + conditions: [{ + id: 'cond1', + type: 'custom', + attrs: [], + ud_attrs: [ + { + id: 'ud_attr1', + key: 'user_tier', + type: 'string', + value: 'premium', + operator: 'eq' + } + ], + session_attrs: [], + }], + operators: [] + } + }; + + + const result = conditionsToCel(conditions); + expect(result).toBe('(event_type == "custom" && event.user_defined_attrs.user_tier == "premium")'); + }); + + test('generates CEL for event with both standard and user-defined attributes', () => { + const conditions: ParsedConditions = { + event: { + conditions: [{ + id: 'cond1', + type: 'exception', + attrs: [{ + id: 'attr1', + key: 'handled', + type: 'boolean', + value: false, + operator: 'eq' + }], + ud_attrs: [{ + id: 'ud_attr1', + key: 'user_tier', + type: 'string', + value: 'premium', + operator: 'eq' + }], + session_attrs: [] + }], + operators: [] + } + }; + + const result = conditionsToCel(conditions); + expect(result).toBe('(event_type == "exception" && exception.handled == false && event.user_defined_attrs.user_tier == "premium")'); + }); + + test('generates CEL for multiple event conditions with AND operator', () => { + const conditions: ParsedConditions = { + event: { + conditions: [{ + id: 'cond1', + type: 'exception', + attrs: [], + ud_attrs: [], + session_attrs: [], + + }, { + id: 'cond2', + type: 'anr', + attrs: [], + ud_attrs: [], + session_attrs: [], + }], + operators: ['AND'] + } + }; + + const result = conditionsToCel(conditions); + expect(result).toBe('((event_type == "exception") && (event_type == "anr"))'); + }); + + test('generates CEL for multiple event conditions with AND operator', () => { + const conditions: ParsedConditions = { + event: { + conditions: [{ + id: 'cond1', + type: 'exception', + attrs: [], + ud_attrs: [], + session_attrs: [], + }, { + id: 'cond2', + type: 'anr', + attrs: [], + ud_attrs: [], + session_attrs: [], + }], + operators: ['OR'] + } + }; + + const result = conditionsToCel(conditions); + expect(result).toBe('((event_type == "exception") || (event_type == "anr"))'); + }); + + test('generates CEL with string contains operator', () => { + const conditions: ParsedConditions = { + event: { + conditions: [{ + id: 'cond1', + type: 'custom', + attrs: [{ + id: 'attr1', + key: 'name', + type: 'string', + value: 'abc', + operator: 'contains' + }], + ud_attrs: [], + session_attrs: [], + }], + operators: [] + } + }; + + const result = conditionsToCel(conditions); + expect(result).toBe('(event_type == "custom" && custom.name.contains("abc"))'); + }); + + test('generates CEL with string startsWith operator', () => { + const conditions: ParsedConditions = { + event: { + conditions: [{ + id: 'cond1', + type: 'custom', + attrs: [{ + id: 'attr1', + key: 'name', + type: 'string', + value: 'prefix_', + operator: 'startsWith' + }], + ud_attrs: [], + session_attrs: [], + }], + operators: [] + } + }; + + const result = conditionsToCel(conditions); + expect(result).toBe('(event_type == "custom" && custom.name.startsWith("prefix_"))'); + }); + + test('generates CEL with numeric comparison operators', () => { + const conditions: ParsedConditions = { + event: { + conditions: [{ + id: 'cond1', + type: 'custom', + attrs: [ + { id: 'attr1', key: 'count', type: 'number', value: 5, operator: 'gt' }, + { id: 'attr2', key: 'limit', type: 'number', value: 10, operator: 'lte' } + ], + ud_attrs: [], + session_attrs: [], + }], + operators: [] + } + }; + + const result = conditionsToCel(conditions); + expect(result).toBe('(event_type == "custom" && custom.count > 5 && custom.limit <= 10)'); + }); + }); + + describe('CEL from session conditions', () => { + test('generates CEL for single session attribute', () => { + const conditions: ParsedConditions = { + session: { + conditions: [{ + id: 'cond1', + attrs: [{ + id: 'attr1', + key: 'is_device_foldable', + type: 'boolean', + value: true, + operator: 'eq' + }] + }], + operators: [] + } + }; + + const result = conditionsToCel(conditions); + expect(result).toBe('(attribute.is_device_foldable == true)'); + }); + + test('generates CEL for multiple session attributes with AND operator', () => { + const conditions: ParsedConditions = { + session: { + conditions: [ + { + id: 'cond1', + attrs: [{ + id: 'attr1', + key: 'is_device_foldable', + type: 'boolean', + value: true, + operator: 'eq' + }] + }, + { + id: 'cond2', + attrs: [{ + id: 'attr2', + key: 'app_version', + type: 'string', + value: '1.0.0', + operator: 'eq' + }] + } + ], + operators: ['AND'] + } + }; + + const result = conditionsToCel(conditions); + expect(result).toBe('((attribute.is_device_foldable == true) && (attribute.app_version == "1.0.0"))'); + }); + + test('generates CEL for session attribute with string operators', () => { + const conditions: ParsedConditions = { + session: { + conditions: [{ + id: 'cond1', + attrs: [{ + id: 'attr1', + key: 'device_model', + type: 'string', + value: 'Samsung', + operator: 'startsWith' + }] + }], + operators: [] + } + }; + + const result = conditionsToCel(conditions); + expect(result).toBe('(attribute.device_model.startsWith("Samsung"))'); + }); + }); + + describe('Trace Conditions', () => { + test('generates CEL for single span name condition', () => { + const conditions: ParsedConditions = { + trace: { + conditions: [{ + id: 'cond1', + spanName: 'Activity TTID', + operator: 'eq', + ud_attrs: [], + session_attrs: [], + }], + operators: [] + } + }; + + const result = conditionsToCel(conditions); + expect(result).toBe('(span_name == "Activity TTID")'); + }); + + test('generates CEL for span name with string contains operator', () => { + const conditions: ParsedConditions = { + trace: { + conditions: [{ + id: 'cond1', + spanName: 'HTTP', + operator: 'contains', + ud_attrs: [], + session_attrs: [], + }], + operators: [] + } + }; + + const result = conditionsToCel(conditions); + expect(result).toBe('(span_name.contains("HTTP"))'); + }); + + test('generates CEL for span name with string startsWith operator', () => { + const conditions: ParsedConditions = { + trace: { + conditions: [{ + id: 'cond1', + spanName: 'HTTP GET', + operator: 'startsWith', + ud_attrs: [], + session_attrs: [], + }], + operators: [] + } + }; + + const result = conditionsToCel(conditions); + expect(result).toBe('(span_name.startsWith("HTTP GET"))'); + }); + + test('generates CEL for span with user-defined attributes', () => { + const conditions: ParsedConditions = { + trace: { + conditions: [{ + id: 'cond1', + spanName: 'Activity TTID', + operator: 'eq', + ud_attrs: [{ + id: 'ud_attr1', + key: 'api_level', + type: 'number', + value: 21, + operator: 'gte' + }], + session_attrs: [], + }], + operators: [] + } + }; + + const result = conditionsToCel(conditions); + expect(result).toBe('(span_name == "Activity TTID" && trace.user_defined_attrs.api_level >= 21)'); + }); + + test('generates CEL for span with multiple user-defined attributes', () => { + const conditions: ParsedConditions = { + trace: { + conditions: [{ + id: 'cond1', + spanName: 'HTTP Request', + operator: 'eq', + ud_attrs: [ + { + id: 'ud_attr1', + key: 'status_code', + type: 'number', + value: 200, + operator: 'eq' + }, + { + id: 'ud_attr2', + key: 'method', + type: 'string', + value: 'GET', + operator: 'eq' + } + ], + session_attrs: [], + }], + operators: [] + } + }; + + const result = conditionsToCel(conditions); + expect(result).toBe('(span_name == "HTTP Request" && trace.user_defined_attrs.status_code == 200 && trace.user_defined_attrs.method == "GET")'); + }); + + test('generates CEL for multiple trace conditions with AND operator', () => { + const conditions: ParsedConditions = { + trace: { + conditions: [ + { + id: 'cond1', + spanName: 'Activity TTID', + operator: 'eq', + ud_attrs: [], + session_attrs: [], + }, + { + id: 'cond2', + spanName: 'HTTP GET', + operator: 'startsWith', + ud_attrs: [], + session_attrs: [], + } + ], + operators: ['AND'] + } + }; + + const result = conditionsToCel(conditions); + expect(result).toBe('((span_name == "Activity TTID") && (span_name.startsWith("HTTP GET")))'); + }); + + test('generates CEL for multiple trace conditions with OR operator', () => { + const conditions: ParsedConditions = { + trace: { + conditions: [ + { + id: 'cond1', + spanName: 'Activity TTID', + operator: 'eq', + ud_attrs: [], + session_attrs: [], + }, + { + id: 'cond2', + spanName: 'HTTP GET /config', + operator: 'startsWith', + ud_attrs: [], + session_attrs: [], + } + ], + operators: ['OR'] + } + }; + + const result = conditionsToCel(conditions); + expect(result).toBe('((span_name == "Activity TTID") || (span_name.startsWith("HTTP GET /config")))'); + }); + + test('generates CEL for user-defined attributes with string operators', () => { + const conditions: ParsedConditions = { + trace: { + conditions: [{ + id: 'cond1', + spanName: 'Database Query', + operator: 'eq', + ud_attrs: [ + { + id: 'ud_attr1', + key: 'table_name', + type: 'string', + value: 'user', + operator: 'contains' + }, + { + id: 'ud_attr2', + key: 'query_type', + type: 'string', + value: 'SELECT', + operator: 'startsWith' + } + ], + session_attrs: [], + }], + operators: [] + } + }; + + const result = conditionsToCel(conditions); + expect(result).toBe('(span_name == "Database Query" && trace.user_defined_attrs.table_name.contains("user") && trace.user_defined_attrs.query_type.startsWith("SELECT"))'); + }); + }); + + describe('CEL from event conditions with session_attrs', () => { + test('generates CEL for event with session_attrs', () => { + const conditions: ParsedConditions = { + event: { + conditions: [{ + id: 'cond1', + type: 'exception', + attrs: [{ + id: 'attr1', + key: 'handled', + type: 'boolean', + value: false, + operator: 'eq' + }], + ud_attrs: [], + session_attrs: [{ + id: 'session1', + key: 'is_device_foldable', + type: 'boolean', + value: true, + operator: 'eq' + }] + }], + operators: [] + } + }; + + const result = conditionsToCel(conditions); + expect(result).toBe('(event_type == "exception" && exception.handled == false && attribute.is_device_foldable == true)'); + }); + + test('generates CEL for event with multiple session_attrs', () => { + const conditions: ParsedConditions = { + event: { + conditions: [{ + id: 'cond1', + type: 'anr', + attrs: [], + ud_attrs: [], + session_attrs: [ + { + id: 'session1', + key: 'app_version', + type: 'string', + value: '1.0.0', + operator: 'eq' + }, + { + id: 'session2', + key: 'device_manufacturer', + type: 'string', + value: 'Samsung', + operator: 'startsWith' + } + ] + }], + operators: [] + } + }; + + const result = conditionsToCel(conditions); + expect(result).toBe('(event_type == "anr" && attribute.app_version == "1.0.0" && attribute.device_manufacturer.startsWith("Samsung"))'); + }); + + test('generates CEL for event with attrs, ud_attrs, and session_attrs', () => { + const conditions: ParsedConditions = { + event: { + conditions: [{ + id: 'cond1', + type: 'custom', + attrs: [{ + id: 'attr1', + key: 'name', + type: 'string', + value: 'login', + operator: 'eq' + }], + ud_attrs: [{ + id: 'ud1', + key: 'user_tier', + type: 'string', + value: 'premium', + operator: 'eq' + }], + session_attrs: [{ + id: 'session1', + key: 'platform', + type: 'string', + value: 'android', + operator: 'eq' + }] + }], + operators: [] + } + }; + + const result = conditionsToCel(conditions); + expect(result).toBe('(event_type == "custom" && custom.name == "login" && event.user_defined_attrs.user_tier == "premium" && attribute.platform == "android")'); + }); + }); + + describe('CEL from trace conditions with session_attrs', () => { + test('generates CEL for trace with session_attrs', () => { + const conditions: ParsedConditions = { + trace: { + conditions: [{ + id: 'cond1', + spanName: 'Activity TTID', + operator: 'eq', + ud_attrs: [], + session_attrs: [{ + id: 'session1', + key: 'is_device_foldable', + type: 'boolean', + value: true, + operator: 'eq' + }] + }], + operators: [] + } + }; + + const result = conditionsToCel(conditions); + expect(result).toBe('(span_name == "Activity TTID" && attribute.is_device_foldable == true)'); + }); + + test('generates CEL for trace with ud_attrs and session_attrs', () => { + const conditions: ParsedConditions = { + trace: { + conditions: [{ + id: 'cond1', + spanName: 'HTTP Request', + operator: 'contains', + ud_attrs: [{ + id: 'ud1', + key: 'api_level', + type: 'number', + value: 21, + operator: 'gte' + }], + session_attrs: [{ + id: 'session1', + key: 'app_version', + type: 'string', + value: '2.0', + operator: 'startsWith' + }] + }], + operators: [] + } + }; + + const result = conditionsToCel(conditions); + expect(result).toBe('(span_name.contains("HTTP Request") && trace.user_defined_attrs.api_level >= 21 && attribute.app_version.startsWith("2.0"))'); + }); + + test('generates CEL for trace with multiple session_attrs', () => { + const conditions: ParsedConditions = { + trace: { + conditions: [{ + id: 'cond1', + spanName: 'Database Query', + operator: 'eq', + ud_attrs: [], + session_attrs: [ + { + id: 'session1', + key: 'device_model', + type: 'string', + value: 'Pixel', + operator: 'contains' + }, + { + id: 'session2', + key: 'os_version', + type: 'number', + value: 13, + operator: 'gte' + } + ] + }], + operators: [] + } + }; + + const result = conditionsToCel(conditions); + expect(result).toBe('(span_name == "Database Query" && attribute.device_model.contains("Pixel") && attribute.os_version >= 13)'); + }); + }); + + describe('CEL from combined conditions', () => { + test('generates CEL for event and session conditions', () => { + const conditions: ParsedConditions = { + event: { + conditions: [{ + id: 'cond1', + type: 'anr', + attrs: [], + ud_attrs: [], + session_attrs: [], + }], + operators: [] + }, + session: { + conditions: [{ + id: 'cond2', + attrs: [{ + id: 'attr1', + key: 'is_device_foldable', + type: 'boolean', + value: true, + operator: 'eq' + }] + }], + operators: [] + } + }; + + const result = conditionsToCel(conditions); + expect(result).toBe('(event_type == "anr") && (attribute.is_device_foldable == true)'); + }); + + test('generates CEL for event, trace, and session conditions', () => { + const conditions: ParsedConditions = { + event: { + conditions: [{ + id: 'cond1', + type: 'custom', + attrs: [], + ud_attrs: [], + session_attrs: [], + }], + operators: [] + }, + trace: { + conditions: [{ + id: 'cond2', + spanName: 'Activity TTID', + operator: 'eq', + ud_attrs: [], + session_attrs: [], + }], + operators: [] + }, + session: { + conditions: [{ + id: 'cond3', + attrs: [{ + id: 'attr1', + key: 'platform', + type: 'string', + value: 'android', + operator: 'eq' + }] + }], + operators: [] + } + }; + + const result = conditionsToCel(conditions); + expect(result).toBe('(event_type == "custom") && (span_name == "Activity TTID") && (attribute.platform == "android")'); + }); + }); +}); \ No newline at end of file diff --git a/frontend/dashboard/__tests__/utils/cel/cel_parser_test.ts b/frontend/dashboard/__tests__/utils/cel/cel_parser_test.ts new file mode 100644 index 000000000..f0d9a5f62 --- /dev/null +++ b/frontend/dashboard/__tests__/utils/cel/cel_parser_test.ts @@ -0,0 +1,335 @@ +import { celToConditions } from "@/app/utils/cel/cel_parser"; + +describe('CEL Parser', () => { + test('parses single event type condition', () => { + const cel = '(event_type == "anr")'; + const conditions = celToConditions(cel); + + expect(conditions.event).toBeDefined(); + expect(conditions.event!.conditions).toHaveLength(1); + expect(conditions.event!.conditions[0]).toMatchObject({ + type: 'anr', + attrs: [], + ud_attrs: [] + }); + expect(conditions.event!.operators).toHaveLength(0); + expect(conditions.session).toBeUndefined(); + expect(conditions.trace).toBeUndefined(); + }); + + test('parses multiple event conditions with AND operator', () => { + const cel = '((event_type == "anr") && (event_type == "exception"))'; + const conditions = celToConditions(cel); + + expect(conditions.event).toBeDefined(); + expect(conditions.event!.conditions).toHaveLength(2); + expect(conditions.event!.conditions[0].type).toBe('anr'); + expect(conditions.event!.conditions[1].type).toBe('exception'); + expect(conditions.event!.operators).toEqual(['AND']); + }); + + test('parses multiple event conditions with OR operator', () => { + const cel = '((event_type == "anr") || (event_type == "exception"))'; + const conditions = celToConditions(cel); + + expect(conditions.event).toBeDefined(); + expect(conditions.event!.conditions).toHaveLength(2); + expect(conditions.event!.conditions[0].type).toBe('anr'); + expect(conditions.event!.conditions[1].type).toBe('exception'); + expect(conditions.event!.operators).toEqual(['OR']); + }); + + test('parses event with attribute', () => { + const cel = '(event_type == "exception" && exception.handled == false)'; + const conditions = celToConditions(cel); + + expect(conditions.event).toBeDefined(); + expect(conditions.event!.conditions).toHaveLength(1); + + const condition = conditions.event!.conditions[0]; + expect(condition.type).toBe('exception'); + expect(condition.attrs).toHaveLength(1); + expect(condition.attrs![0]).toMatchObject({ + key: 'handled', + type: 'bool', + value: false, + operator: 'eq' + }); + }); + + test('parses event with user-defined attribute', () => { + const cel = '(event_type == "custom" && event.user_defined_attrs.is_premium_user == true)'; + const conditions = celToConditions(cel); + + expect(conditions.event).toBeDefined(); + expect(conditions.event!.conditions).toHaveLength(1); + + const condition = conditions.event!.conditions[0]; + expect(condition.type).toBe('custom'); + expect(condition.ud_attrs).toHaveLength(1); + expect(condition.ud_attrs![0]).toMatchObject({ + key: 'is_premium_user', + type: 'bool', + value: true, + operator: 'eq' + }); + }); + + test('parses event with both regular and user-defined attributes', () => { + const cel = '(event_type == "custom" && custom.name == "login" && event.user_defined_attrs.is_premium_user == true)'; + const conditions = celToConditions(cel); + + expect(conditions.event).toBeDefined(); + expect(conditions.event!.conditions).toHaveLength(1); + + const condition = conditions.event!.conditions[0]; + expect(condition.type).toBe('custom'); + expect(condition.attrs).toHaveLength(1); + expect(condition.attrs![0]).toMatchObject({ + key: 'name', + type: 'string', + value: 'login', + operator: 'eq' + }); + expect(condition.ud_attrs).toHaveLength(1); + expect(condition.ud_attrs![0]).toMatchObject({ + key: 'is_premium_user', + type: 'bool', + value: true, + operator: 'eq' + }); + }); + + test('parses session attribute condition', () => { + const cel = '(attribute.is_device_foldable == true)'; + const conditions = celToConditions(cel); + + expect(conditions.session).toBeDefined(); + expect(conditions.session!.conditions).toHaveLength(1); + expect(conditions.session!.conditions[0].attrs).toHaveLength(1); + expect(conditions.session!.conditions[0].attrs[0]).toMatchObject({ + key: 'is_device_foldable', + type: 'bool', + value: true, + operator: 'eq' + }); + expect(conditions.event).toBeUndefined(); + expect(conditions.trace).toBeUndefined(); + }); + + test('parses multiple session attributes with AND operator', () => { + const cel = '((attribute.is_device_foldable == true) && (attribute.app_version == "1.0.0"))'; + const conditions = celToConditions(cel); + + expect(conditions.session).toBeDefined(); + expect(conditions.session!.conditions).toHaveLength(2); + expect(conditions.session!.operators).toEqual(['AND']); + expect(conditions.session!.conditions[0].attrs[0].key).toBe('is_device_foldable'); + expect(conditions.session!.conditions[1].attrs[0].key).toBe('app_version'); + }); + + test('parses multiple session attributes with OR operator', () => { + const cel = '((attribute.is_device_foldable == true) || (attribute.app_version == "1.0.0"))'; + const conditions = celToConditions(cel); + + expect(conditions.session).toBeDefined(); + expect(conditions.session!.conditions).toHaveLength(2); + expect(conditions.session!.operators).toEqual(['OR']); + }); + + test('parses span name condition', () => { + const cel = '(span_name == "Activity TTID")'; + const conditions = celToConditions(cel); + + expect(conditions.trace).toBeDefined(); + expect(conditions.trace!.conditions).toHaveLength(1); + expect(conditions.trace!.conditions[0]).toMatchObject({ + spanName: 'Activity TTID', + operator: 'eq', + ud_attrs: [] + }); + expect(conditions.event).toBeUndefined(); + expect(conditions.session).toBeUndefined(); + }); + + test('parses multiple span conditions with AND operator', () => { + const cel = '((span_name == "Activity TTID") && (span_name.startsWith("HTTP GET /config")))'; + const conditions = celToConditions(cel); + + expect(conditions.trace).toBeDefined(); + expect(conditions.trace!.conditions).toHaveLength(2); + expect(conditions.trace!.operators).toEqual(['AND']); + expect(conditions.trace!.conditions[0].spanName).toBe('Activity TTID'); + expect(conditions.trace!.conditions[1].spanName).toBe('HTTP GET /config'); + expect(conditions.trace!.conditions[1].operator).toBe('startsWith'); + }); + + test('parses span condition with user-defined attributes', () => { + const cel = '(span_name == "Activity TTID" && trace.user_defined_attrs.api_level >= 21)'; + const conditions = celToConditions(cel); + + expect(conditions.trace).toBeDefined(); + expect(conditions.trace!.conditions).toHaveLength(1); + + const condition = conditions.trace!.conditions[0]; + expect(condition.spanName).toBe('Activity TTID'); + expect(condition.ud_attrs).toHaveLength(1); + expect(condition.ud_attrs![0]).toMatchObject({ + key: 'api_level', + type: 'number', + value: 21, + operator: 'gte' + }); + }); + + test('parses combined event and session conditions', () => { + const cel = '(event_type == "anr") && (attribute.is_device_foldable == true)'; + const conditions = celToConditions(cel); + + expect(conditions.event).toBeDefined(); + expect(conditions.session).toBeDefined(); + + expect(conditions.event!.conditions).toHaveLength(1); + expect(conditions.event!.conditions[0].type).toBe('anr'); + + expect(conditions.session!.conditions).toHaveLength(1); + expect(conditions.session!.conditions[0].attrs[0].key).toBe('is_device_foldable'); + }); + + test('parses condition with contains operator', () => { + const cel = '(event_type == "custom" && custom.name.contains("log"))'; + const conditions = celToConditions(cel); + + expect(conditions.event).toBeDefined(); + const condition = conditions.event!.conditions[0]; + expect(condition.attrs![0]).toMatchObject({ + key: 'name', + type: 'string', + value: 'log', + operator: 'contains' + }); + }); + + test('parses condition with startsWith operator', () => { + const cel = '(event_type == "custom" && custom.name.startsWith("log"))'; + const conditions = celToConditions(cel); + + expect(conditions.event).toBeDefined(); + const condition = conditions.event!.conditions[0]; + expect(condition.attrs![0]).toMatchObject({ + key: 'name', + type: 'string', + value: 'log', + operator: 'startsWith' + }); + }); + + test('parses condition with numeric comparison operators', () => { + const testCases = [ + { cel: '(event_type == "custom" && custom.retry_count >= 1)', operator: 'gte' }, + { cel: '(event_type == "custom" && custom.retry_count <= 1)', operator: 'lte' }, + { cel: '(event_type == "custom" && custom.retry_count < 1)', operator: 'lt' }, + { cel: '(event_type == "custom" && custom.retry_count > 1)', operator: 'gt' }, + { cel: '(event_type == "custom" && custom.retry_count != 1)', operator: 'neq' } + ]; + + testCases.forEach(({ cel, operator }) => { + const conditions = celToConditions(cel); + expect(conditions.event).toBeDefined(); + const condition = conditions.event!.conditions[0]; + expect(condition.attrs![0]).toMatchObject({ + key: 'retry_count', + type: 'number', + value: 1, + operator + }); + }); + }); + + test('parses complex combined conditions', () => { + const cel = '((event_type == "anr") && (event_type == "exception")) && (span_name == "Activity TTID" && trace.user_defined_attrs.api_level >= 21) && (attribute.is_device_foldable == true)'; + const conditions = celToConditions(cel); + + // Event conditions + expect(conditions.event).toBeDefined(); + expect(conditions.event!.conditions).toHaveLength(2); + expect(conditions.event!.operators).toEqual(['AND']); + expect(conditions.event!.conditions[0].type).toBe('anr'); + expect(conditions.event!.conditions[1].type).toBe('exception'); + + // Trace conditions + expect(conditions.trace).toBeDefined(); + expect(conditions.trace!.conditions).toHaveLength(1); + expect(conditions.trace!.conditions[0].spanName).toBe('Activity TTID'); + expect(conditions.trace!.conditions[0].ud_attrs).toHaveLength(1); + + // Session conditions + expect(conditions.session).toBeDefined(); + expect(conditions.session!.conditions).toHaveLength(1); + expect(conditions.session!.conditions[0].attrs[0].key).toBe('is_device_foldable'); + }); + + test('parses condition with null value', () => { + const cel = '(event_type == "custom" && custom.value == null)'; + const conditions = celToConditions(cel); + + expect(conditions.event).toBeDefined(); + const condition = conditions.event!.conditions[0]; + expect(condition.attrs![0]).toMatchObject({ + key: 'value', + type: 'null', + value: null, + operator: 'eq' + }); + }); + + test('parses condition with string value', () => { + const cel = '(event_type == "custom" && custom.message == "hello world")'; + const conditions = celToConditions(cel); + + expect(conditions.event).toBeDefined(); + const condition = conditions.event!.conditions[0]; + expect(condition.attrs![0]).toMatchObject({ + key: 'message', + type: 'string', + value: 'hello world', + operator: 'eq' + }); + }); + + test('parses condition with number value', () => { + const cel = '(event_type == "custom" && custom.count == 42)'; + const conditions = celToConditions(cel); + + expect(conditions.event).toBeDefined(); + const condition = conditions.event!.conditions[0]; + expect(condition.attrs![0]).toMatchObject({ + key: 'count', + type: 'number', + value: 42, + operator: 'eq' + }); + }); + + test('handles whitespace in expression', () => { + const cel = ' ( event_type == "anr" ) '; + const conditions = celToConditions(cel); + + expect(conditions.event).toBeDefined(); + expect(conditions.event!.conditions[0].type).toBe('anr'); + }); + + test('returns empty result for invalid expression', () => { + const cel = 'invalid expression'; + const conditions = celToConditions(cel); + + expect(conditions).toEqual({}); + }); + + test('returns empty result for malformed parentheses', () => { + const cel = '(event_type == "anr"'; + const conditions = celToConditions(cel); + + expect(conditions).toEqual({}); + }); +}); \ No newline at end of file diff --git a/frontend/dashboard/app/[teamId]/data/event/[filterId]/edit/page.tsx b/frontend/dashboard/app/[teamId]/data/event/[ruleId]/edit/page.tsx similarity index 100% rename from frontend/dashboard/app/[teamId]/data/event/[filterId]/edit/page.tsx rename to frontend/dashboard/app/[teamId]/data/event/[ruleId]/edit/page.tsx diff --git a/frontend/dashboard/app/[teamId]/data/page.tsx b/frontend/dashboard/app/[teamId]/data/page.tsx index e2ff801a2..b0c0ef970 100644 --- a/frontend/dashboard/app/[teamId]/data/page.tsx +++ b/frontend/dashboard/app/[teamId]/data/page.tsx @@ -9,14 +9,14 @@ import { useEffect, useState } from 'react' import { Button } from '@/app/components/button' import { Plus, Pencil } from 'lucide-react' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/app/components/dropdown_menu' -import EditDefaultRuleDialog, { DefaultRuleState } from '@/app/components/data_rule/edit_default_rule_dialog' +import EditDefaultRuleDialog, { DefaultRuleState as DefaultRuleEditState } from '@/app/components/data_rule/edit_default_rule_dialog' import RulesTable from '@/app/components/data_rule/rule_overrides_table' interface PageState { - dataFiltersApiStatus: DataRulesApiStatus + dataRulesApiStatus: DataRulesApiStatus filters: typeof defaultFilters - dataFilters: DataRulesResponse - defaultRuleEditState: DefaultRuleState | null + dataRules: DataRulesResponse + defaultRuleEditState: DefaultRuleEditState | null } const isDefaultRule = (type: DataRuleType): boolean => { @@ -40,9 +40,9 @@ export default function DataFilters({ params }: { params: { teamId: string } }) const router = useRouter() const initialState: PageState = { - dataFiltersApiStatus: DataRulesApiStatus.Success, + dataRulesApiStatus: DataRulesApiStatus.Success, filters: defaultFilters, - dataFilters: emptyDataFiltersResponse, + dataRules: emptyDataFiltersResponse, defaultRuleEditState: null, } @@ -56,21 +56,21 @@ export default function DataFilters({ params }: { params: { teamId: string } }) } const getDataFilters = async () => { - updatePageState({ dataFiltersApiStatus: DataRulesApiStatus.Loading }) + updatePageState({ dataRulesApiStatus: DataRulesApiStatus.Loading }) const result = await fetchDataFiltersFromServer(pageState.filters.app!.id) switch (result.status) { case DataRulesApiStatus.Error: - updatePageState({ dataFiltersApiStatus: DataRulesApiStatus.Error }) + updatePageState({ dataRulesApiStatus: DataRulesApiStatus.Error }) break case DataRulesApiStatus.NoFilters: - updatePageState({ dataFiltersApiStatus: DataRulesApiStatus.NoFilters }) + updatePageState({ dataRulesApiStatus: DataRulesApiStatus.NoFilters }) break case DataRulesApiStatus.Success: updatePageState({ - dataFiltersApiStatus: DataRulesApiStatus.Success, - dataFilters: result.data + dataRulesApiStatus: DataRulesApiStatus.Success, + dataRules: result.data }) break } @@ -81,7 +81,7 @@ export default function DataFilters({ params }: { params: { teamId: string } }) if (pageState.filters.ready !== updatedFilters.ready || pageState.filters.serialisedFilters !== updatedFilters.serialisedFilters) { updatePageState({ filters: updatedFilters, - dataFilters: emptyDataFiltersResponse, + dataRules: emptyDataFiltersResponse, }) } } @@ -98,10 +98,10 @@ export default function DataFilters({ params }: { params: { teamId: string } }) // getDataFilters() }, [pageState.filters]) - const defaultRules = pageState.dataFilters.results.filter(df => isDefaultRule(df.type)) + const defaultRules = pageState.dataRules.results.filter(df => isDefaultRule(df.type)) const allEventsFilter = defaultRules.find(df => df.type === 'all_events') const allTracesFilter = defaultRules.find(df => df.type === 'all_traces') - const overrideFilters = pageState.dataFilters.results.filter(df => !isDefaultRule(df.type)) + const overrideFilters = pageState.dataRules.results.filter(df => !isDefaultRule(df.type)) const eventFilters = overrideFilters.filter(df => df.type === 'event') const traceFilters = overrideFilters.filter(df => df.type === 'trace') @@ -138,7 +138,7 @@ export default function DataFilters({ params }: { params: { teamId: string } }) @@ -183,14 +183,14 @@ export default function DataFilters({ params }: { params: { teamId: string } }) {/* Error state for data rules fetch */} {pageState.filters.ready - && pageState.dataFiltersApiStatus === DataRulesApiStatus.Error + && pageState.dataRulesApiStatus === DataRulesApiStatus.Error &&

Error fetching data filters, please change filters, refresh page or select a different app to try again

} {/* Main data rules UI */} {pageState.filters.ready - && (pageState.dataFiltersApiStatus === DataRulesApiStatus.Success || pageState.dataFiltersApiStatus === DataRulesApiStatus.Loading) && + && (pageState.dataRulesApiStatus === DataRulesApiStatus.Success || pageState.dataRulesApiStatus === DataRulesApiStatus.Loading) &&
-
+
diff --git a/frontend/dashboard/app/utils/cel/cel_generator.ts b/frontend/dashboard/app/utils/cel/cel_generator.ts new file mode 100644 index 000000000..cffa989ab --- /dev/null +++ b/frontend/dashboard/app/utils/cel/cel_generator.ts @@ -0,0 +1,321 @@ +/** + * Converts UI state conditions into Common + * Expression Language (CEL) strings. + * + * This is the reverse of the CEL parser, used to + * regenerate CEL expressions for filtering and + * evaluation. + */ + +import { EventCondition, AttributeCondition, TraceCondition, EventConditions, SessionConditions, TraceConditions } from "./conditions" +import { ParsedConditions } from "./cel_parser" + +const OPERATOR_MAPPINGS = { + eq: '==', + neq: '!=', + gt: '>', + lt: '<', + gte: '>=', + lte: '<=', + contains: 'contains', + startsWith: 'startsWith' +} as const + +const LOGICAL_OPERATOR_MAPPINGS = { + AND: '&&', + OR: '||' +} as const + +/** + * Formats a value for CEL syntax, wrapping strings in quotes. + */ +function formatValueForCel(value: string | boolean | number, type: string): string { + return type === 'string' ? `"${value}"` : String(value) +} + +/** + * Creates a CEL expression for an event type. + * Example output: 'event_type == "anr"' + */ +function createEventTypeCondition(eventType: string): string { + return `event_type == "${eventType}"` +} + +/** + * Creates a CEL expression for a standard event attribute. + * Example output: 'exception.handled == false' + */ +function createEventAttributeCondition( + attribute: { + key: string; + type: string; + value: string | boolean | number; + operator?: string + }, + eventType: string +): string { + const operator = OPERATOR_MAPPINGS[attribute.operator as keyof typeof OPERATOR_MAPPINGS] || '==' + const formattedValue = formatValueForCel(attribute.value, attribute.type) + const fullKey = `${eventType}.${attribute.key}` + + if (attribute.type === 'string' && (operator === 'contains' || operator === 'startsWith')) { + return `${fullKey}.${operator}(${formattedValue})` + } + + return `${fullKey} ${operator} ${formattedValue}` +} + +/** + * Creates a CEL expression for a user-defined event attribute. + * Example output: 'event.user_defined_attrs.is_premium == true' + */ +function createUserDefinedEventAttributeCondition(attribute: { + key: string; + type: string; + value: string | boolean | number; + operator?: string +}): string { + const operator = OPERATOR_MAPPINGS[attribute.operator as keyof typeof OPERATOR_MAPPINGS] || '==' + const formattedValue = formatValueForCel(attribute.value, attribute.type) + const fullKey = `event.user_defined_attrs.${attribute.key}` + + if (attribute.type === 'string' && (operator === 'contains' || operator === 'startsWith')) { + return `${fullKey}.${operator}(${formattedValue})` + } + + return `${fullKey} ${operator} ${formattedValue}` +} + +/** + * Creates a CEL expression for a session attribute. + * Example output: 'attribute.session_duration > 300' + */ +function createSessionAttributeCondition(attribute: { + key: string; + type: string; + value: string | boolean | number; + operator?: string +}): string { + const operator = OPERATOR_MAPPINGS[attribute.operator as keyof typeof OPERATOR_MAPPINGS] || '==' + const formattedValue = formatValueForCel(attribute.value, attribute.type) + + if (attribute.type === 'string' && (operator === 'contains' || operator === 'startsWith')) { + return `attribute.${attribute.key}.${operator}(${formattedValue})` + } + + return `attribute.${attribute.key} ${operator} ${formattedValue}` +} + +/** + * Creates a CEL expression for a span name. + * Example output: 'span_name.contains("HTTP")' + */ +function createSpanNameCondition(spanName: string, operator: string): string { + const formattedValue = formatValueForCel(spanName, 'string') + + if (operator === 'contains' || operator === 'startsWith') { + return `span_name.${operator}(${formattedValue})` + } + + const celOperator = OPERATOR_MAPPINGS[operator as keyof typeof OPERATOR_MAPPINGS] || '==' + return `span_name ${celOperator} ${formattedValue}` +} + +/** + * Creates a CEL expression for a span's user-defined attribute. + * Example output: 'trace.user_defined_attrs.is_critical == true' + */ +function createSpanUserDefinedAttributeCondition(attribute: { + key: string; + type: string; + value: string | boolean | number; + operator?: string +}): string { + const operator = OPERATOR_MAPPINGS[attribute.operator as keyof typeof OPERATOR_MAPPINGS] || '==' + const formattedValue = formatValueForCel(attribute.value, attribute.type) + const fullKey = `trace.user_defined_attrs.${attribute.key}` + + if (attribute.type === 'string' && (operator === 'contains' || operator === 'startsWith')) { + return `${fullKey}.${operator}(${formattedValue})` + } + + return `${fullKey} ${operator} ${formattedValue}` +} + +/** + * Converts a single event condition object into an + * array of its constituent CEL parts. + * Example output: ['event_type == "exception"', 'exception.handled == false'] + */ +function buildEventConditionParts(condition: EventCondition): string[] { + const parts: string[] = [] + + if (condition.type) { + parts.push(createEventTypeCondition(condition.type)) + } + + condition.attrs?.forEach(attr => + parts.push(createEventAttributeCondition(attr, condition.type)) + ) + + condition.ud_attrs?.forEach(attr => + parts.push(createUserDefinedEventAttributeCondition(attr)) + ) + + condition.session_attrs?.forEach(attr => + parts.push(createSessionAttributeCondition(attr)) + ) + + return parts +} + +/** + * Converts a single session condition object into an + * array of its constituent CEL parts. + * Example output: ['attribute.session_duration > 300', 'attribute.user_country == "US"'] + */ +function buildSessionConditionParts(condition: AttributeCondition): string[] { + return condition.attrs?.map(createSessionAttributeCondition) || [] +} + +/** + * Converts a single trace condition object into an + * array of its constituent CEL parts. + * Example output: ['span_name.contains("HTTP")', 'trace.user_defined_attrs.is_critical == true'] + */ +function buildTraceConditionParts(condition: TraceCondition): string[] { + const parts: string[] = [] + + if (condition.spanName) { + parts.push(createSpanNameCondition(condition.spanName, condition.operator)) + } + + condition.ud_attrs?.forEach(attr => + parts.push(createSpanUserDefinedAttributeCondition(attr)) + ) + + condition.session_attrs?.forEach(attr => + parts.push(createSessionAttributeCondition(attr)) + ) + + return parts +} + +/** + * Joins an array of CEL parts for a + * single condition using the AND (`&&`) operator. + * Example output: 'part1 && part2 && part3' + */ +function combineConditionParts(parts: string[]): string { + if (parts.length === 0) return '' + return parts.join(' && ') +} + +/** + * Combines multiple completed condition strings + * using specified logical operators (`&&` or `||`). + * Example output: '(cond1) && (cond2) || (cond3)' + */ +function combineConditionsWithLogicalOperators( + conditions: string[], + operators: ('AND' | 'OR')[] +): string { + if (conditions.length === 0) return '' + if (conditions.length === 1) return conditions[0] + + let result = `(${conditions[0]})` + + for (let i = 0; i < operators.length && i + 1 < conditions.length; i++) { + const celOperator = LOGICAL_OPERATOR_MAPPINGS[operators[i]] + result = `${result} ${celOperator} (${conditions[i + 1]})` + } + + return result +} + +/** + * High-level processor for all event conditions. + * It builds, combines, and links all event conditions into a single CEL string. + * Example output: '(event_type == "anr" && exception.handled == false) || (event_type == "crash")' + */ +function processEventConditions(eventConditions: EventConditions | undefined): string | null { + if (!eventConditions?.conditions?.length) return null + + const conditionStrings = eventConditions.conditions + .map(buildEventConditionParts) + .filter(parts => parts.length > 0) + .map(combineConditionParts) + + return conditionStrings.length > 0 + ? combineConditionsWithLogicalOperators(conditionStrings, eventConditions.operators || []) + : null +} + +/** + * High-level processor for all session conditions. + * It builds, combines, and links all session conditions into a single CEL string. + * Example output: '(attribute.session_duration > 300) && (attribute.user_country == "US")' + */ +function processSessionConditions(sessionConditions: SessionConditions | undefined): string | null { + if (!sessionConditions?.conditions?.length) return null + + const conditionStrings = sessionConditions.conditions + .map(buildSessionConditionParts) + .filter(parts => parts.length > 0) + .map(combineConditionParts) + + return conditionStrings.length > 0 + ? combineConditionsWithLogicalOperators(conditionStrings, sessionConditions.operators || []) + : null +} + +/** + * High-level processor for all trace conditions. + * Example output: '(span_name.contains("HTTP")) || (trace.user_defined_attrs.is_critical == true)' + */ +function processTraceConditions(traceConditions: TraceConditions | undefined): string | null { + if (!traceConditions?.conditions?.length) return null + + const conditionStrings = traceConditions.conditions + .map(buildTraceConditionParts) + .filter(parts => parts.length > 0) + .map(combineConditionParts) + + return conditionStrings.length > 0 + ? combineConditionsWithLogicalOperators(conditionStrings, traceConditions.operators || []) + : null +} + +/** + * Wraps and joins the final event, session, and trace CEL groups with AND (`&&`). + * Each group is individually wrapped in parentheses. + */ +function wrapConditionGroups(conditionGroups: string[]): string { + if (conditionGroups.length === 0) return '' + + if (conditionGroups.length === 1) { + return `(${conditionGroups[0]})` + } + + const wrappedGroups = conditionGroups.map(group => `(${group})`) + return wrappedGroups.join(' && ') +} + +/** + * Converts a structured `ParsedConditions` object into a final CEL expression string. + * This is the main entry point for the CEL generation logic. + */ +export function conditionsToCel(parsedConditions: ParsedConditions): string | null { + const eventCelExpression = processEventConditions(parsedConditions.event) + const traceCelExpression = processTraceConditions(parsedConditions.trace) + const sessionCelExpression = processSessionConditions(parsedConditions.session) + + const conditionGroups = [eventCelExpression, traceCelExpression, sessionCelExpression] + .filter((group): group is string => Boolean(group)) + + if (conditionGroups.length === 0) { + return null + } + + return wrapConditionGroups(conditionGroups) +} \ No newline at end of file diff --git a/frontend/dashboard/app/utils/cel/cel_parser.ts b/frontend/dashboard/app/utils/cel/cel_parser.ts new file mode 100644 index 000000000..6d859158d --- /dev/null +++ b/frontend/dashboard/app/utils/cel/cel_parser.ts @@ -0,0 +1,618 @@ +/** + * CEL (Common Expression Language) Parser + * + * This module parses tokenized CEL expressions into structured condition objects + * that can be used for event, session, and trace filtering. It builds an Abstract + * Syntax Tree (AST) and converts it into domain-specific condition formats. + */ + +import { EventConditions, SessionConditions, TraceConditions, EventCondition, AttributeCondition, TraceCondition } from './conditions' +import { CelTokenizer, Token, TokenType, CelParseError } from './cel_tokenizer' + +/** + * Result of parsing a CEL expression + */ +export interface ParsedConditions { + event?: EventConditions + session?: SessionConditions + trace?: TraceConditions +} + +/** + * Represents a field path like "event.user_defined_attrs.key" + */ +interface FieldPath { + segments: string[] + position: number +} + +/** + * Represents a literal value in the expression + */ +interface Literal { + type: 'string' | 'number' | 'boolean' | 'null' + value: string | number | boolean | null + position: number +} + +/** + * Represents a comparison operation (field operator value) + */ +interface Comparison { + field: FieldPath + operator: string + value: Literal + position: number +} + +/** + * Represents a logical operation (AND/OR) between expressions + */ +interface LogicalExpression { + left: Comparison | LogicalExpression + operator: 'AND' | 'OR' + right: Comparison | LogicalExpression + position: number +} + +/** + * Union type for all expression types + */ +type Expression = Comparison | LogicalExpression + +/** + * Parser for CEL expressions that builds structured condition objects + */ +class CelParser { + private tokens: Token[] + private currentPosition = 0 + private idGenerator = 0 + + // Condition builders for different types + private eventConditions: EventCondition[] = [] + private sessionConditions: AttributeCondition[] = [] + private traceConditions: TraceCondition[] = [] + + // Operator sequences for combining conditions + private eventOperators: ('AND' | 'OR')[] = [] + private sessionOperators: ('AND' | 'OR')[] = [] + private traceOperators: ('AND' | 'OR')[] = [] + + constructor(tokens: Token[]) { + this.tokens = tokens + } + + /** + * Parses the tokens into structured conditions + * @returns Parsed conditions organized by type (event, session, trace) + */ + parse(): ParsedConditions { + this.resetState() + + try { + const ast = this.parseExpression() + this.convertAstToConditions(ast) + this.validateParsingComplete() + return this.buildResult() + } catch (error) { + return this.handleParsingError(error) + } + } + + /** + * Resets parser state for a new parsing operation + */ + private resetState(): void { + this.currentPosition = 0 + this.idGenerator = 0 + this.clearConditions() + } + + /** + * Clears all condition arrays and operators + */ + private clearConditions(): void { + this.eventConditions = [] + this.sessionConditions = [] + this.traceConditions = [] + this.eventOperators = [] + this.sessionOperators = [] + this.traceOperators = [] + } + + /** + * Parses the top-level expression (handles OR operations) + */ + private parseExpression(): Expression { + return this.parseOrExpression() + } + + /** + * Parses OR expressions (lowest precedence logical operator) + */ + private parseOrExpression(): Expression { + let left = this.parseAndExpression() + + while (this.consumeIfMatches(TokenType.OR)) { + const operatorPos = this.previousToken().position + const right = this.parseAndExpression() + left = { left, operator: 'OR', right, position: operatorPos } + } + + return left + } + + /** + * Parses AND expressions (higher precedence than OR) + */ + private parseAndExpression(): Expression { + let left = this.parsePrimaryExpression() + + while (this.consumeIfMatches(TokenType.AND)) { + const operatorPos = this.previousToken().position + const right = this.parsePrimaryExpression() + left = { left, operator: 'AND', right, position: operatorPos } + } + + return left + } + + /** + * Parses primary expressions (comparisons and parenthesized expressions) + */ + private parsePrimaryExpression(): Expression { + if (this.consumeIfMatches(TokenType.LPAREN)) { + const expr = this.parseExpression() + this.consumeExpected(TokenType.RPAREN, 'Expected closing parenthesis') + return expr + } + + return this.parseComparison() + } + + /** + * Parses a comparison expression (field operator value) + */ + private parseComparison(): Comparison { + const field = this.parseFieldPath() + const operator = this.parseComparisonOperator() + const value = this.parseLiteral() + + // Handle method call syntax (contains, startsWith) + if (operator === 'contains' || operator === 'startsWith') { + this.consumeExpected(TokenType.RPAREN, `Expected closing parenthesis after ${operator} method call`) + } + + return { field, operator, value, position: field.position } + } + + /** + * Parses a field path like "event.user_defined_attrs.key" + */ + private parseFieldPath(): FieldPath { + const startPos = this.currentToken().position + const segments: string[] = [] + + this.validateIdentifierExpected() + segments.push(this.advance().value) + + while (this.consumeIfMatches(TokenType.DOT)) { + // Stop if we encounter method calls + if (this.isCurrentToken(TokenType.CONTAINS) || this.isCurrentToken(TokenType.STARTS_WITH)) { + break + } + + this.validateIdentifierExpected() + segments.push(this.advance().value) + } + + return { segments, position: startPos } + } + + /** + * Parses comparison operators including method calls + */ + private parseComparisonOperator(): string { + const token = this.currentToken() + + // Method call operators + if (this.consumeIfMatches(TokenType.CONTAINS, TokenType.STARTS_WITH)) { + const methodName = this.previousToken().value + this.consumeExpected(TokenType.LPAREN, `Expected opening parenthesis after ${methodName}`) + return methodName + } + + // Standard comparison operators + if (this.consumeIfMatches( + TokenType.EQUALS, TokenType.NOT_EQUALS, + TokenType.GREATER_THAN, TokenType.LESS_THAN, + TokenType.GREATER_EQUAL, TokenType.LESS_EQUAL + )) { + return this.convertTokenTypeToOperator(this.previousToken().type) + } + + throw new CelParseError( + 'Expected comparison operator', + token.position, + token.value, + ['==', '!=', '>', '<', '>=', '<=', 'contains', 'startsWith'] + ) + } + + /** + * Parses literal values (strings, numbers, booleans, null) + */ + private parseLiteral(): Literal { + const token = this.currentToken() + + if (this.consumeIfMatches(TokenType.STRING)) { + return { type: 'string', value: this.previousToken().value, position: token.position } + } + if (this.consumeIfMatches(TokenType.NUMBER)) { + return { type: 'number', value: parseFloat(this.previousToken().value), position: token.position } + } + if (this.consumeIfMatches(TokenType.BOOLEAN)) { + return { type: 'boolean', value: this.previousToken().value === 'true', position: token.position } + } + if (this.consumeIfMatches(TokenType.NULL)) { + return { type: 'null', value: null, position: token.position } + } + + throw new CelParseError( + 'Expected literal value', + token.position, + token.value, + ['string', 'number', 'boolean', 'null'] + ) + } + + /** + * Converts the AST into structured conditions + */ + private convertAstToConditions(expr: Expression): void { + this.processExpression(expr, []) + } + + /** + * Recursively processes expressions and builds conditions + */ + private processExpression(expr: Expression, operators: ('AND' | 'OR')[]): void { + if (this.isLogicalExpression(expr)) { + this.processExpression(expr.left, operators) + operators.push(expr.operator) + this.processExpression(expr.right, operators) + } else { + this.processComparison(expr, operators) + } + } + + /** + * Processes a comparison and adds it to the appropriate condition list + */ + private processComparison(comparison: Comparison, operators: ('AND' | 'OR')[]): void { + const category = this.categorizeFieldPath(comparison.field.segments) + + switch (category) { + case 'event': + this.addEventCondition(comparison) + this.transferOperators(operators, this.eventOperators) + break + case 'session': + this.addSessionCondition(comparison) + this.transferOperators(operators, this.sessionOperators) + break + case 'trace': + this.addTraceCondition(comparison) + this.transferOperators(operators, this.traceOperators) + break + } + } + + /** + * Determines the category of a field based on its path segments + */ + private categorizeFieldPath(segments: string[]): 'event' | 'session' | 'trace' { + const firstSegment = segments[0] + + if (firstSegment === 'event_type' || firstSegment === 'event') { + return 'event' + } + if (firstSegment === 'attribute') { + return 'session' + } + if (firstSegment === 'span_name' || firstSegment === 'trace') { + return 'trace' + } + + // Default to event for unknown fields + return 'event' + } + + /** + * Adds an event condition based on the comparison + */ + private addEventCondition(comparison: Comparison): void { + const segments = comparison.field.segments + + if (segments[0] === 'event_type') { + this.addEventTypeCondition(comparison) + } else if (this.isUserDefinedEventAttribute(segments)) { + this.addEventUserDefinedAttribute(comparison, segments) + } else { + this.addEventAttribute(comparison, segments) + } + } + + /** + * Adds an event type condition + */ + private addEventTypeCondition(comparison: Comparison): void { + const eventType = comparison.value.value as string + let condition = this.findEventConditionByType(eventType) + + if (!condition) { + condition = this.createEventCondition(eventType) + this.eventConditions.push(condition) + } + } + + /** + * Adds a user-defined event attribute + */ + private addEventUserDefinedAttribute(comparison: Comparison, segments: string[]): void { + const key = segments.slice(2).join('.') + const condition = this.getLastEventCondition() + + if (!condition) { + throw new CelParseError('User-defined attribute found without a preceding event_type', comparison.position) + } + + condition.ud_attrs!.push(this.createAttributeFromComparison(comparison, key)) + } + + /** + * Adds a regular event attribute + */ + private addEventAttribute(comparison: Comparison, segments: string[]): void { + const eventType = segments[0] === 'event' ? segments[1] : segments[0] + const fullPathKey = segments.join('.') + + let condition = this.findEventConditionByType(eventType) + if (!condition) { + condition = this.createEventCondition(eventType) + this.eventConditions.push(condition) + } + + condition.attrs!.push(this.createAttributeFromComparison(comparison, fullPathKey)) + } + + /** + * Adds a session condition + */ + private addSessionCondition(comparison: Comparison): void { + const key = comparison.field.segments.join('.') + const condition: AttributeCondition = { + id: this.generateId(), + attrs: [this.createAttributeFromComparison(comparison, key)] + } + this.sessionConditions.push(condition) + } + + /** + * Adds a trace condition + */ + private addTraceCondition(comparison: Comparison): void { + const segments = comparison.field.segments + + if (segments[0] === 'span_name') { + this.addSpanNameCondition(comparison) + } else if (this.isUserDefinedTraceAttribute(segments)) { + this.addTraceUserDefinedAttribute(comparison, segments) + } + } + + /** + * Adds a span name condition + */ + private addSpanNameCondition(comparison: Comparison): void { + const condition: TraceCondition = { + id: this.generateId(), + spanName: comparison.value.value as string, + operator: comparison.operator, + ud_attrs: [], + session_attrs: [] + } + this.traceConditions.push(condition) + } + + /** + * Adds a trace user-defined attribute + */ + private addTraceUserDefinedAttribute(comparison: Comparison, segments: string[]): void { + const currentCondition = this.getLastTraceCondition() + if (!currentCondition) { + throw new CelParseError('Trace attribute found without a preceding span name', comparison.position) + } + + const fullPathKey = segments.join('.') + currentCondition.ud_attrs!.push(this.createAttributeFromComparison(comparison, fullPathKey)) + } + + // Helper methods for token management + private consumeIfMatches(...types: TokenType[]): boolean { + if (types.some(type => this.isCurrentToken(type))) { + this.advance() + return true + } + return false + } + + private isCurrentToken(type: TokenType): boolean { + return !this.isAtEnd() && this.currentToken().type === type + } + + private advance(): Token { + if (!this.isAtEnd()) this.currentPosition++ + return this.previousToken() + } + + private isAtEnd(): boolean { + return this.currentToken().type === TokenType.EOF + } + + private currentToken(): Token { + return this.tokens[this.currentPosition] + } + + private previousToken(): Token { + return this.tokens[this.currentPosition - 1] + } + + private consumeExpected(type: TokenType, message: string): Token { + if (this.isCurrentToken(type)) return this.advance() + + const token = this.currentToken() + throw new CelParseError(message, token.position, token.value, [type]) + } + + // Helper methods for validation and utility + private validateIdentifierExpected(): void { + if (!this.isCurrentToken(TokenType.IDENTIFIER)) { + const token = this.currentToken() + throw new CelParseError( + 'Expected field name', + token.position, + token.value, + ['identifier'] + ) + } + } + + private validateParsingComplete(): void { + if (!this.isAtEnd()) { + const token = this.currentToken() + throw new CelParseError( + 'Unexpected tokens after expression', + token.position, + token.value + ) + } + } + + private handleParsingError(error: unknown): ParsedConditions { + if (error instanceof CelParseError) { + console.error('CEL Parse Error:', error.message, 'at position', error.position, 'token:', error.token, 'expected:', error.expected) + } else { + console.error('Failed to parse CEL expression:', error) + } + return {} + } + + private convertTokenTypeToOperator(type: TokenType): string { + switch (type) { + case TokenType.EQUALS: + return 'eq' + case TokenType.NOT_EQUALS: + return 'neq' + case TokenType.GREATER_THAN: + return 'gt' + case TokenType.LESS_THAN: + return 'lt' + case TokenType.GREATER_EQUAL: + return 'gte' + case TokenType.LESS_EQUAL: + return 'lte' + default: + throw new CelParseError('Invalid comparison operator', this.currentToken().position, this.currentToken().value) + } + } + + // Helper methods for condition management + private generateId(): string { + return `id-${this.idGenerator++}` + } + + private findEventConditionByType(eventType: string): EventCondition | undefined { + return this.eventConditions.find(c => c.type === eventType) + } + + private createEventCondition(eventType: string): EventCondition { + return { + id: this.generateId(), + type: eventType, + attrs: [], + ud_attrs: [], + session_attrs: [] + } + } + + private getLastEventCondition(): EventCondition | undefined { + return this.eventConditions[this.eventConditions.length - 1] + } + + private getLastTraceCondition(): TraceCondition | undefined { + return this.traceConditions[this.traceConditions.length - 1] + } + + private createAttributeFromComparison(comparison: Comparison, key: string) { + const parsedKey = key.split('.').pop() || key + return { + id: this.generateId(), + key: parsedKey, + type: comparison.value.type === 'boolean' ? 'bool' : comparison.value.type, + value: comparison.value.value as string | number | boolean, + operator: comparison.operator + } + } + + private isUserDefinedEventAttribute(segments: string[]): boolean { + return segments[0] === 'event' && segments[1] === 'user_defined_attrs' + } + + private isUserDefinedTraceAttribute(segments: string[]): boolean { + return (segments[0] === 'trace') && segments[1] === 'user_defined_attrs' + } + + private isLogicalExpression(expr: Expression): expr is LogicalExpression { + return 'operator' in expr && (expr.operator === 'AND' || expr.operator === 'OR') + } + + private transferOperators(source: ('AND' | 'OR')[], target: ('AND' | 'OR')[]): void { + if (source.length > 0) { + target.push(...source) + source.length = 0 + } + } + + /** + * Builds the final result from parsed conditions + */ + private buildResult(): ParsedConditions { + const result: ParsedConditions = {} + + if (this.eventConditions.length > 0) { + result.event = { conditions: this.eventConditions, operators: this.eventOperators } + } + if (this.sessionConditions.length > 0) { + result.session = { conditions: this.sessionConditions, operators: this.sessionOperators } + } + if (this.traceConditions.length > 0) { + result.trace = { conditions: this.traceConditions, operators: this.traceOperators } + } + + return result + } +} + +/** + * Parses a CEL expression string into structured conditions + */ +export function celToConditions(expression: string): ParsedConditions { + if (!expression?.trim()) { + return { event: undefined, trace: undefined, session: undefined }; + } + const tokenizer = new CelTokenizer(expression); + const tokens = tokenizer.tokenize(); + const parser = new CelParser(tokens); + return parser.parse(); +} \ No newline at end of file diff --git a/frontend/dashboard/app/utils/cel/cel_tokenizer.ts b/frontend/dashboard/app/utils/cel/cel_tokenizer.ts new file mode 100644 index 000000000..8a362dbe8 --- /dev/null +++ b/frontend/dashboard/app/utils/cel/cel_tokenizer.ts @@ -0,0 +1,314 @@ +/** + * CEL (Common Expression Language) Tokenizer + * + * This module provides tokenization for CEL expressions, breaking down input strings + * into meaningful tokens for parsing. + */ + +/** + * Error thrown during CEL parsing with detailed context information + */ +export class CelParseError extends Error { + constructor( + message: string, + public position: number, + public token?: string, + public expected?: string[] + ) { + const contextInfo = token ? ` (found: '${token}')` : '' + const expectedInfo = expected ? ` (expected: ${expected.join(' or ')})` : '' + super(`${message} at position ${position}${contextInfo}${expectedInfo}`) + this.name = 'CelParseError' + } +} + +/** + * Token types supported by the CEL tokenizer + */ +export enum TokenType { + // Literal values + IDENTIFIER = 'IDENTIFIER', + STRING = 'STRING', + NUMBER = 'NUMBER', + BOOLEAN = 'BOOLEAN', + NULL = 'NULL', + + // Comparison operators + EQUALS = 'EQUALS', + NOT_EQUALS = 'NOT_EQUALS', + GREATER_THAN = 'GREATER_THAN', + LESS_THAN = 'LESS_THAN', + GREATER_EQUAL = 'GREATER_EQUAL', + LESS_EQUAL = 'LESS_EQUAL', + + // String methods + CONTAINS = 'CONTAINS', + STARTS_WITH = 'STARTS_WITH', + + // Logical operators + AND = 'AND', + OR = 'OR', + + // Punctuation + DOT = 'DOT', + LPAREN = 'LPAREN', + RPAREN = 'RPAREN', + COMMA = 'COMMA', + + // End marker + EOF = 'EOF' +} + +/** + * Represents a single token with its type, value, and position + */ +export interface Token { + type: TokenType + value: string + position: number +} + +/** + * Tokenizes CEL expressions into a sequence of tokens + */ +export class CelTokenizer { + private input: string + private position = 0 + + constructor(input: string) { + this.input = input.trim() + } + + /** + * Tokenizes the input string into an array of tokens + * @returns Array of tokens including EOF marker at the end + */ + tokenize(): Token[] { + const tokens: Token[] = [] + this.position = 0 + + while (this.position < this.input.length) { + this.skipWhitespace() + if (this.position >= this.input.length) break + + const startPos = this.position + const token = this.readNextToken() + tokens.push({ ...token, position: startPos }) + } + + tokens.push({ type: TokenType.EOF, value: '', position: this.position }) + return tokens + } + + /** + * Reads and returns the next token from the input + */ + private readNextToken(): Omit { + // Multi-character operators first + const twoCharToken = this.tryReadTwoCharOperator() + if (twoCharToken) return twoCharToken + + const char = this.currentChar() + + // Single character tokens + const singleCharToken = this.tryReadSingleCharToken(char) + if (singleCharToken) return singleCharToken + + // Complex tokens that require parsing + if (char === '"') return { type: TokenType.STRING, value: this.readStringLiteral() } + if (this.isDigit(char)) return { type: TokenType.NUMBER, value: this.readNumber() } + if (this.isIdentifierStart(char)) { + const identifier = this.readIdentifier() + return { type: this.classifyIdentifier(identifier), value: identifier } + } + + throw new CelParseError(`Unexpected character '${char}'`, this.position) + } + + /** + * Skips whitespace characters + */ + private skipWhitespace(): void { + while (this.position < this.input.length && /\s/.test(this.currentChar())) { + this.position++ + } + } + + /** + * Attempts to read a two-character operator + */ + private tryReadTwoCharOperator(): Omit | null { + if (this.position + 1 >= this.input.length) return null + + const twoChar = this.input.slice(this.position, this.position + 2) + const tokenType = this.getTwoCharOperatorType(twoChar) + + if (tokenType) { + this.position += 2 + return { type: tokenType, value: twoChar } + } + + return null + } + + /** + * Maps two-character sequences to their token types + */ + private getTwoCharOperatorType(twoChar: string): TokenType | null { + const operators: Record = { + '==': TokenType.EQUALS, + '!=': TokenType.NOT_EQUALS, + '>=': TokenType.GREATER_EQUAL, + '<=': TokenType.LESS_EQUAL, + '&&': TokenType.AND, + '||': TokenType.OR + } + return operators[twoChar] || null + } + + /** + * Attempts to read a single-character token + */ + private tryReadSingleCharToken(char: string): Omit | null { + const tokenType = this.getSingleCharTokenType(char) + if (tokenType) { + this.position++ + return { type: tokenType, value: char } + } + return null + } + + /** + * Maps single characters to their token types + */ + private getSingleCharTokenType(char: string): TokenType | null { + const tokens: Record = { + '(': TokenType.LPAREN, + ')': TokenType.RPAREN, + '.': TokenType.DOT, + ',': TokenType.COMMA, + '>': TokenType.GREATER_THAN, + '<': TokenType.LESS_THAN + } + return tokens[char] || null + } + + /** + * Classifies an identifier as a keyword. + */ + private classifyIdentifier(identifier: string): TokenType { + const keywords: Record = { + 'true': TokenType.BOOLEAN, + 'false': TokenType.BOOLEAN, + 'null': TokenType.NULL, + 'contains': TokenType.CONTAINS, + 'startsWith': TokenType.STARTS_WITH + } + return keywords[identifier] || TokenType.IDENTIFIER + } + + /** + * Reads a string literal, handling escape sequences + */ + private readStringLiteral(): string { + this.position++ // Skip opening quote + let value = '' + + while (this.position < this.input.length && this.currentChar() !== '"') { + if (this.currentChar() === '\\' && this.position + 1 < this.input.length) { + this.position++ + value += this.readEscapeSequence(this.currentChar()) + } else { + value += this.currentChar() + } + this.position++ + } + + if (this.position >= this.input.length) { + throw new CelParseError('Unterminated string literal', this.position - value.length - 1) + } + + this.position++ // Skip closing quote + return value + } + + /** + * Converts escape sequences to their actual characters + */ + private readEscapeSequence(char: string): string { + const escapeChars: Record = { + 'n': '\n', + 't': '\t', + 'r': '\r', + '\\': '\\', + '"': '"' + } + return escapeChars[char] || char + } + + /** + * Reads a numeric literal (integer or float) + */ + private readNumber(): string { + let value = '' + let hasDecimal = false + + while (this.position < this.input.length) { + const char = this.currentChar() + if (this.isDigit(char)) { + value += char + this.position++ + } else if (char === '.' && !hasDecimal) { + hasDecimal = true + value += char + this.position++ + } else { + break + } + } + + return value + } + + /** + * Reads an identifier (variable name, function name, etc.) + */ + private readIdentifier(): string { + let value = '' + + while (this.position < this.input.length && this.isIdentifierChar(this.currentChar())) { + value += this.currentChar() + this.position++ + } + + return value + } + + /** + * Gets the current character without advancing position + */ + private currentChar(): string { + return this.input[this.position] + } + + /** + * Checks if a character is a digit + */ + private isDigit(char: string): boolean { + return /\d/.test(char) + } + + /** + * Checks if a character can start an identifier + */ + private isIdentifierStart(char: string): boolean { + return /[a-zA-Z_]/.test(char) + } + + /** + * Checks if a character can be part of an identifier + */ + private isIdentifierChar(char: string): boolean { + return /[a-zA-Z0-9_]/.test(char) + } +} \ No newline at end of file diff --git a/frontend/dashboard/app/utils/cel/conditions.ts b/frontend/dashboard/app/utils/cel/conditions.ts new file mode 100644 index 000000000..580a6a37d --- /dev/null +++ b/frontend/dashboard/app/utils/cel/conditions.ts @@ -0,0 +1,41 @@ +// Shared attribute field type +export interface AttributeField { + id: string + key: string + type: string + value: string | boolean | number + hint?: string + operator?: string + hasError?: boolean + errorMessage?: string +} + +export interface EventCondition { + id: string + type: string + attrs: AttributeField[] + ud_attrs: AttributeField[] + session_attrs: AttributeField[] +} + +export interface AttributeCondition { + id: string + attrs: AttributeField[] +} + +export interface TraceCondition { + id: string + spanName: string + operator: string + ud_attrs: AttributeField[] + session_attrs: AttributeField[] +} + +export interface Conditions { + conditions: T[] + operators: ('AND' | 'OR')[] +} + +export type EventConditions = Conditions +export type SessionConditions = Conditions +export type TraceConditions = Conditions \ No newline at end of file From bf7e55309146da10a7159f8a3f9640f8d255eb11 Mon Sep 17 00:00:00 2001 From: Abhay Sood Date: Wed, 5 Nov 2025 21:02:04 +0530 Subject: [PATCH 19/98] refactor(frontend): change name to DataTargeting --- frontend/dashboard/app/[teamId]/data/page.tsx | 52 +++++++++---------- .../{[filterId] => ruleId}/edit/page.tsx | 0 frontend/dashboard/app/api/api_calls.ts | 30 +++++------ .../edit_default_rule_dialog.tsx | 8 +-- .../rule_builder_card.tsx | 0 .../rule_overrides_table.tsx | 10 ++-- .../sampling_rate_input.tsx | 0 7 files changed, 50 insertions(+), 50 deletions(-) rename frontend/dashboard/app/[teamId]/data/trace/{[filterId] => ruleId}/edit/page.tsx (100%) rename frontend/dashboard/app/components/{data_rule => targeting}/edit_default_rule_dialog.tsx (95%) rename frontend/dashboard/app/components/{data_rule => targeting}/rule_builder_card.tsx (100%) rename frontend/dashboard/app/components/{data_rule => targeting}/rule_overrides_table.tsx (90%) rename frontend/dashboard/app/components/{data_rule => targeting}/sampling_rate_input.tsx (100%) diff --git a/frontend/dashboard/app/[teamId]/data/page.tsx b/frontend/dashboard/app/[teamId]/data/page.tsx index b0c0ef970..4f492b72c 100644 --- a/frontend/dashboard/app/[teamId]/data/page.tsx +++ b/frontend/dashboard/app/[teamId]/data/page.tsx @@ -1,29 +1,29 @@ "use client" -import { DataRulesApiStatus, DataRulesResponse, emptyDataFiltersResponse, fetchDataFiltersFromServer, FilterSource } from '@/app/api/api_calls' +import { DataTargetingRulesApiStatus, DataTargetingRulesResponse, emptyDataFiltersResponse, fetchDataTargetingRulesFromServer, FilterSource } from '@/app/api/api_calls' import Filters, { AppVersionsInitialSelectionType, defaultFilters } from '@/app/components/filters' import LoadingBar from '@/app/components/loading_bar' -import { DataRuleCollectionConfig, DataRuleType } from '@/app/api/api_calls' +import { DataTargetingCollectionConfig, DataTargetingRuleType } from '@/app/api/api_calls' import { useRouter } from 'next/navigation' import { useEffect, useState } from 'react' import { Button } from '@/app/components/button' import { Plus, Pencil } from 'lucide-react' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/app/components/dropdown_menu' -import EditDefaultRuleDialog, { DefaultRuleState as DefaultRuleEditState } from '@/app/components/data_rule/edit_default_rule_dialog' -import RulesTable from '@/app/components/data_rule/rule_overrides_table' +import EditDefaultRuleDialog, { DefaultRuleState as DefaultRuleEditState } from '@/app/components/targeting/edit_default_rule_dialog' +import RulesTable from '@/app/components/targeting/rule_overrides_table' interface PageState { - dataRulesApiStatus: DataRulesApiStatus + dataTargetingRulesApiStatus: DataTargetingRulesApiStatus filters: typeof defaultFilters - dataRules: DataRulesResponse + dataTargetingRules: DataTargetingRulesResponse defaultRuleEditState: DefaultRuleEditState | null } -const isDefaultRule = (type: DataRuleType): boolean => { +const isDefaultRule = (type: DataTargetingRuleType): boolean => { return type === 'all_events' || type === 'all_traces' } -const getCollectionConfigDisplay = (collectionConfig: DataRuleCollectionConfig): string => { +const getCollectionConfigDisplay = (collectionConfig: DataTargetingCollectionConfig): string => { switch (collectionConfig.mode) { case 'sample_rate': return `Collect all at ${collectionConfig.sample_rate}% sample rate` @@ -40,9 +40,9 @@ export default function DataFilters({ params }: { params: { teamId: string } }) const router = useRouter() const initialState: PageState = { - dataRulesApiStatus: DataRulesApiStatus.Success, + dataTargetingRulesApiStatus: DataTargetingRulesApiStatus.Success, filters: defaultFilters, - dataRules: emptyDataFiltersResponse, + dataTargetingRules: emptyDataFiltersResponse, defaultRuleEditState: null, } @@ -56,21 +56,21 @@ export default function DataFilters({ params }: { params: { teamId: string } }) } const getDataFilters = async () => { - updatePageState({ dataRulesApiStatus: DataRulesApiStatus.Loading }) + updatePageState({ dataTargetingRulesApiStatus: DataTargetingRulesApiStatus.Loading }) - const result = await fetchDataFiltersFromServer(pageState.filters.app!.id) + const result = await fetchDataTargetingRulesFromServer(pageState.filters.app!.id) switch (result.status) { - case DataRulesApiStatus.Error: - updatePageState({ dataRulesApiStatus: DataRulesApiStatus.Error }) + case DataTargetingRulesApiStatus.Error: + updatePageState({ dataTargetingRulesApiStatus: DataTargetingRulesApiStatus.Error }) break - case DataRulesApiStatus.NoFilters: - updatePageState({ dataRulesApiStatus: DataRulesApiStatus.NoFilters }) + case DataTargetingRulesApiStatus.NoFilters: + updatePageState({ dataTargetingRulesApiStatus: DataTargetingRulesApiStatus.NoFilters }) break - case DataRulesApiStatus.Success: + case DataTargetingRulesApiStatus.Success: updatePageState({ - dataRulesApiStatus: DataRulesApiStatus.Success, - dataRules: result.data + dataTargetingRulesApiStatus: DataTargetingRulesApiStatus.Success, + dataTargetingRules: result.data }) break } @@ -81,7 +81,7 @@ export default function DataFilters({ params }: { params: { teamId: string } }) if (pageState.filters.ready !== updatedFilters.ready || pageState.filters.serialisedFilters !== updatedFilters.serialisedFilters) { updatePageState({ filters: updatedFilters, - dataRules: emptyDataFiltersResponse, + dataTargetingRules: emptyDataFiltersResponse, }) } } @@ -98,10 +98,10 @@ export default function DataFilters({ params }: { params: { teamId: string } }) // getDataFilters() }, [pageState.filters]) - const defaultRules = pageState.dataRules.results.filter(df => isDefaultRule(df.type)) + const defaultRules = pageState.dataTargetingRules.results.filter(df => isDefaultRule(df.type)) const allEventsFilter = defaultRules.find(df => df.type === 'all_events') const allTracesFilter = defaultRules.find(df => df.type === 'all_traces') - const overrideFilters = pageState.dataRules.results.filter(df => !isDefaultRule(df.type)) + const overrideFilters = pageState.dataTargetingRules.results.filter(df => !isDefaultRule(df.type)) const eventFilters = overrideFilters.filter(df => df.type === 'event') const traceFilters = overrideFilters.filter(df => df.type === 'trace') @@ -138,7 +138,7 @@ export default function DataFilters({ params }: { params: { teamId: string } }) @@ -183,14 +183,14 @@ export default function DataFilters({ params }: { params: { teamId: string } }) {/* Error state for data rules fetch */} {pageState.filters.ready - && pageState.dataRulesApiStatus === DataRulesApiStatus.Error + && pageState.dataTargetingRulesApiStatus === DataTargetingRulesApiStatus.Error &&

Error fetching data filters, please change filters, refresh page or select a different app to try again

} {/* Main data rules UI */} {pageState.filters.ready - && (pageState.dataRulesApiStatus === DataRulesApiStatus.Success || pageState.dataRulesApiStatus === DataRulesApiStatus.Loading) && + && (pageState.dataTargetingRulesApiStatus === DataTargetingRulesApiStatus.Success || pageState.dataTargetingRulesApiStatus === DataTargetingRulesApiStatus.Loading) &&
-
+
diff --git a/frontend/dashboard/app/[teamId]/data/trace/[filterId]/edit/page.tsx b/frontend/dashboard/app/[teamId]/data/trace/ruleId/edit/page.tsx similarity index 100% rename from frontend/dashboard/app/[teamId]/data/trace/[filterId]/edit/page.tsx rename to frontend/dashboard/app/[teamId]/data/trace/ruleId/edit/page.tsx diff --git a/frontend/dashboard/app/api/api_calls.ts b/frontend/dashboard/app/api/api_calls.ts index 084032008..514487b41 100644 --- a/frontend/dashboard/app/api/api_calls.ts +++ b/frontend/dashboard/app/api/api_calls.ts @@ -353,7 +353,7 @@ export enum AlertsOverviewApiStatus { Cancelled, } -export enum DataRulesApiStatus { +export enum DataTargetingRulesApiStatus { Loading, Success, Error, @@ -1031,7 +1031,7 @@ export const emptyAlertsOverviewResponse = { }[], } -export type DataRulesResponse = { +export type DataTargetingRulesResponse = { meta: { next: false, previous: false, @@ -1039,28 +1039,28 @@ export type DataRulesResponse = { results: DataRule[], } -export type DataRuleType = "event" | "trace" | "all_events" | "all_traces"; +export type DataTargetingRuleType = "event" | "trace" | "all_events" | "all_traces"; -export type DataRuleCollectionConfig = +export type DataTargetingCollectionConfig = | { mode: 'sample_rate'; sample_rate: number } | { mode: 'timeline_only' } | { mode: 'disable' }; -export type DataRuleAttachmentConfig = 'layout_snapshot' | 'screenshot' | 'none'; +export type DataTargetingAttachmentConfig = 'layout_snapshot' | 'screenshot' | 'none'; export type DataRule = { id: string, - type: DataRuleType, + type: DataTargetingRuleType, rule: string, - collection_config: DataRuleCollectionConfig, - attachment_config: DataRuleAttachmentConfig, + collection_config: DataTargetingCollectionConfig, + attachment_config: DataTargetingAttachmentConfig, created_at: string, created_by: string, updated_at: string, updated_by: string, } -export const emptyDataFiltersResponse: DataRulesResponse = { +export const emptyDataFiltersResponse: DataTargetingRulesResponse = { meta: { next: false, previous: false, @@ -2481,11 +2481,11 @@ export const fetchAlertsOverviewFromServer = async ( } } -export const fetchDataFiltersFromServer = async ( +export const fetchDataTargetingRulesFromServer = async ( appId: String, type?: string, ) => { - let url = `/api/apps/${appId}/dataFilters` + let url = `/api/apps/${appId}/dataTargetingRules` if (type) { url += `?type=${type}` } @@ -2494,17 +2494,17 @@ export const fetchDataFiltersFromServer = async ( const res = await measureAuth.fetchMeasure(url) if (!res.ok) { - return { status: DataRulesApiStatus.Error, data: null } + return { status: DataTargetingRulesApiStatus.Error, data: null } } const data = await res.json() if (data.results === null) { - return { status: DataRulesApiStatus.NoFilters, data: null } + return { status: DataTargetingRulesApiStatus.NoFilters, data: null } } else { - return { status: DataRulesApiStatus.Success, data: data } + return { status: DataTargetingRulesApiStatus.Success, data: data } } } catch { - return { status: DataRulesApiStatus.Cancelled, data: null } + return { status: DataTargetingRulesApiStatus.Cancelled, data: null } } } \ No newline at end of file diff --git a/frontend/dashboard/app/components/data_rule/edit_default_rule_dialog.tsx b/frontend/dashboard/app/components/targeting/edit_default_rule_dialog.tsx similarity index 95% rename from frontend/dashboard/app/components/data_rule/edit_default_rule_dialog.tsx rename to frontend/dashboard/app/components/targeting/edit_default_rule_dialog.tsx index feab0f717..3ea82991e 100644 --- a/frontend/dashboard/app/components/data_rule/edit_default_rule_dialog.tsx +++ b/frontend/dashboard/app/components/targeting/edit_default_rule_dialog.tsx @@ -1,14 +1,14 @@ "use client" -import { DataRuleCollectionConfig, DataRuleType } from '@/app/api/api_calls' +import { DataTargetingCollectionConfig, DataTargetingRuleType } from '@/app/api/api_calls' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/app/components/dialog' import { Button } from '@/app/components/button' -import SamplingRateInput from '@/app/components/data_rule/sampling_rate_input' +import SamplingRateInput from '@/app/components/targeting/sampling_rate_input' export interface DefaultRuleState { id: string - type: DataRuleType - collectionMode: DataRuleCollectionConfig['mode'] + type: DataTargetingRuleType + collectionMode: DataTargetingCollectionConfig['mode'] sampleRate?: number } diff --git a/frontend/dashboard/app/components/data_rule/rule_builder_card.tsx b/frontend/dashboard/app/components/targeting/rule_builder_card.tsx similarity index 100% rename from frontend/dashboard/app/components/data_rule/rule_builder_card.tsx rename to frontend/dashboard/app/components/targeting/rule_builder_card.tsx diff --git a/frontend/dashboard/app/components/data_rule/rule_overrides_table.tsx b/frontend/dashboard/app/components/targeting/rule_overrides_table.tsx similarity index 90% rename from frontend/dashboard/app/components/data_rule/rule_overrides_table.tsx rename to frontend/dashboard/app/components/targeting/rule_overrides_table.tsx index 72d7e13de..a8856e826 100644 --- a/frontend/dashboard/app/components/data_rule/rule_overrides_table.tsx +++ b/frontend/dashboard/app/components/targeting/rule_overrides_table.tsx @@ -1,12 +1,12 @@ "use client" -import { DataRulesResponse, DataRuleCollectionConfig, DataRuleAttachmentConfig, DataRuleType } from '@/app/api/api_calls' +import { DataTargetingRulesResponse, DataTargetingCollectionConfig, DataTargetingAttachmentConfig, DataTargetingRuleType } from '@/app/api/api_calls' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/app/components/table' import { formatDateToHumanReadableDate, formatDateToHumanReadableTime } from '@/app/utils/time_utils' import Paginator from '@/app/components/paginator' import { useState, useEffect } from 'react' -const getFilterDisplayText = (type: DataRuleType, filter: string): string => { +const getFilterDisplayText = (type: DataTargetingRuleType, filter: string): string => { switch (type) { case 'all_events': return 'All Events' @@ -17,7 +17,7 @@ const getFilterDisplayText = (type: DataRuleType, filter: string): string => { } } -const getCollectionConfigDisplay = (collectionConfig: DataRuleCollectionConfig): string => { +const getCollectionConfigDisplay = (collectionConfig: DataTargetingCollectionConfig): string => { switch (collectionConfig.mode) { case 'sample_rate': return `Collect all at ${collectionConfig.sample_rate}% sample rate` @@ -30,7 +30,7 @@ const getCollectionConfigDisplay = (collectionConfig: DataRuleCollectionConfig): } } -const getAttachmentConfigDisplay = (attachmentConfig: DataRuleAttachmentConfig): string => { +const getAttachmentConfigDisplay = (attachmentConfig: DataTargetingAttachmentConfig): string => { if (attachmentConfig === 'none') { return '' } else if (attachmentConfig === 'layout_snapshot') { @@ -41,7 +41,7 @@ const getAttachmentConfigDisplay = (attachmentConfig: DataRuleAttachmentConfig): return attachmentConfig } -type RuleFilter = DataRulesResponse['results'][0] +type RuleFilter = DataTargetingRulesResponse['results'][0] interface RulesTableProps { rules: RuleFilter[] diff --git a/frontend/dashboard/app/components/data_rule/sampling_rate_input.tsx b/frontend/dashboard/app/components/targeting/sampling_rate_input.tsx similarity index 100% rename from frontend/dashboard/app/components/data_rule/sampling_rate_input.tsx rename to frontend/dashboard/app/components/targeting/sampling_rate_input.tsx From fc6c1c8a8122ce2aebb5ed955ddbfc8aa4714063 Mon Sep 17 00:00:00 2001 From: Abhay Sood Date: Wed, 5 Nov 2025 21:06:06 +0530 Subject: [PATCH 20/98] feat(frontend): add empty session targeting pages --- frontend/dashboard/app/[teamId]/data/session/[ruleId]/page.tsx | 0 frontend/dashboard/app/[teamId]/data/session/create/page.tsx | 0 .../app/[teamId]/data/trace/{ruleId => [ruleId]}/edit/page.tsx | 0 3 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 frontend/dashboard/app/[teamId]/data/session/[ruleId]/page.tsx create mode 100644 frontend/dashboard/app/[teamId]/data/session/create/page.tsx rename frontend/dashboard/app/[teamId]/data/trace/{ruleId => [ruleId]}/edit/page.tsx (100%) diff --git a/frontend/dashboard/app/[teamId]/data/session/[ruleId]/page.tsx b/frontend/dashboard/app/[teamId]/data/session/[ruleId]/page.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/frontend/dashboard/app/[teamId]/data/session/create/page.tsx b/frontend/dashboard/app/[teamId]/data/session/create/page.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/frontend/dashboard/app/[teamId]/data/trace/ruleId/edit/page.tsx b/frontend/dashboard/app/[teamId]/data/trace/[ruleId]/edit/page.tsx similarity index 100% rename from frontend/dashboard/app/[teamId]/data/trace/ruleId/edit/page.tsx rename to frontend/dashboard/app/[teamId]/data/trace/[ruleId]/edit/page.tsx From f4f36883523b33fa765432571c1c3889b324d5a9 Mon Sep 17 00:00:00 2001 From: Abhay Sood Date: Wed, 5 Nov 2025 21:40:53 +0530 Subject: [PATCH 21/98] feat(frontend): separate events and traces targeting state --- frontend/dashboard/app/[teamId]/data/page.tsx | 136 ++++++----- frontend/dashboard/app/api/api_calls.ts | 222 +++++++++++------- .../targeting/edit_default_rule_dialog.tsx | 6 +- .../targeting/rule_overrides_table.tsx | 14 +- 4 files changed, 226 insertions(+), 152 deletions(-) diff --git a/frontend/dashboard/app/[teamId]/data/page.tsx b/frontend/dashboard/app/[teamId]/data/page.tsx index 4f492b72c..cb46b4e63 100644 --- a/frontend/dashboard/app/[teamId]/data/page.tsx +++ b/frontend/dashboard/app/[teamId]/data/page.tsx @@ -1,9 +1,9 @@ "use client" -import { DataTargetingRulesApiStatus, DataTargetingRulesResponse, emptyDataFiltersResponse, fetchDataTargetingRulesFromServer, FilterSource } from '@/app/api/api_calls' +import { EventTargetingApiStatus, EventTargetingResponse, emptyEventTargetingResponse, fetchEventTargetingRulesFromServer, TraceTargetingApiStatus, TraceTargetingResponse, emptyTraceTargetingResponse, fetchTraceTargetingRulesFromServer, FilterSource } from '@/app/api/api_calls' import Filters, { AppVersionsInitialSelectionType, defaultFilters } from '@/app/components/filters' import LoadingBar from '@/app/components/loading_bar' -import { DataTargetingCollectionConfig, DataTargetingRuleType } from '@/app/api/api_calls' +import { EventTargetingCollectionConfig } from '@/app/api/api_calls' import { useRouter } from 'next/navigation' import { useEffect, useState } from 'react' import { Button } from '@/app/components/button' @@ -13,17 +13,15 @@ import EditDefaultRuleDialog, { DefaultRuleState as DefaultRuleEditState } from import RulesTable from '@/app/components/targeting/rule_overrides_table' interface PageState { - dataTargetingRulesApiStatus: DataTargetingRulesApiStatus + eventTargetingApiStatus: EventTargetingApiStatus + traceTargetingApiStatus: TraceTargetingApiStatus filters: typeof defaultFilters - dataTargetingRules: DataTargetingRulesResponse + eventTargetingRules: EventTargetingResponse + traceTargetingRules: TraceTargetingResponse defaultRuleEditState: DefaultRuleEditState | null } -const isDefaultRule = (type: DataTargetingRuleType): boolean => { - return type === 'all_events' || type === 'all_traces' -} - -const getCollectionConfigDisplay = (collectionConfig: DataTargetingCollectionConfig): string => { +const getCollectionConfigDisplay = (collectionConfig: EventTargetingCollectionConfig): string => { switch (collectionConfig.mode) { case 'sample_rate': return `Collect all at ${collectionConfig.sample_rate}% sample rate` @@ -40,9 +38,11 @@ export default function DataFilters({ params }: { params: { teamId: string } }) const router = useRouter() const initialState: PageState = { - dataTargetingRulesApiStatus: DataTargetingRulesApiStatus.Success, + eventTargetingApiStatus: EventTargetingApiStatus.Success, + traceTargetingApiStatus: TraceTargetingApiStatus.Success, filters: defaultFilters, - dataTargetingRules: emptyDataFiltersResponse, + eventTargetingRules: emptyEventTargetingResponse, + traceTargetingRules: emptyTraceTargetingResponse, defaultRuleEditState: null, } @@ -56,24 +56,37 @@ export default function DataFilters({ params }: { params: { teamId: string } }) } const getDataFilters = async () => { - updatePageState({ dataTargetingRulesApiStatus: DataTargetingRulesApiStatus.Loading }) - - const result = await fetchDataTargetingRulesFromServer(pageState.filters.app!.id) - - switch (result.status) { - case DataTargetingRulesApiStatus.Error: - updatePageState({ dataTargetingRulesApiStatus: DataTargetingRulesApiStatus.Error }) - break - case DataTargetingRulesApiStatus.NoFilters: - updatePageState({ dataTargetingRulesApiStatus: DataTargetingRulesApiStatus.NoFilters }) - break - case DataTargetingRulesApiStatus.Success: - updatePageState({ - dataTargetingRulesApiStatus: DataTargetingRulesApiStatus.Success, - dataTargetingRules: result.data - }) - break + updatePageState({ + eventTargetingApiStatus: EventTargetingApiStatus.Loading, + traceTargetingApiStatus: TraceTargetingApiStatus.Loading + }) + + const [eventResult, traceResult] = await Promise.all([ + fetchEventTargetingRulesFromServer(pageState.filters.app!.id), + fetchTraceTargetingRulesFromServer(pageState.filters.app!.id) + ]) + + if (eventResult.status === EventTargetingApiStatus.Error || traceResult.status === TraceTargetingApiStatus.Error) { + updatePageState({ + eventTargetingApiStatus: EventTargetingApiStatus.Error, + traceTargetingApiStatus: TraceTargetingApiStatus.Error + }) + return } + + const eventStatus = eventResult.status === EventTargetingApiStatus.NoData + ? EventTargetingApiStatus.NoData + : EventTargetingApiStatus.Success + const traceStatus = traceResult.status === TraceTargetingApiStatus.NoData + ? TraceTargetingApiStatus.NoData + : TraceTargetingApiStatus.Success + + updatePageState({ + eventTargetingApiStatus: eventStatus, + traceTargetingApiStatus: traceStatus, + eventTargetingRules: eventResult.data || emptyEventTargetingResponse, + traceTargetingRules: traceResult.data || emptyTraceTargetingResponse + }) } const handleFiltersChanged = (updatedFilters: typeof defaultFilters) => { @@ -81,7 +94,8 @@ export default function DataFilters({ params }: { params: { teamId: string } }) if (pageState.filters.ready !== updatedFilters.ready || pageState.filters.serialisedFilters !== updatedFilters.serialisedFilters) { updatePageState({ filters: updatedFilters, - dataTargetingRules: emptyDataFiltersResponse, + eventTargetingRules: emptyEventTargetingResponse, + traceTargetingRules: emptyTraceTargetingResponse, }) } } @@ -98,19 +112,35 @@ export default function DataFilters({ params }: { params: { teamId: string } }) // getDataFilters() }, [pageState.filters]) - const defaultRules = pageState.dataTargetingRules.results.filter(df => isDefaultRule(df.type)) - const allEventsFilter = defaultRules.find(df => df.type === 'all_events') - const allTracesFilter = defaultRules.find(df => df.type === 'all_traces') - const overrideFilters = pageState.dataTargetingRules.results.filter(df => !isDefaultRule(df.type)) - const eventFilters = overrideFilters.filter(df => df.type === 'event') - const traceFilters = overrideFilters.filter(df => df.type === 'trace') + const isLoading = () => { + return pageState.eventTargetingApiStatus === EventTargetingApiStatus.Loading || + pageState.traceTargetingApiStatus === TraceTargetingApiStatus.Loading + } + + const hasError = () => { + return pageState.eventTargetingApiStatus === EventTargetingApiStatus.Error || + pageState.traceTargetingApiStatus === TraceTargetingApiStatus.Error + } + + const canShowContent = () => { + const eventReady = pageState.eventTargetingApiStatus === EventTargetingApiStatus.Success || + pageState.eventTargetingApiStatus === EventTargetingApiStatus.Loading + const traceReady = pageState.traceTargetingApiStatus === TraceTargetingApiStatus.Success || + pageState.traceTargetingApiStatus === TraceTargetingApiStatus.Loading + return pageState.filters.ready && eventReady && traceReady + } + + const eventsDefaultRule = pageState.eventTargetingRules.result.default; + const eventsOverideRules = pageState.eventTargetingRules.result.overrides; + const traceDefaultRule = pageState.traceTargetingRules.result.default; + const traceOverrideRules = pageState.traceTargetingRules.result.overrides; - const handleEditFilter = (dataFilter: typeof overrideFilters[0]) => { + const handleEditFilter = (dataFilter: typeof eventsOverideRules[0] | typeof traceOverrideRules[0]) => { const filterType = dataFilter.type === 'event' ? 'event' : 'trace' router.push(`/${params.teamId}/data/${filterType}/${dataFilter.id}/edit`) } - const handleEditDefaultRule = (dataFilter: typeof defaultRules[0]) => { + const handleEditDefaultRule = (dataFilter: typeof eventsDefaultRule | typeof traceDefaultRule) => { updatePageState({ defaultRuleEditState: { id: dataFilter.id, @@ -138,7 +168,7 @@ export default function DataFilters({ params }: { params: { teamId: string } }) @@ -182,15 +212,13 @@ export default function DataFilters({ params }: { params: { teamId: string } })
{/* Error state for data rules fetch */} - {pageState.filters.ready - && pageState.dataTargetingRulesApiStatus === DataTargetingRulesApiStatus.Error - &&

Error fetching data filters, please change filters, refresh page or select a different app to try again

} + {pageState.filters.ready && hasError() && +

Error fetching data filters, please change filters, refresh page or select a different app to try again

} {/* Main data rules UI */} - {pageState.filters.ready - && (pageState.dataTargetingRulesApiStatus === DataTargetingRulesApiStatus.Success || pageState.dataTargetingRulesApiStatus === DataTargetingRulesApiStatus.Loading) && + {canShowContent() &&
-
+
@@ -202,9 +230,9 @@ export default function DataFilters({ params }: { params: { teamId: string } }) {/* Default Event Rule */}

Default Rule

- {allEventsFilter && ( + {eventsDefaultRule && (
- {allEventsFilter && ( + {eventsDefaultRule && (
- {getCollectionConfigDisplay(allEventsFilter.collection_config)} + {getCollectionConfigDisplay(eventsDefaultRule.collection_config)}
)} - +
@@ -231,9 +259,9 @@ export default function DataFilters({ params }: { params: { teamId: string } }) {/* Default Trace Rule */}

Default Rule

- {allTracesFilter && ( + {traceDefaultRule && (
- {allTracesFilter && ( + {traceDefaultRule && (
- {getCollectionConfigDisplay(allTracesFilter.collection_config)} + {getCollectionConfigDisplay(traceDefaultRule.collection_config)}
)} - +
} diff --git a/frontend/dashboard/app/api/api_calls.ts b/frontend/dashboard/app/api/api_calls.ts index 514487b41..d7231f795 100644 --- a/frontend/dashboard/app/api/api_calls.ts +++ b/frontend/dashboard/app/api/api_calls.ts @@ -353,14 +353,21 @@ export enum AlertsOverviewApiStatus { Cancelled, } -export enum DataTargetingRulesApiStatus { +export enum EventTargetingApiStatus { Loading, Success, Error, - NoFilters, + NoData, Cancelled, } +export enum TraceTargetingApiStatus { + Loading, + Success, + Error, + NoData, + Cancelled, +} export enum SessionType { All = "All Sessions", @@ -1031,119 +1038,138 @@ export const emptyAlertsOverviewResponse = { }[], } -export type DataTargetingRulesResponse = { +export type EventTargetingResponse = { meta: { next: false, previous: false, }, - results: DataRule[], -} + result: { + default: EventTargetingRule, + overrides: EventTargetingRule[] + }, +}; -export type DataTargetingRuleType = "event" | "trace" | "all_events" | "all_traces"; +export type EventTargetingRuleType = 'all_events' | 'all_traces' | 'event' | 'trace'; -export type DataTargetingCollectionConfig = +export type EventTargetingCollectionConfig = | { mode: 'sample_rate'; sample_rate: number } | { mode: 'timeline_only' } | { mode: 'disable' }; -export type DataTargetingAttachmentConfig = 'layout_snapshot' | 'screenshot' | 'none'; +export type EventTargetingAttachmentConfig = 'layout_snapshot' | 'screenshot' | 'none'; -export type DataRule = { +export type EventTargetingRule = { id: string, - type: DataTargetingRuleType, + type: EventTargetingRuleType, rule: string, - collection_config: DataTargetingCollectionConfig, - attachment_config: DataTargetingAttachmentConfig, + collection_config: EventTargetingCollectionConfig, + attachment_config: EventTargetingAttachmentConfig, created_at: string, created_by: string, updated_at: string, updated_by: string, } -export const emptyDataFiltersResponse: DataTargetingRulesResponse = { +export type TraceTargetingResponse = { meta: { next: false, previous: false, }, - results: [ - { + result: { + default: TraceTargetingRule, + overrides: TraceTargetingRule[] + }, +}; + +export type TraceTargetingCollectionConfig = + | { mode: 'sample_rate'; sample_rate: number } + | { mode: 'timeline_only' } + | { mode: 'disable' }; + +export type TraceTargetingRule = { + id: string, + type: EventTargetingRuleType, + rule: string, + collection_config: TraceTargetingCollectionConfig, + created_at: string, + created_by: string, + updated_at: string, + updated_by: string, +} + +export const emptyEventTargetingResponse: EventTargetingResponse = { + meta: { + next: false, + previous: false, + }, + result: { + default: { id: "df-global-001", - type: "all_events", + type: 'all_events', rule: 'event_type == "*"', - collection_config: { mode: 'timeline_only'}, + collection_config: { mode: 'timeline_only' }, attachment_config: 'none', created_at: "2024-01-01T00:00:00Z", created_by: "system@example.com", updated_at: "2024-01-01T00:00:00Z", updated_by: "system@example.com", }, - { - id: "df-global-002", - type: "all_traces", - rule: 'span.name == "*"', - collection_config: { mode: 'sample_rate', sample_rate: 1 }, - attachment_config: 'none', + overrides: [ + { + id: "df-001", + type: 'event', + rule: "event.type == 'click' && event.target == 'checkout_button'", + collection_config: { mode: 'sample_rate', sample_rate: 0.5 }, + attachment_config: 'screenshot', + created_at: "2024-01-15T10:30:00Z", + created_by: "user1@example.com", + updated_at: "2024-02-20T14:45:00Z", + updated_by: "user2@example.com", + }, + { + id: "df-003", + type: 'event', + rule: "event.name == 'app_background' && session.is_crash == true", + collection_config: { mode: 'disable' }, + attachment_config: 'none', + created_at: "2024-02-01T12:00:00Z", + created_by: "developer@example.com", + updated_at: "2024-03-10T09:30:00Z", + updated_by: "lead@example.com", + }, + { + id: "df-005", + type: 'event', + rule: "event.type == 'gesture' && device.manufacturer == 'Samsung'", + collection_config: { mode: 'sample_rate', sample_rate: 1.0 }, + attachment_config: 'screenshot', + created_at: "2024-03-05T13:45:00Z", + created_by: "user3@example.com", + updated_at: "2024-03-05T13:45:00Z", + updated_by: "user3@example.com", + }, + ] + }, +} + +export const emptyTraceTargetingResponse: TraceTargetingResponse = { + meta: { + next: false, + previous: false, + }, + result: { + default: { + id: "df-global-traces-001", + type: 'all_traces', + rule: 'trace_type == "*"', + collection_config: { mode: 'timeline_only' }, created_at: "2024-01-01T00:00:00Z", created_by: "system@example.com", updated_at: "2024-01-01T00:00:00Z", updated_by: "system@example.com", }, - { - id: "df-001", - type: "event", - rule: "event.type == 'click' && event.target == 'checkout_button'", - collection_config: { mode: 'sample_rate', sample_rate: 0.5 }, - attachment_config: 'screenshot', - created_at: "2024-01-15T10:30:00Z", - created_by: "user1@example.com", - updated_at: "2024-02-20T14:45:00Z", - updated_by: "user2@example.com", - }, - { - id: "df-002", - type: "trace", - rule: "trace.duration > 5000 && trace.status == 'error'", - collection_config: { mode: 'timeline_only' }, - attachment_config: 'layout_snapshot', - created_at: "2024-01-20T08:15:00Z", - created_by: "admin@example.com", - updated_at: "2024-01-20T08:15:00Z", - updated_by: "admin@example.com", - }, - { - id: "df-003", - type: "event", - rule: "event.name == 'app_background' && session.is_crash == true", - collection_config: { mode: 'disable' }, - attachment_config: 'none', - created_at: "2024-02-01T12:00:00Z", - created_by: "developer@example.com", - updated_at: "2024-03-10T09:30:00Z", - updated_by: "lead@example.com", - }, - { - id: "df-004", - type: "trace", - rule: "trace.name == 'network_request' && trace.http.status_code >= 400", - collection_config: { mode: 'sample_rate', sample_rate: 0.25 }, - attachment_config: 'none', - created_at: "2024-02-10T16:20:00Z", - created_by: "qa@example.com", - updated_at: "2024-02-28T11:15:00Z", - updated_by: "qa@example.com", - }, - { - id: "df-005", - type: "event", - rule: "event.type == 'gesture' && device.manufacturer == 'Samsung'", - collection_config: { mode: 'sample_rate', sample_rate: 1.0 }, - attachment_config: 'screenshot', - created_at: "2024-03-05T13:45:00Z", - created_by: "user3@example.com", - updated_at: "2024-03-05T13:45:00Z", - updated_by: "user3@example.com", - }, - ], + overrides: [] + }, } export class AppVersion { @@ -2481,30 +2507,50 @@ export const fetchAlertsOverviewFromServer = async ( } } -export const fetchDataTargetingRulesFromServer = async ( +export const fetchEventTargetingRulesFromServer = async ( appId: String, - type?: string, ) => { - let url = `/api/apps/${appId}/dataTargetingRules` - if (type) { - url += `?type=${type}` + let url = `/api/apps/${appId}/targetingRules/events` + + try { + const res = await measureAuth.fetchMeasure(url) + + if (!res.ok) { + return { status: EventTargetingApiStatus.Error, data: null } + } + + const data = await res.json() + + if (data.results === null) { + return { status: EventTargetingApiStatus.NoData, data: null } + } else { + return { status: EventTargetingApiStatus.Success, data: data } + } + } catch { + return { status: EventTargetingApiStatus.Cancelled, data: null } } +} + +export const fetchTraceTargetingRulesFromServer = async ( + appId: String, +) => { + let url = `/api/apps/${appId}/targetingRules/traces` try { const res = await measureAuth.fetchMeasure(url) if (!res.ok) { - return { status: DataTargetingRulesApiStatus.Error, data: null } + return { status: TraceTargetingApiStatus.Error, data: null } } const data = await res.json() if (data.results === null) { - return { status: DataTargetingRulesApiStatus.NoFilters, data: null } + return { status: TraceTargetingApiStatus.NoData, data: null } } else { - return { status: DataTargetingRulesApiStatus.Success, data: data } + return { status: TraceTargetingApiStatus.Success, data: data } } } catch { - return { status: DataTargetingRulesApiStatus.Cancelled, data: null } + return { status: TraceTargetingApiStatus.Cancelled, data: null } } } \ No newline at end of file diff --git a/frontend/dashboard/app/components/targeting/edit_default_rule_dialog.tsx b/frontend/dashboard/app/components/targeting/edit_default_rule_dialog.tsx index 3ea82991e..a4360459a 100644 --- a/frontend/dashboard/app/components/targeting/edit_default_rule_dialog.tsx +++ b/frontend/dashboard/app/components/targeting/edit_default_rule_dialog.tsx @@ -1,14 +1,14 @@ "use client" -import { DataTargetingCollectionConfig, DataTargetingRuleType } from '@/app/api/api_calls' +import { EventTargetingCollectionConfig, EventTargetingRuleType } from '@/app/api/api_calls' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/app/components/dialog' import { Button } from '@/app/components/button' import SamplingRateInput from '@/app/components/targeting/sampling_rate_input' export interface DefaultRuleState { id: string - type: DataTargetingRuleType - collectionMode: DataTargetingCollectionConfig['mode'] + type: EventTargetingRuleType + collectionMode: EventTargetingCollectionConfig['mode'] sampleRate?: number } diff --git a/frontend/dashboard/app/components/targeting/rule_overrides_table.tsx b/frontend/dashboard/app/components/targeting/rule_overrides_table.tsx index a8856e826..c256ae070 100644 --- a/frontend/dashboard/app/components/targeting/rule_overrides_table.tsx +++ b/frontend/dashboard/app/components/targeting/rule_overrides_table.tsx @@ -1,12 +1,12 @@ "use client" -import { DataTargetingRulesResponse, DataTargetingCollectionConfig, DataTargetingAttachmentConfig, DataTargetingRuleType } from '@/app/api/api_calls' +import { EventTargetingRule, TraceTargetingRule, EventTargetingCollectionConfig, EventTargetingAttachmentConfig, EventTargetingRuleType } from '@/app/api/api_calls' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/app/components/table' import { formatDateToHumanReadableDate, formatDateToHumanReadableTime } from '@/app/utils/time_utils' import Paginator from '@/app/components/paginator' import { useState, useEffect } from 'react' -const getFilterDisplayText = (type: DataTargetingRuleType, filter: string): string => { +const getFilterDisplayText = (type: EventTargetingRuleType, filter: string): string => { switch (type) { case 'all_events': return 'All Events' @@ -17,7 +17,7 @@ const getFilterDisplayText = (type: DataTargetingRuleType, filter: string): stri } } -const getCollectionConfigDisplay = (collectionConfig: DataTargetingCollectionConfig): string => { +const getCollectionConfigDisplay = (collectionConfig: EventTargetingCollectionConfig): string => { switch (collectionConfig.mode) { case 'sample_rate': return `Collect all at ${collectionConfig.sample_rate}% sample rate` @@ -30,8 +30,8 @@ const getCollectionConfigDisplay = (collectionConfig: DataTargetingCollectionCon } } -const getAttachmentConfigDisplay = (attachmentConfig: DataTargetingAttachmentConfig): string => { - if (attachmentConfig === 'none') { +const getAttachmentConfigDisplay = (attachmentConfig?: EventTargetingAttachmentConfig): string => { + if (!attachmentConfig || attachmentConfig === 'none') { return '' } else if (attachmentConfig === 'layout_snapshot') { return 'With layout snapshot' @@ -41,7 +41,7 @@ const getAttachmentConfigDisplay = (attachmentConfig: DataTargetingAttachmentCon return attachmentConfig } -type RuleFilter = DataTargetingRulesResponse['results'][0] +type RuleFilter = EventTargetingRule | TraceTargetingRule interface RulesTableProps { rules: RuleFilter[] @@ -108,7 +108,7 @@ export default function RulesTable({ rules, onRuleClick }: RulesTableProps) {

{getFilterDisplayText(dataFilter.type, dataFilter.rule)}

{getCollectionConfigDisplay(dataFilter.collection_config)}

-

{getAttachmentConfigDisplay(dataFilter.attachment_config)}

+

{getAttachmentConfigDisplay('attachment_config' in dataFilter ? dataFilter.attachment_config : undefined)}

{formatDateToHumanReadableDate(dataFilter.updated_at)}

From 28557fe22e932867072a5ed82e0358c615497eef Mon Sep 17 00:00:00 2001 From: Abhay Sood Date: Wed, 5 Nov 2025 21:47:58 +0530 Subject: [PATCH 22/98] feat(frontend): implement pagination for both APIs --- frontend/dashboard/app/[teamId]/data/page.tsx | 50 +++++++++++++++++-- frontend/dashboard/app/api/api_calls.ts | 14 ++++++ .../targeting/rule_overrides_table.tsx | 36 ++++--------- 3 files changed, 69 insertions(+), 31 deletions(-) diff --git a/frontend/dashboard/app/[teamId]/data/page.tsx b/frontend/dashboard/app/[teamId]/data/page.tsx index cb46b4e63..ebca6bf18 100644 --- a/frontend/dashboard/app/[teamId]/data/page.tsx +++ b/frontend/dashboard/app/[teamId]/data/page.tsx @@ -18,9 +18,13 @@ interface PageState { filters: typeof defaultFilters eventTargetingRules: EventTargetingResponse traceTargetingRules: TraceTargetingResponse + eventPaginationOffset: number + tracePaginationOffset: number defaultRuleEditState: DefaultRuleEditState | null } +const paginationLimit = 5 + const getCollectionConfigDisplay = (collectionConfig: EventTargetingCollectionConfig): string => { switch (collectionConfig.mode) { case 'sample_rate': @@ -44,6 +48,8 @@ export default function DataFilters({ params }: { params: { teamId: string } }) eventTargetingRules: emptyEventTargetingResponse, traceTargetingRules: emptyTraceTargetingResponse, defaultRuleEditState: null, + eventPaginationOffset: 0, + tracePaginationOffset: 0, } const [pageState, setPageState] = useState(initialState) @@ -62,8 +68,8 @@ export default function DataFilters({ params }: { params: { teamId: string } }) }) const [eventResult, traceResult] = await Promise.all([ - fetchEventTargetingRulesFromServer(pageState.filters.app!.id), - fetchTraceTargetingRulesFromServer(pageState.filters.app!.id) + fetchEventTargetingRulesFromServer(pageState.filters.app!.id, paginationLimit, pageState.eventPaginationOffset), + fetchTraceTargetingRulesFromServer(pageState.filters.app!.id, paginationLimit, pageState.tracePaginationOffset) ]) if (eventResult.status === EventTargetingApiStatus.Error || traceResult.status === TraceTargetingApiStatus.Error) { @@ -96,10 +102,28 @@ export default function DataFilters({ params }: { params: { teamId: string } }) filters: updatedFilters, eventTargetingRules: emptyEventTargetingResponse, traceTargetingRules: emptyTraceTargetingResponse, + eventPaginationOffset: 0, + tracePaginationOffset: 0, }) } } + const handleEventNextPage = () => { + updatePageState({ eventPaginationOffset: pageState.eventPaginationOffset + paginationLimit }) + } + + const handleEventPrevPage = () => { + updatePageState({ eventPaginationOffset: Math.max(0, pageState.eventPaginationOffset - paginationLimit) }) + } + + const handleTraceNextPage = () => { + updatePageState({ tracePaginationOffset: pageState.tracePaginationOffset + paginationLimit }) + } + + const handleTracePrevPage = () => { + updatePageState({ tracePaginationOffset: Math.max(0, pageState.tracePaginationOffset - paginationLimit) }) + } + useEffect(() => { if (!pageState.filters.ready) { return @@ -110,7 +134,7 @@ export default function DataFilters({ params }: { params: { teamId: string } }) // TODO: Re-enable API call when ready // getDataFilters() - }, [pageState.filters]) + }, [pageState.filters, pageState.eventPaginationOffset, pageState.tracePaginationOffset]) const isLoading = () => { return pageState.eventTargetingApiStatus === EventTargetingApiStatus.Loading || @@ -246,7 +270,15 @@ export default function DataFilters({ params }: { params: { teamId: string } })
)} - + 0} + />
@@ -275,7 +307,15 @@ export default function DataFilters({ params }: { params: { teamId: string } })
)} - + 0} + />
} diff --git a/frontend/dashboard/app/api/api_calls.ts b/frontend/dashboard/app/api/api_calls.ts index d7231f795..4a9a4d85f 100644 --- a/frontend/dashboard/app/api/api_calls.ts +++ b/frontend/dashboard/app/api/api_calls.ts @@ -2509,9 +2509,16 @@ export const fetchAlertsOverviewFromServer = async ( export const fetchEventTargetingRulesFromServer = async ( appId: String, + limit: number, + offset: number, ) => { let url = `/api/apps/${appId}/targetingRules/events` + const searchParams = new URLSearchParams() + searchParams.append("limit", String(limit)) + searchParams.append("offset", String(offset)) + url += `?${searchParams.toString()}` + try { const res = await measureAuth.fetchMeasure(url) @@ -2533,9 +2540,16 @@ export const fetchEventTargetingRulesFromServer = async ( export const fetchTraceTargetingRulesFromServer = async ( appId: String, + limit: number, + offset: number, ) => { let url = `/api/apps/${appId}/targetingRules/traces` + const searchParams = new URLSearchParams() + searchParams.append("limit", String(limit)) + searchParams.append("offset", String(offset)) + url += `?${searchParams.toString()}` + try { const res = await measureAuth.fetchMeasure(url) diff --git a/frontend/dashboard/app/components/targeting/rule_overrides_table.tsx b/frontend/dashboard/app/components/targeting/rule_overrides_table.tsx index c256ae070..e9de6d626 100644 --- a/frontend/dashboard/app/components/targeting/rule_overrides_table.tsx +++ b/frontend/dashboard/app/components/targeting/rule_overrides_table.tsx @@ -4,7 +4,6 @@ import { EventTargetingRule, TraceTargetingRule, EventTargetingCollectionConfig, import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/app/components/table' import { formatDateToHumanReadableDate, formatDateToHumanReadableTime } from '@/app/utils/time_utils' import Paginator from '@/app/components/paginator' -import { useState, useEffect } from 'react' const getFilterDisplayText = (type: EventTargetingRuleType, filter: string): string => { switch (type) { @@ -46,45 +45,30 @@ type RuleFilter = EventTargetingRule | TraceTargetingRule interface RulesTableProps { rules: RuleFilter[] onRuleClick: (rule: RuleFilter) => void + prevEnabled: boolean + nextEnabled: boolean + onNext: () => void + onPrev: () => void + showPaginator: boolean } -const paginationLimit = 5 - -export default function RulesTable({ rules, onRuleClick }: RulesTableProps) { - const [paginationOffset, setPaginationOffset] = useState(0) - - useEffect(() => { - setPaginationOffset(0) - }, [rules]) - +export default function RulesTable({ rules, onRuleClick, prevEnabled, nextEnabled, onNext, onPrev, showPaginator }: RulesTableProps) { if (rules.length === 0) { return null } - const handleNextPage = () => { - setPaginationOffset(paginationOffset + paginationLimit) - } - - const handlePrevPage = () => { - setPaginationOffset(Math.max(0, paginationOffset - paginationLimit)) - } - - const prevEnabled = paginationOffset > 0 - const nextEnabled = paginationOffset + paginationLimit < rules.length - const paginatedRules = rules.slice(paginationOffset, paginationOffset + paginationLimit) - return ( <>

Overrides

- {rules.length > paginationLimit && ( + {showPaginator && ( )}
@@ -98,7 +82,7 @@ export default function RulesTable({ rules, onRuleClick }: RulesTableProps) { - {paginatedRules.map((dataFilter, idx) => ( + {rules.map((dataFilter, idx) => ( Date: Wed, 5 Nov 2025 22:13:39 +0530 Subject: [PATCH 23/98] feat(frontend): refactor edit default rule flow --- frontend/dashboard/app/[teamId]/data/page.tsx | 69 ++++--- frontend/dashboard/app/api/api_calls.ts | 9 - .../targeting/edit_default_rule_dialog.tsx | 177 +++++++++++------- 3 files changed, 146 insertions(+), 109 deletions(-) diff --git a/frontend/dashboard/app/[teamId]/data/page.tsx b/frontend/dashboard/app/[teamId]/data/page.tsx index ebca6bf18..e2616be6d 100644 --- a/frontend/dashboard/app/[teamId]/data/page.tsx +++ b/frontend/dashboard/app/[teamId]/data/page.tsx @@ -9,8 +9,9 @@ import { useEffect, useState } from 'react' import { Button } from '@/app/components/button' import { Plus, Pencil } from 'lucide-react' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/app/components/dropdown_menu' -import EditDefaultRuleDialog, { DefaultRuleState as DefaultRuleEditState } from '@/app/components/targeting/edit_default_rule_dialog' +import EditDefaultRuleDialog from '@/app/components/targeting/edit_default_rule_dialog' import RulesTable from '@/app/components/targeting/rule_overrides_table' +import { toastPositive, toastNegative } from '@/app/utils/use_toast' interface PageState { eventTargetingApiStatus: EventTargetingApiStatus @@ -20,7 +21,7 @@ interface PageState { traceTargetingRules: TraceTargetingResponse eventPaginationOffset: number tracePaginationOffset: number - defaultRuleEditState: DefaultRuleEditState | null + editingDefaultRule: 'event' | 'trace' | null } const paginationLimit = 5 @@ -47,9 +48,9 @@ export default function DataFilters({ params }: { params: { teamId: string } }) filters: defaultFilters, eventTargetingRules: emptyEventTargetingResponse, traceTargetingRules: emptyTraceTargetingResponse, - defaultRuleEditState: null, eventPaginationOffset: 0, tracePaginationOffset: 0, + editingDefaultRule: null, } const [pageState, setPageState] = useState(initialState) @@ -159,28 +160,16 @@ export default function DataFilters({ params }: { params: { teamId: string } }) const traceDefaultRule = pageState.traceTargetingRules.result.default; const traceOverrideRules = pageState.traceTargetingRules.result.overrides; - const handleEditFilter = (dataFilter: typeof eventsOverideRules[0] | typeof traceOverrideRules[0]) => { - const filterType = dataFilter.type === 'event' ? 'event' : 'trace' + const handleEditFilter = (dataFilter: typeof eventsOverideRules[0] | typeof traceOverrideRules[0], filterType: 'event' | 'trace') => { router.push(`/${params.teamId}/data/${filterType}/${dataFilter.id}/edit`) } - const handleEditDefaultRule = (dataFilter: typeof eventsDefaultRule | typeof traceDefaultRule) => { - updatePageState({ - defaultRuleEditState: { - id: dataFilter.id, - type: dataFilter.type, - collectionMode: dataFilter.collection_config.mode, - sampleRate: dataFilter.collection_config.mode === 'sample_rate' ? dataFilter.collection_config.sample_rate : undefined - } - }) - } - - const handleSaveDefaultRule = () => { - updatePageState({ defaultRuleEditState: null }) + const handleDefaultRuleUpdateSuccess = () => { + toastPositive('Rule updated successfully') } - const handleCancelDefaultRule = () => { - updatePageState({ defaultRuleEditState: null }) + const handleDefaultRuleUpdateError = (error: string) => { + toastNegative('Failed to update rule', error) } return ( @@ -256,7 +245,7 @@ export default function DataFilters({ params }: { params: { teamId: string } })

Default Rule

{eventsDefaultRule && (
} {/* Default Rule Edit Dialog */} - updatePageState({ defaultRuleEditState: updatedRule })} - /> + {pageState.editingDefaultRule && ( + updatePageState({ editingDefaultRule: null })} + onSuccess={handleDefaultRuleUpdateSuccess} + onError={handleDefaultRuleUpdateError} + ruleType={pageState.editingDefaultRule} + ruleId={ + pageState.editingDefaultRule === 'event' + ? eventsDefaultRule.id + : traceDefaultRule.id + } + appId={pageState.filters.app!.id} + initialCollectionMode={ + pageState.editingDefaultRule === 'event' + ? eventsDefaultRule.collection_config.mode + : traceDefaultRule.collection_config.mode + } + initialSampleRate={ + pageState.editingDefaultRule === 'event' + ? (eventsDefaultRule.collection_config.mode === 'sample_rate' ? eventsDefaultRule.collection_config.sample_rate : undefined) + : (traceDefaultRule.collection_config.mode === 'sample_rate' ? traceDefaultRule.collection_config.sample_rate : undefined) + } + /> + )}
) } diff --git a/frontend/dashboard/app/api/api_calls.ts b/frontend/dashboard/app/api/api_calls.ts index 4a9a4d85f..abd4969dd 100644 --- a/frontend/dashboard/app/api/api_calls.ts +++ b/frontend/dashboard/app/api/api_calls.ts @@ -1049,8 +1049,6 @@ export type EventTargetingResponse = { }, }; -export type EventTargetingRuleType = 'all_events' | 'all_traces' | 'event' | 'trace'; - export type EventTargetingCollectionConfig = | { mode: 'sample_rate'; sample_rate: number } | { mode: 'timeline_only' } @@ -1060,7 +1058,6 @@ export type EventTargetingAttachmentConfig = 'layout_snapshot' | 'screenshot' | export type EventTargetingRule = { id: string, - type: EventTargetingRuleType, rule: string, collection_config: EventTargetingCollectionConfig, attachment_config: EventTargetingAttachmentConfig, @@ -1088,7 +1085,6 @@ export type TraceTargetingCollectionConfig = export type TraceTargetingRule = { id: string, - type: EventTargetingRuleType, rule: string, collection_config: TraceTargetingCollectionConfig, created_at: string, @@ -1105,7 +1101,6 @@ export const emptyEventTargetingResponse: EventTargetingResponse = { result: { default: { id: "df-global-001", - type: 'all_events', rule: 'event_type == "*"', collection_config: { mode: 'timeline_only' }, attachment_config: 'none', @@ -1117,7 +1112,6 @@ export const emptyEventTargetingResponse: EventTargetingResponse = { overrides: [ { id: "df-001", - type: 'event', rule: "event.type == 'click' && event.target == 'checkout_button'", collection_config: { mode: 'sample_rate', sample_rate: 0.5 }, attachment_config: 'screenshot', @@ -1128,7 +1122,6 @@ export const emptyEventTargetingResponse: EventTargetingResponse = { }, { id: "df-003", - type: 'event', rule: "event.name == 'app_background' && session.is_crash == true", collection_config: { mode: 'disable' }, attachment_config: 'none', @@ -1139,7 +1132,6 @@ export const emptyEventTargetingResponse: EventTargetingResponse = { }, { id: "df-005", - type: 'event', rule: "event.type == 'gesture' && device.manufacturer == 'Samsung'", collection_config: { mode: 'sample_rate', sample_rate: 1.0 }, attachment_config: 'screenshot', @@ -1160,7 +1152,6 @@ export const emptyTraceTargetingResponse: TraceTargetingResponse = { result: { default: { id: "df-global-traces-001", - type: 'all_traces', rule: 'trace_type == "*"', collection_config: { mode: 'timeline_only' }, created_at: "2024-01-01T00:00:00Z", diff --git a/frontend/dashboard/app/components/targeting/edit_default_rule_dialog.tsx b/frontend/dashboard/app/components/targeting/edit_default_rule_dialog.tsx index a4360459a..0107f6080 100644 --- a/frontend/dashboard/app/components/targeting/edit_default_rule_dialog.tsx +++ b/frontend/dashboard/app/components/targeting/edit_default_rule_dialog.tsx @@ -1,108 +1,147 @@ "use client" -import { EventTargetingCollectionConfig, EventTargetingRuleType } from '@/app/api/api_calls' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/app/components/dialog' import { Button } from '@/app/components/button' import SamplingRateInput from '@/app/components/targeting/sampling_rate_input' +import { useState, useEffect } from 'react' -export interface DefaultRuleState { - id: string - type: EventTargetingRuleType - collectionMode: EventTargetingCollectionConfig['mode'] - sampleRate?: number -} +type CollectionMode = 'sample_rate' | 'timeline_only' | 'disable' interface EditDefaultRuleDialogProps { isOpen: boolean - defaultRule: DefaultRuleState | null + ruleType: 'event' | 'trace' + ruleId: string + appId: string + initialCollectionMode: CollectionMode + initialSampleRate?: number onClose: () => void - onSave: () => void - onUpdate: (updatedRule: DefaultRuleState) => void + onSuccess: () => void + onError: (error: string) => void } export default function EditDefaultRuleDialog({ isOpen, - defaultRule, onClose, - onSave, - onUpdate + onSuccess, + onError, + ruleType, + ruleId, + appId, + initialCollectionMode, + initialSampleRate }: EditDefaultRuleDialogProps) { + const [collectionMode, setCollectionMode] = useState(initialCollectionMode) + const [sampleRate, setSampleRate] = useState(initialSampleRate || 100) + const [isSaving, setIsSaving] = useState(false) + + useEffect(() => { + if (isOpen) { + setCollectionMode(initialCollectionMode) + setSampleRate(initialSampleRate || 100) + } + }, [isOpen, initialCollectionMode, initialSampleRate]) + + const handleSave = async () => { + setIsSaving(true) + + try { + // TODO: Implement actual API call + // const result = await updateDefaultTargetingRule(appId, ruleId, { + // collection_config: collectionMode === 'sample_rate' + // ? { mode: 'sample_rate', sample_rate: sampleRate } + // : { mode: collectionMode } + // }) + + // Simulate API call + await new Promise(resolve => setTimeout(resolve, 500)) + + // For now, always succeed + onSuccess() + onClose() + } catch (error) { + onError(error instanceof Error ? error.message : 'Failed to update rule') + } finally { + setIsSaving(false) + } + } + + const isEvent = ruleType === 'event' + const displayName = isEvent ? 'Events' : 'Traces' + const displayNameLower = isEvent ? 'events' : 'traces' + return ( !open && onClose()}> - Edit Default {defaultRule?.type === 'all_events' ? 'Events' : 'Traces'} Rule + Edit Default {displayName} Rule -
- - - - - +
+ {/* Collection Config Section */} +
+

Collection Config

+ + + + + + +
@@ -289,9 +311,9 @@ export default function DataFilters({ params }: { params: { teamId: string } })
)} - handleEditFilter(rule, 'event')} + onRuleClick={(rule) => handleEditRule(rule, 'event')} prevEnabled={pageState.eventTargetingRules.meta.previous} nextEnabled={pageState.eventTargetingRules.meta.next} onNext={handleEventNextPage} @@ -326,9 +348,9 @@ export default function DataFilters({ params }: { params: { teamId: string } })
)} - handleEditFilter(rule, 'trace')} + onRuleClick={(rule) => handleEditRule(rule, 'trace')} prevEnabled={pageState.traceTargetingRules.meta.previous} nextEnabled={pageState.traceTargetingRules.meta.next} onNext={handleTraceNextPage} @@ -336,6 +358,25 @@ export default function DataFilters({ params }: { params: { teamId: string } }) showPaginator={traceOverrideRules.length > 0} />
+ +
+ + {/* Session Timeline Rules Section */} +
+

Session Timeline Rules

+ +
+ + handleEditRule(rule, 'session')} + prevEnabled={pageState.sessionTargetingRules.meta.previous} + nextEnabled={pageState.sessionTargetingRules.meta.next} + onNext={handleSessionNextPage} + onPrev={handleSessionPrevPage} + showPaginator={sessionTargetingRules.length > 0} + /> +
} {/* Default Rule Edit Dialog */} diff --git a/frontend/dashboard/app/[teamId]/data/session/[ruleId]/edit/page.tsx b/frontend/dashboard/app/[teamId]/data/session/[ruleId]/edit/page.tsx new file mode 100644 index 000000000..e0d6b9c68 --- /dev/null +++ b/frontend/dashboard/app/[teamId]/data/session/[ruleId]/edit/page.tsx @@ -0,0 +1,50 @@ +"use client" + +import { useRouter } from 'next/navigation' +import { Button } from '@/app/components/button' +import { Card, CardContent, CardFooter } from '@/app/components/card' + +export default function EditSessionFilter({ params }: { params: { teamId: string, ruleId: string } }) { + const router = useRouter() + + const handleCancel = () => { + router.push(`/${params.teamId}/data`) + } + + const handleSave = () => { + // TODO: Implement save logic + router.push(`/${params.teamId}/data`) + } + + return ( +
+

Edit Session Filter

+
+ + + +
+ {/* TODO: Add form fields */} +
+
+ + + + + +
+
+ ) +} diff --git a/frontend/dashboard/app/[teamId]/data/session/[ruleId]/page.tsx b/frontend/dashboard/app/[teamId]/data/session/[ruleId]/page.tsx deleted file mode 100644 index e69de29bb..000000000 diff --git a/frontend/dashboard/app/[teamId]/data/session/create/page.tsx b/frontend/dashboard/app/[teamId]/data/session/create/page.tsx index e69de29bb..54258ee5f 100644 --- a/frontend/dashboard/app/[teamId]/data/session/create/page.tsx +++ b/frontend/dashboard/app/[teamId]/data/session/create/page.tsx @@ -0,0 +1,50 @@ +"use client" + +import { useRouter } from 'next/navigation' +import { Button } from '@/app/components/button' +import { Card, CardContent, CardFooter } from '@/app/components/card' + +export default function CreateSessionFilter({ params }: { params: { teamId: string } }) { + const router = useRouter() + + const handleCancel = () => { + router.push(`/${params.teamId}/data`) + } + + const handleCreate = () => { + // TODO: Implement create logic + router.push(`/${params.teamId}/data`) + } + + return ( +
+

Create Session Filter

+
+ + + +
+ {/* TODO: Add form fields */} +
+
+ + + + + +
+
+ ) +} diff --git a/frontend/dashboard/app/[teamId]/data/trace/[ruleId]/edit/page.tsx b/frontend/dashboard/app/[teamId]/data/trace/[ruleId]/edit/page.tsx index e6e64ae04..5fd711ad4 100644 --- a/frontend/dashboard/app/[teamId]/data/trace/[ruleId]/edit/page.tsx +++ b/frontend/dashboard/app/[teamId]/data/trace/[ruleId]/edit/page.tsx @@ -8,12 +8,12 @@ export default function EditTraceFilter({ params }: { params: { teamId: string, const router = useRouter() const handleCancel = () => { - router.push(`/${params.teamId}/data_filters`) + router.push(`/${params.teamId}/data`) } const handleSave = () => { // TODO: Implement save logic - router.push(`/${params.teamId}/data_filters`) + router.push(`/${params.teamId}/data`) } return ( diff --git a/frontend/dashboard/app/[teamId]/data/trace/create/page.tsx b/frontend/dashboard/app/[teamId]/data/trace/create/page.tsx index 663ffb23a..d59a2f4a8 100644 --- a/frontend/dashboard/app/[teamId]/data/trace/create/page.tsx +++ b/frontend/dashboard/app/[teamId]/data/trace/create/page.tsx @@ -8,12 +8,12 @@ export default function CreateTraceFilter({ params }: { params: { teamId: string const router = useRouter() const handleCancel = () => { - router.push(`/${params.teamId}/data_filters`) + router.push(`/${params.teamId}/data`) } const handleCreate = () => { // TODO: Implement create logic - router.push(`/${params.teamId}/data_filters`) + router.push(`/${params.teamId}/data`) } return ( diff --git a/frontend/dashboard/app/api/api_calls.ts b/frontend/dashboard/app/api/api_calls.ts index abd4969dd..65e1e9b33 100644 --- a/frontend/dashboard/app/api/api_calls.ts +++ b/frontend/dashboard/app/api/api_calls.ts @@ -369,6 +369,14 @@ export enum TraceTargetingApiStatus { Cancelled, } +export enum SessionTargetingApiStatus { + Loading, + Success, + Error, + NoData, + Cancelled, +} + export enum SessionType { All = "All Sessions", Crashes = "Crash Sessions", @@ -1061,8 +1069,6 @@ export type EventTargetingRule = { rule: string, collection_config: EventTargetingCollectionConfig, attachment_config: EventTargetingAttachmentConfig, - created_at: string, - created_by: string, updated_at: string, updated_by: string, } @@ -1087,8 +1093,22 @@ export type TraceTargetingRule = { id: string, rule: string, collection_config: TraceTargetingCollectionConfig, - created_at: string, - created_by: string, + updated_at: string, + updated_by: string, +} + +export type SessionTargetingResponse = { + meta: { + next: false, + previous: false, + }, + results: SessionTargetingRule[] +}; + +export type SessionTargetingRule = { + id: string, + rule: string, + sampling_rate: number, updated_at: string, updated_by: string, } @@ -1104,43 +1124,10 @@ export const emptyEventTargetingResponse: EventTargetingResponse = { rule: 'event_type == "*"', collection_config: { mode: 'timeline_only' }, attachment_config: 'none', - created_at: "2024-01-01T00:00:00Z", - created_by: "system@example.com", updated_at: "2024-01-01T00:00:00Z", updated_by: "system@example.com", }, - overrides: [ - { - id: "df-001", - rule: "event.type == 'click' && event.target == 'checkout_button'", - collection_config: { mode: 'sample_rate', sample_rate: 0.5 }, - attachment_config: 'screenshot', - created_at: "2024-01-15T10:30:00Z", - created_by: "user1@example.com", - updated_at: "2024-02-20T14:45:00Z", - updated_by: "user2@example.com", - }, - { - id: "df-003", - rule: "event.name == 'app_background' && session.is_crash == true", - collection_config: { mode: 'disable' }, - attachment_config: 'none', - created_at: "2024-02-01T12:00:00Z", - created_by: "developer@example.com", - updated_at: "2024-03-10T09:30:00Z", - updated_by: "lead@example.com", - }, - { - id: "df-005", - rule: "event.type == 'gesture' && device.manufacturer == 'Samsung'", - collection_config: { mode: 'sample_rate', sample_rate: 1.0 }, - attachment_config: 'screenshot', - created_at: "2024-03-05T13:45:00Z", - created_by: "user3@example.com", - updated_at: "2024-03-05T13:45:00Z", - updated_by: "user3@example.com", - }, - ] + overrides: [] }, } @@ -1154,8 +1141,6 @@ export const emptyTraceTargetingResponse: TraceTargetingResponse = { id: "df-global-traces-001", rule: 'trace_type == "*"', collection_config: { mode: 'timeline_only' }, - created_at: "2024-01-01T00:00:00Z", - created_by: "system@example.com", updated_at: "2024-01-01T00:00:00Z", updated_by: "system@example.com", }, @@ -1163,6 +1148,23 @@ export const emptyTraceTargetingResponse: TraceTargetingResponse = { }, } +export const emptySessionTargetingResponse: SessionTargetingResponse = { + meta: { + next: false, + previous: false, + }, + results: [ + { + id: "df-sessions-001", + rule: "Default Session Targeting Rule", + rule: 'event.event_type == "exception"', + sampling_rate: 0, + updated_at: "2024-01-01T00:00:00Z", + updated_by: "system@example.com", + }, + ] +} + export class AppVersion { name: string code: string @@ -2558,4 +2560,35 @@ export const fetchTraceTargetingRulesFromServer = async ( } catch { return { status: TraceTargetingApiStatus.Cancelled, data: null } } +} + +export const fetchSessionTargetingRulesFromServer = async ( + appId: String, + limit: number, + offset: number, +) => { + let url = `/api/apps/${appId}/targetingRules/sessions` + + const searchParams = new URLSearchParams() + searchParams.append("limit", String(limit)) + searchParams.append("offset", String(offset)) + url += `?${searchParams.toString()}` + + try { + const res = await measureAuth.fetchMeasure(url) + + if (!res.ok) { + return { status: SessionTargetingApiStatus.Error, data: null } + } + + const data = await res.json() + + if (data.results === null) { + return { status: SessionTargetingApiStatus.NoData, data: null } + } else { + return { status: SessionTargetingApiStatus.Success, data: data } + } + } catch { + return { status: SessionTargetingApiStatus.Cancelled, data: null } + } } \ No newline at end of file diff --git a/frontend/dashboard/app/components/targeting/rule_overrides_table.tsx b/frontend/dashboard/app/components/targeting/rule_overrides_table.tsx index e9de6d626..1eade276b 100644 --- a/frontend/dashboard/app/components/targeting/rule_overrides_table.tsx +++ b/frontend/dashboard/app/components/targeting/rule_overrides_table.tsx @@ -40,11 +40,11 @@ const getAttachmentConfigDisplay = (attachmentConfig?: EventTargetingAttachmentC return attachmentConfig } -type RuleFilter = EventTargetingRule | TraceTargetingRule +type Rule = EventTargetingRule | TraceTargetingRule interface RulesTableProps { - rules: RuleFilter[] - onRuleClick: (rule: RuleFilter) => void + rules: Rule[] + onRuleClick: (rule: Rule) => void prevEnabled: boolean nextEnabled: boolean onNext: () => void @@ -52,7 +52,7 @@ interface RulesTableProps { showPaginator: boolean } -export default function RulesTable({ rules, onRuleClick, prevEnabled, nextEnabled, onNext, onPrev, showPaginator }: RulesTableProps) { +export default function RulesOverridesTable({ rules, onRuleClick, prevEnabled, nextEnabled, onNext, onPrev, showPaginator }: RulesTableProps) { if (rules.length === 0) { return null } diff --git a/frontend/dashboard/app/components/targeting/session_targeting_table.tsx b/frontend/dashboard/app/components/targeting/session_targeting_table.tsx new file mode 100644 index 000000000..37496d10c --- /dev/null +++ b/frontend/dashboard/app/components/targeting/session_targeting_table.tsx @@ -0,0 +1,75 @@ +"use client" + +import { SessionTargetingRule } from '@/app/api/api_calls' +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/app/components/table' +import { formatDateToHumanReadableDate, formatDateToHumanReadableTime } from '@/app/utils/time_utils' +import Paginator from '@/app/components/paginator' + +const getSamplingRateDisplay = (samplingRate: number): string => { + return `${samplingRate}% sampling rate` +} + +interface SessionTargetingTableProps { + rules: SessionTargetingRule[] + onRuleClick: (rule: SessionTargetingRule) => void + prevEnabled: boolean + nextEnabled: boolean + onNext: () => void + onPrev: () => void + showPaginator: boolean +} + +export default function SessionTargetingTable({ rules, onRuleClick, prevEnabled, nextEnabled, onNext, onPrev, showPaginator }: SessionTargetingTableProps) { + if (rules.length === 0) { + return null + } + + return ( + <> +
+ {showPaginator && ( + + )} +
+
+
+ + + Rule + Updated At + Updated By + + + + {rules.map((rule, idx) => ( + onRuleClick(rule)} + > + +

{rule.rule}

+
+

{getSamplingRateDisplay(rule.sampling_rate)}

+ + +

{formatDateToHumanReadableDate(rule.updated_at)}

+
+

{formatDateToHumanReadableTime(rule.updated_at)}

+ + +

{rule.updated_by}

+
+ + ))} + +
+ + ) +} From 438fec3651c74c8a3d0b2761ddfbebbb07c2d848 Mon Sep 17 00:00:00 2001 From: Abhay Sood Date: Thu, 6 Nov 2025 00:16:04 +0530 Subject: [PATCH 27/98] feat(frontend): add dummy data --- frontend/dashboard/app/api/api_calls.ts | 37 +++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/frontend/dashboard/app/api/api_calls.ts b/frontend/dashboard/app/api/api_calls.ts index 65e1e9b33..ed300e0b2 100644 --- a/frontend/dashboard/app/api/api_calls.ts +++ b/frontend/dashboard/app/api/api_calls.ts @@ -1127,7 +1127,24 @@ export const emptyEventTargetingResponse: EventTargetingResponse = { updated_at: "2024-01-01T00:00:00Z", updated_by: "system@example.com", }, - overrides: [] + overrides: [ + { + id: "df-events-001", + rule: 'event.event_type == "crash"', + collection_config: { mode: 'sample_rate', sample_rate: 5 }, + attachment_config: 'screenshot', + updated_at: "2024-01-01T00:00:00Z", + updated_by: "system@example.com", + }, + { + id: "df-events-002", + rule: 'event.event_type == "anr"', + collection_config: { mode: 'disable' }, + attachment_config: 'none', + updated_at: "2024-01-01T00:00:00Z", + updated_by: "system@example.com", + } + ] }, } @@ -1144,7 +1161,22 @@ export const emptyTraceTargetingResponse: TraceTargetingResponse = { updated_at: "2024-01-01T00:00:00Z", updated_by: "system@example.com", }, - overrides: [] + overrides: [ + { + id: "df-traces-001", + rule: 'trace.trace_name == "root"', + collection_config: { mode: 'sample_rate', sample_rate: 10 }, + updated_at: "2024-01-01T00:00:00Z", + updated_by: "system@example.com", + }, + { + id: "df-traces-002", + rule: 'trace.trace_name == "activity.onCreate"', + collection_config: { mode: 'disable' }, + updated_at: "2024-01-01T00:00:00Z", + updated_by: "system@example.com", + } + ] }, } @@ -1156,7 +1188,6 @@ export const emptySessionTargetingResponse: SessionTargetingResponse = { results: [ { id: "df-sessions-001", - rule: "Default Session Targeting Rule", rule: 'event.event_type == "exception"', sampling_rate: 0, updated_at: "2024-01-01T00:00:00Z", From 826566a2e09055923f5cb29c70d58133dc79db94 Mon Sep 17 00:00:00 2001 From: Abhay Sood Date: Thu, 6 Nov 2025 00:29:28 +0530 Subject: [PATCH 28/98] feat(frontend): basic scaffloding for create and edit event/trace rule --- .../data/event/[ruleId]/edit/page.tsx | 39 +----- .../app/[teamId]/data/event/create/page.tsx | 38 +---- .../data/trace/[ruleId]/edit/page.tsx | 38 +---- .../app/[teamId]/data/trace/create/page.tsx | 38 +---- .../targeting/event_trace_rule_builder.tsx | 67 +++++++++ .../targeting/rule_builder_card.tsx | 130 ------------------ 6 files changed, 95 insertions(+), 255 deletions(-) create mode 100644 frontend/dashboard/app/components/targeting/event_trace_rule_builder.tsx delete mode 100644 frontend/dashboard/app/components/targeting/rule_builder_card.tsx diff --git a/frontend/dashboard/app/[teamId]/data/event/[ruleId]/edit/page.tsx b/frontend/dashboard/app/[teamId]/data/event/[ruleId]/edit/page.tsx index 2a7515775..4f73fcd0d 100644 --- a/frontend/dashboard/app/[teamId]/data/event/[ruleId]/edit/page.tsx +++ b/frontend/dashboard/app/[teamId]/data/event/[ruleId]/edit/page.tsx @@ -1,8 +1,7 @@ "use client" import { useRouter } from 'next/navigation' -import { Button } from '@/app/components/button' -import { Card, CardContent, CardFooter } from '@/app/components/card' +import EventTraceRuleBuilder from '@/app/components/targeting/event_trace_rule_builder' export default function EditEventFilter({ params }: { params: { teamId: string, filterId: string } }) { const router = useRouter() @@ -12,39 +11,15 @@ export default function EditEventFilter({ params }: { params: { teamId: string, } const handleSave = () => { - // TODO: Implement save logic router.push(`/${params.teamId}/data`) } return ( -
-

Edit Event Filter

-
- - - -
- {/* TODO: Add form fields */} -
-
- - - - - -
-
+ ) } diff --git a/frontend/dashboard/app/[teamId]/data/event/create/page.tsx b/frontend/dashboard/app/[teamId]/data/event/create/page.tsx index 582d386e3..d9a22fa56 100644 --- a/frontend/dashboard/app/[teamId]/data/event/create/page.tsx +++ b/frontend/dashboard/app/[teamId]/data/event/create/page.tsx @@ -1,8 +1,7 @@ "use client" import { useRouter } from 'next/navigation' -import { Button } from '@/app/components/button' -import { Card, CardContent, CardFooter } from '@/app/components/card' +import EventTraceRuleBuilder from '@/app/components/targeting/event_trace_rule_builder' export default function CreateEventFilter({ params }: { params: { teamId: string } }) { const router = useRouter() @@ -17,34 +16,11 @@ export default function CreateEventFilter({ params }: { params: { teamId: string } return ( -
-

Create Event Filter

-
- - - -
- {/* TODO: Add form fields */} -
-
- - - - - -
-
+ ) } diff --git a/frontend/dashboard/app/[teamId]/data/trace/[ruleId]/edit/page.tsx b/frontend/dashboard/app/[teamId]/data/trace/[ruleId]/edit/page.tsx index 5fd711ad4..ac794e782 100644 --- a/frontend/dashboard/app/[teamId]/data/trace/[ruleId]/edit/page.tsx +++ b/frontend/dashboard/app/[teamId]/data/trace/[ruleId]/edit/page.tsx @@ -1,8 +1,7 @@ "use client" import { useRouter } from 'next/navigation' -import { Button } from '@/app/components/button' -import { Card, CardContent, CardFooter } from '@/app/components/card' +import EventTraceRuleBuilder from '@/app/components/targeting/event_trace_rule_builder' export default function EditTraceFilter({ params }: { params: { teamId: string, filterId: string } }) { const router = useRouter() @@ -17,34 +16,11 @@ export default function EditTraceFilter({ params }: { params: { teamId: string, } return ( -
-

Edit Trace Filter

-
- - - -
- {/* TODO: Add form fields */} -
-
- - - - - -
-
+ ) } diff --git a/frontend/dashboard/app/[teamId]/data/trace/create/page.tsx b/frontend/dashboard/app/[teamId]/data/trace/create/page.tsx index d59a2f4a8..9f78675b0 100644 --- a/frontend/dashboard/app/[teamId]/data/trace/create/page.tsx +++ b/frontend/dashboard/app/[teamId]/data/trace/create/page.tsx @@ -1,8 +1,7 @@ "use client" import { useRouter } from 'next/navigation' -import { Button } from '@/app/components/button' -import { Card, CardContent, CardFooter } from '@/app/components/card' +import EventTraceRuleBuilder from '@/app/components/targeting/event_trace_rule_builder' export default function CreateTraceFilter({ params }: { params: { teamId: string } }) { const router = useRouter() @@ -17,34 +16,11 @@ export default function CreateTraceFilter({ params }: { params: { teamId: string } return ( -
-

Create Trace Filter

-
- - - -
- {/* TODO: Add form fields */} -
-
- - - - - -
-
+ ) } diff --git a/frontend/dashboard/app/components/targeting/event_trace_rule_builder.tsx b/frontend/dashboard/app/components/targeting/event_trace_rule_builder.tsx new file mode 100644 index 000000000..d4d54e983 --- /dev/null +++ b/frontend/dashboard/app/components/targeting/event_trace_rule_builder.tsx @@ -0,0 +1,67 @@ +"use client" + +import { Button } from '@/app/components/button' +import { Card, CardContent, CardFooter } from '@/app/components/card' +import RuleBuilderCard from './rule_builder_card' + +interface EventTraceRuleBuilderProps { + type: 'event' | 'trace' + mode: 'create' | 'edit' + onCancel: () => void + onPrimaryAction: () => void + children?: React.ReactNode +} + +export default function EventTraceRuleBuilder({ + type, + mode, + onCancel, + onPrimaryAction, + children +}: EventTraceRuleBuilderProps) { + // Derive title based on type and mode + const getTitle = () => { + const typeLabel = type === 'event' ? 'Event' : 'Trace' + if (mode === 'create') { + return `Create ${typeLabel} Filter` + } + return `Edit ${typeLabel} Rule` + } + + // Derive primary action label based on mode + const getPrimaryActionLabel = () => { + return mode === 'create' ? 'Create Rule' : 'Save Changes' + } + + return ( +
+

{getTitle()}

+
+ + + +
+ {children} +
+
+ + + + + +
+
+ ) +} diff --git a/frontend/dashboard/app/components/targeting/rule_builder_card.tsx b/frontend/dashboard/app/components/targeting/rule_builder_card.tsx deleted file mode 100644 index 53f46007d..000000000 --- a/frontend/dashboard/app/components/targeting/rule_builder_card.tsx +++ /dev/null @@ -1,130 +0,0 @@ -"use client" - -import { useState } from "react" -import { Card, CardContent, CardFooter } from "@/app/components/card" -import { Button } from "@/app/components/button" - -interface RuleBuilderCardProps { - type: 'event' | 'trace' | 'session_attr' - onCancel: () => void - onSave: (rule: any) => void - initialData?: any | null -} - -interface EventFilter { - id: string - key: string - type: string - value: string | boolean | number - operator: string - hasError?: boolean - errorMessage?: string - hint?: string -} - -// Sample data - replace with actual data from your API -const eventTypes = ["click", "view", "error", "custom"] -const attributeKeys = ["user_id", "session_id", "device_type", "app_version"] -const operators = ["equals", "not equals", "contains", "greater than", "less than"] - -// Operator types mapping for different attribute types -const operatorTypesMapping = { - string: ['eq', 'neq', 'contains'], - number: ['eq', 'neq', 'gt', 'gte', 'lt', 'lte'], - int64: ['eq', 'neq', 'gt', 'gte', 'lt', 'lte'], - float64: ['eq', 'neq', 'gt', 'gte', 'lt', 'lte'], - bool: ['eq', 'neq'] -} - -const getOperatorsForType = (mapping: any, type: string): string[] => { - return mapping[type] || mapping.string -} - -export default function RuleBuilderCard({ type, onCancel, onSave, initialData }: RuleBuilderCardProps) { - const [selectedEventType, setSelectedEventType] = useState(initialData?.eventType || eventTypes[0]) - const [eventFilters, setEventFilters] = useState(initialData?.eventFilters || []) - - // For session_attr type - const [sessionAttr, setSessionAttr] = useState(initialData?.attribute || { - id: 'session-attr', - key: attributeKeys[0], - type: 'string', - operator: 'eq', - value: "" - }) - - const [collectionMode, setCollectionMode] = useState<'none' | 'sample' | 'timeline'>(initialData?.collectionMode || 'sample') - const [eventTraceSamplingRate, setEventTraceSamplingRate] = useState(initialData?.eventTraceSamplingRate?.toString() || "100") - const [snapshotType, setSnapshotType] = useState<'none' | 'screenshot' | 'layout'>(initialData?.snapshotType || 'none') - - const addEventFilter = () => { - const newFilter: EventFilter = { - id: `filter-${Date.now()}`, - key: attributeKeys[0], - type: 'string', - operator: 'eq', - value: "" - } - setEventFilters([...eventFilters, newFilter]) - } - - const removeEventFilter = (conditionId: string, attrId: string) => { - setEventFilters(eventFilters.filter(filter => filter.id !== attrId)) - } - - const updateEventFilter = (conditionId: string, attrId: string, field: 'key' | 'type' | 'value' | 'operator', value: any) => { - setEventFilters(eventFilters.map(filter => - filter.id === attrId ? { ...filter, [field]: value } : filter - )) - } - - const updateSessionAttr = (conditionId: string, attrId: string, field: 'key' | 'type' | 'value' | 'operator', value: any) => { - setSessionAttr(prev => ({ ...prev, [field]: value })) - } - - const handleSave = () => { - let rule: any = { - type: type, - collectEvent: collectionMode === 'sample', - collectTimeline: collectionMode === 'timeline', - eventTraceSamplingRate: collectionMode === 'sample' ? parseFloat(eventTraceSamplingRate) : null, - snapshotType: collectionMode === 'none' ? null : snapshotType - } - - if (type === 'event' || type === 'trace') { - rule.eventType = selectedEventType - rule.eventFilters = eventFilters - } else if (type === 'session_attr') { - rule.attribute = sessionAttr - } - - onSave(rule) - } - - const checkboxStyle = "appearance-none border-black rounded-xs font-display checked:bg-neutral-950 checked:hover:bg-neutral-950 focus:ring-offset-yellow-200 focus:ring-0 checked:focus:bg-neutral-950" - const radioStyle = "appearance-none border-black rounded-full font-display checked:bg-neutral-950 checked:hover:bg-neutral-950 focus:ring-offset-yellow-200 focus:ring-0 checked:focus:bg-neutral-950" - - return ( - - - - - - - - - - ) -} From 781a025b82df25c9ab119263ee02e32a77fc3508 Mon Sep 17 00:00:00 2001 From: Abhay Sood Date: Thu, 6 Nov 2025 00:33:20 +0530 Subject: [PATCH 29/98] feat(frontend): fix rule name rendering --- .../targeting/event_trace_rule_builder.tsx | 1 - .../targeting/rule_overrides_table.tsx | 23 +++++-------------- 2 files changed, 6 insertions(+), 18 deletions(-) diff --git a/frontend/dashboard/app/components/targeting/event_trace_rule_builder.tsx b/frontend/dashboard/app/components/targeting/event_trace_rule_builder.tsx index d4d54e983..1a889d8bd 100644 --- a/frontend/dashboard/app/components/targeting/event_trace_rule_builder.tsx +++ b/frontend/dashboard/app/components/targeting/event_trace_rule_builder.tsx @@ -2,7 +2,6 @@ import { Button } from '@/app/components/button' import { Card, CardContent, CardFooter } from '@/app/components/card' -import RuleBuilderCard from './rule_builder_card' interface EventTraceRuleBuilderProps { type: 'event' | 'trace' diff --git a/frontend/dashboard/app/components/targeting/rule_overrides_table.tsx b/frontend/dashboard/app/components/targeting/rule_overrides_table.tsx index 1eade276b..e9bd19c24 100644 --- a/frontend/dashboard/app/components/targeting/rule_overrides_table.tsx +++ b/frontend/dashboard/app/components/targeting/rule_overrides_table.tsx @@ -1,21 +1,10 @@ "use client" -import { EventTargetingRule, TraceTargetingRule, EventTargetingCollectionConfig, EventTargetingAttachmentConfig, EventTargetingRuleType } from '@/app/api/api_calls' +import { EventTargetingRule, TraceTargetingRule, EventTargetingCollectionConfig, EventTargetingAttachmentConfig } from '@/app/api/api_calls' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/app/components/table' import { formatDateToHumanReadableDate, formatDateToHumanReadableTime } from '@/app/utils/time_utils' import Paginator from '@/app/components/paginator' -const getFilterDisplayText = (type: EventTargetingRuleType, filter: string): string => { - switch (type) { - case 'all_events': - return 'All Events' - case 'all_traces': - return 'All Traces' - default: - return filter - } -} - const getCollectionConfigDisplay = (collectionConfig: EventTargetingCollectionConfig): string => { switch (collectionConfig.mode) { case 'sample_rate': @@ -40,11 +29,11 @@ const getAttachmentConfigDisplay = (attachmentConfig?: EventTargetingAttachmentC return attachmentConfig } -type Rule = EventTargetingRule | TraceTargetingRule +type RuleFilter = EventTargetingRule | TraceTargetingRule interface RulesTableProps { - rules: Rule[] - onRuleClick: (rule: Rule) => void + rules: RuleFilter[] + onRuleClick: (rule: RuleFilter) => void prevEnabled: boolean nextEnabled: boolean onNext: () => void @@ -52,7 +41,7 @@ interface RulesTableProps { showPaginator: boolean } -export default function RulesOverridesTable({ rules, onRuleClick, prevEnabled, nextEnabled, onNext, onPrev, showPaginator }: RulesTableProps) { +export default function RulesTable({ rules, onRuleClick, prevEnabled, nextEnabled, onNext, onPrev, showPaginator }: RulesTableProps) { if (rules.length === 0) { return null } @@ -89,7 +78,7 @@ export default function RulesOverridesTable({ rules, onRuleClick, prevEnabled, n onClick={() => onRuleClick(dataFilter)} > -

{getFilterDisplayText(dataFilter.type, dataFilter.rule)}

+

{dataFilter.rule}

{getCollectionConfigDisplay(dataFilter.collection_config)}

{getAttachmentConfigDisplay('attachment_config' in dataFilter ? dataFilter.attachment_config : undefined)}

From 69f1d76ad5f269accc855e4ce07665fcd23f3e56 Mon Sep 17 00:00:00 2001 From: Abhay Sood Date: Thu, 6 Nov 2025 00:33:42 +0530 Subject: [PATCH 30/98] feat(frontend): remove unnecessary comments --- .../app/components/targeting/event_trace_rule_builder.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/frontend/dashboard/app/components/targeting/event_trace_rule_builder.tsx b/frontend/dashboard/app/components/targeting/event_trace_rule_builder.tsx index 1a889d8bd..7fd693c7d 100644 --- a/frontend/dashboard/app/components/targeting/event_trace_rule_builder.tsx +++ b/frontend/dashboard/app/components/targeting/event_trace_rule_builder.tsx @@ -18,7 +18,6 @@ export default function EventTraceRuleBuilder({ onPrimaryAction, children }: EventTraceRuleBuilderProps) { - // Derive title based on type and mode const getTitle = () => { const typeLabel = type === 'event' ? 'Event' : 'Trace' if (mode === 'create') { @@ -27,7 +26,6 @@ export default function EventTraceRuleBuilder({ return `Edit ${typeLabel} Rule` } - // Derive primary action label based on mode const getPrimaryActionLabel = () => { return mode === 'create' ? 'Create Rule' : 'Save Changes' } From 8f353a1f7eafebdbdbe039b618d9d5720a0721f4 Mon Sep 17 00:00:00 2001 From: Abhay Sood Date: Thu, 6 Nov 2025 00:38:12 +0530 Subject: [PATCH 31/98] feat(frontend): refactor names --- frontend/dashboard/app/[teamId]/data/page.tsx | 10 +++++----- ...able.tsx => event_tracce_targeting_rules_table.tsx} | 0 ...ing_table.tsx => session_targeting_rules_table.tsx} | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) rename frontend/dashboard/app/components/targeting/{rule_overrides_table.tsx => event_tracce_targeting_rules_table.tsx} (100%) rename frontend/dashboard/app/components/targeting/{session_targeting_table.tsx => session_targeting_rules_table.tsx} (93%) diff --git a/frontend/dashboard/app/[teamId]/data/page.tsx b/frontend/dashboard/app/[teamId]/data/page.tsx index 88f798f7c..827efcaf6 100644 --- a/frontend/dashboard/app/[teamId]/data/page.tsx +++ b/frontend/dashboard/app/[teamId]/data/page.tsx @@ -9,8 +9,8 @@ import { Button } from '@/app/components/button' import { Plus, Pencil } from 'lucide-react' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/app/components/dropdown_menu' import EditDefaultRuleDialog from '@/app/components/targeting/edit_default_rule_dialog' -import RulesOverridesTable from '@/app/components/targeting/rule_overrides_table' -import SessionTargetingTable from '@/app/components/targeting/session_targeting_table' +import EventTraceTargetingRulesTable from '@/app/components/targeting/event_tracce_targeting_rules_table' +import SessionTargetingRulesTable from '@/app/components/targeting/session_targeting_rules_table' import { toastPositive, toastNegative } from '@/app/utils/use_toast' interface PageState { @@ -311,7 +311,7 @@ export default function DataFilters({ params }: { params: { teamId: string } })
)} - handleEditRule(rule, 'event')} prevEnabled={pageState.eventTargetingRules.meta.previous} @@ -348,7 +348,7 @@ export default function DataFilters({ params }: { params: { teamId: string } })
)} - handleEditRule(rule, 'trace')} prevEnabled={pageState.traceTargetingRules.meta.previous} @@ -367,7 +367,7 @@ export default function DataFilters({ params }: { params: { teamId: string } })
- handleEditRule(rule, 'session')} prevEnabled={pageState.sessionTargetingRules.meta.previous} diff --git a/frontend/dashboard/app/components/targeting/rule_overrides_table.tsx b/frontend/dashboard/app/components/targeting/event_tracce_targeting_rules_table.tsx similarity index 100% rename from frontend/dashboard/app/components/targeting/rule_overrides_table.tsx rename to frontend/dashboard/app/components/targeting/event_tracce_targeting_rules_table.tsx diff --git a/frontend/dashboard/app/components/targeting/session_targeting_table.tsx b/frontend/dashboard/app/components/targeting/session_targeting_rules_table.tsx similarity index 93% rename from frontend/dashboard/app/components/targeting/session_targeting_table.tsx rename to frontend/dashboard/app/components/targeting/session_targeting_rules_table.tsx index 37496d10c..98d967c8c 100644 --- a/frontend/dashboard/app/components/targeting/session_targeting_table.tsx +++ b/frontend/dashboard/app/components/targeting/session_targeting_rules_table.tsx @@ -9,7 +9,7 @@ const getSamplingRateDisplay = (samplingRate: number): string => { return `${samplingRate}% sampling rate` } -interface SessionTargetingTableProps { +interface SessionTargetingRulesTableProps { rules: SessionTargetingRule[] onRuleClick: (rule: SessionTargetingRule) => void prevEnabled: boolean @@ -19,7 +19,7 @@ interface SessionTargetingTableProps { showPaginator: boolean } -export default function SessionTargetingTable({ rules, onRuleClick, prevEnabled, nextEnabled, onNext, onPrev, showPaginator }: SessionTargetingTableProps) { +export default function SessionTargetingRulesTable({ rules, onRuleClick, prevEnabled, nextEnabled, onNext, onPrev, showPaginator }: SessionTargetingRulesTableProps) { if (rules.length === 0) { return null } From 40b7908f0e5ba591c00add236e5e342c3570e19d Mon Sep 17 00:00:00 2001 From: Abhay Sood Date: Thu, 6 Nov 2025 00:55:18 +0530 Subject: [PATCH 32/98] feat(frontend): pass on ruleId to rule builder component --- .../dashboard/app/[teamId]/data/event/[ruleId]/edit/page.tsx | 3 ++- .../dashboard/app/[teamId]/data/trace/[ruleId]/edit/page.tsx | 3 ++- .../app/components/targeting/event_trace_rule_builder.tsx | 2 ++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/frontend/dashboard/app/[teamId]/data/event/[ruleId]/edit/page.tsx b/frontend/dashboard/app/[teamId]/data/event/[ruleId]/edit/page.tsx index 4f73fcd0d..5741cb745 100644 --- a/frontend/dashboard/app/[teamId]/data/event/[ruleId]/edit/page.tsx +++ b/frontend/dashboard/app/[teamId]/data/event/[ruleId]/edit/page.tsx @@ -3,7 +3,7 @@ import { useRouter } from 'next/navigation' import EventTraceRuleBuilder from '@/app/components/targeting/event_trace_rule_builder' -export default function EditEventFilter({ params }: { params: { teamId: string, filterId: string } }) { +export default function EditEventFilter({ params }: { params: { teamId: string, ruleId: string } }) { const router = useRouter() const handleCancel = () => { @@ -18,6 +18,7 @@ export default function EditEventFilter({ params }: { params: { teamId: string, diff --git a/frontend/dashboard/app/[teamId]/data/trace/[ruleId]/edit/page.tsx b/frontend/dashboard/app/[teamId]/data/trace/[ruleId]/edit/page.tsx index ac794e782..a597e0d1f 100644 --- a/frontend/dashboard/app/[teamId]/data/trace/[ruleId]/edit/page.tsx +++ b/frontend/dashboard/app/[teamId]/data/trace/[ruleId]/edit/page.tsx @@ -3,7 +3,7 @@ import { useRouter } from 'next/navigation' import EventTraceRuleBuilder from '@/app/components/targeting/event_trace_rule_builder' -export default function EditTraceFilter({ params }: { params: { teamId: string, filterId: string } }) { +export default function EditTraceFilter({ params }: { params: { teamId: string, ruleId: string } }) { const router = useRouter() const handleCancel = () => { @@ -19,6 +19,7 @@ export default function EditTraceFilter({ params }: { params: { teamId: string, diff --git a/frontend/dashboard/app/components/targeting/event_trace_rule_builder.tsx b/frontend/dashboard/app/components/targeting/event_trace_rule_builder.tsx index 7fd693c7d..b0f8436d5 100644 --- a/frontend/dashboard/app/components/targeting/event_trace_rule_builder.tsx +++ b/frontend/dashboard/app/components/targeting/event_trace_rule_builder.tsx @@ -6,6 +6,7 @@ import { Card, CardContent, CardFooter } from '@/app/components/card' interface EventTraceRuleBuilderProps { type: 'event' | 'trace' mode: 'create' | 'edit' + ruleId?: string onCancel: () => void onPrimaryAction: () => void children?: React.ReactNode @@ -14,6 +15,7 @@ interface EventTraceRuleBuilderProps { export default function EventTraceRuleBuilder({ type, mode, + ruleId, onCancel, onPrimaryAction, children From e42dac4c173564bbf04cadcfc51737bf7f35a945 Mon Sep 17 00:00:00 2001 From: Abhay Sood Date: Thu, 6 Nov 2025 01:13:57 +0530 Subject: [PATCH 33/98] feat(frontend): navigation fixes use appId for edit and create pages --- .../event/[ruleId]/edit/page.tsx | 7 +- .../data/{ => [appId]}/event/create/page.tsx | 7 +- .../session/[ruleId]/edit/page.tsx | 6 +- .../{ => [appId]}/session/create/page.tsx | 6 +- .../trace/[ruleId]/edit/page.tsx | 7 +- .../data/{ => [appId]}/trace/create/page.tsx | 7 +- frontend/dashboard/app/[teamId]/data/page.tsx | 8 +- frontend/dashboard/app/api/api_calls.ts | 78 +++++++++++ .../targeting/event_trace_rule_builder.tsx | 131 +++++++++++++++--- 9 files changed, 218 insertions(+), 39 deletions(-) rename frontend/dashboard/app/[teamId]/data/{ => [appId]}/event/[ruleId]/edit/page.tsx (80%) rename frontend/dashboard/app/[teamId]/data/{ => [appId]}/event/create/page.tsx (82%) rename frontend/dashboard/app/[teamId]/data/{ => [appId]}/session/[ruleId]/edit/page.tsx (91%) rename frontend/dashboard/app/[teamId]/data/{ => [appId]}/session/create/page.tsx (92%) rename frontend/dashboard/app/[teamId]/data/{ => [appId]}/trace/[ruleId]/edit/page.tsx (81%) rename frontend/dashboard/app/[teamId]/data/{ => [appId]}/trace/create/page.tsx (82%) diff --git a/frontend/dashboard/app/[teamId]/data/event/[ruleId]/edit/page.tsx b/frontend/dashboard/app/[teamId]/data/[appId]/event/[ruleId]/edit/page.tsx similarity index 80% rename from frontend/dashboard/app/[teamId]/data/event/[ruleId]/edit/page.tsx rename to frontend/dashboard/app/[teamId]/data/[appId]/event/[ruleId]/edit/page.tsx index 5741cb745..097dc6cda 100644 --- a/frontend/dashboard/app/[teamId]/data/event/[ruleId]/edit/page.tsx +++ b/frontend/dashboard/app/[teamId]/data/[appId]/event/[ruleId]/edit/page.tsx @@ -3,21 +3,22 @@ import { useRouter } from 'next/navigation' import EventTraceRuleBuilder from '@/app/components/targeting/event_trace_rule_builder' -export default function EditEventFilter({ params }: { params: { teamId: string, ruleId: string } }) { +export default function EditEventFilter({ params }: { params: { teamId: string, appId: string, ruleId: string } }) { const router = useRouter() const handleCancel = () => { - router.push(`/${params.teamId}/data`) + router.back() } const handleSave = () => { - router.push(`/${params.teamId}/data`) + router.back() } return ( { - router.push(`/${params.teamId}/data`) + router.back() } const handleCreate = () => { // TODO: Implement create logic - router.push(`/${params.teamId}/data`) + router.back() } return ( diff --git a/frontend/dashboard/app/[teamId]/data/session/[ruleId]/edit/page.tsx b/frontend/dashboard/app/[teamId]/data/[appId]/session/[ruleId]/edit/page.tsx similarity index 91% rename from frontend/dashboard/app/[teamId]/data/session/[ruleId]/edit/page.tsx rename to frontend/dashboard/app/[teamId]/data/[appId]/session/[ruleId]/edit/page.tsx index e0d6b9c68..569a94850 100644 --- a/frontend/dashboard/app/[teamId]/data/session/[ruleId]/edit/page.tsx +++ b/frontend/dashboard/app/[teamId]/data/[appId]/session/[ruleId]/edit/page.tsx @@ -4,16 +4,16 @@ import { useRouter } from 'next/navigation' import { Button } from '@/app/components/button' import { Card, CardContent, CardFooter } from '@/app/components/card' -export default function EditSessionFilter({ params }: { params: { teamId: string, ruleId: string } }) { +export default function EditSessionFilter({ params }: { params: { teamId: string, appId: string, ruleId: string } }) { const router = useRouter() const handleCancel = () => { - router.push(`/${params.teamId}/data`) + router.back() } const handleSave = () => { // TODO: Implement save logic - router.push(`/${params.teamId}/data`) + router.back() } return ( diff --git a/frontend/dashboard/app/[teamId]/data/session/create/page.tsx b/frontend/dashboard/app/[teamId]/data/[appId]/session/create/page.tsx similarity index 92% rename from frontend/dashboard/app/[teamId]/data/session/create/page.tsx rename to frontend/dashboard/app/[teamId]/data/[appId]/session/create/page.tsx index 54258ee5f..633aaa286 100644 --- a/frontend/dashboard/app/[teamId]/data/session/create/page.tsx +++ b/frontend/dashboard/app/[teamId]/data/[appId]/session/create/page.tsx @@ -4,16 +4,16 @@ import { useRouter } from 'next/navigation' import { Button } from '@/app/components/button' import { Card, CardContent, CardFooter } from '@/app/components/card' -export default function CreateSessionFilter({ params }: { params: { teamId: string } }) { +export default function CreateSessionFilter({ params }: { params: { teamId: string, appId: string } }) { const router = useRouter() const handleCancel = () => { - router.push(`/${params.teamId}/data`) + router.back() } const handleCreate = () => { // TODO: Implement create logic - router.push(`/${params.teamId}/data`) + router.back() } return ( diff --git a/frontend/dashboard/app/[teamId]/data/trace/[ruleId]/edit/page.tsx b/frontend/dashboard/app/[teamId]/data/[appId]/trace/[ruleId]/edit/page.tsx similarity index 81% rename from frontend/dashboard/app/[teamId]/data/trace/[ruleId]/edit/page.tsx rename to frontend/dashboard/app/[teamId]/data/[appId]/trace/[ruleId]/edit/page.tsx index a597e0d1f..3b0cdd57c 100644 --- a/frontend/dashboard/app/[teamId]/data/trace/[ruleId]/edit/page.tsx +++ b/frontend/dashboard/app/[teamId]/data/[appId]/trace/[ruleId]/edit/page.tsx @@ -3,22 +3,23 @@ import { useRouter } from 'next/navigation' import EventTraceRuleBuilder from '@/app/components/targeting/event_trace_rule_builder' -export default function EditTraceFilter({ params }: { params: { teamId: string, ruleId: string } }) { +export default function EditTraceFilter({ params }: { params: { teamId: string, appId: string, ruleId: string } }) { const router = useRouter() const handleCancel = () => { - router.push(`/${params.teamId}/data`) + router.back() } const handleSave = () => { // TODO: Implement save logic - router.push(`/${params.teamId}/data`) + router.back() } return ( { - router.push(`/${params.teamId}/data`) + router.back() } const handleCreate = () => { // TODO: Implement create logic - router.push(`/${params.teamId}/data`) + router.back() } return ( diff --git a/frontend/dashboard/app/[teamId]/data/page.tsx b/frontend/dashboard/app/[teamId]/data/page.tsx index 827efcaf6..f069be1df 100644 --- a/frontend/dashboard/app/[teamId]/data/page.tsx +++ b/frontend/dashboard/app/[teamId]/data/page.tsx @@ -176,7 +176,7 @@ export default function DataFilters({ params }: { params: { teamId: string } }) const sessionTargetingRules = pageState.sessionTargetingRules.results; const handleEditRule = (dataFilter: typeof eventsOverideRules[0] | typeof traceOverrideRules[0] | typeof sessionTargetingRules[0], filterType: 'event' | 'trace' | 'session') => { - router.push(`/${params.teamId}/data/${filterType}/${dataFilter.id}/edit`) + router.push(`/${params.teamId}/data/${pageState.filters.app!.id}/${filterType}/${dataFilter.id}/edit`) } const handleDefaultRuleUpdateSuccess = (collectionMode: 'sample_rate' | 'timeline_only' | 'disable', sampleRate?: number) => { @@ -236,13 +236,13 @@ export default function DataFilters({ params }: { params: { teamId: string } }) - router.push(`/${params.teamId}/data/event/create`)}> + router.push(`/${params.teamId}/data/${pageState.filters.app!.id}/event/create`)}> Event Rule - router.push(`/${params.teamId}/data/trace/create`)}> + router.push(`/${params.teamId}/data/${pageState.filters.app!.id}/trace/create`)}> Trace Rule - router.push(`/${params.teamId}/data/session/create`)}> + router.push(`/${params.teamId}/data/${pageState.filters.app!.id}/session/create`)}> Session Rule diff --git a/frontend/dashboard/app/api/api_calls.ts b/frontend/dashboard/app/api/api_calls.ts index ed300e0b2..8144d350f 100644 --- a/frontend/dashboard/app/api/api_calls.ts +++ b/frontend/dashboard/app/api/api_calls.ts @@ -377,6 +377,27 @@ export enum SessionTargetingApiStatus { Cancelled, } +export enum EventTargetingRuleApiStatus { + Loading, + Success, + Error, + Cancelled, +} + +export enum TraceTargetingRuleApiStatus { + Loading, + Success, + Error, + Cancelled, +} + +export enum SessionTargetingRuleApiStatus { + Loading, + Success, + Error, + Cancelled, +} + export enum SessionType { All = "All Sessions", Crashes = "Crash Sessions", @@ -2622,4 +2643,61 @@ export const fetchSessionTargetingRulesFromServer = async ( } catch { return { status: SessionTargetingApiStatus.Cancelled, data: null } } +} + +export const fetchEventTargetingRuleFromServer = async ( + appId: String, + ruleId: string, +) => { + const url = `/api/apps/${appId}/targetingRules/events/${ruleId}` + + try { + const res = await measureAuth.fetchMeasure(url) + + if (!res.ok) { + return { status: EventTargetingRuleApiStatus.Error, data: null } + } + + const data = await res.json() + return { status: EventTargetingRuleApiStatus.Success, data: data } + } catch { + return { status: EventTargetingRuleApiStatus.Cancelled, data: null } + } +} + +export const fetchTraceTargetingRuleFromServer = async ( + appId: String, + ruleId: string, +) => { + const url = `/api/apps/${appId}/targetingRules/traces/${ruleId}` + + try { + const res = await measureAuth.fetchMeasure(url) + + if (!res.ok) { + return { status: TraceTargetingRuleApiStatus.Error, data: null } + } + const data = await res.json() + return { status: TraceTargetingRuleApiStatus.Success, data: data } + } catch { + return { status: TraceTargetingRuleApiStatus.Cancelled, data: null } + } +} + +export const fetchSessionTargetingRuleFromServer = async ( + appId: String, + ruleId: string, +) => { + const url = `/api/apps/${appId}/targetingRules/sessions/${ruleId}` + + try { + const res = await measureAuth.fetchMeasure(url) + if (!res.ok) { + return { status: SessionTargetingRuleApiStatus.Error, data: null } + } + const data = await res.json() + return { status: SessionTargetingRuleApiStatus.Success, data: data } + } catch { + return { status: SessionTargetingRuleApiStatus.Cancelled, data: null } + } } \ No newline at end of file diff --git a/frontend/dashboard/app/components/targeting/event_trace_rule_builder.tsx b/frontend/dashboard/app/components/targeting/event_trace_rule_builder.tsx index b0f8436d5..faf029fd4 100644 --- a/frontend/dashboard/app/components/targeting/event_trace_rule_builder.tsx +++ b/frontend/dashboard/app/components/targeting/event_trace_rule_builder.tsx @@ -2,10 +2,21 @@ import { Button } from '@/app/components/button' import { Card, CardContent, CardFooter } from '@/app/components/card' +import { useEffect, useState } from 'react' +import { + EventTargetingRuleApiStatus, + TraceTargetingRuleApiStatus, + EventTargetingRule, + TraceTargetingRule, + fetchEventTargetingRuleFromServer, + fetchTraceTargetingRuleFromServer +} from '@/app/api/api_calls' +import LoadingBar from '@/app/components/loading_bar' interface EventTraceRuleBuilderProps { type: 'event' | 'trace' mode: 'create' | 'edit' + appId: string ruleId?: string onCancel: () => void onPrimaryAction: () => void @@ -15,11 +26,65 @@ interface EventTraceRuleBuilderProps { export default function EventTraceRuleBuilder({ type, mode, + appId, ruleId, onCancel, onPrimaryAction, children }: EventTraceRuleBuilderProps) { + const [apiStatus, setApiStatus] = useState( + mode === 'create' + ? EventTargetingRuleApiStatus.Success + : EventTargetingRuleApiStatus.Loading + ) + const [ruleData, setRuleData] = useState(null) + + useEffect(() => { + if (mode === 'edit' && ruleId) { + fetchRuleData() + } + }, [mode, ruleId, appId]) + + const fetchEventRuleData = async () => { + if (!ruleId) return + + setApiStatus(EventTargetingRuleApiStatus.Loading) + + const result = await fetchEventTargetingRuleFromServer(appId, ruleId) + + if (result.status === EventTargetingRuleApiStatus.Error) { + setApiStatus(EventTargetingRuleApiStatus.Error) + return + } + + setRuleData(result.data) + setApiStatus(EventTargetingRuleApiStatus.Success) + } + + const fetchTraceRuleData = async () => { + if (!ruleId) return + + setApiStatus(TraceTargetingRuleApiStatus.Loading) + + const result = await fetchTraceTargetingRuleFromServer(appId, ruleId) + + if (result.status === TraceTargetingRuleApiStatus.Error) { + setApiStatus(TraceTargetingRuleApiStatus.Error) + return + } + + setRuleData(result.data) + setApiStatus(TraceTargetingRuleApiStatus.Success) + } + + const fetchRuleData = async () => { + if (type === 'event') { + await fetchEventRuleData() + } else { + await fetchTraceRuleData() + } + } + const getTitle = () => { const typeLabel = type === 'event' ? 'Event' : 'Trace' if (mode === 'create') { @@ -32,35 +97,67 @@ export default function EventTraceRuleBuilder({ return mode === 'create' ? 'Create Rule' : 'Save Changes' } + const isLoading = apiStatus === EventTargetingRuleApiStatus.Loading || + apiStatus === TraceTargetingRuleApiStatus.Loading + const hasError = apiStatus === EventTargetingRuleApiStatus.Error || + apiStatus === TraceTargetingRuleApiStatus.Error + const isReady = apiStatus === EventTargetingRuleApiStatus.Success || + apiStatus === TraceTargetingRuleApiStatus.Success + return (

{getTitle()}

- - -
- {children} -
-
+ {/* Loading indicator */} +
+ +
- + {/* Error state */} + {hasError && ( +
+

+ Error loading rule data. Please try again or go back. +

+
- - - +
+ )} + + {/* Main content */} + {isReady && ( + + +
+ {children} +
+
+ + + + + +
+ )}
) } From 617aaf1fc119b7b2db6c99cfc2b768f34707cd14 Mon Sep 17 00:00:00 2001 From: Abhay Sood Date: Thu, 6 Nov 2025 01:19:44 +0530 Subject: [PATCH 34/98] feat(frontend): minor changes --- frontend/dashboard/app/[teamId]/data/page.tsx | 8 ++++++-- .../app/components/targeting/event_trace_rule_builder.tsx | 6 +++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/frontend/dashboard/app/[teamId]/data/page.tsx b/frontend/dashboard/app/[teamId]/data/page.tsx index f069be1df..8920b604b 100644 --- a/frontend/dashboard/app/[teamId]/data/page.tsx +++ b/frontend/dashboard/app/[teamId]/data/page.tsx @@ -235,7 +235,11 @@ export default function DataFilters({ params }: { params: { teamId: string } }) Create Rule - + e.preventDefault()} + > router.push(`/${params.teamId}/data/${pageState.filters.app!.id}/event/create`)}> Event Rule @@ -243,7 +247,7 @@ export default function DataFilters({ params }: { params: { teamId: string } }) Trace Rule router.push(`/${params.teamId}/data/${pageState.filters.app!.id}/session/create`)}> - Session Rule + Session Timeline Rule diff --git a/frontend/dashboard/app/components/targeting/event_trace_rule_builder.tsx b/frontend/dashboard/app/components/targeting/event_trace_rule_builder.tsx index faf029fd4..562688467 100644 --- a/frontend/dashboard/app/components/targeting/event_trace_rule_builder.tsx +++ b/frontend/dashboard/app/components/targeting/event_trace_rule_builder.tsx @@ -116,15 +116,15 @@ export default function EventTraceRuleBuilder({ {/* Error state */} {hasError && ( -
-

+

+

Error loading rule data. Please try again or go back.

From 2ae4fa5f6803a63414ddcac9fa3405475d109638 Mon Sep 17 00:00:00 2001 From: Abhay Sood Date: Thu, 6 Nov 2025 01:23:42 +0530 Subject: [PATCH 35/98] feat(frontend): add dummy response for edit event and trace rule --- frontend/dashboard/app/api/api_calls.ts | 17 +++++++++ .../targeting/event_trace_rule_builder.tsx | 38 +++++++++++-------- 2 files changed, 39 insertions(+), 16 deletions(-) diff --git a/frontend/dashboard/app/api/api_calls.ts b/frontend/dashboard/app/api/api_calls.ts index 8144d350f..126033d21 100644 --- a/frontend/dashboard/app/api/api_calls.ts +++ b/frontend/dashboard/app/api/api_calls.ts @@ -1217,6 +1217,23 @@ export const emptySessionTargetingResponse: SessionTargetingResponse = { ] } +export const emptyEventTargetingRule: EventTargetingRule = { + id: "df-events-001", + rule: 'event.event_type == "crash"', + collection_config: { mode: 'sample_rate', sample_rate: 5 }, + attachment_config: 'screenshot', + updated_at: "2024-01-01T00:00:00Z", + updated_by: "system@example.com", +} + +export const emptyTraceTargetingRule: TraceTargetingRule = { + id: "df-traces-001", + rule: 'trace.trace_name == "root"', + collection_config: { mode: 'sample_rate', sample_rate: 10 }, + updated_at: "2024-01-01T00:00:00Z", + updated_by: "system@example.com", +} + export class AppVersion { name: string code: string diff --git a/frontend/dashboard/app/components/targeting/event_trace_rule_builder.tsx b/frontend/dashboard/app/components/targeting/event_trace_rule_builder.tsx index 562688467..9c50a0503 100644 --- a/frontend/dashboard/app/components/targeting/event_trace_rule_builder.tsx +++ b/frontend/dashboard/app/components/targeting/event_trace_rule_builder.tsx @@ -9,7 +9,9 @@ import { EventTargetingRule, TraceTargetingRule, fetchEventTargetingRuleFromServer, - fetchTraceTargetingRuleFromServer + fetchTraceTargetingRuleFromServer, + emptyEventTargetingRule, + emptyTraceTargetingRule } from '@/app/api/api_calls' import LoadingBar from '@/app/components/loading_bar' @@ -50,14 +52,16 @@ export default function EventTraceRuleBuilder({ setApiStatus(EventTargetingRuleApiStatus.Loading) - const result = await fetchEventTargetingRuleFromServer(appId, ruleId) + // TEMPORARY: Using dummy response instead of actual API call + // const result = await fetchEventTargetingRuleFromServer(appId, ruleId) + // if (result.status === EventTargetingRuleApiStatus.Error) { + // setApiStatus(EventTargetingRuleApiStatus.Error) + // return + // } + // setRuleData(result.data) - if (result.status === EventTargetingRuleApiStatus.Error) { - setApiStatus(EventTargetingRuleApiStatus.Error) - return - } - - setRuleData(result.data) + // Using dummy data temporarily + setRuleData(emptyEventTargetingRule) setApiStatus(EventTargetingRuleApiStatus.Success) } @@ -66,14 +70,16 @@ export default function EventTraceRuleBuilder({ setApiStatus(TraceTargetingRuleApiStatus.Loading) - const result = await fetchTraceTargetingRuleFromServer(appId, ruleId) - - if (result.status === TraceTargetingRuleApiStatus.Error) { - setApiStatus(TraceTargetingRuleApiStatus.Error) - return - } + // TEMPORARY: Using dummy response instead of actual API call + // const result = await fetchTraceTargetingRuleFromServer(appId, ruleId) + // if (result.status === TraceTargetingRuleApiStatus.Error) { + // setApiStatus(TraceTargetingRuleApiStatus.Error) + // return + // } + // setRuleData(result.data) - setRuleData(result.data) + // Using dummy data temporarily + setRuleData(emptyTraceTargetingRule) setApiStatus(TraceTargetingRuleApiStatus.Success) } @@ -118,7 +124,7 @@ export default function EventTraceRuleBuilder({ {hasError && (

- Error loading rule data. Please try again or go back. + Error loading rule. Please try again or go back.

- - +
+
) } diff --git a/frontend/dashboard/app/[teamId]/data/[appId]/session/create/page.tsx b/frontend/dashboard/app/[teamId]/data/[appId]/session/create/page.tsx index b59dc9dfe..bd8d37a9e 100644 --- a/frontend/dashboard/app/[teamId]/data/[appId]/session/create/page.tsx +++ b/frontend/dashboard/app/[teamId]/data/[appId]/session/create/page.tsx @@ -2,7 +2,6 @@ import { useRouter } from 'next/navigation' import { Button } from '@/app/components/button' -import { Card, CardContent, CardFooter } from '@/app/components/card' export default function CreateSessionTimelineRule({ params }: { params: { teamId: string, appId: string } }) { const router = useRouter() @@ -21,14 +20,14 @@ export default function CreateSessionTimelineRule({ params }: { params: { teamId

Create Session Timeline Rule

- - -
- {/* TODO: Add form fields */} -
-
+
+ {/* Reserved space for content */} +
+ {/* TODO: Add form fields */} +
- + {/* Action buttons */} +
- - +
+
) } diff --git a/frontend/dashboard/app/[teamId]/data/page.tsx b/frontend/dashboard/app/[teamId]/data/page.tsx index 8920b604b..e3baa3dfe 100644 --- a/frontend/dashboard/app/[teamId]/data/page.tsx +++ b/frontend/dashboard/app/[teamId]/data/page.tsx @@ -9,7 +9,7 @@ import { Button } from '@/app/components/button' import { Plus, Pencil } from 'lucide-react' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/app/components/dropdown_menu' import EditDefaultRuleDialog from '@/app/components/targeting/edit_default_rule_dialog' -import EventTraceTargetingRulesTable from '@/app/components/targeting/event_tracce_targeting_rules_table' +import EventTraceTargetingRulesTable from '@/app/components/targeting/event_trace_targeting_rules_table' import SessionTargetingRulesTable from '@/app/components/targeting/session_targeting_rules_table' import { toastPositive, toastNegative } from '@/app/utils/use_toast' diff --git a/frontend/dashboard/app/[teamId]/layout.tsx b/frontend/dashboard/app/[teamId]/layout.tsx index cc8eb59ce..e41a21c32 100644 --- a/frontend/dashboard/app/[teamId]/layout.tsx +++ b/frontend/dashboard/app/[teamId]/layout.tsx @@ -91,12 +91,6 @@ const initNavData = { { title: "Settings", items: [ - { - title: "Data", - url: "data", - isActive: false, - external: false, - }, { title: "Apps", url: "apps", @@ -109,6 +103,12 @@ const initNavData = { isActive: false, external: false, }, + { + title: "Data", + url: "data", + isActive: false, + external: false, + }, { title: "Usage", url: "usage", diff --git a/frontend/dashboard/app/components/targeting/event_trace_rule_builder.tsx b/frontend/dashboard/app/components/targeting/event_trace_rule_builder.tsx index 0ed3bc76a..580584eba 100644 --- a/frontend/dashboard/app/components/targeting/event_trace_rule_builder.tsx +++ b/frontend/dashboard/app/components/targeting/event_trace_rule_builder.tsx @@ -1,7 +1,6 @@ "use client" import { Button } from '@/app/components/button' -import { Card, CardContent, CardFooter } from '@/app/components/card' import { useEffect, useState } from 'react' import { EventTargetingRuleApiStatus, @@ -199,13 +198,13 @@ export default function EventTraceRuleBuilder({ {/* Main content */} {isReady() && ( - - -
-
-
+
+ {/* Reserved space for content */} +
+
- + {/* Action buttons */} +
- - +
+
)}
) diff --git a/frontend/dashboard/app/components/targeting/event_tracce_targeting_rules_table.tsx b/frontend/dashboard/app/components/targeting/event_trace_targeting_rules_table.tsx similarity index 100% rename from frontend/dashboard/app/components/targeting/event_tracce_targeting_rules_table.tsx rename to frontend/dashboard/app/components/targeting/event_trace_targeting_rules_table.tsx From 7e28b4aab2a8ca712ee970ca46f263ec2d7b4531 Mon Sep 17 00:00:00 2001 From: Abhay Sood Date: Thu, 6 Nov 2025 13:55:56 +0530 Subject: [PATCH 44/98] feat(frontend): improve the layout to remove pagination --- frontend/dashboard/app/[teamId]/data/page.tsx | 4 +- frontend/dashboard/app/api/api_calls.ts | 117 ++++++++++++++++++ .../targeting/edit_default_rule_dialog.tsx | 2 +- .../event_trace_targeting_rules_table.tsx | 105 ++++++++-------- .../session_targeting_rules_table.tsx | 92 ++++++-------- 5 files changed, 215 insertions(+), 105 deletions(-) diff --git a/frontend/dashboard/app/[teamId]/data/page.tsx b/frontend/dashboard/app/[teamId]/data/page.tsx index e3baa3dfe..34731c8a8 100644 --- a/frontend/dashboard/app/[teamId]/data/page.tsx +++ b/frontend/dashboard/app/[teamId]/data/page.tsx @@ -298,7 +298,7 @@ export default function DataFilters({ params }: { params: { teamId: string } }) {/* Default Event Rule */}
-

Default Rule

+

Default Behaviour

{eventsDefaultRule && ( +
+ )} + + {/* Main content */} + {isReady() && ( +
+ {/* When section */} +
+
+ When + { + const config = pageState.configData?.result || (pageState.configData as any) + return config?.trace_config?.map((t: any) => t.name) || [] + })()} + initialSelected={pageState.currentRuleState?.condition.spanName || ''} + onChangeSelected={(selected) => handleSpanNameChange(selected as string)} + /> + span ends +
+ +
+ +
+ + {/* Then section */} +
+

Then

+ + {/* Collection config */} +
+

Collection

+
+ + + + + +
+
+
+ + {/* Action buttons */} +
+ + +
+
+ )} +
+ ) +} diff --git a/frontend/dashboard/app/utils/cel/cel_generator.ts b/frontend/dashboard/app/utils/cel/cel_generator.ts index cffa989ab..4d842fef5 100644 --- a/frontend/dashboard/app/utils/cel/cel_generator.ts +++ b/frontend/dashboard/app/utils/cel/cel_generator.ts @@ -301,6 +301,38 @@ function wrapConditionGroups(conditionGroups: string[]): string { return wrappedGroups.join(' && ') } +/** + * Converts a single EventCondition into a CEL expression string. + * This is a simpler version for when you only have one event condition. + * Example output: '(event_type == "anr" && exception.handled == false)' + */ +export function eventConditionToCel(condition: EventCondition): string | null { + const parts = buildEventConditionParts(condition) + + if (parts.length === 0) { + return null + } + + const combined = combineConditionParts(parts) + return `(${combined})` +} + +/** + * Converts a single TraceCondition into a CEL expression string. + * This is a simpler version for when you only have one trace condition. + * Example output: '(span_name.contains("HTTP") && trace.user_defined_attrs.is_critical == true)' + */ +export function traceConditionToCel(condition: TraceCondition): string | null { + const parts = buildTraceConditionParts(condition) + + if (parts.length === 0) { + return null + } + + const combined = combineConditionParts(parts) + return `(${combined})` +} + /** * Converts a structured `ParsedConditions` object into a final CEL expression string. * This is the main entry point for the CEL generation logic. @@ -318,4 +350,4 @@ export function conditionsToCel(parsedConditions: ParsedConditions): string | nu } return wrapConditionGroups(conditionGroups) -} \ No newline at end of file +} diff --git a/self-host/postgres/20251107111347_create_event_targeting_rules_table.sql b/self-host/postgres/20251107111347_create_event_targeting_rules_table.sql index 837dd6329..633a4fe5d 100644 --- a/self-host/postgres/20251107111347_create_event_targeting_rules_table.sql +++ b/self-host/postgres/20251107111347_create_event_targeting_rules_table.sql @@ -8,6 +8,7 @@ create table if not exists measure.event_targeting_rules ( collection_mode text not null, take_screenshot boolean not null default false, take_layout_snapshot boolean not null default false, + is_default_rule boolean not null default false, created_at timestamptz not null default now(), created_by uuid not null, updated_at timestamptz not null default now(), @@ -21,6 +22,7 @@ comment on column measure.event_targeting_rules.id is 'id of the rule'; comment on column measure.event_targeting_rules.sampling_rate is 'the percentage sampling rate applied'; comment on column measure.event_targeting_rules.condition is 'the condition represented as a CEL expression'; comment on column measure.event_targeting_rules.collection_mode is 'the collection mode for the event (e.g., "sampled", "session_timeline", "none")'; +comment on column measure.event_targeting_rules.is_default_rule is 'whether this is the default rule'; comment on column measure.event_targeting_rules.take_screenshot is 'whether to take a screenshot for the event'; comment on column measure.event_targeting_rules.take_layout_snapshot is 'whether to take a layout snapshot for the event'; comment on column measure.event_targeting_rules.created_at is 'utc timestamp at the time of rule creation'; diff --git a/self-host/postgres/20251107111408_create_trace_targeting_rules_table.sql b/self-host/postgres/20251107111408_create_trace_targeting_rules_table.sql index c968bc7da..897512566 100644 --- a/self-host/postgres/20251107111408_create_trace_targeting_rules_table.sql +++ b/self-host/postgres/20251107111408_create_trace_targeting_rules_table.sql @@ -6,6 +6,7 @@ create table if not exists measure.trace_targeting_rules ( sampling_rate numeric(9, 6) not null, condition text not null, collection_mode text not null, + is_default_rule boolean not null default false, created_at timestamptz not null default now(), created_by uuid not null, updated_at timestamptz not null default now(), @@ -19,6 +20,7 @@ comment on column measure.trace_targeting_rules.id is 'id of the rule'; comment on column measure.trace_targeting_rules.sampling_rate is 'the percentage sampling rate applied'; comment on column measure.trace_targeting_rules.condition is 'the condition represented as a CEL expression'; comment on column measure.trace_targeting_rules.collection_mode is 'the collection mode for the trace (e.g., "sampled", "session_timeline", "none")'; +comment on column measure.trace_targeting_rules.is_default_rule is 'whether this is the default rule'; comment on column measure.trace_targeting_rules.created_at is 'utc timestamp at the time of rule creation'; comment on column measure.trace_targeting_rules.created_by is ' id of the user who created the rule'; comment on column measure.trace_targeting_rules.updated_at is 'utc timestamp at the time of rule update'; From 1ebe5ef11c51f6defc316a87f55df76886586d9c Mon Sep 17 00:00:00 2001 From: Abhay Sood Date: Mon, 10 Nov 2025 11:02:21 +0530 Subject: [PATCH 58/98] feat(backend): modify dropdown select --- .../app/components/dropdown_select.tsx | 5 +- .../targeting/event_rule_builder.tsx | 269 ++++++++++++++++-- .../targeting/rule_builder_attribute_row.tsx | 132 +++++++++ 3 files changed, 375 insertions(+), 31 deletions(-) create mode 100644 frontend/dashboard/app/components/targeting/rule_builder_attribute_row.tsx diff --git a/frontend/dashboard/app/components/dropdown_select.tsx b/frontend/dashboard/app/components/dropdown_select.tsx index 01ad157e2..a772f1218 100644 --- a/frontend/dashboard/app/components/dropdown_select.tsx +++ b/frontend/dashboard/app/components/dropdown_select.tsx @@ -30,9 +30,10 @@ interface DropdownSelectProps { items: string[] | AppVersion[] | OsVersion[] initialSelected: string | AppVersion | OsVersion | string[] | AppVersion[] | OsVersion[] onChangeSelected?: (item: string | AppVersion | OsVersion | string[] | AppVersion[] | OsVersion[]) => void + buttonClassName?: string } -const DropdownSelect: React.FC = ({ title, type, items, initialSelected, onChangeSelected }) => { +const DropdownSelect: React.FC = ({ title, type, items, initialSelected, onChangeSelected, buttonClassName }) => { const [open, setOpen] = useState(false) const [selected, setSelected] = useState(initialSelected) const [searchValue, setSearchValue] = useState("") @@ -222,7 +223,7 @@ const DropdownSelect: React.FC = ({ title, type, items, ini
- + + {/* Event Attributes Section - Always shown if event has attrs */} + {pageState.currentRuleState?.condition.attrs && pageState.currentRuleState.condition.attrs.length > 0 && ( +
+ {pageState.currentRuleState.condition.attrs.map(attr => ( + { + const config = pageState.configData?.result || (pageState.configData as any) + return config?.operator_types || {} + })()} + getOperatorsForType={getOperatorsForType} + onUpdateAttr={handleUpdateAttr} + showDeleteButton={false} + /> + ))} +
+ )} + + {/* Add Filter Button - Only shown if event has ud_attrs capability */} + {(() => { + const config = pageState.configData?.result || (pageState.configData as any) + const currentEventType = pageState.currentRuleState?.condition.type + const eventConfig = config?.events?.find((e: any) => e.type === currentEventType) + const hasUdAttrs = eventConfig?.has_ud_attrs + const hasCombinedAttrs = pageState.currentRuleState?.condition.ud_attrs.length > 0 || + pageState.currentRuleState?.condition.session_attrs.length > 0 + + return hasUdAttrs && !hasCombinedAttrs && ( + + ) + })()} + + {/* User-Defined & Session Attributes Section */} + {(pageState.currentRuleState?.condition.ud_attrs.length > 0 || + pageState.currentRuleState?.condition.session_attrs.length > 0) && ( +
+ {/* Render session attrs */} + {pageState.currentRuleState.condition.session_attrs.map(attr => ( + { + const config = pageState.configData?.result || (pageState.configData as any) + return config?.operator_types || {} + })()} + getOperatorsForType={getOperatorsForType} + onUpdateAttr={handleUpdateAttr} + onRemoveAttr={handleRemoveAttr} + showDeleteButton={true} + /> + ))} + {/* Render ud attrs */} + {pageState.currentRuleState.condition.ud_attrs.map(attr => ( + { + const config = pageState.configData?.result || (pageState.configData as any) + return config?.operator_types || {} + })()} + getOperatorsForType={getOperatorsForType} + onUpdateAttr={handleUpdateAttr} + onRemoveAttr={handleRemoveAttr} + showDeleteButton={true} + /> + ))} + {/* Add more attributes button */} + +
+ )}
diff --git a/frontend/dashboard/app/components/targeting/rule_builder_attribute_row.tsx b/frontend/dashboard/app/components/targeting/rule_builder_attribute_row.tsx new file mode 100644 index 000000000..68db613ad --- /dev/null +++ b/frontend/dashboard/app/components/targeting/rule_builder_attribute_row.tsx @@ -0,0 +1,132 @@ +"use client" + +import DropdownSelect, { DropdownSelectType } from '@/app/components/dropdown_select'; +import { Button } from '@/app/components/button'; +import { X } from 'lucide-react'; + +type AttrType = 'attrs' | 'ud_attrs'; + +const getTypeDisplayName = (type: string): string => { + const typeMap: { [key: string]: string } = { + 'float64': 'decimal', + 'int64': 'number', + 'number': 'number', + 'string': 'text' + }; + + return typeMap[type] || type; +}; + +const RuleBuilderAttributeRow = ({ + attr, + conditionId, + attrType, + attrKeys, + operatorTypesMapping, + getOperatorsForType, + onUpdateAttr, + onRemoveAttr, + showDeleteButton = true +}: { + attr: { id: string; key: string; type: string; value: string | boolean | number; operator?: string; hasError?: boolean; errorMessage?: string; hint?: string }; + conditionId: string; + attrType: AttrType; + attrKeys: string[]; + operatorTypesMapping: any; + getOperatorsForType: (mapping: any, type: string) => string[]; + onUpdateAttr: (conditionId: string, attrId: string, field: 'key' | 'type' | 'value' | 'operator', value: any, attrType: AttrType) => void; + onRemoveAttr?: (conditionId: string, attrId: string, attrType: AttrType) => void; + showDeleteButton?: boolean; +}) => { + const operatorTypes = getOperatorsForType(operatorTypesMapping, attr.type); + + const handleValueChange = (newValue: string | boolean | number) => { + onUpdateAttr(conditionId, attr.id, 'value', newValue, attrType); + }; + + return ( +
+ {/* Attribute Key Dropdown */} +
+ { + onUpdateAttr(conditionId, attr.id, 'key', selected as string, attrType); + }} + buttonClassName="flex justify-between font-display border border-black w-full select-none" + /> +
+ + {/* Operator Dropdown */} +
+ { + onUpdateAttr(conditionId, attr.id, 'operator', selected as string, attrType); + }} + buttonClassName="flex justify-between font-display border border-black w-full select-none" + /> +
+ + {/* Value section */} +
+ {attr.type === 'bool' ? ( + { + handleValueChange((selected as string) === 'true') + }} + buttonClassName="flex justify-between font-display border border-black w-full select-none" + /> + ) : ( +
+ { + const value = (attr.type === 'number' || attr.type === 'int64' || attr.type === 'float64') ? + (e.target.value === '' ? '' : Number(e.target.value)) : + e.target.value + handleValueChange(value) + }} + className={`w-full border rounded-md outline-hidden text-sm focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] py-2 px-4 font-body placeholder:text-neutral-400 ${attr.hasError ? 'border-red-500' : 'border-black'}`} + /> + {attr.hasError && attr.errorMessage && ( +

{attr.errorMessage}

+ )} +
+ )} +
+ + {/* Remove Attribute Button */} +
+ {showDeleteButton && onRemoveAttr ? ( + + ) : ( +
+ )} +
+
+ ) +}; + +export default RuleBuilderAttributeRow; \ No newline at end of file From b5dc91b01dfbabb73350b3215b51d5ef9d46be97 Mon Sep 17 00:00:00 2001 From: Abhay Sood Date: Mon, 10 Nov 2025 11:14:33 +0530 Subject: [PATCH 59/98] feat(frontend): fix sampling rate input --- .../targeting/event_rule_builder.tsx | 14 ++---- .../targeting/sampling_rate_input.tsx | 46 +++++++------------ .../targeting/trace_rule_builder.tsx | 14 ++---- 3 files changed, 27 insertions(+), 47 deletions(-) diff --git a/frontend/dashboard/app/components/targeting/event_rule_builder.tsx b/frontend/dashboard/app/components/targeting/event_rule_builder.tsx index 535e06e3e..d7afe67a3 100644 --- a/frontend/dashboard/app/components/targeting/event_rule_builder.tsx +++ b/frontend/dashboard/app/components/targeting/event_rule_builder.tsx @@ -22,6 +22,7 @@ import { celToConditions } from '@/app/utils/cel/cel_parser' import DropdownSelect, { DropdownSelectType } from '@/app/components/dropdown_select' import { eventConditionToCel } from '@/app/utils/cel/cel_generator' import RuleBuilderAttributeRow from '@/app/components/targeting/rule_builder_attribute_row' +import SamplingRateInput from '@/app/components/targeting/sampling_rate_input' interface EventRuleBuilderProps { mode: 'create' | 'edit' @@ -573,17 +574,12 @@ export default function EventRuleBuilder({ onChange={() => updateRuleState({ collectionMode: 'sampled' })} className="appearance-none w-4 h-4 border border-gray-400 rounded-full checked:bg-black checked:border-black cursor-pointer outline-none focus:outline-none focus:ring-0 focus-visible:ring-0 flex-shrink-0" /> - Collect at sampling rate - updateRuleState({ sampleRate: parseFloat(e.target.value) || 0 })} - min="0" - max="100" - step="0.000001" - className="w-32 border border-black rounded-md outline-hidden text-sm focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] py-2 px-4 font-body" + onChange={(value) => updateRuleState({ sampleRate: value })} + disabled={pageState.currentRuleState?.collectionMode !== 'sampled'} + type="events" /> - %