Skip to content

Commit 36fe38d

Browse files
atrakhConvex, Inc.
authored andcommitted
dashboard: add log drilldown component (#41586)
Adds LogDrilldown, a replacement for RequestIdLogs that displays as a side-panel with more context about the execution, request, and function call tree for a specific log. This view is feature flagged. <img width="1572" height="1229" alt="Screenshot 2025-10-03 at 4 58 43 PM" src="https://github.com/user-attachments/assets/479142b1-265a-4bf4-8a40-256301710c32" /> <img width="1572" height="1229" alt="Screenshot 2025-10-03 at 4 58 46 PM" src="https://github.com/user-attachments/assets/eaac63f7-c590-4d63-a127-53ca509d40f9" /> GitOrigin-RevId: 765dcfcd4da88de48846a6db23f403f9fe436884
1 parent 9d9c553 commit 36fe38d

File tree

9 files changed

+855
-16
lines changed

9 files changed

+855
-16
lines changed

npm-packages/dashboard-common/src/features/logs/components/LogDrilldown.stories.tsx

Lines changed: 567 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import { Crosshair2Icon } from "@radix-ui/react-icons";
2+
import { useMemo } from "react";
3+
import { Tab as HeadlessTab } from "@headlessui/react";
4+
import { UdfLog } from "@common/lib/useLogs";
5+
import { ClosePanelButton } from "@ui/ClosePanelButton";
6+
import { Button } from "@ui/Button";
7+
import { LiveTimestampDistance } from "@common/elements/TimestampDistance";
8+
import { LogLevel } from "@common/elements/LogLevel";
9+
import { LogStatusLine } from "@common/features/logs/components/LogStatusLine";
10+
import { LogOutput, messagesToString } from "@common/elements/LogOutput";
11+
import { CopyButton } from "@common/elements/CopyButton";
12+
import { Tab } from "@ui/Tab";
13+
import { FunctionCallTree } from "./FunctionCallTree";
14+
import { LogMetadata } from "./LogMetadata";
15+
16+
export function LogDrilldown({
17+
requestId,
18+
logs,
19+
onClose,
20+
selectedLogTimestamp,
21+
onFilterByRequestId,
22+
onSelectLog,
23+
}: {
24+
requestId: string;
25+
logs: UdfLog[];
26+
onClose: () => void;
27+
selectedLogTimestamp?: number;
28+
onFilterByRequestId?: (requestId: string) => void;
29+
onSelectLog?: (timestamp: number) => void;
30+
}) {
31+
// Get the selected log to display based on timestamp
32+
const selectedLog = useMemo(() => {
33+
if (selectedLogTimestamp !== undefined) {
34+
return logs.find((log) => log.timestamp === selectedLogTimestamp) || null;
35+
}
36+
return null;
37+
}, [logs, selectedLogTimestamp]);
38+
39+
if (!selectedLog) {
40+
return null;
41+
}
42+
43+
return (
44+
<div className="flex h-full max-h-full flex-col overflow-hidden border-l bg-background-primary/70">
45+
{/* Header */}
46+
<div className="border-b bg-background-secondary px-2 pt-4 pb-4">
47+
<div className="flex flex-wrap items-center justify-between gap-4">
48+
<h4 className="flex flex-wrap items-center gap-2">
49+
<div className="flex flex-wrap items-center gap-2">
50+
<span className="font-mono text-sm">
51+
{selectedLog.localizedTimestamp}
52+
<span className="text-content-secondary">
53+
.
54+
{new Date(selectedLog.timestamp)
55+
.toISOString()
56+
.split(".")[1]
57+
.slice(0, -1)}
58+
</span>
59+
</span>
60+
<span className="text-xs font-normal text-nowrap text-content-secondary">
61+
(
62+
<LiveTimestampDistance
63+
date={new Date(selectedLog.timestamp)}
64+
className="inline"
65+
/>
66+
)
67+
</span>
68+
<div className="font-mono text-xs">
69+
{selectedLog.kind === "log" && selectedLog.output.level && (
70+
<LogLevel level={selectedLog.output.level} />
71+
)}
72+
{selectedLog.kind === "outcome" && (
73+
<LogStatusLine outcome={selectedLog.outcome} />
74+
)}
75+
</div>
76+
</div>
77+
</h4>
78+
<div className="flex items-center gap-1">
79+
{selectedLog && (
80+
<Button
81+
icon={<Crosshair2Icon />}
82+
variant="neutral"
83+
size="xs"
84+
inline
85+
tip="View log line in context"
86+
onClick={() => {
87+
onFilterByRequestId?.(requestId);
88+
}}
89+
/>
90+
)}
91+
<ClosePanelButton onClose={onClose} />
92+
</div>
93+
</div>
94+
</div>
95+
96+
<div className="scrollbar grow animate-fadeInFromLoading gap-2 overflow-y-auto py-2">
97+
{/* Selected Log Output - show when a log is selected and it's not a completion */}
98+
{selectedLog && selectedLog.kind === "log" && (
99+
<div className="m-2 mt-0 animate-fadeInFromLoading rounded-md border bg-background-secondary">
100+
<div className="mb-1 flex items-center justify-between gap-1 px-2 pt-2">
101+
<p className="text-xs font-semibold">Log Message</p>
102+
<CopyButton
103+
text={`${messagesToString(selectedLog.output)}${selectedLog.output.isTruncated ? " (truncated due to length)" : ""}`}
104+
inline
105+
/>
106+
</div>
107+
<div className="px-2 pb-2 font-mono text-xs">
108+
<LogOutput output={selectedLog.output} wrap />
109+
</div>
110+
</div>
111+
)}
112+
113+
{/* Error message for outcome logs with errors */}
114+
{selectedLog && selectedLog.kind === "outcome" && selectedLog.error && (
115+
<div className="m-2 mt-0 animate-fadeInFromLoading rounded-md border bg-background-secondary">
116+
<div className="mb-1 flex items-center justify-between gap-1 px-2 pt-2">
117+
<p className="text-xs font-semibold">Error</p>
118+
<CopyButton text={selectedLog.error} inline />
119+
</div>
120+
<div className="px-2 pb-2 font-mono text-xs">
121+
<LogOutput
122+
output={{
123+
isTruncated: false,
124+
messages: [selectedLog.error],
125+
level: "FAILURE",
126+
}}
127+
wrap
128+
/>
129+
</div>
130+
</div>
131+
)}
132+
133+
{/* Tabs for Execution Info, Request Info, and Functions Called */}
134+
<HeadlessTab.Group>
135+
<div className="px-2">
136+
<HeadlessTab.List className="flex gap-1 rounded-t-md border bg-background-secondary px-1">
137+
{selectedLog && <Tab>Execution</Tab>}
138+
<Tab>Request</Tab>
139+
<Tab>Functions Called</Tab>
140+
</HeadlessTab.List>
141+
</div>
142+
143+
<div className="mx-2 scrollbar flex h-fit min-h-0 flex-col overflow-y-auto rounded rounded-t-none border border-t-0 bg-background-secondary">
144+
<div className="flex flex-col gap-2">
145+
<HeadlessTab.Panels>
146+
{selectedLog && (
147+
<HeadlessTab.Panel>
148+
<LogMetadata
149+
requestId={requestId}
150+
logs={logs}
151+
executionId={selectedLog.executionId}
152+
/>
153+
</HeadlessTab.Panel>
154+
)}
155+
<HeadlessTab.Panel>
156+
<LogMetadata
157+
requestId={requestId}
158+
logs={logs}
159+
executionId={undefined}
160+
/>
161+
</HeadlessTab.Panel>
162+
<HeadlessTab.Panel>
163+
<FunctionCallTree
164+
logs={logs}
165+
onFunctionSelect={(executionId) => {
166+
// Find the outcome log for this execution
167+
const outcomeLog = logs.find(
168+
(log) =>
169+
log.kind === "outcome" &&
170+
log.executionId === executionId,
171+
);
172+
if (outcomeLog && onSelectLog) {
173+
onSelectLog(outcomeLog.timestamp);
174+
}
175+
}}
176+
/>
177+
</HeadlessTab.Panel>
178+
</HeadlessTab.Panels>
179+
</div>
180+
</div>
181+
</HeadlessTab.Group>
182+
</div>
183+
</div>
184+
);
185+
}

npm-packages/dashboard-common/src/features/logs/components/LogList.tsx

Lines changed: 83 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,15 @@ import {
66
InfoCircledIcon,
77
QuestionMarkCircledIcon,
88
} from "@radix-ui/react-icons";
9-
import { Fragment, memo, useCallback, useMemo, useRef, useState } from "react";
9+
import {
10+
Fragment,
11+
memo,
12+
useCallback,
13+
useContext,
14+
useMemo,
15+
useRef,
16+
useState,
17+
} from "react";
1018
import { FixedSizeList, ListOnScrollProps, areEqual } from "react-window";
1119
import { useDebounce, useMeasure } from "react-use";
1220
import { Transition, Dialog } from "@headlessui/react";
@@ -37,12 +45,18 @@ import { MultiSelectValue } from "@ui/MultiSelectCombobox";
3745
import { LogListResources } from "@common/features/logs/components/LogListResources";
3846
import { shallowNavigate } from "@common/lib/useTableMetadata";
3947
import { useRouter } from "next/router";
48+
import { Panel, PanelGroup } from "react-resizable-panels";
49+
import { cn } from "@ui/cn";
50+
import { ResizeHandle } from "@common/layouts/SidebarDetailLayout";
51+
import { DeploymentInfoContext } from "@common/lib/deploymentContext";
52+
import { LogDrilldown } from "./LogDrilldown";
4053

4154
export type LogListProps = {
4255
logs?: UdfLog[];
4356
filteredLogs?: UdfLog[];
4457
deploymentAuditLogs?: DeploymentAuditLogEvent[];
4558
filter: string;
59+
setFilter?: (filter: string) => void;
4660
clearedLogs: number[];
4761
setClearedLogs: (clearedLogs: number[]) => void;
4862
nents: Nent[];
@@ -61,6 +75,7 @@ export function LogList({
6175
paused,
6276
setPaused,
6377
setManuallyPaused,
78+
setFilter,
6479
}: LogListProps) {
6580
const router = useRouter();
6681

@@ -99,6 +114,16 @@ export function LogList({
99114
[router],
100115
);
101116

117+
const selectLogByTimestamp = useCallback(
118+
(timestamp: number) => {
119+
void shallowNavigate(router, {
120+
...router.query,
121+
logTs: timestamp.toString(),
122+
});
123+
},
124+
[router],
125+
);
126+
102127
const hasFilters =
103128
!!logs && !!filteredLogs && filteredLogs.length !== logs.length;
104129

@@ -113,19 +138,25 @@ export function LogList({
113138
[paused, setPaused],
114139
);
115140

141+
const { newLogsPageSidepanel } = useContext(DeploymentInfoContext);
142+
116143
return (
117-
<div className="flex h-full w-full flex-auto flex-col gap-2 overflow-hidden">
118-
{shownLog && logs && (
119-
<RequestIdLogs
120-
requestId={shownLog}
121-
logs={logs.filter((log) => log.requestId === shownLog?.requestId)}
122-
onClose={() => setShownLog(undefined)}
123-
nents={nents}
124-
/>
125-
)}
126-
{interleavedLogs !== undefined && (
127-
<Sheet className="min-h-full w-full" padding={false} ref={sheetRef}>
128-
{heightOfListContainer !== 0 && (
144+
<Sheet className="h-full w-full" padding={false} ref={sheetRef}>
145+
<PanelGroup
146+
direction="horizontal"
147+
className="flex h-full w-full flex-auto overflow-hidden"
148+
autoSaveId="logs-content"
149+
>
150+
<Panel
151+
className={cn(
152+
"flex shrink flex-col",
153+
"max-w-full",
154+
shownLog ? "min-w-[16rem]" : "min-w-[20rem]",
155+
)}
156+
defaultSize={shownLog ? 60 : 100}
157+
minSize={10}
158+
>
159+
{interleavedLogs !== undefined && heightOfListContainer !== 0 && (
129160
<WindowedLogList
130161
{...{
131162
onScroll,
@@ -139,9 +170,45 @@ export function LogList({
139170
}}
140171
/>
141172
)}
142-
</Sheet>
143-
)}
144-
</div>
173+
</Panel>
174+
{shownLog &&
175+
logs &&
176+
(newLogsPageSidepanel ? (
177+
<>
178+
<ResizeHandle collapsed={false} direction="left" />
179+
<Panel
180+
defaultSize={0}
181+
minSize={10}
182+
className="flex min-w-[32rem] flex-col"
183+
>
184+
<LogDrilldown
185+
requestId={shownLog.requestId}
186+
logs={
187+
filteredLogs
188+
? filteredLogs.filter(
189+
(log) => log.requestId === shownLog.requestId,
190+
)
191+
: []
192+
}
193+
onClose={() => setShownLog(undefined)}
194+
selectedLogTimestamp={shownLog.timestamp}
195+
onFilterByRequestId={(requestId) => {
196+
setFilter?.(requestId);
197+
}}
198+
onSelectLog={selectLogByTimestamp}
199+
/>
200+
</Panel>
201+
</>
202+
) : (
203+
<RequestIdLogs
204+
requestId={shownLog}
205+
logs={logs.filter((log) => log.requestId === shownLog?.requestId)}
206+
onClose={() => setShownLog(undefined)}
207+
nents={nents}
208+
/>
209+
))}
210+
</PanelGroup>
211+
</Sheet>
145212
);
146213
}
147214

npm-packages/dashboard-common/src/features/logs/components/Logs.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,15 @@ export function Logs({
169169
[innerFilter],
170170
);
171171

172+
// Function to set filter that also updates the text input
173+
const setFilterAndInput = useCallback(
174+
(newFilter: string) => {
175+
setFilter(newFilter);
176+
setInnerFilter(newFilter);
177+
},
178+
[setFilter],
179+
);
180+
172181
// Note: fromTimestamp used to be a `useMemo` result, but it was causing a bug
173182
// where fromTimestamp would keep changing and causing the query to be refetched
174183
// every time the first log entry changed
@@ -243,6 +252,7 @@ export function Logs({
243252
filteredLogs={filteredLogs}
244253
deploymentAuditLogs={deploymentAuditLogs}
245254
filter={filter}
255+
setFilter={setFilterAndInput}
246256
clearedLogs={clearedLogs}
247257
setClearedLogs={setClearedLogs}
248258
paused={paused > 0 || manuallyPaused}

npm-packages/dashboard-common/src/lib/deploymentContext.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ export type DeploymentInfo = (
106106
projectsURI: string;
107107
deploymentsURI: string;
108108
isSelfHosted: boolean;
109+
newLogsPageSidepanel: boolean;
109110
};
110111

111112
export const DeploymentInfoContext = createContext<DeploymentInfo>(

npm-packages/dashboard-common/src/lib/mockDeploymentInfo.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,4 +50,5 @@ export const mockDeploymentInfo: DeploymentInfo = {
5050
projectsURI: "",
5151
deploymentsURI: "",
5252
isSelfHosted: true,
53+
newLogsPageSidepanel: false,
5354
};

npm-packages/dashboard-self-hosted/src/pages/_app.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,7 @@ const deploymentInfo: Omit<DeploymentInfo, "deploymentUrl" | "adminKey"> = {
224224
projectsURI: "",
225225
deploymentsURI: "",
226226
isSelfHosted: true,
227+
newLogsPageSidepanel: false,
227228
};
228229

229230
function DeploymentInfoProvider({

npm-packages/dashboard/src/hooks/useLaunchDarkly.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@ import kebabCase from "lodash/kebabCase";
44
const flagDefaults: {
55
commandPalette: boolean;
66
commandPaletteDeleteProjects: boolean;
7+
newLogsPageSidepanel: boolean;
78
} = {
89
commandPalette: false,
910
commandPaletteDeleteProjects: false,
11+
newLogsPageSidepanel: false,
1012
};
1113

1214
function kebabCaseKeys(object: typeof flagDefaults) {

0 commit comments

Comments
 (0)