diff --git a/src/components/Insights/InsightList/index.tsx b/src/components/Insights/InsightList/index.tsx index 82a791264..ab2fb0765 100644 --- a/src/components/Insights/InsightList/index.tsx +++ b/src/components/Insights/InsightList/index.tsx @@ -21,6 +21,7 @@ import { NPlusOneInsight } from "../NPlusOneInsight"; import { NoObservabilityCard } from "../NoObservabilityCard"; import { NoScalingIssueInsight } from "../NoScalingIssueInsight"; import { PerformanceAtScaleInsight } from "../PerformanceAtScaleInsight"; +import { QueryOptimizationInsight } from "../QueryOptimizationInsight"; import { RequestBreakdownInsight } from "../RequestBreakdownInsight"; import { ScalingIssueInsight } from "../ScalingIssueInsight"; import { SessionInViewInsight } from "../SessionInViewInsight"; @@ -52,6 +53,7 @@ import { isSpanInsight, isSpanNPlusOneInsight, isSpanNexusInsight, + isSpanQueryOptimizationInsight, isSpanScalingBadlyInsight, isSpanScalingInsufficientDataInsight, isSpanScalingWellInsight, @@ -574,6 +576,20 @@ const renderInsightCard = ( /> ); } + + if (isSpanQueryOptimizationInsight(insight)) { + return ( + + ); + } }; export const InsightList = (props: InsightListProps) => { diff --git a/src/components/Insights/QueryOptimizationInsight/QueryOptimizationInsight.stories.tsx b/src/components/Insights/QueryOptimizationInsight/QueryOptimizationInsight.stories.tsx new file mode 100644 index 000000000..67ad0ed13 --- /dev/null +++ b/src/components/Insights/QueryOptimizationInsight/QueryOptimizationInsight.stories.tsx @@ -0,0 +1,32 @@ +import { Meta, StoryObj } from "@storybook/react"; +import { QueryOptimizationInsight } from "."; +import { mockedQueryOptimizationInsight } from "./mockData"; + +// More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction +const meta: Meta = { + title: "Insights/QueryOptimizationInsight", + component: QueryOptimizationInsight, + parameters: { + // More on how to position stories at: https://storybook.js.org/docs/react/configure/story-layout + layout: "fullscreen" + } +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + insight: mockedQueryOptimizationInsight + } +}; + +export const LinkedJira: Story = { + args: { + insight: { + ...mockedQueryOptimizationInsight, + ticketLink: "https://digma.ai/1" + } + } +}; diff --git a/src/components/Insights/QueryOptimizationInsight/index.tsx b/src/components/Insights/QueryOptimizationInsight/index.tsx new file mode 100644 index 000000000..0f57428bd --- /dev/null +++ b/src/components/Insights/QueryOptimizationInsight/index.tsx @@ -0,0 +1,115 @@ +import { useContext } from "react"; +import { InsightType } from "../../../types"; +import { getDurationString } from "../../../utils/getDurationString"; +import { sendTrackingEvent } from "../../../utils/sendTrackingEvent"; +import { ConfigContext } from "../../common/App/ConfigContext"; +import { Tooltip } from "../../common/Tooltip"; +import { CrosshairIcon } from "../../common/icons/CrosshairIcon"; +import { InsightCard } from "../InsightCard"; +import { JiraButton } from "../common/JiraButton"; +import { Description, Link } from "../styles"; +import { trackingEvents } from "../tracking"; +import { Trace } from "../types"; +import * as s from "./styles"; +import { QueryOptimizationInsightProps } from "./types"; + +export const QueryOptimizationInsight = ( + props: QueryOptimizationInsightProps +) => { + const config = useContext(ConfigContext); + + const handleSpanLinkClick = (spanCodeObjectId?: string) => { + spanCodeObjectId && + props.onAssetLinkClick(spanCodeObjectId, props.insight.type); + }; + + const handleTraceButtonClick = ( + trace: Trace, + insightType: InsightType, + spanCodeObjectId?: string + ) => { + props.onTraceButtonClick(trace, insightType, spanCodeObjectId); + }; + + const handleCreateJiraTicketButtonClick = () => { + sendTrackingEvent(trackingEvents.JIRA_TICKET_INFO_BUTTON_CLICKED, { + insightType: props.insight.type + }); + + props.onJiraTicketCreate && + props.onJiraTicketCreate( + props.insight, + props.insight.spanInfo?.spanCodeObjectId + ); + }; + + const spanName = props.insight.spanInfo?.displayName || undefined; + const spanCodeObjectId = + props.insight.spanInfo?.spanCodeObjectId || undefined; + const traceId = props.insight.traceId; + + return ( + + + Query is slow compared to other{" "} + {props.insight.dbStatement.toUpperCase()} requests. + + + + + {spanCodeObjectId ? ( + handleSpanLinkClick(spanCodeObjectId)}> + {spanName} + + ) : ( + spanName + )} + + + {config.isJaegerEnabled && traceId && ( + + handleTraceButtonClick( + { + name: spanName, + id: traceId + }, + props.insight.type, + spanCodeObjectId + ) + } + icon={{ component: CrosshairIcon }} + > + Trace + + )} + + + + Duration + {getDurationString(props.insight.duration)} + + + Typical Duration + {getDurationString(props.insight.typicalDuration)} + + + + } + onRecalculate={props.onRecalculate} + onRefresh={props.onRefresh} + buttons={[ + + ]} + /> + ); +}; diff --git a/src/components/Insights/QueryOptimizationInsight/mockData.ts b/src/components/Insights/QueryOptimizationInsight/mockData.ts new file mode 100644 index 000000000..66bd4a645 --- /dev/null +++ b/src/components/Insights/QueryOptimizationInsight/mockData.ts @@ -0,0 +1,85 @@ +import { InsightType } from "../../../types"; +import { + InsightCategory, + InsightScope, + QueryOptimizationInsight +} from "../types"; + +export const mockedQueryOptimizationInsight: QueryOptimizationInsight = { + firstDetected: "2023-12-05T17:25:47.010Z", + lastDetected: "2024-01-05T13:14:47.010Z", + criticality: 0, + firstCommitId: "b3f7b3f", + lastCommitId: "a1b2c3d", + deactivatedCommitId: null, + reopenCount: 0, + ticketLink: null, + impact: 0, + name: "QueryOptimization", + type: InsightType.SpanQueryOptimization, + category: InsightCategory.Performance, + specifity: 2, + importance: 2, + span: { + name: "OwnerValidation.ValidateOwner", + displayName: "OwnerValidation.ValidateOwner", + instrumentationLibrary: + "io.opentelemetry.opentelemetry-instrumentation-annotations-1.16", + spanCodeObjectId: + "span:io.opentelemetry.opentelemetry-instrumentation-annotations-1.16$_$OwnerValidation.ValidateOwner", + methodCodeObjectId: + "org.springframework.samples.petclinic.domain.OwnerValidation$_$ValidateOwner", + kind: "Internal", + codeObjectId: + "org.springframework.samples.petclinic.domain.OwnerValidation$_$ValidateOwner" + }, + traceId: "00D37A4E7208E0F6E89AA7E2E37446A6", + duration: { + value: 12.34, + unit: "ms", + raw: 1636050588.0 + }, + typicalDuration: { + value: 4.56, + unit: "ms", + raw: 0 + }, + dbStatement: "select", + serviceName: "Petclinic", + dbName: "postgresql", + scope: InsightScope.Span, + spanInfo: { + name: "OwnerValidation.ValidateOwner", + displayName: "OwnerValidation.ValidateOwner", + instrumentationLibrary: + "io.opentelemetry.opentelemetry-instrumentation-annotations-1.16", + spanCodeObjectId: + "span:io.opentelemetry.opentelemetry-instrumentation-annotations-1.16$_$OwnerValidation.ValidateOwner", + methodCodeObjectId: + "org.springframework.samples.petclinic.domain.OwnerValidation$_$ValidateOwner", + kind: "Internal", + codeObjectId: + "org.springframework.samples.petclinic.domain.OwnerValidation$_$ValidateOwner" + }, + shortDisplayInfo: { + title: "", + targetDisplayName: "", + subtitle: "", + description: "" + }, + codeObjectId: + "org.springframework.samples.petclinic.domain.OwnerValidation$_$ValidateOwner", + decorators: [ + { + title: "N+1", + description: "Supected NPlus One" + } + ], + environment: "BOB-LAPTOP[LOCAL]", + severity: 0.0, + isRecalculateEnabled: false, + prefixedCodeObjectId: + "method:org.springframework.samples.petclinic.domain.OwnerValidation$_$ValidateOwner", + customStartTime: null, + actualStartTime: "2023-07-27T08:23:56.500827Z" +}; diff --git a/src/components/Insights/QueryOptimizationInsight/styles.ts b/src/components/Insights/QueryOptimizationInsight/styles.ts new file mode 100644 index 000000000..8f41d24f8 --- /dev/null +++ b/src/components/Insights/QueryOptimizationInsight/styles.ts @@ -0,0 +1,52 @@ +import styled from "styled-components"; +import { Button as CommonButton } from "../../common/Button"; + +export const Stats = styled.span` + display: flex; + flex-wrap: wrap; + gap: 8px 24px; +`; + +export const Stat = styled.span` + display: flex; + gap: 4px; +`; + +export const ContentContainer = styled.div` + display: flex; + flex-direction: column; + gap: 8px; + color: ${({ theme }) => { + switch (theme.mode) { + case "light": + return "#49494d"; + case "dark": + case "dark-jetbrains": + return "#dadada"; + } + }}; +`; + +export const SpanContainer = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + gap: 4px; +`; + +export const Button = styled(CommonButton)` + height: fit-content; +`; + +export const Name = styled.span` + font-weight: 500; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + max-width: fit-content; +`; + +export const CriticalityValue = styled.span` + display: flex; + gap: 4px; +`; diff --git a/src/components/Insights/QueryOptimizationInsight/types.ts b/src/components/Insights/QueryOptimizationInsight/types.ts new file mode 100644 index 000000000..49032c28c --- /dev/null +++ b/src/components/Insights/QueryOptimizationInsight/types.ts @@ -0,0 +1,15 @@ +import { InsightType } from "../../../types"; +import { InsightProps, QueryOptimizationInsight, Trace } from "../types"; + +export interface QueryOptimizationInsightProps extends InsightProps { + insight: QueryOptimizationInsight; + onAssetLinkClick: ( + spanCodeObjectId: string, + insightType: InsightType + ) => void; + onTraceButtonClick: ( + trace: Trace, + insightType: InsightType, + spanCodeObjectId?: string + ) => void; +} diff --git a/src/components/Insights/index.tsx b/src/components/Insights/index.tsx index beadc15a9..d5bb284f9 100644 --- a/src/components/Insights/index.tsx +++ b/src/components/Insights/index.tsx @@ -26,12 +26,14 @@ import * as s from "./styles"; import { BottleneckInsightTicket } from "./tickets/BottleneckInsightTicket"; import { EndpointNPlusOneInsightTicket } from "./tickets/EndpointNPlusOneInsightTicket"; import { NPlusOneInsightTicket } from "./tickets/NPlusOneInsightTicket"; +import { QueryOptimizationInsightTicket } from "./tickets/QueryOptimizationInsightTicket"; import { SpanBottleneckInsightTicket } from "./tickets/SpanBottleneckInsightTicket"; import { isEndpointSlowestSpansInsight, isEndpointSuspectedNPlusOneInsight, isSpanEndpointBottleneckInsight, - isSpanNPlusOneInsight + isSpanNPlusOneInsight, + isSpanQueryOptimizationInsight } from "./typeGuards"; import { EndpointSlowestSpansInsight, @@ -42,6 +44,7 @@ import { InsightsProps, InsightsStatus, Method, + QueryOptimizationInsight, SpanEndpointBottleneckInsight, SpanNPlusOneInsight, ViewMode @@ -81,6 +84,13 @@ const renderInsightTicket = ( return ; } + if (isSpanQueryOptimizationInsight(data.insight) && data.spanCodeObjectId) { + const ticketData = data as InsightTicketInfo; + return ( + + ); + } + return null; }; diff --git a/src/components/Insights/tickets/QueryOptimizationInsightTicket/QueryOptimizationInsightTicket.stories.tsx b/src/components/Insights/tickets/QueryOptimizationInsightTicket/QueryOptimizationInsightTicket.stories.tsx new file mode 100644 index 000000000..bf953be6f --- /dev/null +++ b/src/components/Insights/tickets/QueryOptimizationInsightTicket/QueryOptimizationInsightTicket.stories.tsx @@ -0,0 +1,25 @@ +import { Meta, StoryObj } from "@storybook/react"; +import { QueryOptimizationInsightTicket } from "."; +import { mockedQueryOptimizationInsight } from "../../QueryOptimizationInsight/mockData"; + +// More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction +const meta: Meta = { + title: "Insights/tickets/QueryOptimizationInsightTicket", + component: QueryOptimizationInsightTicket, + parameters: { + // More on how to position stories at: https://storybook.js.org/docs/react/configure/story-layout + layout: "fullscreen" + } +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + data: { + insight: mockedQueryOptimizationInsight + } + } +}; diff --git a/src/components/Insights/tickets/QueryOptimizationInsightTicket/index.tsx b/src/components/Insights/tickets/QueryOptimizationInsightTicket/index.tsx new file mode 100644 index 000000000..1580ebc9a --- /dev/null +++ b/src/components/Insights/tickets/QueryOptimizationInsightTicket/index.tsx @@ -0,0 +1,123 @@ +import { ReactElement, useContext, useEffect, useState } from "react"; +import { dispatcher } from "../../../../dispatcher"; +import { getCriticalityLabel } from "../../../../utils/getCriticalityLabel"; +import { getDurationString } from "../../../../utils/getDurationString"; +import { intersperse } from "../../../../utils/intersperse"; +import { ConfigContext } from "../../../common/App/ConfigContext"; +import { JiraTicket } from "../../JiraTicket"; +import { actions } from "../../actions"; +import { QueryOptimizationInsight } from "../../types"; +import { CommitInfos } from "../common/CommitInfos"; +import { DigmaSignature } from "../common/DigmaSignature"; +import { getInsightCommits } from "../getInsightCommits"; +import { CommitInfosData, InsightTicketProps } from "../types"; + +export const QueryOptimizationInsightTicket = ( + props: InsightTicketProps +) => { + const [isInitialLoading, setIsInitialLoading] = useState(false); + const [commitInfos, setCommitInfos] = useState(); + const config = useContext(ConfigContext); + + const criticalityString = + props.data.insight.criticality > 0 + ? `Criticality: ${getCriticalityLabel(props.data.insight.criticality)}` + : ""; + + const dbStatement = props.data.insight.dbStatement.toUpperCase(); + + const summary = [ + `Slow ${dbStatement} query found on db: [${ + props.data.insight.dbName || "" + }]`, + props.data.insight.serviceName || "", + criticalityString + ] + .filter(Boolean) + .join(" - "); + + const queryString = props.data.insight.spanInfo?.displayName || ""; + + const renderDescription = () => ( + <> + {intersperse( + [ +
+ The following {dbStatement} query is abnormally slow. Please + consider optimizing or adding indexes. +
, +
{queryString}
, +
+ Typical duration for {dbStatement} queries in this DB:{" "} + {getDurationString(props.data.insight.typicalDuration)} + {"\n"} + This query: {getDurationString(props.data.insight.duration)} +
, + , + + ], + (i: number) => ( +
+ ) + )} + + ); + + const traceId = props.data.insight.traceId; + const attachment = traceId + ? { + url: `${config.jaegerURL}/api/traces/${traceId}?prettyPrint=true`, + fileName: `trace-${traceId}.json` + } + : undefined; + + useEffect(() => { + setIsInitialLoading(true); + + const commits = getInsightCommits(props.data.insight); + + if (commits.length > 0) { + window.sendMessageToDigma({ + action: actions.GET_COMMIT_INFO, + payload: { + commits + } + }); + } + + const handleCommitInfosData = (data: unknown) => { + const commitInfosData = data as CommitInfosData; + setCommitInfos(commitInfosData); + setIsInitialLoading(false); + }; + + dispatcher.addActionListener( + actions.SET_COMMIT_INFO, + handleCommitInfosData + ); + + return () => { + dispatcher.removeActionListener( + actions.SET_COMMIT_INFO, + handleCommitInfosData + ); + }; + }, []); + + return ( + + ); +}; diff --git a/src/components/Insights/typeGuards.ts b/src/components/Insights/typeGuards.ts index 7209e0e1f..7edffc869 100644 --- a/src/components/Insights/typeGuards.ts +++ b/src/components/Insights/typeGuards.ts @@ -14,6 +14,7 @@ import { EndpointSlowestSpansInsight, EndpointSuspectedNPlusOneInsight, InsightScope, + QueryOptimizationInsight, SessionInViewEndpointInsight, SlowEndpointInsight, SpanDurationBreakdownInsight, @@ -138,3 +139,8 @@ export const isEndpointHighNumberOfQueriesInsight = ( export const isSpanNexusInsight = ( insight: CodeObjectInsight ): insight is SpanNexusInsight => insight.type === InsightType.SpanNexus; + +export const isSpanQueryOptimizationInsight = ( + insight: CodeObjectInsight +): insight is QueryOptimizationInsight => + insight.type === InsightType.SpanQueryOptimization; diff --git a/src/components/Insights/types.ts b/src/components/Insights/types.ts index e3cafe43f..57bb28908 100644 --- a/src/components/Insights/types.ts +++ b/src/components/Insights/types.ts @@ -690,3 +690,14 @@ export interface SpanNexusInsight extends SpanInsight { flows: number; usage: string | null; } + +export interface QueryOptimizationInsight extends SpanInsight { + type: InsightType.SpanQueryOptimization; + duration: Duration; + typicalDuration: Duration; + dbStatement: string; + traceId: string | null; + span: SpanInfo; + serviceName: string; + dbName: string; +} diff --git a/src/types.ts b/src/types.ts index 26988fc9b..16dff3355 100644 --- a/src/types.ts +++ b/src/types.ts @@ -32,7 +32,8 @@ export enum InsightType { EndpointSessionInView = "EndpointSessionInView", EndpointChattyApi = "EndpointChattyApi", EndpointHighNumberOfQueries = "EndpointHighNumberOfQueries", - SpanNexus = "SpanNexus" + SpanNexus = "SpanNexus", + SpanQueryOptimization = "SpanQueryOptimization" } export type PercentileKey = "p50" | "p95"; diff --git a/src/utils/getInsightTypeInfo.ts b/src/utils/getInsightTypeInfo.ts index 973512de0..943bfae69 100644 --- a/src/utils/getInsightTypeInfo.ts +++ b/src/utils/getInsightTypeInfo.ts @@ -117,6 +117,10 @@ export const getInsightTypeInfo = ( [InsightType.SpanNexus]: { icon: BottleneckIcon, // todo changes label: "Code Nexus Point" + }, + [InsightType.SpanQueryOptimization]: { + icon: SQLDatabaseIcon, + label: "Query Optimization Suggested" } }; diff --git a/src/utils/getInsightTypeOrderPriority.ts b/src/utils/getInsightTypeOrderPriority.ts index fcd23bfa1..a504e5f59 100644 --- a/src/utils/getInsightTypeOrderPriority.ts +++ b/src/utils/getInsightTypeOrderPriority.ts @@ -13,6 +13,8 @@ export const getInsightTypeOrderPriority = (type: string): number => { [InsightType.SpanDurationChange]: 66, [InsightType.SpanEndpointBottleneck]: 67, [InsightType.SpanDurationBreakdown]: 68, + [InsightType.SpanNexus]: 69, + [InsightType.SpanQueryOptimization]: 70, [InsightType.EndpointSpanNPlusOne]: 55, [InsightType.EndpointSessionInView]: 56,