From ca8007ec80b02606d7b5dc4efa9fd37e32c54fe3 Mon Sep 17 00:00:00 2001 From: Lakshan Perera Date: Thu, 16 Nov 2023 07:10:18 +1100 Subject: [PATCH] feat: Show resource usage in function metrics (#18956) * feat: show resource usage charts in function metrics * fix: show highlighted value in StackedBarChart * fix: PR feedback updates --- .../components/ui/Charts/StackedBarChart.tsx | 39 ++- .../analytics/functions-req-stats-query.ts | 62 ++++ .../functions-resource-usage-query.ts | 62 ++++ apps/studio/data/analytics/keys.ts | 72 ++++- .../[ref]/functions/[functionSlug]/index.tsx | 267 +++++++++++++++--- 5 files changed, 453 insertions(+), 49 deletions(-) create mode 100644 apps/studio/data/analytics/functions-req-stats-query.ts create mode 100644 apps/studio/data/analytics/functions-resource-usage-query.ts diff --git a/apps/studio/components/ui/Charts/StackedBarChart.tsx b/apps/studio/components/ui/Charts/StackedBarChart.tsx index 582b27bb024..3c252cc7039 100644 --- a/apps/studio/components/ui/Charts/StackedBarChart.tsx +++ b/apps/studio/components/ui/Charts/StackedBarChart.tsx @@ -1,5 +1,7 @@ import { useState } from 'react' import { Bar, BarChart, Cell, Legend, Tooltip, XAxis } from 'recharts' +import dayjs from 'dayjs' +import utc from 'dayjs/plugin/utc' import ChartHeader from './ChartHeader' import { CHART_COLORS, @@ -9,8 +11,16 @@ import { ValidStackColor, } from './Charts.constants' import { CommonChartProps } from './Charts.types' -import { precisionFormatter, timestampFormatter, useChartSize, useStacked } from './Charts.utils' +import { + numberFormatter, + precisionFormatter, + timestampFormatter, + useChartSize, + useStacked, +} from './Charts.utils' import NoDataPlaceholder from './NoDataPlaceholder' +dayjs.extend(utc) + interface Props extends CommonChartProps { xAxisKey: string yAxisKey: string @@ -31,8 +41,11 @@ const StackedBarChart: React.FC = ({ yAxisKey, customDateFormat = DateTimeFormats.FULL, title, + highlightedValue, + highlightedLabel, format, minimalHeader = false, + valuePrecision, onBarClick, variant, xAxisFormatAsDate = true, @@ -50,6 +63,18 @@ const StackedBarChart: React.FC = ({ variant, }) const [focusDataIndex, setFocusDataIndex] = useState(null) + + const day = (value: number | string) => (displayDateInUtc ? dayjs(value).utc() : dayjs(value)) + const resolvedHighlightedLabel = + (focusDataIndex !== null && + data && + data[focusDataIndex] !== undefined && + day(data[focusDataIndex][xAxisKey]).format(customDateFormat)) || + highlightedLabel + + const resolvedHighlightedValue = + focusDataIndex !== null ? data[focusDataIndex]?.[yAxisKey] : highlightedValue + if (!data || data.length === 0) return const stackColorScales = genStackColorScales(stackColors) return ( @@ -60,6 +85,12 @@ const StackedBarChart: React.FC = ({ format={format} customDateFormat={customDateFormat} minimalHeader={minimalHeader} + highlightedValue={ + typeof resolvedHighlightedValue === 'number' + ? numberFormatter(resolvedHighlightedValue, valuePrecision) + : resolvedHighlightedValue + } + highlightedLabel={resolvedHighlightedLabel} /> )} @@ -137,7 +168,11 @@ const StackedBarChart: React.FC = ({ }} cursor={false} labelClassName="text-white" - contentStyle={{ backgroundColor: '#444444', borderColor: '#444444', fontSize: '12px' }} + contentStyle={{ + backgroundColor: '#444444', + borderColor: '#444444', + fontSize: '12px', + }} wrapperClassName="bg-gray-600 rounded min-w-md" /> diff --git a/apps/studio/data/analytics/functions-req-stats-query.ts b/apps/studio/data/analytics/functions-req-stats-query.ts new file mode 100644 index 00000000000..f41f3dfa337 --- /dev/null +++ b/apps/studio/data/analytics/functions-req-stats-query.ts @@ -0,0 +1,62 @@ +import { useQuery, UseQueryOptions } from '@tanstack/react-query' +import { get } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { analyticsKeys } from './keys' + +export type FunctionsReqStatsVariables = { + projectRef?: string + functionId?: string + interval?: string +} + +export type FunctionsReqStatsResponse = any + +export async function getFunctionsReqStats( + { projectRef, functionId, interval }: FunctionsReqStatsVariables, + signal?: AbortSignal +) { + if (!projectRef) { + throw new Error('projectRef is required') + } + if (!functionId) { + throw new Error('functionId is required') + } + if (!interval) { + throw new Error('interval is required') + } + + const response = await get( + `${API_URL}/projects/${projectRef}/analytics/endpoints/functions.req-stats?interval=${interval}&function_id=${functionId}`, + { + signal, + } + ) + if (response.error) { + throw response.error + } + + return response +} + +export type FunctionsReqStatsData = Awaited> +export type FunctionsReqStatsError = unknown + +export const useFunctionsReqStatsQuery = ( + { projectRef, functionId, interval }: FunctionsReqStatsVariables, + { + enabled = true, + ...options + }: UseQueryOptions = {} +) => + useQuery( + analyticsKeys.functionsReqStats(projectRef, { functionId, interval }), + ({ signal }) => getFunctionsReqStats({ projectRef, functionId, interval }, signal), + { + enabled: + enabled && + typeof projectRef !== 'undefined' && + typeof functionId !== 'undefined' && + typeof interval !== 'undefined', + ...options, + } + ) diff --git a/apps/studio/data/analytics/functions-resource-usage-query.ts b/apps/studio/data/analytics/functions-resource-usage-query.ts new file mode 100644 index 00000000000..38479ee393b --- /dev/null +++ b/apps/studio/data/analytics/functions-resource-usage-query.ts @@ -0,0 +1,62 @@ +import { useQuery, UseQueryOptions } from '@tanstack/react-query' +import { get } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { analyticsKeys } from './keys' + +export type FunctionsResourceUsageVariables = { + projectRef?: string + functionId?: string + interval?: string +} + +export type FunctionsResourceUsageResponse = any + +export async function getFunctionsResourceUsage( + { projectRef, functionId, interval }: FunctionsResourceUsageVariables, + signal?: AbortSignal +) { + if (!projectRef) { + throw new Error('projectRef is required') + } + if (!functionId) { + throw new Error('functionId is required') + } + if (!interval) { + throw new Error('interval is required') + } + + const response = await get( + `${API_URL}/projects/${projectRef}/analytics/endpoints/functions.resource-usage?interval=${interval}&function_id=${functionId}`, + { + signal, + } + ) + if (response.error) { + throw response.error + } + + return response +} + +export type FunctionsResourceUsageData = Awaited> +export type FunctionsResourceUsageError = unknown + +export const useFunctionsResourceUsageQuery = ( + { projectRef, functionId, interval }: FunctionsResourceUsageVariables, + { + enabled = true, + ...options + }: UseQueryOptions = {} +) => + useQuery( + analyticsKeys.functionsResourceUsage(projectRef, { functionId, interval }), + ({ signal }) => getFunctionsResourceUsage({ projectRef, functionId, interval }, signal), + { + enabled: + enabled && + typeof projectRef !== 'undefined' && + typeof functionId !== 'undefined' && + typeof interval !== 'undefined', + ...options, + } + ) diff --git a/apps/studio/data/analytics/keys.ts b/apps/studio/data/analytics/keys.ts index 41029a404ff..84986a68455 100644 --- a/apps/studio/data/analytics/keys.ts +++ b/apps/studio/data/analytics/keys.ts @@ -1,8 +1,62 @@ export const analyticsKeys = { functionsInvStats: ( projectRef: string | undefined, - { interval, functionId }: { functionId: string | undefined; interval: string | undefined } - ) => ['projects', projectRef, 'functions-inv-stats', { interval, functionId }] as const, + { + interval, + functionId, + }: { + functionId: string | undefined + interval: string | undefined + } + ) => + [ + 'projects', + projectRef, + 'functions-inv-stats', + { + interval, + functionId, + }, + ] as const, + functionsReqStats: ( + projectRef: string | undefined, + { + interval, + functionId, + }: { + functionId: string | undefined + interval: string | undefined + } + ) => + [ + 'projects', + projectRef, + 'functions-req-stats', + { + interval, + functionId, + }, + ] as const, + functionsResourceUsage: ( + projectRef: string | undefined, + { + interval, + functionId, + }: { + functionId: string | undefined + interval: string | undefined + } + ) => + [ + 'projects', + projectRef, + 'functions-resource-usage', + { + interval, + functionId, + }, + ] as const, + dailyStats: ( projectRef: string | undefined, { @@ -10,7 +64,12 @@ export const analyticsKeys = { startDate, endDate, interval, - }: { attribute?: string; startDate?: string; endDate?: string; interval?: string } + }: { + attribute?: string + startDate?: string + endDate?: string + interval?: string + } ) => [ 'projects', @@ -58,7 +117,12 @@ export const analyticsKeys = { startDate, endDate, interval, - }: { attribute?: string; startDate?: string; endDate?: string; interval?: string } + }: { + attribute?: string + startDate?: string + endDate?: string + interval?: string + } ) => [ 'projects', diff --git a/apps/studio/pages/project/[ref]/functions/[functionSlug]/index.tsx b/apps/studio/pages/project/[ref]/functions/[functionSlug]/index.tsx index 84983c243c9..085fb700cf6 100644 --- a/apps/studio/pages/project/[ref]/functions/[functionSlug]/index.tsx +++ b/apps/studio/pages/project/[ref]/functions/[functionSlug]/index.tsx @@ -5,11 +5,14 @@ import { isUnixMicro, unixMicroToIsoTimestamp } from 'components/interfaces/Sett import FunctionsLayout from 'components/layouts/FunctionsLayout' import AreaChart from 'components/ui/Charts/AreaChart' import BarChart from 'components/ui/Charts/BarChart' +import StackedBarChart from 'components/ui/Charts/StackedBarChart' import NoPermission from 'components/ui/NoPermission' import { useFunctionsInvStatsQuery } from 'data/analytics/functions-inv-stats-query' +import { useFunctionsReqStatsQuery } from 'data/analytics/functions-req-stats-query' +import { useFunctionsResourceUsageQuery } from 'data/analytics/functions-resource-usage-query' import { useEdgeFunctionQuery } from 'data/edge-functions/edge-function-query' import dayjs, { Dayjs } from 'dayjs' -import { useCheckPermissions } from 'hooks' +import { useCheckPermissions, useFlag } from 'hooks' import useFillTimeseriesSorted from 'hooks/analytics/useFillTimeseriesSorted' import sumBy from 'lodash/sumBy' import { observer } from 'mobx-react-lite' @@ -26,10 +29,34 @@ const CHART_INTERVALS: ChartIntervals[] = [ startUnit: 'minute', format: 'MMM D, h:mm:ssa', }, - { key: '15min', label: '15 min', startValue: 15, startUnit: 'minute', format: 'MMM D, h:mma' }, - { key: '1hr', label: '1 hour', startValue: 1, startUnit: 'hour', format: 'MMM D, h:mma' }, - { key: '1day', label: '1 day', startValue: 1, startUnit: 'hour', format: 'MMM D, h:mma' }, - { key: '7day', label: '7 days', startValue: 7, startUnit: 'day', format: 'MMM D' }, + { + key: '15min', + label: '15 min', + startValue: 15, + startUnit: 'minute', + format: 'MMM D, h:mma', + }, + { + key: '1hr', + label: '1 hour', + startValue: 1, + startUnit: 'hour', + format: 'MMM D, h:mma', + }, + { + key: '1day', + label: '1 day', + startValue: 1, + startUnit: 'hour', + format: 'MMM D, h:mma', + }, + { + key: '7day', + label: '7 days', + startValue: 7, + startUnit: 'day', + format: 'MMM D', + }, ] const PageLayout: NextPageWithLayout = () => { @@ -37,21 +64,50 @@ const PageLayout: NextPageWithLayout = () => { const { ref: projectRef, functionSlug } = useParams() const [interval, setInterval] = useState('15min') const selectedInterval = CHART_INTERVALS.find((i) => i.key === interval) || CHART_INTERVALS[1] - const { data: selectedFunction } = useEdgeFunctionQuery({ projectRef, slug: functionSlug }) - const id = selectedFunction?.id - - const { data, error } = useFunctionsInvStatsQuery({ + const { data: selectedFunction } = useEdgeFunctionQuery({ projectRef, - functionId: id, - interval: selectedInterval.key, + slug: functionSlug, }) - const isChartLoading = !data?.result && !error ? true : false - const normalizedData = useMemo(() => { - return (data?.result || []).map((d: any) => ({ - ...d, - timestamp: isUnixMicro(d.timestamp) ? unixMicroToIsoTimestamp(d.timestamp) : d.timestamp, - })) - }, [data?.result]) + const id = selectedFunction?.id + const resourceUsageMetricsEnabled = useFlag('enableResourceUsageMetricsForEdgeFunctions') + + const invStatsResult = useFunctionsInvStatsQuery( + { + projectRef, + functionId: id, + interval: selectedInterval.key, + }, + { enabled: !resourceUsageMetricsEnabled } + ) + + const reqStatsResult = useFunctionsReqStatsQuery( + { + projectRef, + functionId: id, + interval: selectedInterval.key, + }, + { enabled: resourceUsageMetricsEnabled } + ) + + const resourceUsageResult = useFunctionsResourceUsageQuery( + { + projectRef, + functionId: id, + interval: selectedInterval.key, + }, + { enabled: resourceUsageMetricsEnabled } + ) + + const reqStatsData = useMemo(() => { + const result = resourceUsageMetricsEnabled + ? reqStatsResult.data?.result + : invStatsResult.data?.result + return result || [] + }, [invStatsResult.data, reqStatsResult.data, resourceUsageMetricsEnabled]) + + const resourceUsageData = useMemo(() => { + return resourceUsageResult.data?.result || [] + }, [resourceUsageResult.data]) const [startDate, endDate]: [Dayjs, Dayjs] = useMemo(() => { const start = dayjs() @@ -61,10 +117,29 @@ const PageLayout: NextPageWithLayout = () => { const end = dayjs().startOf(selectedInterval.startUnit as dayjs.ManipulateType) return [start, end] }, [selectedInterval]) - const chartData = useFillTimeseriesSorted( - normalizedData, + + const execTimeChartData = useFillTimeseriesSorted( + reqStatsData, 'timestamp', - ['avg_execution_time', 'count'], + ['avg_execution_time'], + 0, + startDate.toISOString(), + endDate.toISOString() + ) + + const invocationsChartData = useFillTimeseriesSorted( + reqStatsData, + 'timestamp', + ['count', 'success_count', 'redirect_count', 'client_err_count', 'server_err_count'], + 0, + startDate.toISOString(), + endDate.toISOString() + ) + + const resourceUsageChartData = useFillTimeseriesSorted( + resourceUsageData, + 'timestamp', + ['avg_cpu_time_used', 'avg_memory_used'], 0, startDate.toISOString(), endDate.toISOString() @@ -114,10 +189,12 @@ const PageLayout: NextPageWithLayout = () => { { - const latest = normalizedData[normalizedData.length - 1] + const latest = props.data[props.data.length - 1] let highlightedValue if (latest) { highlightedValue = latest['avg_execution_time'] @@ -135,28 +212,132 @@ const PageLayout: NextPageWithLayout = () => { ) }} /> - ( - { - router.push( - `/project/${projectRef}/functions/${functionSlug}/invocations?its=${startDate.toISOString()}&ite=${ - v.timestamp - }` + {resourceUsageMetricsEnabled ? ( + { + const data = props.data + .map((d: any) => [ + { + status: '2xx', + count: d.success_count, + timestamp: d.timestamp, + }, + { + status: '3xx', + count: d.redirect_count, + timestamp: d.timestamp, + }, + { + status: '4xx', + count: d.client_err_count, + timestamp: d.timestamp, + }, + { + status: '5xx', + count: d.server_err_count, + timestamp: d.timestamp, + }, + ]) + .flat() + + return ( + { + router.push( + `/project/${projectRef}/functions/${functionSlug}/invocations?its=${startDate.toISOString()}` + ) + }} + /> + ) + }} + /> + ) : ( + ( + { + router.push( + `/project/${projectRef}/functions/${functionSlug}/invocations?its=${startDate.toISOString()}&ite=${ + v.timestamp + }` + ) + }} + /> + )} + /> + )} + {resourceUsageMetricsEnabled && ( + <> + { + const latest = props.data[props.data.length - 1] + let highlightedValue + if (latest) { + highlightedValue = latest['avg_cpu_time_used'] + } + return ( + ) }} /> - )} - /> + { + const latest = props.data[props.data.length - 1] + let highlightedValue + if (latest) { + highlightedValue = latest['avg_memory_used'] + } + return ( + + ) + }} + /> + + )}