diff --git a/apps/opik-frontend/src/components/pages/ExperimentsPage/charts/ExperimentChartContainer.tsx b/apps/opik-frontend/src/components/pages/ExperimentsPage/charts/ExperimentChartContainer.tsx index 1b5c2302fd..04001e53e3 100644 --- a/apps/opik-frontend/src/components/pages/ExperimentsPage/charts/ExperimentChartContainer.tsx +++ b/apps/opik-frontend/src/components/pages/ExperimentsPage/charts/ExperimentChartContainer.tsx @@ -66,7 +66,7 @@ const ExperimentChartContainer: React.FC = ({ width: tickWidth, ticks, domain, - tickFormatter, + yTickFormatter, interval: tickInterval, } = useChartTickDefaultConfig(values, { tickPrecision: 2, @@ -120,7 +120,7 @@ const ExperimentChartContainer: React.FC = ({ = ({ tick={DEFAULT_CHART_TICK} interval={tickInterval} ticks={ticks} - tickFormatter={tickFormatter} + tickFormatter={yTickFormatter} domain={domain} /> + value; + interface MetricChartProps { name: string; projectId: string; @@ -34,6 +37,9 @@ interface MetricChartProps { intervalEnd: string; disableLoadingData: boolean; metricName: METRIC_NAME_TYPE; + renderValue?: (data: ChartTooltipRenderValueArguments) => ValueType; + labelsMap?: Record; + customYTickFormatter?: (value: number, maxDecimalLength?: number) => string; } type TransformedDataValueType = null | number | string; @@ -47,6 +53,9 @@ const MetricChart = ({ intervalStart, intervalEnd, disableLoadingData, + renderValue = renderTooltipValue, + labelsMap, + customYTickFormatter, }: MetricChartProps) => { const { data: traces, isPending } = useProjectMetric( { @@ -83,20 +92,22 @@ const MetricChart = ({ }); }); - return [transformedData, lines, values]; + return [transformedData, lines.sort(), values]; }, [traces]); const config = useMemo(() => { - return getDefaultHashedColorsChartConfig(lines); - }, [lines]); + return getDefaultHashedColorsChartConfig(lines, labelsMap); + }, [lines, labelsMap]); const { width: yTickWidth, ticks, - tickFormatter: yTickFormatter, domain, interval: yTickInterval, - } = useChartTickDefaultConfig(values); + yTickFormatter, + } = useChartTickDefaultConfig(values, { + tickFormatter: customYTickFormatter, + }); const renderChartTooltipHeader = useCallback( ({ payload }: ChartTooltipRenderHeaderArguments) => { @@ -109,17 +120,6 @@ const MetricChart = ({ [], ); - const renderTooltipValue = useCallback( - ({ value }: ChartTooltipRenderValueArguments) => { - if (metricName === METRIC_NAME_TYPE.COST) { - return formatCost(value as number); - } - - return value; - }, - [metricName], - ); - const xTickFormatter = useCallback( (val: string) => { if (interval === INTERVAL_TYPE.HOURLY) { @@ -178,7 +178,7 @@ const MetricChart = ({ content={ } /> diff --git a/apps/opik-frontend/src/components/pages/TracesPage/MetricsTab/MetricsTab.tsx b/apps/opik-frontend/src/components/pages/TracesPage/MetricsTab/MetricsTab.tsx index 3c30cac23a..1bc0213d2e 100644 --- a/apps/opik-frontend/src/components/pages/TracesPage/MetricsTab/MetricsTab.tsx +++ b/apps/opik-frontend/src/components/pages/TracesPage/MetricsTab/MetricsTab.tsx @@ -14,6 +14,9 @@ import useTracesOrSpansList, { TRACE_DATA_TYPE, } from "@/hooks/useTracesOrSpansList"; import NoTracesPage from "@/components/pages/TracesPage/NoTracesPage"; +import { ChartTooltipRenderValueArguments } from "@/components/shared/ChartTooltipContent/ChartTooltipContent"; +import { formatCost } from "@/lib/money"; +import { formatDuration } from "@/lib/date"; enum DAYS_OPTION_TYPE { ONE_DAY = "1", @@ -41,12 +44,27 @@ const DAYS_OPTIONS = [ }, ]; +const DURATION_LABELS_MAP = { + "duration.p50": "Percentile 50", + "duration.p90": "Percentile 90", + "duration.p99": "Percentile 99", +}; + const POSSIBLE_DAYS_OPTIONS = Object.values(DAYS_OPTION_TYPE); const DEFAULT_DAYS_VALUE = DAYS_OPTION_TYPE.THIRTY_DAYS; const nowUTC = dayjs().utc(); const intervalEnd = nowUTC.format(); +const renderCostTooltipValue = ({ value }: ChartTooltipRenderValueArguments) => + formatCost(value as number); + +const renderDurationTooltipValue = ({ + value, +}: ChartTooltipRenderValueArguments) => formatDuration(value as number); + +const durationYTickFormatter = (value: number) => formatDuration(value); + interface MetricsTabProps { projectId: string; } @@ -151,6 +169,21 @@ const MetricsTab = ({ projectId }: MetricsTabProps) => {
+
+ +
+
{ disableLoadingData={!isValidDays} />
- +
+
{ intervalEnd={intervalEnd} projectId={projectId} disableLoadingData={!isValidDays} + renderValue={renderCostTooltipValue} />
diff --git a/apps/opik-frontend/src/components/pages/TracesPage/TracesSpansTab.tsx b/apps/opik-frontend/src/components/pages/TracesPage/TracesSpansTab.tsx index 389d13470e..310ad29d29 100644 --- a/apps/opik-frontend/src/components/pages/TracesPage/TracesSpansTab.tsx +++ b/apps/opik-frontend/src/components/pages/TracesPage/TracesSpansTab.tsx @@ -50,8 +50,7 @@ import FeedbackScoreCell from "@/components/shared/DataTableCells/FeedbackScoreC import FeedbackScoreHeader from "@/components/shared/DataTableHeaders/FeedbackScoreHeader"; import TraceDetailsPanel from "@/components/shared/TraceDetailsPanel/TraceDetailsPanel"; import TooltipWrapper from "@/components/shared/TooltipWrapper/TooltipWrapper"; -import { formatDate } from "@/lib/date"; -import { millisecondsToSeconds } from "@/lib/utils"; +import { formatDate, formatDuration } from "@/lib/date"; import useTracesOrSpansStatistic from "@/hooks/useTracesOrSpansStatistic"; import { useDynamicColumnsCache } from "@/hooks/useDynamicColumnsCache"; @@ -102,8 +101,7 @@ const SHARED_COLUMNS: ColumnData[] = [ label: "Duration", type: COLUMN_TYPE.duration, cell: DurationCell as never, - statisticDataFormater: (value: number) => - isNaN(value) ? "NA" : `${millisecondsToSeconds(value)}s`, + statisticDataFormater: formatDuration, }, { id: "metadata", diff --git a/apps/opik-frontend/src/components/shared/DataTableCells/DurationCell.tsx b/apps/opik-frontend/src/components/shared/DataTableCells/DurationCell.tsx index f12b0c7cda..cee4d676c4 100644 --- a/apps/opik-frontend/src/components/shared/DataTableCells/DurationCell.tsx +++ b/apps/opik-frontend/src/components/shared/DataTableCells/DurationCell.tsx @@ -1,6 +1,6 @@ import { CellContext } from "@tanstack/react-table"; import CellWrapper from "@/components/shared/DataTableCells/CellWrapper"; -import { millisecondsToSeconds } from "@/lib/utils"; +import { formatDuration } from "@/lib/date"; const DurationCell = (context: CellContext) => { const value = context.getValue(); @@ -10,7 +10,7 @@ const DurationCell = (context: CellContext) => { metadata={context.column.columnDef.meta} tableMetadata={context.table.options.meta} > - {isNaN(value) ? "NA" : `${millisecondsToSeconds(value)}s`} + {formatDuration(value)} ); }; diff --git a/apps/opik-frontend/src/components/shared/TraceDetailsPanel/TraceDataViewer/TraceDataViewer.tsx b/apps/opik-frontend/src/components/shared/TraceDetailsPanel/TraceDataViewer/TraceDataViewer.tsx index 2e3600be27..861d8bf028 100644 --- a/apps/opik-frontend/src/components/shared/TraceDetailsPanel/TraceDataViewer/TraceDataViewer.tsx +++ b/apps/opik-frontend/src/components/shared/TraceDetailsPanel/TraceDataViewer/TraceDataViewer.tsx @@ -22,7 +22,7 @@ import AgentGraphTab from "./AgentGraphTab"; import ErrorTab from "./ErrorTab"; import { Button } from "@/components/ui/button"; import { useToast } from "@/components/ui/use-toast"; -import { millisecondsToSeconds } from "@/lib/utils"; +import { formatDuration } from "@/lib/date"; import { isObjectSpan } from "@/lib/traces"; import isUndefined from "lodash/isUndefined"; import { formatCost } from "@/lib/money"; @@ -114,9 +114,7 @@ const TraceDataViewer: React.FunctionComponent = ({ className="flex items-center gap-2 px-1" > - {isNaN(data.duration) - ? "NA" - : `${millisecondsToSeconds(data.duration)} seconds`} + {formatDuration(data.duration)} {isNumber(tokens) && ( diff --git a/apps/opik-frontend/src/components/shared/TraceDetailsPanel/TraceTreeViewer/treeRenderers.tsx b/apps/opik-frontend/src/components/shared/TraceDetailsPanel/TraceTreeViewer/treeRenderers.tsx index 1c1a3d0d71..762f56853d 100644 --- a/apps/opik-frontend/src/components/shared/TraceDetailsPanel/TraceTreeViewer/treeRenderers.tsx +++ b/apps/opik-frontend/src/components/shared/TraceDetailsPanel/TraceTreeViewer/treeRenderers.tsx @@ -1,7 +1,6 @@ import React from "react"; import isEmpty from "lodash/isEmpty"; import isNumber from "lodash/isNumber"; -import isNull from "lodash/isNull"; import isUndefined from "lodash/isUndefined"; import { TreeRenderProps } from "react-complex-tree"; import { @@ -12,9 +11,10 @@ import { Hash, PenLine, } from "lucide-react"; -import { cn, millisecondsToSeconds } from "@/lib/utils"; -import { BASE_TRACE_DATA_TYPE } from "@/types/traces"; +import { cn } from "@/lib/utils"; +import { formatDuration } from "@/lib/date"; import { formatCost } from "@/lib/money"; +import { BASE_TRACE_DATA_TYPE } from "@/types/traces"; import BaseTraceDataTypeIcon from "../BaseTraceDataTypeIcon"; import TooltipWrapper from "@/components/shared/TooltipWrapper/TooltipWrapper"; import styles from "./TraceTreeViewer.module.scss"; @@ -62,12 +62,7 @@ export const treeRenderers: TreeRenderProps = { 0, ); - const duration = - isNaN(props.item.data.duration) || - isUndefined(props.item.data.duration) || - isNull(props.item.data.duration) - ? "NA" - : `${millisecondsToSeconds(props.item.data.duration)}s`; + const duration = formatDuration(props.item.data.duration); const name = props.item.data.name || "NA"; const tokens = props.item.data.tokens; diff --git a/apps/opik-frontend/src/hooks/charts/useChartTickDefaultConfig.ts b/apps/opik-frontend/src/hooks/charts/useChartTickDefaultConfig.ts index c38ca137c3..e483639859 100644 --- a/apps/opik-frontend/src/hooks/charts/useChartTickDefaultConfig.ts +++ b/apps/opik-frontend/src/hooks/charts/useChartTickDefaultConfig.ts @@ -3,16 +3,22 @@ import isInteger from "lodash/isInteger"; import max from "lodash/max"; import min from "lodash/min"; import isNull from "lodash/isNull"; -import { getDefaultChartYTickWidth } from "@/lib/charts"; -import floor from "lodash/floor"; import { AxisDomain, AxisInterval } from "recharts/types/util/types"; +import { getTextWidth } from "@/lib/utils"; + +const defaultTickFormatter = (value: number, maxDecimalLength?: number) => + maxDecimalLength ? value.toFixed(maxDecimalLength) : value.toString(); const DEFAULT_NUMBER_OF_TICKS = 5; const DEFAULT_TICK_PRECISION = 6; +const MIN_Y_AXIS_WIDTH = 26; +const MAX_Y_AXIS_WIDTH = 80; +const Y_AXIS_EXTRA_WIDTH = 10; interface UseChartTickDefaultConfigProps { numberOfTicks?: number; tickPrecision?: number; + tickFormatter?: (value: number, maxDecimalLength?: number) => string; } const generateEvenlySpacedValues = ( @@ -51,20 +57,15 @@ const useChartTickDefaultConfig = ( { numberOfTicks = DEFAULT_NUMBER_OF_TICKS, tickPrecision = DEFAULT_TICK_PRECISION, + tickFormatter = defaultTickFormatter, }: UseChartTickDefaultConfigProps = {}, ) => { const filteredValues = useMemo(() => { return values.filter((v) => !isNull(v)) as number[]; }, [values]); - const areValuesWithDecimals = useMemo(() => { - return filteredValues.some((v) => !isInteger(v)); - }, [filteredValues]); - const maxDecimalNumbersLength = useMemo(() => { - if (!areValuesWithDecimals) { - return 0; - } + if (!filteredValues.some((v) => !isInteger(v))) return 0; return filteredValues.reduce((maxLen, v) => { const partition = v.toString().split("."); @@ -77,44 +78,41 @@ const useChartTickDefaultConfig = ( return maxLen; }, 0); - }, [areValuesWithDecimals, filteredValues, tickPrecision]); + }, [filteredValues, tickPrecision]); const ticks = useMemo(() => { return generateEvenlySpacedValues( min([...filteredValues, 0]) as number, max(filteredValues) as number, numberOfTicks, - areValuesWithDecimals, + Boolean(maxDecimalNumbersLength), ); - }, [filteredValues, areValuesWithDecimals, numberOfTicks]); - - const areTicksWithDecimals = useMemo(() => { - return ticks.some((v: number) => !isInteger(floor(v, tickPrecision))); - }, [ticks, tickPrecision]); - - const tickWidth = useMemo(() => { - return getDefaultChartYTickWidth({ - values: ticks, - tickPrecision, - withDecimals: areTicksWithDecimals, - }); - }, [areTicksWithDecimals, tickPrecision, ticks]); - - const tickFormatter = useCallback( - (value: number) => { - if (areTicksWithDecimals) { - return value.toFixed(maxDecimalNumbersLength); - } + }, [filteredValues, maxDecimalNumbersLength, numberOfTicks]); + + const width = useMemo(() => { + return Math.min( + Math.max( + Math.max( + ...getTextWidth( + ticks.map((value) => tickFormatter(value, maxDecimalNumbersLength)), + { font: "10px Inter" }, + ), + ) + Y_AXIS_EXTRA_WIDTH, + MIN_Y_AXIS_WIDTH, + ), + MAX_Y_AXIS_WIDTH, + ); + }, [ticks, tickFormatter, maxDecimalNumbersLength]); - return value.toString(); - }, - [areTicksWithDecimals, maxDecimalNumbersLength], + const yTickFormatter = useCallback( + (value: number) => tickFormatter(value, maxDecimalNumbersLength), + [maxDecimalNumbersLength, tickFormatter], ); return { - width: tickWidth, + width, ticks, - tickFormatter, + yTickFormatter, domain: DEFAULT_DOMAIN, interval: DEFAULT_INTERVAL, }; diff --git a/apps/opik-frontend/src/lib/charts.ts b/apps/opik-frontend/src/lib/charts.ts index 824450902a..0fcf975584 100644 --- a/apps/opik-frontend/src/lib/charts.ts +++ b/apps/opik-frontend/src/lib/charts.ts @@ -2,47 +2,13 @@ import { ChartConfig } from "@/components/ui/chart"; import { TAG_VARIANTS_COLOR_MAP } from "@/components/ui/tag"; import { generateTagVariant } from "@/lib/traces"; -const DEFAULT_TICK_PRECISION = 6; - -interface GetDefaultChartYTickWidthArguments { - values: (number | null)[]; - characterWidth?: number; - minWidth?: number; - maxWidth?: number; - extraSpace?: number; - tickPrecision?: number; - withDecimals?: boolean; -} - -export const getDefaultChartYTickWidth = ({ - values, - characterWidth = 7, - minWidth = 26, - maxWidth = 80, - extraSpace = 10, - tickPrecision = DEFAULT_TICK_PRECISION, - withDecimals = false, -}: GetDefaultChartYTickWidthArguments) => { - const lengths = values - .filter((v) => v !== null) - .map((v) => { - if (withDecimals) { - return v?.toFixed(tickPrecision).toString().length || 0; - } - - return Math.round(v!).toString().length; - }); - - return Math.min( - Math.max(minWidth, Math.max(...lengths) * characterWidth + extraSpace), - maxWidth, - ); -}; - -export const getDefaultHashedColorsChartConfig = (lines: string[]) => { +export const getDefaultHashedColorsChartConfig = ( + lines: string[], + labelsMap?: Record, +) => { return lines.reduce((acc, line) => { acc[line] = { - label: line, + label: labelsMap?.[line] ?? line, color: TAG_VARIANTS_COLOR_MAP[generateTagVariant(line)!], }; return acc; diff --git a/apps/opik-frontend/src/lib/date.ts b/apps/opik-frontend/src/lib/date.ts index aebdb9e7d1..a94c4ea5c6 100644 --- a/apps/opik-frontend/src/lib/date.ts +++ b/apps/opik-frontend/src/lib/date.ts @@ -1,6 +1,9 @@ import dayjs from "dayjs"; import utc from "dayjs/plugin/utc"; import isString from "lodash/isString"; +import round from "lodash/round"; +import isUndefined from "lodash/isUndefined"; +import isNull from "lodash/isNull"; dayjs.extend(utc); @@ -23,3 +26,18 @@ export const makeStartOfDay = (value: string) => { export const makeEndOfDay = (value: string) => { return dayjs(value).endOf("date").utcOffset(0, true).toISOString(); }; + +const millisecondsToSeconds = (milliseconds: number) => { + // rounds with precision, one character after the point + return round(milliseconds / 1000, 1); +}; + +export const secondsToMilliseconds = (seconds: number) => { + return seconds * 1000; +}; + +export const formatDuration = (value?: number | null) => { + return isUndefined(value) || isNull(value) || isNaN(value) + ? "NA" + : `${millisecondsToSeconds(value)}s`; +}; diff --git a/apps/opik-frontend/src/lib/filters.ts b/apps/opik-frontend/src/lib/filters.ts index 7ea1e3212d..b5ea04d178 100644 --- a/apps/opik-frontend/src/lib/filters.ts +++ b/apps/opik-frontend/src/lib/filters.ts @@ -2,8 +2,11 @@ import uniqid from "uniqid"; import flatten from "lodash/flatten"; import { Filter } from "@/types/filters"; import { COLUMN_TYPE, DYNAMIC_COLUMN_TYPE } from "@/types/shared"; -import { makeEndOfDay, makeStartOfDay } from "@/lib/date"; -import { secondsToMilliseconds } from "@/lib/utils"; +import { + makeEndOfDay, + makeStartOfDay, + secondsToMilliseconds, +} from "@/lib/date"; export const isFilterValid = (filter: Filter) => { return ( diff --git a/apps/opik-frontend/src/lib/utils.ts b/apps/opik-frontend/src/lib/utils.ts index 167a1b854c..154fa9ea9f 100644 --- a/apps/opik-frontend/src/lib/utils.ts +++ b/apps/opik-frontend/src/lib/utils.ts @@ -1,5 +1,4 @@ import { type ClassValue, clsx } from "clsx"; -import round from "lodash/round"; import isObject from "lodash/isObject"; import isUndefined from "lodash/isUndefined"; import { twMerge } from "tailwind-merge"; @@ -39,15 +38,6 @@ export const safelyParseJSON = (string: string) => { } }; -export const millisecondsToSeconds = (milliseconds: number) => { - // rounds with precision, one character after the point - return round(milliseconds / 1000, 1); -}; - -export const secondsToMilliseconds = (seconds: number) => { - return seconds * 1000; -}; - export const getTextWidth = ( text: string[], properties: {