ProjectMetricsPage.tsx•9.24 kB
import { forwardRef, memo, Suspense, useMemo, useRef } from "react";
import { useParams } from "react-router";
import { css } from "@emotion/react";
import {
Flex,
Heading,
Loading,
Text,
useTimeRange,
View,
} from "@phoenix/components";
import { ErrorBoundary } from "@phoenix/components/exception";
import { ONE_MONTH_MS } from "@phoenix/constants/timeConstants";
import { TopModelsByCost } from "@phoenix/pages/project/metrics/TopModelsByCost";
import { TopModelsByToken } from "@phoenix/pages/project/metrics/TopModelsByToken";
import { TraceErrorsTimeSeries } from "@phoenix/pages/project/metrics/TraceErrorsTimeSeries";
import { LLMSpanCountTimeSeries } from "./LLMSpanCountTimeSeries";
import { LLMSpanErrorsTimeSeries } from "./LLMSpanErrorsTimeSeries";
import { SpanAnnotationScoreTimeSeries } from "./SpanAnnotationScoreTimeSeries";
import { ToolSpanCountTimeSeries } from "./ToolSpanCountTimeSeries";
import { ToolSpanErrorsTimeSeries } from "./ToolSpanErrorsTimeSeries";
import { TraceCountTimeSeries } from "./TraceCountTimeSeries";
import { TraceLatencyPercentilesTimeSeries } from "./TraceLatencyPercentilesTimeSeries";
import { TraceTokenCostTimeSeries } from "./TraceTokenCostTimeSeries";
import { TraceTokenCountTimeSeries } from "./TraceTokenCountTimeSeries";
interface MetricPanelHeaderProps {
title: string;
subtitle?: string;
}
function MetricPanelHeader({ title, subtitle }: MetricPanelHeaderProps) {
return (
<div
css={css`
padding: var(--ac-global-dimension-size-100)
var(--ac-global-dimension-size-200) 0
var(--ac-global-dimension-size-200);
display: flex;
flex-direction: row;
gap: var(--ac-global-dimension-size-100);
`}
className="dashboard-panel-header"
>
<Flex direction="column">
<Heading>{title}</Heading>
{subtitle && (
<Text size="XS" color="grey-600">
{subtitle}
</Text>
)}
</Flex>
</div>
);
}
interface MetricPanelProps extends MetricPanelHeaderProps {
children: React.ReactNode;
}
export const MetricPanel = forwardRef(function MetricPanel(
{ title, subtitle, children }: MetricPanelProps,
ref: React.Ref<HTMLDivElement>
) {
return (
<View
borderWidth="thin"
borderColor="grey-200"
borderRadius="medium"
height="100%"
width="100%"
data-testid={`dashboard-panel`}
backgroundColor="grey-75"
ref={ref}
>
<div
css={css`
display: flex;
flex-direction: column;
height: 100%;
`}
>
<MetricPanelHeader title={title} subtitle={subtitle} />
<div
css={css`
flex: 1 1 auto;
padding: var(--ac-global-dimension-size-200);
height: 190px;
overflow: auto;
`}
>
<ErrorBoundary>
<Suspense fallback={<Loading />}>{children}</Suspense>
</ErrorBoundary>
</div>
</div>
</View>
);
});
type EpochTimeRange = {
start: number;
end: number;
};
/**
* Hook that converts an open time range from context into a closed time range.
* If the time range is already closed, it returns it as-is.
* If it's open, it fills in missing start/end values based on a frozen "now" timestamp.
*
* The "now" timestamp is frozen and only updates when the context time range actually changes,
* preventing unnecessary recalculations on every render.
*/
function useClosedTimeRange(): EpochTimeRange {
const { timeRange: contextTimeRange } = useTimeRange();
// Extract and memoize timestamps to get stable primitive values
const startMs = useMemo(
() => (contextTimeRange.start ? contextTimeRange.start.getTime() : null),
[contextTimeRange.start]
);
const endMs = useMemo(
() => (contextTimeRange.end ? contextTimeRange.end.getTime() : null),
[contextTimeRange.end]
);
// Use a ref to freeze "now" until the context time range actually changes
const lastTimestampsRef = useRef({ startMs, endMs });
// eslint-disable-next-line react-hooks/purity
const frozenNowMsRef = useRef<number>(Date.now());
// Only update frozen "now" when timestamps actually change
if (
lastTimestampsRef.current.startMs !== startMs ||
lastTimestampsRef.current.endMs !== endMs
) {
lastTimestampsRef.current = { startMs, endMs };
// eslint-disable-next-line react-hooks/purity
frozenNowMsRef.current = Date.now();
}
const frozenNowMs = frozenNowMsRef.current;
const epochTimeRange = useMemo<EpochTimeRange>(() => {
let start = startMs;
let end = endMs;
if (start !== null && end !== null) {
// closed range from context
return { start, end };
} else if (start === null && end !== null) {
return { start: end - ONE_MONTH_MS, end };
} else if (start !== null && end === null) {
// If start is in the past, close at "now"; else, one month after start
end = start < frozenNowMs ? frozenNowMs : start + ONE_MONTH_MS;
return { start, end };
} else {
// both null → last month to now
end = frozenNowMs;
start = end - ONE_MONTH_MS;
return { start, end };
}
}, [startMs, endMs, frozenNowMs]);
return epochTimeRange;
}
export function ProjectMetricsPage() {
const { projectId } = useParams();
if (!projectId) {
throw new Error("projectId is required");
}
const epochTimeRange = useClosedTimeRange();
return (
<main
css={css`
width: 100%;
height: 100%;
box-sizing: border-box;
overflow-y: auto;
`}
>
<MetricPanels projectId={projectId} epochTimeRange={epochTimeRange} />
</main>
);
}
const MetricPanels = memo(function MetricPanels({
projectId,
epochTimeRange,
}: {
projectId: string;
epochTimeRange: EpochTimeRange;
}) {
const timeRange = useMemo(
() => ({
start: new Date(epochTimeRange.start),
end: new Date(epochTimeRange.end),
}),
[epochTimeRange]
);
return (
<div
css={css`
display: flex;
flex-direction: column;
gap: var(--ac-global-dimension-size-200);
padding: var(--ac-global-dimension-size-200);
`}
>
<Flex direction="row" gap="size-200">
<MetricPanel
title="Traces over time"
subtitle="Overall volume of traces"
>
<TraceCountTimeSeries projectId={projectId} timeRange={timeRange} />
</MetricPanel>
<MetricPanel
title="Traces with errors"
subtitle="Overall volume of traces with errors"
>
<TraceErrorsTimeSeries projectId={projectId} timeRange={timeRange} />
</MetricPanel>
</Flex>
<Flex direction="row" gap="size-200">
<MetricPanel title="Trace Latency" subtitle="Latency percentiles">
<TraceLatencyPercentilesTimeSeries
projectId={projectId}
timeRange={timeRange}
/>
</MetricPanel>
<MetricPanel
title="Annotation scores"
subtitle="Average annotation scores"
>
<SpanAnnotationScoreTimeSeries
projectId={projectId}
timeRange={timeRange}
/>
</MetricPanel>
</Flex>
<Flex direction="row" gap="size-200">
<MetricPanel title="Cost" subtitle="Estimated cost in USD">
<TraceTokenCostTimeSeries
projectId={projectId}
timeRange={timeRange}
/>
</MetricPanel>
<MetricPanel title="Top models by cost">
<TopModelsByCost projectId={projectId} timeRange={timeRange} />
</MetricPanel>
</Flex>
<Flex direction="row" gap="size-200">
<MetricPanel
title="Token usage"
subtitle="Token usage by prompt and completion"
>
<TraceTokenCountTimeSeries
projectId={projectId}
timeRange={timeRange}
/>
</MetricPanel>
<MetricPanel title="Top models by tokens">
<TopModelsByToken projectId={projectId} timeRange={timeRange} />
</MetricPanel>
</Flex>
<Flex direction="row" gap="size-200">
<MetricPanel title="LLM spans" subtitle="LLM span count over time">
<LLMSpanCountTimeSeries projectId={projectId} timeRange={timeRange} />
</MetricPanel>
<MetricPanel
title="LLM spans with errors"
subtitle="LLM spans with errors over time"
>
<LLMSpanErrorsTimeSeries
projectId={projectId}
timeRange={timeRange}
/>
</MetricPanel>
</Flex>
<Flex direction="row" gap="size-200">
<MetricPanel title="Tool spans" subtitle="Tool span count over time">
<ToolSpanCountTimeSeries
projectId={projectId}
timeRange={timeRange}
/>
</MetricPanel>
<MetricPanel
title="Tool spans with errors"
subtitle="Tool spans with errors over time"
>
<ToolSpanErrorsTimeSeries
projectId={projectId}
timeRange={timeRange}
/>
</MetricPanel>
</Flex>
</div>
);
});