From 915f07d59d7aab8529398211849b495779e65067 Mon Sep 17 00:00:00 2001 From: Wes Date: Mon, 23 Sep 2024 08:11:17 -0700 Subject: [PATCH] feat: traces page and basic setup (#2764) Fixes #2762 https://github.com/user-attachments/assets/e57009f3-bf9b-411e-8956-afd0795bc5ed --- .../console/src/features/calls/CallList.tsx | 3 +- .../src/features/timeline/Timeline.tsx | 5 +- .../src/features/timeline/TimelinePage.tsx | 2 +- .../timeline/details/TimelineCallDetails.tsx | 9 +- .../details/TimelineIngressDetails.tsx | 9 +- .../src/features/traces/TraceDetails.tsx | 97 +++++++++++++++++++ .../src/features/traces/TraceGraph.tsx | 2 +- .../src/features/traces/TraceGraphHeader.tsx | 39 ++++++++ .../src/features/traces/TracesPage.tsx | 75 ++++++++++++++ .../traces/details/TraceDetailsCall.tsx | 60 ++++++++++++ .../traces/details/TraceDetailsIngress.tsx | 72 ++++++++++++++ .../src/providers/routing-provider.tsx | 2 + 12 files changed, 360 insertions(+), 15 deletions(-) create mode 100644 frontend/console/src/features/traces/TraceDetails.tsx create mode 100644 frontend/console/src/features/traces/TraceGraphHeader.tsx create mode 100644 frontend/console/src/features/traces/TracesPage.tsx create mode 100644 frontend/console/src/features/traces/details/TraceDetailsCall.tsx create mode 100644 frontend/console/src/features/traces/details/TraceDetailsIngress.tsx diff --git a/frontend/console/src/features/calls/CallList.tsx b/frontend/console/src/features/calls/CallList.tsx index 56a7900008..a45fdd1f74 100644 --- a/frontend/console/src/features/calls/CallList.tsx +++ b/frontend/console/src/features/calls/CallList.tsx @@ -16,8 +16,7 @@ export const CallList = ({ calls }: { calls: Event[] | undefined }) => { return } setSelectedCallId(event.id) - const call = event.entry.value as CallEvent - openPanel() + openPanel() } return ( diff --git a/frontend/console/src/features/timeline/Timeline.tsx b/frontend/console/src/features/timeline/Timeline.tsx index 59709988a8..c5c7f1aea5 100644 --- a/frontend/console/src/features/timeline/Timeline.tsx +++ b/frontend/console/src/features/timeline/Timeline.tsx @@ -1,4 +1,3 @@ -import type { Timestamp } from '@bufbuild/protobuf' import { useContext, useEffect, useState } from 'react' import { useSearchParams } from 'react-router-dom' import { timeFilter, useTimeline } from '../../api/timeline/index.ts' @@ -56,7 +55,7 @@ export const Timeline = ({ timeSettings, filters }: { timeSettings: TimeSettings switch (entry.entry?.case) { case 'call': - openPanel(, handlePanelClosed) + openPanel(, handlePanelClosed) break case 'log': openPanel(, handlePanelClosed) @@ -68,7 +67,7 @@ export const Timeline = ({ timeSettings, filters }: { timeSettings: TimeSettings openPanel(, handlePanelClosed) break case 'ingress': - openPanel(, handlePanelClosed) + openPanel(, handlePanelClosed) break default: break diff --git a/frontend/console/src/features/timeline/TimelinePage.tsx b/frontend/console/src/features/timeline/TimelinePage.tsx index 1655e24f33..f56c829f7c 100644 --- a/frontend/console/src/features/timeline/TimelinePage.tsx +++ b/frontend/console/src/features/timeline/TimelinePage.tsx @@ -18,7 +18,7 @@ export const TimelinePage = () => { useEffect(() => { if (initialEventId) { // if we're loading a specific event, we don't want to tail. - setSelectedTimeRange(TIME_RANGES['5m']) + setSelectedTimeRange(TIME_RANGES['24h']) setIsTimelinePaused(true) } }, []) diff --git a/frontend/console/src/features/timeline/details/TimelineCallDetails.tsx b/frontend/console/src/features/timeline/details/TimelineCallDetails.tsx index ca731380f4..8710153ef3 100644 --- a/frontend/console/src/features/timeline/details/TimelineCallDetails.tsx +++ b/frontend/console/src/features/timeline/details/TimelineCallDetails.tsx @@ -1,4 +1,3 @@ -import type { Timestamp } from '@bufbuild/protobuf' import { useContext } from 'react' import { AttributeBadge } from '../../../components/AttributeBadge' import { CloseButton } from '../../../components/CloseButton' @@ -8,10 +7,11 @@ import { SidePanelContext } from '../../../providers/side-panel-provider' import { formatDuration } from '../../../utils/date.utils' import { DeploymentCard } from '../../deployments/DeploymentCard' import { TraceGraph } from '../../traces/TraceGraph' +import { TraceGraphHeader } from '../../traces/TraceGraphHeader' import { verbRefString } from '../../verbs/verb.utils' import { TimelineTimestamp } from './TimelineTimestamp' -export const TimelineCallDetails = ({ timestamp, event }: { timestamp?: Timestamp; event: Event }) => { +export const TimelineCallDetails = ({ event }: { event: Event }) => { const { closePanel } = useContext(SidePanelContext) const call = event.entry.value as CallEvent @@ -26,12 +26,13 @@ export const TimelineCallDetails = ({ timestamp, event }: { timestamp?: Timestam )} - + -
+
+
diff --git a/frontend/console/src/features/timeline/details/TimelineIngressDetails.tsx b/frontend/console/src/features/timeline/details/TimelineIngressDetails.tsx index c2c09bf8e3..7a404133da 100644 --- a/frontend/console/src/features/timeline/details/TimelineIngressDetails.tsx +++ b/frontend/console/src/features/timeline/details/TimelineIngressDetails.tsx @@ -1,4 +1,3 @@ -import type { Timestamp } from '@bufbuild/protobuf' import { useContext } from 'react' import { AttributeBadge } from '../../../components/AttributeBadge' import { CloseButton } from '../../../components/CloseButton' @@ -8,10 +7,11 @@ import { SidePanelContext } from '../../../providers/side-panel-provider' import { formatDuration } from '../../../utils/date.utils' import { DeploymentCard } from '../../deployments/DeploymentCard' import { TraceGraph } from '../../traces/TraceGraph' +import { TraceGraphHeader } from '../../traces/TraceGraphHeader' import { verbRefString } from '../../verbs/verb.utils' import { TimelineTimestamp } from './TimelineTimestamp' -export const TimelineIngressDetails = ({ timestamp, event }: { timestamp?: Timestamp; event: Event }) => { +export const TimelineIngressDetails = ({ event }: { event: Event }) => { const { closePanel } = useContext(SidePanelContext) const ingress = event.entry.value as IngressEvent @@ -26,12 +26,13 @@ export const TimelineIngressDetails = ({ timestamp, event }: { timestamp?: Times
)} - + -
+
+
diff --git a/frontend/console/src/features/traces/TraceDetails.tsx b/frontend/console/src/features/traces/TraceDetails.tsx new file mode 100644 index 0000000000..9f145051af --- /dev/null +++ b/frontend/console/src/features/traces/TraceDetails.tsx @@ -0,0 +1,97 @@ +import type React from 'react' +import { useNavigate } from 'react-router-dom' +import type { TraceEvent } from '../../api/timeline/use-request-trace-events' +import { CallEvent, type Event, IngressEvent } from '../../protos/xyz/block/ftl/v1/console/console_pb' +import { TimelineIcon } from '../timeline/TimelineIcon' + +interface TraceDetailsProps { + requestKey: string + events: Event[] + selectedEventId?: bigint +} + +export const TraceDetails: React.FC = ({ events, selectedEventId, requestKey }) => { + const navigate = useNavigate() + + const firstTimeStamp = events[0]?.timeStamp + const firstEvent = events[0].entry.value as TraceEvent + const firstDuration = firstEvent?.duration + const totalDurationMillis = (firstDuration?.nanos ?? 0) / 1000000 + + const handleEventClick = (eventId: bigint) => { + navigate(`/traces/${requestKey}?event_id=${eventId}`) + } + + return ( +
+
+

+ Total Duration: {totalDurationMillis} ms +

+

+ Start Time: {firstTimeStamp?.toDate().toLocaleString()} +

+
+ +
    + {events.map((event, index) => { + const traceEvent = event.entry.value as TraceEvent + const durationInMillis = (traceEvent.duration?.nanos ?? 0) / 1000000 + + let width = (durationInMillis / totalDurationMillis) * 100 + if (width < 1) width = 1 + + const callTime = traceEvent.timeStamp?.toDate() ?? new Date() + const initialTime = firstTimeStamp?.toDate() ?? new Date() + const offsetInMillis = callTime.getTime() - initialTime.getTime() + const leftOffsetPercentage = (offsetInMillis / totalDurationMillis) * 100 + + let barColor = 'bg-pink-500' + let action = '' + let eventName = '' + const icon = + + if (traceEvent instanceof CallEvent) { + barColor = 'bg-indigo-500' + action = 'Call' + eventName = `${traceEvent.destinationVerbRef?.module}.${traceEvent.destinationVerbRef?.name}` + } else if (traceEvent instanceof IngressEvent) { + barColor = 'bg-yellow-500' + action = `HTTP ${traceEvent.method}` + eventName = `${traceEvent.path}` + } + + if (event.id === selectedEventId) { + barColor = 'bg-pink-500' + } + + const isSelected = event.id === selectedEventId + const listItemClass = isSelected + ? 'flex items-center justify-between p-2 bg-indigo-100/50 dark:bg-indigo-700 rounded cursor-pointer' + : 'flex items-center justify-between p-2 hover:bg-indigo-500/10 rounded cursor-pointer' + + return ( +
  • handleEventClick(event.id)}> + + {icon} + {action} + {eventName} + + +
    +
    +
    + {durationInMillis} ms +
  • + ) + })} +
+
+ ) +} diff --git a/frontend/console/src/features/traces/TraceGraph.tsx b/frontend/console/src/features/traces/TraceGraph.tsx index 2509223b75..165826cf26 100644 --- a/frontend/console/src/features/traces/TraceGraph.tsx +++ b/frontend/console/src/features/traces/TraceGraph.tsx @@ -35,7 +35,7 @@ const EventBlock = ({ barColor = 'bg-indigo-500' eventTarget = `${event.destinationVerbRef?.module}.${event.destinationVerbRef?.name}` } else if (event instanceof IngressEvent) { - barColor = 'bg-blue-500' + barColor = 'bg-yellow-500' eventTarget = event.path } diff --git a/frontend/console/src/features/traces/TraceGraphHeader.tsx b/frontend/console/src/features/traces/TraceGraphHeader.tsx new file mode 100644 index 0000000000..9752953ac1 --- /dev/null +++ b/frontend/console/src/features/traces/TraceGraphHeader.tsx @@ -0,0 +1,39 @@ +import { Activity03Icon } from 'hugeicons-react' +import { useNavigate } from 'react-router-dom' +import { type TraceEvent, useRequestTraceEvents } from '../../api/timeline/use-request-trace-events' + +export const TraceGraphHeader = ({ requestKey, eventId }: { requestKey?: string; eventId: bigint }) => { + const navigate = useNavigate() + const requestEvents = useRequestTraceEvents(requestKey) + const events = requestEvents.data?.reverse() ?? [] + + if (events.length === 0) { + return null + } + + const firstTimeStamp = events[0].timeStamp + const traceEvent = events[0].entry.value as TraceEvent + const firstDuration = traceEvent.duration + if (firstTimeStamp === undefined || firstDuration === undefined) { + return null + } + + const totalDurationMillis = (firstDuration.nanos ?? 0) / 1000000 + + return ( +
+ + Total {totalDurationMillis}ms + + + +
+ ) +} diff --git a/frontend/console/src/features/traces/TracesPage.tsx b/frontend/console/src/features/traces/TracesPage.tsx new file mode 100644 index 0000000000..b22a54cc2c --- /dev/null +++ b/frontend/console/src/features/traces/TracesPage.tsx @@ -0,0 +1,75 @@ +import { ArrowLeft02Icon } from 'hugeicons-react' +import { useNavigate, useParams, useSearchParams } from 'react-router-dom' +import { useRequestTraceEvents } from '../../api/timeline/use-request-trace-events' +import { Loader } from '../../components/Loader' +import { TraceDetails } from './TraceDetails' +import { TraceDetailsCall } from './details/TraceDetailsCall' +import { TraceDetailsIngress } from './details/TraceDetailsIngress' + +export const TracesPage = () => { + const navigate = useNavigate() + + const { requestKey } = useParams<{ requestKey: string }>() + const requestEvents = useRequestTraceEvents(requestKey) + const events = requestEvents.data?.reverse() ?? [] + + const [searchParams] = useSearchParams() + const eventIdParam = searchParams.get('event_id') + const selectedEventId = eventIdParam ? BigInt(eventIdParam) : undefined + + if (events.length === 0) { + return + } + + if (requestKey === undefined) { + return + } + + const handleBack = () => { + if (window.history.length > 1) { + navigate(-1) + } else { + navigate('/modules') + } + } + + if (requestEvents.isLoading) { + return ( +
+ +
+ ) + } + + const selectedEvent = events.find((event) => event.id === selectedEventId) + let eventDetailsComponent: React.ReactNode + switch (selectedEvent?.entry.case) { + case 'call': + eventDetailsComponent = + break + case 'ingress': + eventDetailsComponent = + break + default: + eventDetailsComponent =

No details available for this event type.

+ break + } + + return ( +
+
+
+ + Trace Details +
+ +
+ +
+ +
{eventDetailsComponent}
+
+ ) +} diff --git a/frontend/console/src/features/traces/details/TraceDetailsCall.tsx b/frontend/console/src/features/traces/details/TraceDetailsCall.tsx new file mode 100644 index 0000000000..91600650b5 --- /dev/null +++ b/frontend/console/src/features/traces/details/TraceDetailsCall.tsx @@ -0,0 +1,60 @@ +import { AttributeBadge } from '../../../components/AttributeBadge' +import { CodeBlock } from '../../../components/CodeBlock' +import type { CallEvent, Event } from '../../../protos/xyz/block/ftl/v1/console/console_pb' +import { formatDuration } from '../../../utils/date.utils' +import { DeploymentCard } from '../../deployments/DeploymentCard' +import { verbRefString } from '../../verbs/verb.utils' + +export const TraceDetailsCall = ({ event }: { event: Event }) => { + const call = event.entry.value as CallEvent + return ( + <> + Call Details +
Request
+ + + {call.response !== 'null' && ( + <> +
Response
+ + + )} + + {call.error && ( + <> +

Error

+ + {call.stack && ( + <> +

Stack

+ + + )} + + )} + + + +
    + {call.requestKey && ( +
  • + +
  • + )} +
  • + +
  • + {call.destinationVerbRef && ( +
  • + +
  • + )} + {call.sourceVerbRef && ( +
  • + +
  • + )} +
+ + ) +} diff --git a/frontend/console/src/features/traces/details/TraceDetailsIngress.tsx b/frontend/console/src/features/traces/details/TraceDetailsIngress.tsx new file mode 100644 index 0000000000..2d70bc6f3b --- /dev/null +++ b/frontend/console/src/features/traces/details/TraceDetailsIngress.tsx @@ -0,0 +1,72 @@ +import { AttributeBadge } from '../../../components/AttributeBadge' +import { CodeBlock } from '../../../components/CodeBlock' +import type { Event, IngressEvent } from '../../../protos/xyz/block/ftl/v1/console/console_pb' +import { formatDuration } from '../../../utils/date.utils' +import { DeploymentCard } from '../../deployments/DeploymentCard' +import { verbRefString } from '../../verbs/verb.utils' + +export const TraceDetailsIngress = ({ event }: { event: Event }) => { + const ingress = event.entry.value as IngressEvent + return ( + <> + Call Details +
Request
+ + + {ingress.response !== 'null' && ( + <> +
Response
+ + + )} + + {ingress.requestHeader !== 'null' && ( + <> +
Request Header
+ + + )} + + {ingress.responseHeader !== 'null' && ( + <> +
Response Header
+ + + )} + + {ingress.error && ( + <> +

Error

+ + + )} + + + +
    +
  • + +
  • +
  • + +
  • +
  • + +
  • + {ingress.requestKey && ( +
  • + +
  • + )} +
  • + +
  • + {ingress.verbRef && ( +
  • + +
  • + )} +
+ + ) +} diff --git a/frontend/console/src/providers/routing-provider.tsx b/frontend/console/src/providers/routing-provider.tsx index 9e1025652d..7e6494e6a5 100644 --- a/frontend/console/src/providers/routing-provider.tsx +++ b/frontend/console/src/providers/routing-provider.tsx @@ -5,6 +5,7 @@ import { ModulePanel } from '../features/modules/ModulePanel' import { ModulesPage, ModulesPanel } from '../features/modules/ModulesPage' import { DeclPanel } from '../features/modules/decls/DeclPanel' import { TimelinePage } from '../features/timeline/TimelinePage' +import { TracesPage } from '../features/traces/TracesPage' import { Layout } from '../layout/Layout' import { NotFoundPage } from '../layout/NotFoundPage' @@ -20,6 +21,7 @@ const router = createBrowserRouter( } /> } /> } /> + } /> } />