diff --git a/src/components/Insights/InsightsCatalog/InsightsPage/insightCards/common/InsightCard/ActionButton/types.ts b/src/components/Insights/InsightsCatalog/InsightsPage/insightCards/common/InsightCard/ActionButton/types.ts index c9ca96128..fea0a4537 100644 --- a/src/components/Insights/InsightsCatalog/InsightsPage/insightCards/common/InsightCard/ActionButton/types.ts +++ b/src/components/Insights/InsightsCatalog/InsightsPage/insightCards/common/InsightCard/ActionButton/types.ts @@ -10,4 +10,6 @@ export interface ActionButtonProps { title?: ReactNode; onClick: () => void; isDisabled?: boolean; + className?: string; + tooltip?: ReactNode; } diff --git a/src/components/Insights/InsightsCatalog/InsightsPage/insightCards/common/InsightCard/InsightHeader/index.tsx b/src/components/Insights/InsightsCatalog/InsightsPage/insightCards/common/InsightCard/InsightHeader/index.tsx index f0574c05c..183b8b788 100644 --- a/src/components/Insights/InsightsCatalog/InsightsPage/insightCards/common/InsightCard/InsightHeader/index.tsx +++ b/src/components/Insights/InsightsCatalog/InsightsPage/insightCards/common/InsightCard/InsightHeader/index.tsx @@ -67,9 +67,6 @@ export const InsightHeader = ({ )} {insightTypeInfo?.label} - {insightTypeInfo?.description && ( - } /> - )} {lastUpdateTimer && ( Updated: diff --git a/src/components/Insights/InsightsCatalog/InsightsPage/insightCards/common/InsightCard/InsightsInfo/InsightsInfo.stories.tsx b/src/components/Insights/InsightsCatalog/InsightsPage/insightCards/common/InsightCard/InsightsInfo/InsightsInfo.stories.tsx new file mode 100644 index 000000000..4eb9da4cf --- /dev/null +++ b/src/components/Insights/InsightsCatalog/InsightsPage/insightCards/common/InsightCard/InsightsInfo/InsightsInfo.stories.tsx @@ -0,0 +1,29 @@ +import { Meta, StoryObj } from "@storybook/react"; +import { InsightsInfo } from "."; + +// More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction +const meta: Meta = { + title: "Insights/InsightsCatalog/InsightsPage/InsightsInfo", + component: InsightsInfo, + 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: { + isOpen: true, + description: () => ( + <> + This area significantly slows down the entire request and takes up at + least 30% of the request time. You should consider making this code + asynchronous or otherwise optimize it. + + ) + } +}; diff --git a/src/components/Insights/InsightsCatalog/InsightsPage/insightCards/common/InsightCard/InsightsInfo/index.tsx b/src/components/Insights/InsightsCatalog/InsightsPage/insightCards/common/InsightCard/InsightsInfo/index.tsx new file mode 100644 index 000000000..ae5072795 --- /dev/null +++ b/src/components/Insights/InsightsCatalog/InsightsPage/insightCards/common/InsightCard/InsightsInfo/index.tsx @@ -0,0 +1,63 @@ +import { InsightsInfoProps } from "./types"; + +import { openURLInDefaultBrowser } from "../../../../../../../../utils/actions/openURLInDefaultBrowser"; +import { sendUserActionTrackingEvent } from "../../../../../../../../utils/actions/sendUserActionTrackingEvent"; +import { CrossIcon } from "../../../../../../../common/icons/12px/CrossIcon"; +import { trackingEvents } from "../../../../../../tracking"; +import * as s from "./styles"; + +export const InsightsInfo = ({ + isOpen, + children, + description: Description, + documentationLink, + onClose +}: InsightsInfoProps) => { + const handleOpenDocsClick = () => { + if (!documentationLink) { + return; + } + + sendUserActionTrackingEvent(trackingEvents.INSIGHTS_INFO_OPEN_DOCS_CLICKED); + openURLInDefaultBrowser(documentationLink); + onClose(); + }; + + const handleCloseClick = () => { + sendUserActionTrackingEvent(trackingEvents.INSIGHTS_INFO_OPEN_DOCS_CLICKED); + onClose(); + }; + + return ( + + + } + size={"small"} + buttonType="secondaryBorderless" + > + + + {Description && } + {documentationLink && ( + + Open Docs + + )} + + + } + hideArrow={true} + fullWidth={true} + > + {children} + + ); +}; diff --git a/src/components/Insights/InsightsCatalog/InsightsPage/insightCards/common/InsightCard/InsightsInfo/styles.ts b/src/components/Insights/InsightsCatalog/InsightsPage/insightCards/common/InsightCard/InsightsInfo/styles.ts new file mode 100644 index 000000000..4ebe07aa4 --- /dev/null +++ b/src/components/Insights/InsightsCatalog/InsightsPage/insightCards/common/InsightCard/InsightsInfo/styles.ts @@ -0,0 +1,51 @@ +import styled from "styled-components"; +import { + footnoteRegularTypography, + subscriptRegularTypography +} from "../../../../../../../common/App/typographies"; +import { Link } from "../../../../../../../common/v3/Link"; +import { NewIconButton } from "../../../../../../../common/v3/NewIconButton"; +import { Tooltip } from "../../../../../../../common/v3/Tooltip"; + +export const CloseButton = styled(NewIconButton)` + display: flex; + background: none; + border: none; + cursor: pointer; + padding: 0; +`; + +export const Container = styled.div` + display: flex; + flex-direction: column; + border-radius: 4px; +`; + +export const Header = styled.div` + display: flex; + align-items: center; + justify-content: end; + height: 20px; +`; + +export const Description = styled.div` + ${footnoteRegularTypography} + color: ${({ theme }) => theme.colors.v3.text.secondary}; +`; + +export const Content = styled.div` + display: flex; + flex-direction: column; + gap: 8px; + padding: 0 12px 12px; +`; + +export const StyledLink = styled(Link)` + ${subscriptRegularTypography} + text-decoration: underline; + padding: 4px 0; +`; + +export const InfoTooltip = styled(Tooltip)` + max-width: 256px; +`; diff --git a/src/components/Insights/InsightsCatalog/InsightsPage/insightCards/common/InsightCard/InsightsInfo/types.ts b/src/components/Insights/InsightsCatalog/InsightsPage/insightCards/common/InsightCard/InsightsInfo/types.ts new file mode 100644 index 000000000..85ac2fd46 --- /dev/null +++ b/src/components/Insights/InsightsCatalog/InsightsPage/insightCards/common/InsightCard/InsightsInfo/types.ts @@ -0,0 +1,9 @@ +import { ReactElement } from "react"; + +export interface InsightsInfoProps { + isOpen?: boolean; + children: ReactElement; + description?: () => JSX.Element; + onClose: () => void; + documentationLink?: string | null; +} diff --git a/src/components/Insights/InsightsCatalog/InsightsPage/insightCards/common/InsightCard/index.tsx b/src/components/Insights/InsightsCatalog/InsightsPage/insightCards/common/InsightCard/index.tsx index 94630ecb2..489dfe59d 100644 --- a/src/components/Insights/InsightsCatalog/InsightsPage/insightCards/common/InsightCard/index.tsx +++ b/src/components/Insights/InsightsCatalog/InsightsPage/insightCards/common/InsightCard/index.tsx @@ -1,15 +1,17 @@ -import { Fragment, useEffect, useState } from "react"; +import { Fragment, useEffect, useRef, useState } from "react"; import { dispatcher } from "../../../../../../../dispatcher"; import { usePrevious } from "../../../../../../../hooks/usePrevious"; import { useConfigSelector } from "../../../../../../../store/config/useConfigSelector"; import { isString } from "../../../../../../../typeGuards/isString"; import { sendUserActionTrackingEvent } from "../../../../../../../utils/actions/sendUserActionTrackingEvent"; +import { getInsightTypeInfo } from "../../../../../../../utils/getInsightTypeInfo"; import { Spinner } from "../../../../../../Navigation/CodeButtonMenu/Spinner"; import { CheckmarkCircleIcon } from "../../../../../../common/icons/12px/CheckmarkCircleIcon"; import { TraceIcon } from "../../../../../../common/icons/12px/TraceIcon"; import { DoubleCircleIcon } from "../../../../../../common/icons/16px/DoubleCircleIcon"; import { HistogramIcon } from "../../../../../../common/icons/16px/HistogramIcon"; import { PinIcon } from "../../../../../../common/icons/16px/PinIcon"; +import { QuestionMark } from "../../../../../../common/icons/16px/QuestionMark"; import { RecheckIcon } from "../../../../../../common/icons/16px/RecheckIcon"; import { CrossIcon } from "../../../../../../common/icons/CrossIcon"; import { JiraButton } from "../../../../../../common/v3/JiraButton"; @@ -23,6 +25,7 @@ import { IssueCompactCard } from "../IssueCompactCard"; import { ActionButton } from "./ActionButton"; import { ActionButtonType } from "./ActionButton/types"; import { InsightHeader } from "./InsightHeader"; +import { InsightsInfo } from "./InsightsInfo"; import { ProductionAffectionBar } from "./ProductionAffectionBar"; import { RecalculateBar } from "./RecalculateBar"; import { useDismissal } from "./hooks/useDismissal"; @@ -62,6 +65,8 @@ export const InsightCard = ({ const previousIsOperationInProgress = usePrevious(isOperationInProgress); const { isJaegerEnabled } = useConfigSelector(); const [insightStatus, setInsightStatus] = useState(insight.status); + const cardRef = useRef(null); + const [showInfo, setShowInfo] = useState(false); const isCritical = insight.criticality > HIGH_CRITICALITY_THRESHOLD; @@ -231,6 +236,7 @@ export const InsightCard = ({ ); } + const insightTypeInfo = getInsightTypeInfo(insight.type, insight.subType); const renderAction = (action: Action, type: ActionButtonType) => { switch (action) { case "markAsRead": @@ -313,6 +319,25 @@ export const InsightCard = ({ } /> ); + case "info": + return ( + { + setShowInfo(false); + }} + > + { + setShowInfo(!showInfo); + }} + /> + + ); default: return null; } @@ -320,6 +345,9 @@ export const InsightCard = ({ const renderActions = () => { const actions: Action[] = []; + if (insightTypeInfo?.description) { + actions.push("info"); + } if ( isMarkAsReadButtonEnabled && @@ -377,84 +405,90 @@ export const InsightCard = ({ const { showBanner, showTimer } = getRecalculateVisibilityParams(); const isFooterVisible = Boolean(renderActions() ?? insight.isDismissible); - return ( - - } - content={ - - {isCritical && ( - - )} - {showBanner && } - {content} - - } - footer={ - isFooterVisible ? ( - <> - {!isDismissConfirmationOpened ? ( - - {insight.isDismissible && ( - - {insight.isDismissed ? ( - - ) : ( - setDismissConfirmationOpened(true)} - /> - )} - {isDismissalChangeInProgress && } - - )} - {renderActions()} - - ) : ( - - Dismiss insight? - - setDismissConfirmationOpened(false)} - /> - - - + <> + + } + content={ + + {isCritical && ( + )} - - ) : undefined - } - /> + {showBanner && } + {content} + + } + footer={ + isFooterVisible ? ( + <> + {!isDismissConfirmationOpened ? ( + + {insight.isDismissible && ( + + {insight.isDismissed ? ( + + ) : ( + setDismissConfirmationOpened(true)} + /> + )} + {isDismissalChangeInProgress && } + + )} + {renderActions()} + + ) : ( + + Dismiss insight? + + setDismissConfirmationOpened(false)} + /> + + + + )} + + ) : undefined + } + /> + ); }; diff --git a/src/components/Insights/InsightsCatalog/InsightsPage/insightCards/common/InsightCard/styles.ts b/src/components/Insights/InsightsCatalog/InsightsPage/insightCards/common/InsightCard/styles.ts index 1bd1c0479..f6d53e7ef 100644 --- a/src/components/Insights/InsightsCatalog/InsightsPage/insightCards/common/InsightCard/styles.ts +++ b/src/components/Insights/InsightsCatalog/InsightsPage/insightCards/common/InsightCard/styles.ts @@ -1,6 +1,7 @@ import styled, { css } from "styled-components"; import { subscriptRegularTypography } from "../../../../../../common/App/typographies"; import { Card } from "../../../../../../common/v3/Card"; +import { NewIconButton } from "../../../../../../common/v3/NewIconButton"; import { StyledCardProps } from "./types"; export const InsightFooter = styled.div` @@ -78,3 +79,9 @@ export const ButtonContainer = styled.div` display: flex; align-items: center; `; + +export const InfoActionButton = styled(NewIconButton)` + &:hover { + color: ${({ theme }) => theme.colors.v3.status.medium}; + } +`; diff --git a/src/components/Insights/InsightsCatalog/InsightsPage/insightCards/common/InsightCard/types.ts b/src/components/Insights/InsightsCatalog/InsightsPage/insightCards/common/InsightCard/types.ts index 28ae4433c..a756601ca 100644 --- a/src/components/Insights/InsightsCatalog/InsightsPage/insightCards/common/InsightCard/types.ts +++ b/src/components/Insights/InsightsCatalog/InsightsPage/insightCards/common/InsightCard/types.ts @@ -98,4 +98,5 @@ export type Action = | "viewTicketInfo" | "openTrace" | "openLiveView" - | "pin"; + | "pin" + | "info"; diff --git a/src/components/Insights/tracking.ts b/src/components/Insights/tracking.ts index de25434aa..b7a1474e1 100644 --- a/src/components/Insights/tracking.ts +++ b/src/components/Insights/tracking.ts @@ -24,7 +24,9 @@ export const trackingEvents = addPrefix( "promotion open expanded view button clicked", ENVIRONMENT_SELECTED: "environment selected", ENVIRONMENT_MENU_BUTTON_CLICKED: "environment menu button clicked", - FILTER_ICON_BUTTON_CLICKED: "filter icon button clicked" + FILTER_ICON_BUTTON_CLICKED: "filter icon button clicked", + INSIGHTS_INFO_OPEN_DOCS_CLICKED: "insights info open docs clicked", + INSIGHTS_INFO_CLOSE_CLICKED: "insights info close clicked" }, " " ); diff --git a/src/components/common/icons/16px/QuestionMark.tsx b/src/components/common/icons/16px/QuestionMark.tsx new file mode 100644 index 000000000..1d6fecb05 --- /dev/null +++ b/src/components/common/icons/16px/QuestionMark.tsx @@ -0,0 +1,28 @@ +import React from "react"; +import { useIconProps } from "../hooks"; +import { IconProps } from "../types"; + +const QuestionMarkComponent = (props: IconProps) => { + const { size, color } = useIconProps(props); + + return ( + + + + + + + + ); +}; + +export const QuestionMark = React.memo(QuestionMarkComponent); diff --git a/src/components/common/v3/Tooltip/index.tsx b/src/components/common/v3/Tooltip/index.tsx index 9faa3c17a..9b65782c3 100644 --- a/src/components/common/v3/Tooltip/index.tsx +++ b/src/components/common/v3/Tooltip/index.tsx @@ -9,6 +9,7 @@ import { offset, shift, useClientPoint, + useDismiss, useFloating, useHover, useInteractions, @@ -78,29 +79,47 @@ export const Tooltip = ({ fullWidth, title, boundary, - followCursor + followCursor, + hideArrow, + className, + onDismiss }: TooltipProps) => { const [isOpen, setIsOpen] = useState(false); const arrowRef = useRef(null); const theme = useTheme(); + const offsetValue = hideArrow ? GAP : GAP + ARROW_HEIGHT + ARROW_MARGIN; + const middlewares = [ + offset(offsetValue), + flip({ + boundary + }), + shift() + ]; + if (!hideArrow) { + middlewares.push( + arrow({ + element: arrowRef + }) + ); + } const { refs, floatingStyles, context, middlewareData } = useFloating({ whileElementsMounted: autoUpdate, placement, open: isBoolean(forcedIsOpen) ? forcedIsOpen : isOpen, - onOpenChange: isBoolean(forcedIsOpen) ? undefined : setIsOpen, - middleware: [ - offset(GAP + ARROW_HEIGHT + ARROW_MARGIN), - flip({ - boundary - }), - shift(), - arrow({ - element: arrowRef - }), - hide() - ] + onOpenChange: (open, event) => { + if (onDismiss && !open && event?.type === "click") { + onDismiss(); + } + + if (isBoolean(forcedIsOpen)) { + return; + } + + setIsOpen(open); + }, + middleware: [...middlewares, hide()] }); const { @@ -118,13 +137,19 @@ export const Tooltip = ({ enabled: !isBoolean(forcedIsOpen) || !followCursor }); + const dismiss = useDismiss(context, { + enabled: Boolean(onDismiss), + outsidePressEvent: "click" + }); + const clientPoint = useClientPoint(context, { enabled: Boolean(followCursor) }); const { getReferenceProps, getFloatingProps } = useInteractions([ hover, - clientPoint + clientPoint, + dismiss ]); const renderArrow = (withShadow: boolean) => ( @@ -159,6 +184,7 @@ export const Tooltip = ({ (isBoolean(forcedIsOpen) ? forcedIsOpen && isMounted : isMounted) && ( - {renderArrow(true)} - {renderArrow(false)} + {!hideArrow && ( + <> + {renderArrow(true)} + {renderArrow(false)} + + )} {title} diff --git a/src/components/common/v3/Tooltip/types.ts b/src/components/common/v3/Tooltip/types.ts index b05e73c6e..85df28817 100644 --- a/src/components/common/v3/Tooltip/types.ts +++ b/src/components/common/v3/Tooltip/types.ts @@ -16,6 +16,9 @@ export interface TooltipProps { isDisabled?: boolean; boundary?: HTMLElement; followCursor?: boolean; + hideArrow?: boolean; + className?: string; + onDismiss?: () => void; } export interface TooltipComponentProps { diff --git a/src/constants.ts b/src/constants.ts index c07b7ae9f..84629199e 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -24,6 +24,27 @@ export const PERFORMANCE_IMPACT_DOCUMENTATION_URL = export const SCALING_ISSUE_DOCUMENTATION_URL = "https://docs.digma.ai/digma-developer-guide/digma-features/insights/scaling-issue"; +export const BOTTLENECK_ISSUE_DOCUMENTATION_URL = + "https://docs.digma.ai/digma-developer-guide/digma-features/insights/bottleneck"; + +export const SUSPECTED_N_PLUS_ONE_ISSUE_DOCUMENTATION_URL = + "https://docs.digma.ai/digma-developer-guide/digma-features/insights/suspected-n+1"; + +export const SESSION_IN_VIEW_DOCUMENTATION_URL = + "https://docs.digma.ai/digma-developer-guide/digma-features/insights/session-in-view-query-detected"; + +export const HIGH_NUMBER_OF_QUERIES_DOCUMENTATION_URL = + "https://docs.digma.ai/digma-developer-guide/digma-features/insights/high-number-of-queries"; + +export const CHATTY_API_ISSUE_DOCUMENTATION_URL = + "https://docs.digma.ai/digma-developer-guide/digma-features/insights/excessive-api-calls-chatty-api"; + +export const QUERY_OPTIMIZATION_ISSUES_DOCUMENTATION_URL = + "https://docs.digma.ai/digma-developer-guide/digma-features/insights/query-optimization-suggested"; + +export const CODE_NEXUS_DOCUMENTATION_URL = + "https://docs.digma.ai/digma-developer-guide/digma-features/analytics/code-nexus"; + export const TEST_OBSERVABILITY_DOCUMENTATION_URL = "https://docs.digma.ai/digma-developer-guide/digma-features/test-observability"; diff --git a/src/utils/getInsightTypeInfo.ts b/src/utils/getInsightTypeInfo.ts index e989369cc..76c117d75 100644 --- a/src/utils/getInsightTypeInfo.ts +++ b/src/utils/getInsightTypeInfo.ts @@ -14,12 +14,23 @@ import { SnailIcon } from "../components/common/icons/SnailIcon"; import { SpotIcon } from "../components/common/icons/SpotIcon"; import { WarningCircleIcon } from "../components/common/icons/WarningCircleIcon"; import { IconProps } from "../components/common/icons/types"; +import { + BOTTLENECK_ISSUE_DOCUMENTATION_URL, + CHATTY_API_ISSUE_DOCUMENTATION_URL, + CODE_NEXUS_DOCUMENTATION_URL, + HIGH_NUMBER_OF_QUERIES_DOCUMENTATION_URL, + QUERY_OPTIMIZATION_ISSUES_DOCUMENTATION_URL, + SCALING_ISSUE_DOCUMENTATION_URL, + SESSION_IN_VIEW_DOCUMENTATION_URL, + SUSPECTED_N_PLUS_ONE_ISSUE_DOCUMENTATION_URL +} from "../constants"; import { InsightType } from "../types"; export interface InsightTypeInfo { icon: MemoExoticComponent<(props: IconProps) => JSX.Element>; label: string; description?: () => JSX.Element; + documentationLink?: string | null; subTypes?: Record>; } @@ -57,13 +68,15 @@ export const getInsightTypeInfo = ( [InsightType.EndpointBottleneck]: { icon: BottleneckIcon, label: "Bottleneck", - description: descriptionProvider.BottleneckDescription + description: descriptionProvider.BottleneckDescription, + documentationLink: BOTTLENECK_ISSUE_DOCUMENTATION_URL }, [InsightType.EndpointSpanNPlusOne]: { icon: SQLDatabaseIcon, label: "Suspected N+1", description: descriptionProvider.NPlusOneDescription, + documentationLink: SUSPECTED_N_PLUS_ONE_ISSUE_DOCUMENTATION_URL, subTypes: { repeatedQueries: { icon: SQLDatabaseIcon, @@ -79,6 +92,7 @@ export const getInsightTypeInfo = ( icon: SQLDatabaseIcon, label: "Suspected N+1", description: descriptionProvider.NPlusOneDescription, + documentationLink: SUSPECTED_N_PLUS_ONE_ISSUE_DOCUMENTATION_URL, subTypes: { repeatedQueries: { icon: SQLDatabaseIcon, @@ -93,12 +107,14 @@ export const getInsightTypeInfo = ( [InsightType.SpanEndpointBottleneck]: { icon: BottleneckIcon, label: "Bottleneck", - description: descriptionProvider.BottleneckDescription + description: descriptionProvider.BottleneckDescription, + documentationLink: BOTTLENECK_ISSUE_DOCUMENTATION_URL }, [InsightType.SpanScaling]: { icon: ScalesIcon, label: "Scaling Issue Found", - description: descriptionProvider.SpanScalingDescription + description: descriptionProvider.SpanScalingDescription, + documentationLink: SCALING_ISSUE_DOCUMENTATION_URL }, [InsightType.SpanUsages]: { icon: SineIcon, @@ -131,32 +147,38 @@ export const getInsightTypeInfo = ( [InsightType.EndpointSessionInView]: { icon: SQLDatabaseIcon, label: "Session in View Query Detected", - description: descriptionProvider.EndpointSessionInViewDescription + description: descriptionProvider.EndpointSessionInViewDescription, + documentationLink: SESSION_IN_VIEW_DOCUMENTATION_URL }, [InsightType.EndpointChattyApiV2]: { icon: SQLDatabaseIcon, label: "Excessive API Calls Detected", - description: descriptionProvider.ChattyApiDescription + description: descriptionProvider.ChattyApiDescription, + documentationLink: CHATTY_API_ISSUE_DOCUMENTATION_URL }, [InsightType.EndpointHighNumberOfQueries]: { icon: SQLDatabaseIcon, label: "High number of queries", - description: descriptionProvider.EndpointHighNumberOfQueriesDescription + description: descriptionProvider.EndpointHighNumberOfQueriesDescription, + documentationLink: HIGH_NUMBER_OF_QUERIES_DOCUMENTATION_URL }, [InsightType.SpanNexus]: { icon: BottleneckIcon, label: "Code Nexus Point", - description: descriptionProvider.CodeNexusDescription + description: descriptionProvider.CodeNexusDescription, + documentationLink: CODE_NEXUS_DOCUMENTATION_URL }, [InsightType.SpanQueryOptimization]: { icon: SQLDatabaseIcon, label: "Inefficient Query", - description: descriptionProvider.QueryOptimizationDescription + description: descriptionProvider.QueryOptimizationDescription, + documentationLink: QUERY_OPTIMIZATION_ISSUES_DOCUMENTATION_URL }, [InsightType.EndpointQueryOptimizationV2]: { icon: SQLDatabaseIcon, label: "Inefficient Query", - description: descriptionProvider.QueryOptimizationDescription + description: descriptionProvider.QueryOptimizationDescription, + documentationLink: QUERY_OPTIMIZATION_ISSUES_DOCUMENTATION_URL } };