From 86d1e848d09543bf947e8a49ea461f1251b372ec Mon Sep 17 00:00:00 2001 From: Anup Cowkur Date: Fri, 1 Nov 2024 12:15:46 +0530 Subject: [PATCH] feat(frontend): replace exception detail journey with attr distribution plot closes #1300 --- backend/api/main.go | 2 + backend/api/measure/app.go | 226 ++++++++++++++++++ backend/api/measure/event.go | 115 +++++++++ frontend/dashboard/app/api/api_calls.ts | 34 +++ .../app/components/exceptions_details.tsx | 13 +- .../exceptions_distribution_plot.tsx | 131 ++++++++++ frontend/dashboard/package-lock.json | 24 ++ frontend/dashboard/package.json | 1 + 8 files changed, 538 insertions(+), 8 deletions(-) create mode 100644 frontend/dashboard/app/components/exceptions_distribution_plot.tsx diff --git a/backend/api/main.go b/backend/api/main.go index a8def2296..3c014c4fc 100644 --- a/backend/api/main.go +++ b/backend/api/main.go @@ -93,11 +93,13 @@ func main() { apps.GET(":id/crashGroups/plots/instances", measure.GetCrashOverviewPlotInstances) apps.GET(":id/crashGroups/:crashGroupId/crashes", measure.GetCrashDetailCrashes) apps.GET(":id/crashGroups/:crashGroupId/plots/instances", measure.GetCrashDetailPlotInstances) + apps.GET(":id/crashGroups/:crashGroupId/plots/distribution", measure.GetCrashDetailAttributeDistribution) apps.GET(":id/crashGroups/:crashGroupId/plots/journey", measure.GetCrashDetailPlotJourney) apps.GET(":id/anrGroups", measure.GetANROverview) apps.GET(":id/anrGroups/plots/instances", measure.GetANROverviewPlotInstances) apps.GET(":id/anrGroups/:anrGroupId/anrs", measure.GetANRDetailANRs) apps.GET(":id/anrGroups/:anrGroupId/plots/instances", measure.GetANRDetailPlotInstances) + apps.GET(":id/anrGroups/:anrGroupId/plots/distribution", measure.GetANRDetailAttributeDistribution) apps.GET(":id/anrGroups/:anrGroupId/plots/journey", measure.GetANRDetailPlotJourney) apps.GET(":id/sessions", measure.GetSessionsOverview) apps.GET(":id/sessions/:sessionId", measure.GetSession) diff --git a/backend/api/measure/app.go b/backend/api/measure/app.go index 065c2bfad..294cfb167 100644 --- a/backend/api/measure/app.go +++ b/backend/api/measure/app.go @@ -2868,6 +2868,122 @@ func GetCrashDetailPlotInstances(c *gin.Context) { c.JSON(http.StatusOK, instances) } +func GetCrashDetailAttributeDistribution(c *gin.Context) { + ctx := c.Request.Context() + id, err := uuid.Parse(c.Param("id")) + if err != nil { + msg := `id invalid or missing` + fmt.Println(msg, err) + c.JSON(http.StatusBadRequest, gin.H{"error": msg}) + return + } + + crashGroupId, err := uuid.Parse(c.Param("crashGroupId")) + if err != nil { + msg := `crash group id is invalid or missing` + fmt.Println(msg, err) + c.JSON(http.StatusBadRequest, gin.H{"error": msg}) + return + } + + af := filter.AppFilter{ + AppID: id, + Limit: filter.DefaultPaginationLimit, + } + + if err := c.ShouldBindQuery(&af); err != nil { + msg := `failed to parse query parameters` + fmt.Println(msg, err) + c.JSON(http.StatusBadRequest, gin.H{ + "error": msg, + "details": err.Error(), + }) + return + } + + af.Expand() + + msg := "app filters request validation failed" + if err := af.Validate(); err != nil { + fmt.Println(msg, err) + c.JSON(http.StatusBadRequest, gin.H{ + "error": msg, + "details": err.Error(), + }) + return + } + + if len(af.Versions) > 0 || len(af.VersionCodes) > 0 { + if err := af.ValidateVersions(); err != nil { + fmt.Println(msg, err) + c.JSON(http.StatusBadRequest, gin.H{ + "error": msg, + "details": err.Error(), + }) + return + } + } + + app := App{ + ID: &id, + } + team, err := app.getTeam(ctx) + if err != nil { + msg := "failed to get team from app id" + fmt.Println(msg, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": msg}) + return + } + if team == nil { + msg := fmt.Sprintf("no team exists for app [%s]", app.ID) + c.JSON(http.StatusBadRequest, gin.H{"error": msg}) + return + } + + userId := c.GetString("userId") + okTeam, err := PerformAuthz(userId, team.ID.String(), *ScopeTeamRead) + if err != nil { + msg := `failed to perform authorization` + fmt.Println(msg, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": msg}) + return + } + + okApp, err := PerformAuthz(userId, team.ID.String(), *ScopeAppRead) + if err != nil { + msg := `failed to perform authorization` + fmt.Println(msg, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": msg}) + return + } + + if !okTeam || !okApp { + msg := `you are not authorized to access this app` + c.JSON(http.StatusForbidden, gin.H{"error": msg}) + return + } + + group, err := app.GetExceptionGroup(ctx, crashGroupId) + if err != nil { + msg := fmt.Sprintf("failed to get exception group with id %q", crashGroupId.String()) + fmt.Println(msg, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": msg}) + return + } + + distribution, err := GetIssuesAttributeDistribution(ctx, group, &af) + if err != nil { + msg := `failed to query data for crash distribution plot` + fmt.Println(msg, err) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": msg, + }) + return + } + + c.JSON(http.StatusOK, distribution) +} + func GetCrashDetailPlotJourney(c *gin.Context) { ctx := c.Request.Context() id, err := uuid.Parse(c.Param("id")) @@ -3600,6 +3716,116 @@ func GetANRDetailPlotInstances(c *gin.Context) { c.JSON(http.StatusOK, instances) } +func GetANRDetailAttributeDistribution(c *gin.Context) { + ctx := c.Request.Context() + id, err := uuid.Parse(c.Param("id")) + if err != nil { + msg := `id invalid or missing` + fmt.Println(msg, err) + c.JSON(http.StatusBadRequest, gin.H{"error": msg}) + return + } + + anrGroupId, err := uuid.Parse(c.Param("anrGroupId")) + if err != nil { + msg := `anr group id is invalid or missing` + fmt.Println(msg, err) + c.JSON(http.StatusBadRequest, gin.H{"error": msg}) + return + } + + af := filter.AppFilter{ + AppID: id, + Limit: filter.DefaultPaginationLimit, + } + + if err := c.ShouldBindQuery(&af); err != nil { + msg := `failed to parse query parameters` + fmt.Println(msg, err) + c.JSON(http.StatusBadRequest, gin.H{"error": msg, "details": err.Error()}) + return + } + + af.Expand() + + msg := "app filters request validation failed" + if err := af.Validate(); err != nil { + fmt.Println(msg, err) + c.JSON(http.StatusBadRequest, gin.H{"error": msg, "details": err.Error()}) + return + } + + if len(af.Versions) > 0 || len(af.VersionCodes) > 0 { + if err := af.ValidateVersions(); err != nil { + fmt.Println(msg, err) + c.JSON(http.StatusBadRequest, gin.H{ + "error": msg, + "details": err.Error(), + }) + return + } + } + + app := App{ + ID: &id, + } + team, err := app.getTeam(ctx) + if err != nil { + msg := "failed to get team from app id" + fmt.Println(msg, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": msg}) + return + } + if team == nil { + msg := fmt.Sprintf("no team exists for app [%s]", app.ID) + c.JSON(http.StatusBadRequest, gin.H{"error": msg}) + return + } + + userId := c.GetString("userId") + okTeam, err := PerformAuthz(userId, team.ID.String(), *ScopeTeamRead) + if err != nil { + msg := `failed to perform authorization` + fmt.Println(msg, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": msg}) + return + } + + okApp, err := PerformAuthz(userId, team.ID.String(), *ScopeAppRead) + if err != nil { + msg := `failed to perform authorization` + fmt.Println(msg, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": msg}) + return + } + + if !okTeam || !okApp { + msg := `you are not authorized to access this app` + c.JSON(http.StatusForbidden, gin.H{"error": msg}) + return + } + + group, err := app.GetANRGroup(ctx, anrGroupId) + if err != nil { + msg := fmt.Sprintf("failed to get anr group with id %q", anrGroupId.String()) + fmt.Println(msg, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": msg}) + return + } + + distribution, err := GetIssuesAttributeDistribution(ctx, group, &af) + if err != nil { + msg := `failed to query data for anr distribution plot` + fmt.Println(msg, err) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": msg, + }) + return + } + + c.JSON(http.StatusOK, distribution) +} + func GetANRDetailPlotJourney(c *gin.Context) { ctx := c.Request.Context() id, err := uuid.Parse(c.Param("id")) diff --git a/backend/api/measure/event.go b/backend/api/measure/event.go index 27e16c508..67d61844e 100644 --- a/backend/api/measure/event.go +++ b/backend/api/measure/event.go @@ -1512,6 +1512,121 @@ func GetANRPlotInstances(ctx context.Context, af *filter.AppFilter) (issueInstan return } +// GetIssuesAttributeDistribution queries distribution of attributes +// based on datetime and filters. +func GetIssuesAttributeDistribution(ctx context.Context, g group.IssueGroup, af *filter.AppFilter) (map[string]map[string]uint64, error) { + fingerprint := g.GetFingerprint() + groupType := event.TypeException + + switch g.(type) { + case *group.ANRGroup: + groupType = event.TypeANR + case *group.ExceptionGroup: + groupType = event.TypeException + default: + err := errors.New("couldn't determine correct type of issue group") + return nil, err + } + + stmt := sqlf. + From("default.events"). + Select("concat(toString(attribute.app_version), ' (', toString(attribute.app_build), ')') as app_version"). + Select("concat(toString(attribute.os_name), ' ', toString(attribute.os_version)) as os_version"). + Select("toString(inet.country_code) as country"). + Select("toString(attribute.network_type) as network_type"). + Select("toString(attribute.device_locale) as locale"). + Select("concat(toString(attribute.device_manufacturer), ' - ', toString(attribute.device_name)) as device"). + Select("uniq(id) as count"). + Clause(fmt.Sprintf("prewhere app_id = toUUID(?) and %s.fingerprint = ?", groupType), af.AppID, fingerprint). + GroupBy("app_version"). + GroupBy("os_version"). + GroupBy("country"). + GroupBy("network_type"). + GroupBy("locale"). + GroupBy("device") + + // Add filters as necessary + stmt.Where("timestamp >= ? and timestamp <= ?", af.From, af.To) + if len(af.Versions) > 0 { + stmt.Where("attribute.app_version in ?", af.Versions) + } + if len(af.VersionCodes) > 0 { + stmt.Where("attribute.app_build in ?", af.VersionCodes) + } + if len(af.OsNames) > 0 { + stmt.Where("attribute.os_name in ?", af.OsNames) + } + if len(af.OsVersions) > 0 { + stmt.Where("attribute.os_version in ?", af.OsVersions) + } + if len(af.Countries) > 0 { + stmt.Where("inet.country_code in ?", af.Countries) + } + if len(af.NetworkTypes) > 0 { + stmt.Where("attribute.network_type in ?", af.NetworkTypes) + } + if len(af.NetworkGenerations) > 0 { + stmt.Where("attribute.network_generation in ?", af.NetworkGenerations) + } + if len(af.Locales) > 0 { + stmt.Where("attribute.device_locale in ?", af.Locales) + } + if len(af.DeviceManufacturers) > 0 { + stmt.Where("attribute.device_manufacturer in ?", af.DeviceManufacturers) + } + if len(af.DeviceNames) > 0 { + stmt.Where("attribute.device_name in ?", af.DeviceNames) + } + + // Execute the query and parse results + rows, err := server.Server.ChPool.Query(ctx, stmt.String(), stmt.Args()...) + if err != nil { + return nil, err + } + defer rows.Close() + + // Initialize a map to store distribution results for each attribute. + attributeDistributions := map[string]map[string]uint64{ + "app_version": make(map[string]uint64), + "os_version": make(map[string]uint64), + "country": make(map[string]uint64), + "network_type": make(map[string]uint64), + "locale": make(map[string]uint64), + "device": make(map[string]uint64), + } + + // Parse each row in the result set. + for rows.Next() { + var ( + appVersion string + osVersion string + country string + networkType string + locale string + device string + count uint64 + ) + + if err := rows.Scan(&appVersion, &osVersion, &country, &networkType, &locale, &device, &count); err != nil { + return nil, err + } + + // Update counts in the distribution map + attributeDistributions["app_version"][appVersion] += count + attributeDistributions["os_version"][osVersion] += count + attributeDistributions["country"][country] += count + attributeDistributions["network_type"][networkType] += count + attributeDistributions["locale"][locale] += count + attributeDistributions["device"][device] += count + } + + if rows.Err() != nil { + return nil, rows.Err() + } + + return attributeDistributions, nil +} + // GetIssuesPlot aggregates issue free percentage for plotting // visually from an ExceptionGroup or ANRGroup. func GetIssuesPlot(ctx context.Context, g group.IssueGroup, af *filter.AppFilter) (issueInstances []event.IssueInstance, err error) { diff --git a/frontend/dashboard/app/api/api_calls.ts b/frontend/dashboard/app/api/api_calls.ts index 9e5913927..1312c55f7 100644 --- a/frontend/dashboard/app/api/api_calls.ts +++ b/frontend/dashboard/app/api/api_calls.ts @@ -87,6 +87,12 @@ export enum ExceptionsDetailsPlotApiStatus { Error, NoData } +export enum ExceptionsDistributionPlotApiStatus { + Loading, + Success, + Error, + NoData +} export enum CreateTeamApiStatus { Init, @@ -994,6 +1000,34 @@ export const fetchExceptionsDetailsPlotFromServer = async (exceptionsType: Excep return { status: ExceptionsDetailsPlotApiStatus.Success, data: data } } +export const fetchExceptionsDistributionPlotFromServer = async (exceptionsType: ExceptionsType, exceptionsGroupdId: string, filters: Filters, router: AppRouterInstance) => { + const origin = process.env.NEXT_PUBLIC_API_BASE_URL + + var url = "" + if (exceptionsType === ExceptionsType.Crash) { + url = `${origin}/apps/${filters.app.id}/crashGroups/${exceptionsGroupdId}/plots/distribution?` + } else { + url = `${origin}/apps/${filters.app.id}/anrGroups/${exceptionsGroupdId}/plots/distribution?` + } + + url = applyGenericFiltersToUrl(url, filters, null, null, null) + + const res = await fetchAuth(url); + + if (!res.ok) { + logoutIfAuthError(auth, router, res) + return { status: ExceptionsDistributionPlotApiStatus.Error, data: null } + } + + const data = await res.json() + + if (data === null) { + return { status: ExceptionsDistributionPlotApiStatus.NoData, data: null } + } + + return { status: ExceptionsDistributionPlotApiStatus.Success, data: data } +} + export const fetchAuthzAndMembersFromServer = async (teamId: string, router: AppRouterInstance) => { const origin = process.env.NEXT_PUBLIC_API_BASE_URL diff --git a/frontend/dashboard/app/components/exceptions_details.tsx b/frontend/dashboard/app/components/exceptions_details.tsx index d4c281090..909c57916 100644 --- a/frontend/dashboard/app/components/exceptions_details.tsx +++ b/frontend/dashboard/app/components/exceptions_details.tsx @@ -13,6 +13,7 @@ import Journey, { JourneyType } from './journey_sankey'; import Image from 'next/image'; import CopyAiContext from './copy_ai_context'; import LoadingSpinner from './loading_spinner'; +import ExceptionsDistributionPlot from './exceptions_distribution_plot'; interface ExceptionsDetailsProps { exceptionsType: ExceptionsType, @@ -115,14 +116,10 @@ export const ExceptionsDetails: React.FC = ({ exceptions exceptionsGroupId={exceptionsGroupId} filters={filters} />
-
- -
+
diff --git a/frontend/dashboard/app/components/exceptions_distribution_plot.tsx b/frontend/dashboard/app/components/exceptions_distribution_plot.tsx new file mode 100644 index 000000000..7b8e62fdc --- /dev/null +++ b/frontend/dashboard/app/components/exceptions_distribution_plot.tsx @@ -0,0 +1,131 @@ +"use client" + +import React, { useEffect, useState } from 'react'; +import { ExceptionsDistributionPlotApiStatus, ExceptionsType, fetchExceptionsDistributionPlotFromServer } from '../api/api_calls'; +import { useRouter } from 'next/navigation'; +import { Filters } from './filters'; +import LoadingSpinner from './loading_spinner'; +import { ResponsiveBar } from '@nivo/bar'; + +interface ExceptionsDistributionPlotProps { + exceptionsType: ExceptionsType, + exceptionsGroupId: string, + filters: Filters +} + +type ExceptionsDistributionPlot = { + attribute: string; + [key: string]: number | string; +}[]; + +const formatAttribute = (str: string): string => { + return str + .split('_') + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); +}; + +const ExceptionsDistributionPlot: React.FC = ({ exceptionsType, exceptionsGroupId, filters }) => { + const router = useRouter() + + const [exceptionsDistributionPlotApiStatus, setExceptionsDistributionPlotApiStatus] = useState(ExceptionsDistributionPlotApiStatus.Loading); + const [plotKeys, setPlotKeys] = useState([]); + const [plot, setPlot] = useState(); + + const getExceptionsDistributionPlot = async () => { + // Don't try to fetch plot if filters aren't ready + if (!filters.ready) { + return + } + + setExceptionsDistributionPlotApiStatus(ExceptionsDistributionPlotApiStatus.Loading) + + const result = await fetchExceptionsDistributionPlotFromServer(exceptionsType, exceptionsGroupId, filters, router) + + switch (result.status) { + case ExceptionsDistributionPlotApiStatus.Error: + setExceptionsDistributionPlotApiStatus(ExceptionsDistributionPlotApiStatus.Error) + break + case ExceptionsDistributionPlotApiStatus.NoData: + setExceptionsDistributionPlotApiStatus(ExceptionsDistributionPlotApiStatus.NoData) + break + case ExceptionsDistributionPlotApiStatus.Success: + setExceptionsDistributionPlotApiStatus(ExceptionsDistributionPlotApiStatus.Success) + + // map result data to chart format + const parsedPlotKeys: string[] = [] + const parsedPlot = Object.entries(result.data).map(([attribute, values]) => { + Object.keys(values as { [key: string]: number }).forEach(key => { + if (!parsedPlotKeys.includes(key)) { + parsedPlotKeys.push(key); + } + }); + + return { + attribute: formatAttribute(attribute), + ...(values as { [key: string]: number }), + }; + }) + + setPlot(parsedPlot) + setPlotKeys(parsedPlotKeys) + break + } + } + + useEffect(() => { + getExceptionsDistributionPlot() + }, [exceptionsType, exceptionsGroupId, filters]); + + return ( +
+ {exceptionsDistributionPlotApiStatus === ExceptionsDistributionPlotApiStatus.Loading && } + {exceptionsDistributionPlotApiStatus === ExceptionsDistributionPlotApiStatus.Error &&

Error fetching plot, please change filters or refresh page to try again

} + {exceptionsDistributionPlotApiStatus === ExceptionsDistributionPlotApiStatus.NoData &&

No Data

} + {exceptionsDistributionPlotApiStatus === ExceptionsDistributionPlotApiStatus.Success && + { + return ( +
+
+
+
+

{id} -

+
+

{value} {value > 1 ? 'instances' : 'instance'}

+
+
+ ) + }} + />} +
+ ) + +}; + +export default ExceptionsDistributionPlot; \ No newline at end of file diff --git a/frontend/dashboard/package-lock.json b/frontend/dashboard/package-lock.json index a20f3514d..b166758c0 100644 --- a/frontend/dashboard/package-lock.json +++ b/frontend/dashboard/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@dagrejs/dagre": "^1.1.3", "@highlight-run/next": "^7.5.12", + "@nivo/bar": "^0.87.0", "@nivo/core": "^0.87.0", "@nivo/line": "^0.87.0", "@nivo/pie": "^0.87.0", @@ -1696,6 +1697,29 @@ "react": ">= 16.14.0 < 19.0.0" } }, + "node_modules/@nivo/bar": { + "version": "0.87.0", + "resolved": "https://registry.npmjs.org/@nivo/bar/-/bar-0.87.0.tgz", + "integrity": "sha512-r/MEVCNAHKfmsy1Fb+JztVczOhIEtAx4VFs2XUbn9YpEDgxydavUJyfoy5/nGq6h5jG1/t47cfB4nZle7c0fyQ==", + "dependencies": { + "@nivo/annotations": "0.87.0", + "@nivo/axes": "0.87.0", + "@nivo/colors": "0.87.0", + "@nivo/core": "0.87.0", + "@nivo/legends": "0.87.0", + "@nivo/scales": "0.87.0", + "@nivo/tooltip": "0.87.0", + "@react-spring/web": "9.4.5 || ^9.7.2", + "@types/d3-scale": "^4.0.8", + "@types/d3-shape": "^3.1.6", + "d3-scale": "^4.0.2", + "d3-shape": "^3.2.0", + "lodash": "^4.17.21" + }, + "peerDependencies": { + "react": ">= 16.14.0 < 19.0.0" + } + }, "node_modules/@nivo/colors": { "version": "0.87.0", "resolved": "https://registry.npmjs.org/@nivo/colors/-/colors-0.87.0.tgz", diff --git a/frontend/dashboard/package.json b/frontend/dashboard/package.json index 86d3f7637..6a7340742 100644 --- a/frontend/dashboard/package.json +++ b/frontend/dashboard/package.json @@ -14,6 +14,7 @@ "dependencies": { "@dagrejs/dagre": "^1.1.3", "@highlight-run/next": "^7.5.12", + "@nivo/bar": "^0.87.0", "@nivo/core": "^0.87.0", "@nivo/line": "^0.87.0", "@nivo/pie": "^0.87.0",