diff --git a/.changeset/short-donkeys-live.md b/.changeset/short-donkeys-live.md new file mode 100644 index 0000000000..25b45f7076 --- /dev/null +++ b/.changeset/short-donkeys-live.md @@ -0,0 +1,12 @@ +--- +'hive': minor +--- + +Show Impact metric in the Operations list on the Insights page. +Impact equals to the total time spent on this operation in the selected period in seconds. +It helps assess which operations contribute the most to overall latency. + + +``` +Impact = Requests * avg/1000 +``` diff --git a/packages/services/api/src/modules/operations/module.graphql.mappers.ts b/packages/services/api/src/modules/operations/module.graphql.mappers.ts index c5d5dcd06d..33cdcdded1 100644 --- a/packages/services/api/src/modules/operations/module.graphql.mappers.ts +++ b/packages/services/api/src/modules/operations/module.graphql.mappers.ts @@ -28,6 +28,7 @@ export interface OperationsStatsMapper { clients: readonly string[]; } export interface DurationValuesMapper { + avg: number | null; p75: number | null; p90: number | null; p95: number | null; diff --git a/packages/services/api/src/modules/operations/module.graphql.ts b/packages/services/api/src/modules/operations/module.graphql.ts index 26f7b466c2..c49455a1d2 100644 --- a/packages/services/api/src/modules/operations/module.graphql.ts +++ b/packages/services/api/src/modules/operations/module.graphql.ts @@ -136,6 +136,7 @@ export default gql` } type DurationValues { + avg: Int! p75: Int! p90: Int! p95: Int! diff --git a/packages/services/api/src/modules/operations/providers/operations-manager.ts b/packages/services/api/src/modules/operations/providers/operations-manager.ts index b66438541a..7b63517d34 100644 --- a/packages/services/api/src/modules/operations/providers/operations-manager.ts +++ b/packages/services/api/src/modules/operations/providers/operations-manager.ts @@ -715,7 +715,7 @@ export class OperationsManager { } @cache<{ period: DateRange } & TargetSelector>(selector => JSON.stringify(selector)) - async readDetailedDurationPercentiles({ + async readDetailedDurationMetrics({ period, organizationId: organization, projectId: project, @@ -744,7 +744,7 @@ export class OperationsManager { }, }); - return this.reader.durationPercentiles({ + return this.reader.durationMetrics({ target, period, operations, diff --git a/packages/services/api/src/modules/operations/providers/operations-reader.ts b/packages/services/api/src/modules/operations/providers/operations-reader.ts index 87701bf1ed..3c4c57cfbf 100644 --- a/packages/services/api/src/modules/operations/providers/operations-reader.ts +++ b/packages/services/api/src/modules/operations/providers/operations-reader.ts @@ -32,28 +32,21 @@ function toUnixTimestamp(utcDate: string): any { return new UTCDate(iso).getTime(); } -export interface Percentiles { +export interface DurationMetrics { + avg: number; p75: number; p90: number; p95: number; p99: number; } -function toPercentiles(item: Percentiles | number[]) { - if (Array.isArray(item)) { - return { - p75: item[0], - p90: item[1], - p95: item[2], - p99: item[3], - }; - } - +function toDurationMetrics(percentiles: [number, number, number, number], avg: number) { return { - p75: item.p75, - p90: item.p90, - p95: item.p95, - p99: item.p99, + avg, + p75: percentiles[0], + p90: percentiles[1], + p95: percentiles[2], + p99: percentiles[3], }; } @@ -575,7 +568,7 @@ export class OperationsReader { operation_kind: string; }>({ query: sql` - SELECT + SELECT name, hash, operation_kind @@ -651,7 +644,7 @@ export class OperationsReader { type: 'query' | 'mutation' | 'subscription'; }>({ query: sql` - SELECT + SELECT "operation_collection_details"."hash" AS "hash", "operation_collection_details"."operation_kind" AS "type", "operation_collection_details"."name" AS "name", @@ -697,7 +690,7 @@ export class OperationsReader { }>( this.pickAggregationByPeriod({ query: aggregationTableName => sql` - SELECT + SELECT coordinate FROM ${aggregationTableName('coordinates')} ${this.createFilter({ @@ -747,7 +740,7 @@ export class OperationsReader { }>( this.pickAggregationByPeriod({ query: aggregationTableName => sql` - SELECT + SELECT sum(total) as total, client_name, client_version @@ -844,7 +837,7 @@ export class OperationsReader { }>( this.pickAggregationByPeriod({ query: aggregationTableName => sql` - SELECT + SELECT sum(total) as total, client_version FROM ${aggregationTableName('clients')} @@ -892,7 +885,7 @@ export class OperationsReader { SELECT SUM("result"."total") AS "amountOfRequests" FROM ( - SELECT + SELECT SUM("operations_daily"."total") AS "total" FROM "operations_daily" @@ -904,7 +897,7 @@ export class OperationsReader { UNION ALL - SELECT + SELECT SUM("subscription_operations_daily"."total") AS "total" FROM "subscription_operations_daily" @@ -1005,7 +998,7 @@ export class OperationsReader { SELECT "operation_collection_details"."name", "operation_collection_details"."hash" - FROM + FROM "operation_collection_details" PREWHERE "operation_collection_details"."target" IN (${sql.array(args.targetIds, 'String')}) @@ -1262,7 +1255,7 @@ export class OperationsReader { }>( this.pickAggregationByPeriod({ query: aggregationTableName => sql` - SELECT + SELECT count(distinct client_version) as total FROM ${aggregationTableName('clients')} ${this.createFilter({ @@ -1470,7 +1463,7 @@ export class OperationsReader { client_name: string; }>({ query: sql` - SELECT + SELECT sum(total) as count, client_name FROM clients_daily @@ -1573,7 +1566,7 @@ export class OperationsReader { }>( this.pickAggregationByPeriod({ query: aggregationTableName => sql` - SELECT + SELECT toDateTime( intDiv( toUnixTimestamp(timestamp), @@ -1585,7 +1578,7 @@ export class OperationsReader { FROM ${aggregationTableName('operations')} ${this.createFilter({ target: targets, period: roundedPeriod })} GROUP BY target, date - ORDER BY + ORDER BY target, date WITH FILL @@ -1723,7 +1716,7 @@ export class OperationsReader { }): Promise< Array<{ date: any; - duration: Percentiles; + duration: DurationMetrics; }> > { return this.getDurationAndCountOverTime({ @@ -1745,13 +1738,15 @@ export class OperationsReader { period: DateRange; operations?: readonly string[]; clients?: readonly string[]; - }): Promise { + }): Promise { const result = await this.clickHouse.query<{ percentiles: [number, number, number, number]; + average: number; }>( this.pickAggregationByPeriod({ query: aggregationTableName => sql` - SELECT + SELECT + avgMerge(duration_avg) as average, quantilesMerge(0.75, 0.90, 0.95, 0.99)(duration_quantiles) as percentiles FROM ${aggregationTableName('operations')} ${this.createFilter({ target, period, operations, clients })} @@ -1762,10 +1757,10 @@ export class OperationsReader { }), ); - return toPercentiles(result.data[0].percentiles); + return toDurationMetrics(result.data[0].percentiles, result.data[0].average); } - async durationPercentiles({ + async durationMetrics({ target, period, operations, @@ -1780,12 +1775,14 @@ export class OperationsReader { }) { const result = await this.clickHouse.query<{ hash: string; + average: number; percentiles: [number, number, number, number]; }>( this.pickAggregationByPeriod({ query: aggregationTableName => sql` - SELECT + SELECT hash, + avgMerge(duration_avg) as average, quantilesMerge(0.75, 0.90, 0.95, 0.99)(duration_quantiles) as percentiles FROM ${aggregationTableName('operations')} ${this.createFilter({ @@ -1813,10 +1810,10 @@ export class OperationsReader { }), ); - const collection = new Map(); + const collection = new Map(); result.data.forEach(row => { - collection.set(row.hash, toPercentiles(row.percentiles)); + collection.set(row.hash, toDurationMetrics(row.percentiles, row.average)); }); return collection; @@ -1881,6 +1878,7 @@ export class OperationsReader { return sql` SELECT date, + average, percentiles, total, totalOk @@ -1892,6 +1890,7 @@ export class OperationsReader { toUInt32(${String(interval.seconds)}) ) * toUInt32(${String(interval.seconds)}) ) as date, + avgMerge(duration_avg) as average, quantilesMerge(0.75, 0.90, 0.95, 0.99)(duration_quantiles) as percentiles, sum(total) as total, sum(total_ok) as totalOk @@ -1929,6 +1928,7 @@ export class OperationsReader { date: string; total: number; totalOk: number; + average: number; percentiles: [number, number, number, number]; }>(query); @@ -1937,7 +1937,7 @@ export class OperationsReader { date: toUnixTimestamp(row.date), total: ensureNumber(row.total), totalOk: ensureNumber(row.totalOk), - duration: toPercentiles(row.percentiles), + duration: toDurationMetrics(row.percentiles, row.average), }; }); } @@ -2148,7 +2148,7 @@ export class OperationsReader { }>( this.pickAggregationByPeriod({ query: aggregationTableName => sql` - SELECT + SELECT toDateTime( intDiv( toUnixTimestamp(timestamp), diff --git a/packages/services/api/src/modules/operations/resolvers/ClientStats.ts b/packages/services/api/src/modules/operations/resolvers/ClientStats.ts index 45b000b903..7ae72b925d 100644 --- a/packages/services/api/src/modules/operations/resolvers/ClientStats.ts +++ b/packages/services/api/src/modules/operations/resolvers/ClientStats.ts @@ -45,7 +45,7 @@ export const ClientStats: ClientStatsResolvers = { period, clients: clientName === 'unknown' ? ['unknown', ''] : [clientName], }), - operationsManager.readDetailedDurationPercentiles({ + operationsManager.readDetailedDurationMetrics({ organizationId: organization, projectId: project, targetId: target, diff --git a/packages/services/api/src/modules/operations/resolvers/DurationValues.ts b/packages/services/api/src/modules/operations/resolvers/DurationValues.ts index 4983d5dfb5..7427f794ba 100644 --- a/packages/services/api/src/modules/operations/resolvers/DurationValues.ts +++ b/packages/services/api/src/modules/operations/resolvers/DurationValues.ts @@ -2,6 +2,9 @@ import { nsToMs } from '../../../shared/helpers'; import type { DurationValuesResolvers } from './../../../__generated__/types'; export const DurationValues: DurationValuesResolvers = { + avg: value => { + return transformPercentile(value.avg); + }, p75: value => { return transformPercentile(value.p75); }, diff --git a/packages/services/api/src/modules/operations/resolvers/OperationsStats.ts b/packages/services/api/src/modules/operations/resolvers/OperationsStats.ts index ac751db39e..68e114eff2 100644 --- a/packages/services/api/src/modules/operations/resolvers/OperationsStats.ts +++ b/packages/services/api/src/modules/operations/resolvers/OperationsStats.ts @@ -18,7 +18,7 @@ export const OperationsStats: OperationsStatsResolvers = { operations: operationsFilter, clients, }), - operationsManager.readDetailedDurationPercentiles({ + operationsManager.readDetailedDurationMetrics({ organizationId: organization, projectId: project, targetId: target, diff --git a/packages/services/api/src/modules/operations/resolvers/SchemaCoordinateStats.ts b/packages/services/api/src/modules/operations/resolvers/SchemaCoordinateStats.ts index 22e723437f..ed2cd9da75 100644 --- a/packages/services/api/src/modules/operations/resolvers/SchemaCoordinateStats.ts +++ b/packages/services/api/src/modules/operations/resolvers/SchemaCoordinateStats.ts @@ -40,7 +40,7 @@ export const SchemaCoordinateStats: SchemaCoordinateStatsResolvers = { period, schemaCoordinate, }), - operationsManager.readDetailedDurationPercentiles({ + operationsManager.readDetailedDurationMetrics({ organizationId: organization, projectId: project, targetId: target, diff --git a/packages/web/app/src/components/target/insights/List.tsx b/packages/web/app/src/components/target/insights/List.tsx index 8a78ed1992..6fe4d4954d 100644 --- a/packages/web/app/src/components/target/insights/List.tsx +++ b/packages/web/app/src/components/target/insights/List.tsx @@ -1,11 +1,13 @@ import { ReactElement, SetStateAction, useCallback, useEffect, useMemo, useState } from 'react'; import clsx from 'clsx'; +import { InfoIcon } from 'lucide-react'; import { useQuery } from 'urql'; import { useDebouncedCallback } from 'use-debounce'; import { Scale, Section } from '@/components/common'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; -import { Sortable, Table, TBody, Td, Th, THead, Tooltip, Tr } from '@/components/v2'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { Sortable, Table, TBody, Td, Th, THead, Tr } from '@/components/v2'; import { env } from '@/env/frontend'; import { FragmentType, graphql, useFragment } from '@/gql'; import { DateRangeInput } from '@/gql/graphql'; @@ -34,6 +36,7 @@ interface Operation { failureRate: number; requests: number; percentage: number; + impact: number; hash: string; } @@ -56,6 +59,9 @@ function OperationRow({ const p90 = useFormattedDuration(operation.p90); const p95 = useFormattedDuration(operation.p95); const p99 = useFormattedDuration(operation.p99); + const impact = useFormattedNumber( + operation.impact < 1000 ? Math.round(operation.impact * 100) / 100 : operation.impact, + ); return ( <> @@ -83,11 +89,16 @@ function OperationRow({ {operation.name === 'anonymous' && ( - - - + + + + + + + Anonymous operation detected. Naming your operations is a recommended practice + - + )} @@ -99,6 +110,7 @@ function OperationRow({ {p99} {failureRate}% {count} + {impact} {percentage}% @@ -155,6 +167,12 @@ const columns = [ align: 'center', }, }), + columnHelper.accessor('impact', { + header: 'Impact', + meta: { + align: 'center', + }, + }), columnHelper.accessor('percentage', { header: 'Traffic', meta: { @@ -222,7 +240,7 @@ function OperationsTable({ - + {headers.map(header => { const canSort = header.column.getCanSort(); const align: 'center' | 'left' | 'right' = @@ -230,21 +248,39 @@ function OperationsTable({ const name = flexRender(header.column.columnDef.header, header.getContext()); return ( ); })} - + {tableInstance @@ -326,6 +362,7 @@ const OperationsTableContainer_OperationsStatsFragment = graphql(` p90 p95 p99 + avg } countOk count @@ -381,6 +418,7 @@ function OperationsTableContainer({ failureRate: (1 - op.countOk / op.count) * 100, requests: op.count, percentage: op.percentage, + impact: op.duration.avg > 0 ? op.count * (op.duration.avg / 1000) : 0, hash: op.operationHash!, }); } diff --git a/packages/web/app/src/components/v2/sortable.tsx b/packages/web/app/src/components/v2/sortable.tsx index 9d3e438d22..d54eacccd2 100644 --- a/packages/web/app/src/components/v2/sortable.tsx +++ b/packages/web/app/src/components/v2/sortable.tsx @@ -1,14 +1,9 @@ import { ComponentProps, ReactElement, ReactNode } from 'react'; -import { Tooltip } from '@/components/v2/tooltip'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { TriangleUpIcon } from '@radix-ui/react-icons'; import { SortDirection } from '@tanstack/react-table'; -export function Sortable({ - children, - sortOrder, - otherColumnSorted, - onClick, -}: { +export function Sortable(props: { children: ReactNode; sortOrder: SortDirection | false; /** @@ -16,28 +11,39 @@ export function Sortable({ * It's used to show a different tooltip when sorting by multiple columns. */ otherColumnSorted?: boolean; - onClick: ComponentProps<'button'>['onClick']; + onClick?: ComponentProps<'button'>['onClick']; }): ReactElement { const tooltipText = - sortOrder === false - ? 'Click to sort descending' + otherColumnSorted + props.sortOrder === false + ? 'Click to sort descending' + props.otherColumnSorted ? ' (hold shift to sort by multiple columns)' : '' : { asc: 'Click to cancel sorting', desc: 'Click to sort ascending', - }[sortOrder]; + }[props.sortOrder]; return ( - - - + {props.sortOrder === 'asc' ? : null} + {props.sortOrder === 'desc' ? ( + + ) : null} + + + {tooltipText} + + ); }
- {canSort ? ( - id !== header.id)} - > - {name} - - ) : ( - name - )} +
+ {canSort ? ( + id !== header.id)} + > + {name} + + ) : ( + name + )} + {header.column.columnDef.header === 'Impact' ? ( + + + + + + +

+ Equals to the total time spent on this operation in the selected + period in seconds. +

+ Impact = Requests * avg/1000 +
+
+
+ ) : null} +