diff --git a/backend/controller/console/console.go b/backend/controller/console/console.go index e381dfa8f5..f31f766032 100644 --- a/backend/controller/console/console.go +++ b/backend/controller/console/console.go @@ -820,6 +820,7 @@ func eventDALToProto(event timeline.Event) *pbconsole.Event { AsyncExecute: &pbconsole.AsyncExecuteEvent{ DeploymentKey: event.DeploymentKey.String(), RequestKey: requestKey, + TimeStamp: timestamppb.New(event.Time), AsyncEventType: asyncEventType, VerbRef: &schemapb.Ref{ Module: event.Verb.Module, diff --git a/backend/controller/timeline/internal/timeline_test.go b/backend/controller/timeline/internal/timeline_test.go index eee7b3a72b..1621046f61 100644 --- a/backend/controller/timeline/internal/timeline_test.go +++ b/backend/controller/timeline/internal/timeline_test.go @@ -112,9 +112,9 @@ func TestTimeline(t *testing.T) { ingressEvent := &timeline2.IngressEvent{ DeploymentKey: deploymentKey, RequestKey: optional.Some(requestKey), - Verb: schema.Ref{Module: "echo", Name: "echo"}, + Verb: schema.Ref{Module: "time", Name: "time"}, Method: "GET", - Path: "/echo", + Path: "/time", StatusCode: 200, Time: time.Now().Round(time.Millisecond), Request: []byte(`{"request":"body"}`), @@ -264,13 +264,20 @@ func TestTimeline(t *testing.T) { }) t.Run("ByModule", func(t *testing.T) { - events, err := timeline.QueryTimeline(ctx, 1000, timeline2.FilterTypes(timeline2.EventTypeIngress), timeline2.FilterModule("echo", optional.None[string]())) + eventTypes := []timeline2.EventType{ + timeline2.EventTypeCall, + timeline2.EventTypeIngress, + timeline2.EventTypeAsyncExecute, + timeline2.EventTypePubSubPublish, + timeline2.EventTypePubSubConsume, + } + events, err := timeline.QueryTimeline(ctx, 1000, timeline2.FilterTypes(eventTypes...), timeline2.FilterModule("time", optional.None[string]())) assert.NoError(t, err) - assertEventsEqual(t, []timeline2.Event{ingressEvent}, events) + assertEventsEqual(t, []timeline2.Event{callEvent, ingressEvent, asyncEvent, pubSubPublishEvent, pubSubConsumeEvent}, events) }) t.Run("ByModuleWithVerb", func(t *testing.T) { - events, err := timeline.QueryTimeline(ctx, 1000, timeline2.FilterTypes(timeline2.EventTypeIngress), timeline2.FilterModule("echo", optional.Some("echo"))) + events, err := timeline.QueryTimeline(ctx, 1000, timeline2.FilterTypes(timeline2.EventTypeIngress), timeline2.FilterModule("time", optional.Some("time"))) assert.NoError(t, err) assertEventsEqual(t, []timeline2.Event{ingressEvent}, events) }) diff --git a/backend/controller/timeline/query.go b/backend/controller/timeline/query.go index e18ca216bd..d24a8afef1 100644 --- a/backend/controller/timeline/query.go +++ b/backend/controller/timeline/query.go @@ -224,11 +224,28 @@ func (s *Service) QueryTimeline(ctx context.Context, limit int, filters ...Timel q += " OR " } if verb, ok := module.verb.Get(); ok { - q += fmt.Sprintf("((e.type = 'call' AND e.custom_key_3 = $%d AND e.custom_key_4 = $%d) OR (e.type = 'ingress' AND e.custom_key_1 = $%d AND e.custom_key_2 = $%d))", - param(module.module), param(verb), param(module.module), param(verb)) + q += fmt.Sprintf( + "((e.type = 'call' AND e.custom_key_3 = $%d AND e.custom_key_4 = $%d) OR "+ + "(e.type = 'ingress' AND e.custom_key_1 = $%d AND e.custom_key_2 = $%d) OR "+ + "(e.type = 'async_execute' AND e.custom_key_1 = $%d AND e.custom_key_2 = $%d) OR "+ + "(e.type = 'pubsub_publish' AND e.custom_key_1 = $%d AND e.custom_key_2 = $%d) OR "+ + "(e.type = 'pubsub_consume' AND e.custom_key_1 = $%d AND e.custom_key_2 = $%d))", + param(module.module), param(verb), + param(module.module), param(verb), + param(module.module), param(verb), + param(module.module), param(verb), + param(module.module), param(verb), + ) } else { - q += fmt.Sprintf("((e.type = 'call' AND e.custom_key_3 = $%d) OR (e.type = 'ingress' AND e.custom_key_1 = $%d))", - param(module.module), param(module.module)) + q += fmt.Sprintf( + "((e.type = 'call' AND e.custom_key_3 = $%d) OR "+ + "(e.type = 'ingress' AND e.custom_key_1 = $%d) OR "+ + "(e.type = 'async_execute' AND e.custom_key_1 = $%d) OR "+ + "(e.type = 'pubsub_publish' AND e.custom_key_1 = $%d) OR "+ + "(e.type = 'pubsub_consume' AND e.custom_key_1 = $%d))", + param(module.module), param(module.module), param(module.module), + param(module.module), param(module.module), + ) } } q += ")\n" @@ -454,7 +471,7 @@ func (s *Service) transformRowsToTimelineEvents(deploymentKeys map[int64]model.D } out = append(out, &PubSubConsumeEvent{ ID: row.ID, - Duration: time.Since(row.TimeStamp), + Duration: time.Duration(jsonPayload.DurationMS) * time.Millisecond, PubSubConsume: PubSubConsume{ DeploymentKey: row.DeploymentKey, RequestKey: requestKey, diff --git a/frontend/console/src/api/timeline/use-request-trace-events.ts b/frontend/console/src/api/timeline/use-request-trace-events.ts index 885c38da7c..31ebcf1b86 100644 --- a/frontend/console/src/api/timeline/use-request-trace-events.ts +++ b/frontend/console/src/api/timeline/use-request-trace-events.ts @@ -1,15 +1,33 @@ -import { type CallEvent, EventType, type EventsQuery_Filter, type IngressEvent } from '../../protos/xyz/block/ftl/v1/console/console_pb.ts' +import { + type AsyncExecuteEvent, + type CallEvent, + EventType, + type EventsQuery_Filter, + type IngressEvent, + type PubSubConsumeEvent, + type PubSubPublishEvent, +} from '../../protos/xyz/block/ftl/v1/console/console_pb.ts' import { eventTypesFilter, requestKeysFilter } from './timeline-filters.ts' import { useTimeline } from './use-timeline.ts' -export type TraceEvent = CallEvent | IngressEvent +export type TraceEvent = CallEvent | IngressEvent | AsyncExecuteEvent | PubSubPublishEvent | PubSubConsumeEvent export const useRequestTraceEvents = (requestKey?: string, filters: EventsQuery_Filter[] = []) => { - const eventTypes = [EventType.CALL, EventType.INGRESS] + const eventTypes = [EventType.CALL, EventType.ASYNC_EXECUTE, EventType.INGRESS, EventType.PUBSUB_CONSUME, EventType.PUBSUB_PUBLISH] + const allFilters = [...filters, requestKeysFilter([requestKey || '']), eventTypesFilter(eventTypes)] const timelineQuery = useTimeline(true, allFilters, 500, !!requestKey) - const data = timelineQuery.data?.filter((event) => event.entry.case === 'call' || event.entry.case === 'ingress') ?? [] + const data = + timelineQuery.data?.filter( + (event) => + event.entry.case === 'call' || + event.entry.case === 'ingress' || + event.entry.case === 'asyncExecute' || + event.entry.case === 'pubsubPublish' || + event.entry.case === 'pubsubConsume', + ) ?? [] + return { ...timelineQuery, data, diff --git a/frontend/console/src/features/modules/ModulesTree.tsx b/frontend/console/src/features/modules/ModulesTree.tsx index 454801e391..cbde67e4d2 100644 --- a/frontend/console/src/features/modules/ModulesTree.tsx +++ b/frontend/console/src/features/modules/ModulesTree.tsx @@ -1,4 +1,4 @@ -import { ArrowRight01Icon, ArrowShrink02Icon, CircleArrowRight02Icon, PackageIcon, Upload01Icon } from 'hugeicons-react' +import { ArrowRight01Icon, ArrowShrink02Icon, CircleArrowRight02Icon, CodeFolderIcon, Upload01Icon } from 'hugeicons-react' import { useEffect, useMemo, useRef, useState } from 'react' import { Link, useParams, useSearchParams } from 'react-router-dom' import { Multiselect, sortMultiselectOpts } from '../../components/Multiselect' @@ -99,7 +99,7 @@ const ModuleSection = ({ onClick={() => toggleExpansion(module.name)} > - {module.name} e.stopPropagation()}> diff --git a/frontend/console/src/features/modules/module.utils.ts b/frontend/console/src/features/modules/module.utils.ts index 6d5d056aa7..7271a9489b 100644 --- a/frontend/console/src/features/modules/module.utils.ts +++ b/frontend/console/src/features/modules/module.utils.ts @@ -3,6 +3,7 @@ import { BubbleChatIcon, Clock01Icon, CodeIcon, + CodeSquareIcon, DatabaseIcon, FunctionIcon, type HugeiconsProps, @@ -186,7 +187,7 @@ export const declTypeName = (declCase: string, decl: DeclSumType) => { const declIcons: Record & React.RefAttributes>> = { config: Settings02Icon, - data: CodeIcon, + data: CodeSquareIcon, database: DatabaseIcon, enum: LeftToRightListNumberIcon, topic: BubbleChatIcon, diff --git a/frontend/console/src/features/timeline/details/TimelineAsyncExecuteDetails.tsx b/frontend/console/src/features/timeline/details/TimelineAsyncExecuteDetails.tsx index c4d0a05421..ef232445e2 100644 --- a/frontend/console/src/features/timeline/details/TimelineAsyncExecuteDetails.tsx +++ b/frontend/console/src/features/timeline/details/TimelineAsyncExecuteDetails.tsx @@ -3,6 +3,8 @@ import { CodeBlock } from '../../../components/CodeBlock' import type { AsyncExecuteEvent, Event } from '../../../protos/xyz/block/ftl/v1/console/console_pb' import { formatDuration } from '../../../utils/date.utils' import { DeploymentCard } from '../../deployments/DeploymentCard' +import { TraceGraph } from '../../traces/TraceGraph' +import { TraceGraphHeader } from '../../traces/TraceGraphHeader' import { refString } from '../../verbs/verb.utils' import { asyncEventTypeString } from '../timeline.utils' @@ -12,16 +14,24 @@ export const TimelineAsyncExecuteDetails = ({ event }: { event: Event }) => { return ( <>
+
+ + +
+ {asyncEvent.error && ( <>

Error

)} - -
    + {asyncEvent.requestKey && ( +
  • + +
  • + )}
  • diff --git a/frontend/console/src/features/timeline/details/TimelinePubSubConsumeDetails.tsx b/frontend/console/src/features/timeline/details/TimelinePubSubConsumeDetails.tsx index dccdc95108..27c6aab458 100644 --- a/frontend/console/src/features/timeline/details/TimelinePubSubConsumeDetails.tsx +++ b/frontend/console/src/features/timeline/details/TimelinePubSubConsumeDetails.tsx @@ -18,6 +18,12 @@ export const TimelinePubSubConsumeDetails = ({ event }: { event: Event }) => {
      + {pubSubConsume.requestKey && ( +
    • + +
    • + )} +
    • diff --git a/frontend/console/src/features/timeline/timeline.utils.ts b/frontend/console/src/features/timeline/timeline.utils.ts index e7e62bd0aa..d3838e6933 100644 --- a/frontend/console/src/features/timeline/timeline.utils.ts +++ b/frontend/console/src/features/timeline/timeline.utils.ts @@ -59,5 +59,8 @@ const isError = (event: Event) => { if (event.entry.case === 'ingress' && event.entry.value.error) { return true } + if (event.entry.case === 'asyncExecute' && event.entry.value.error) { + return true + } return false } diff --git a/frontend/console/src/features/traces/TraceDetailItem.tsx b/frontend/console/src/features/traces/TraceDetailItem.tsx index 6bdddf8d9a..a2ba833a01 100644 --- a/frontend/console/src/features/traces/TraceDetailItem.tsx +++ b/frontend/console/src/features/traces/TraceDetailItem.tsx @@ -1,6 +1,5 @@ -import type { Timestamp } from '@bufbuild/protobuf' 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 { AsyncExecuteEvent, CallEvent, type Event, IngressEvent, PubSubPublishEvent } from '../../protos/xyz/block/ftl/v1/console/console_pb' import { classNames } from '../../utils' import { TimelineIcon } from '../timeline/TimelineIcon' import { eventBackgroundColor } from '../timeline/timeline.utils' @@ -11,7 +10,7 @@ interface TraceDetailItemProps { traceEvent: TraceEvent eventDurationMs: number requestDurationMs: number - requestStartTime: Timestamp | undefined + requestStartTime: number selectedEventId: bigint | undefined handleEventClick: (eventId: bigint) => void } @@ -40,6 +39,12 @@ export const TraceDetailItem: React.FC = ({ } else if (traceEvent instanceof IngressEvent) { action = `HTTP ${traceEvent.method}` eventName = `${traceEvent.path}` + } else if (traceEvent instanceof AsyncExecuteEvent) { + action = 'Async' + eventName = `${traceEvent.verbRef?.module}.${traceEvent.verbRef?.name}` + } else if (traceEvent instanceof PubSubPublishEvent) { + action = 'Publish' + eventName = `${traceEvent.topic}` } const barColor = event.id === selectedEventId ? 'bg-green-500' : eventBackgroundColor(event) diff --git a/frontend/console/src/features/traces/TraceDetails.tsx b/frontend/console/src/features/traces/TraceDetails.tsx index 1c091f8d46..968c29d7f8 100644 --- a/frontend/console/src/features/traces/TraceDetails.tsx +++ b/frontend/console/src/features/traces/TraceDetails.tsx @@ -2,6 +2,7 @@ import type React from 'react' import { useNavigate } from 'react-router-dom' import type { TraceEvent } from '../../api/timeline/use-request-trace-events' import type { Event } from '../../protos/xyz/block/ftl/v1/console/console_pb' +import { durationToMillis } from '../../utils' import { TraceDetailItem } from './TraceDetailItem' interface TraceDetailsProps { @@ -13,9 +14,15 @@ interface TraceDetailsProps { export const TraceDetails: React.FC = ({ events, selectedEventId, requestKey }) => { const navigate = useNavigate() - const requestStartTime = events[0]?.timeStamp - const firstEvent = events[0].entry.value as TraceEvent - const requestDurationMs = (firstEvent?.duration?.nanos ?? 0) / 1000000 + const traceEvents = events.map((event) => event.entry.value as TraceEvent) + const requestStartTime = Math.min(...traceEvents.map((event) => event.timeStamp?.toDate().getTime() ?? 0)) + const requestEndTime = Math.max( + ...traceEvents.map((event) => { + const eventDuration = event.duration ? durationToMillis(event.duration) : 0 + return (event.timeStamp?.toDate().getTime() ?? 0) + eventDuration + }), + ) + const totalEventDuration = requestEndTime - requestStartTime const handleEventClick = (eventId: bigint) => { navigate(`/traces/${requestKey}?event_id=${eventId}`) @@ -25,10 +32,10 @@ export const TraceDetails: React.FC = ({ events, selectedEven

      - Total Duration: {requestDurationMs} ms + Total Duration: {totalEventDuration} ms

      - Start Time: {requestStartTime?.toDate().toLocaleString()} + Start Time: {new Date(requestStartTime).toLocaleString()}

      @@ -43,7 +50,7 @@ export const TraceDetails: React.FC = ({ events, selectedEven event={event} traceEvent={traceEvent} eventDurationMs={eventDurationMs} - requestDurationMs={requestDurationMs} + requestDurationMs={totalEventDuration} requestStartTime={requestStartTime} selectedEventId={selectedEventId} handleEventClick={handleEventClick} diff --git a/frontend/console/src/features/traces/TraceGraph.tsx b/frontend/console/src/features/traces/TraceGraph.tsx index bd4bd4f24c..8b61f40c5f 100644 --- a/frontend/console/src/features/traces/TraceGraph.tsx +++ b/frontend/console/src/features/traces/TraceGraph.tsx @@ -1,8 +1,14 @@ -import type { Duration, Timestamp } from '@bufbuild/protobuf' import { useState } from 'react' import { type TraceEvent, useRequestTraceEvents } from '../../api/timeline/use-request-trace-events' -import { CallEvent, type Event, IngressEvent } from '../../protos/xyz/block/ftl/v1/console/console_pb' -import { classNames } from '../../utils' +import { + AsyncExecuteEvent, + CallEvent, + type Event, + IngressEvent, + PubSubConsumeEvent, + PubSubPublishEvent, +} from '../../protos/xyz/block/ftl/v1/console/console_pb' +import { classNames, durationToMillis } from '../../utils' import { eventBackgroundColor } from '../timeline/timeline.utils' import { eventBarLeftOffsetPercentage } from './traces.utils' @@ -14,27 +20,38 @@ const EventBlock = ({ }: { event: Event isSelected: boolean - requestStartTime: Timestamp - requestDuration: Duration + requestStartTime: number + requestDuration: number }) => { const [isHovering, setIsHovering] = useState(false) const traceEvent = event.entry.value as TraceEvent - const totalDurationMillis = (requestDuration.nanos ?? 0) / 1000000 - const durationInMillis = (traceEvent.duration?.nanos ?? 0) / 1000000 - let width = (durationInMillis / totalDurationMillis) * 100 + const durationInMillis = traceEvent.duration ? durationToMillis(traceEvent.duration) : 0 + let width = (durationInMillis / requestDuration) * 100 if (width < 1) { width = 1 } - const leftOffsetPercentage = eventBarLeftOffsetPercentage(event, requestStartTime, totalDurationMillis) + const leftOffsetPercentage = eventBarLeftOffsetPercentage(event, requestStartTime, requestDuration) + let eventType = '' let eventTarget = '' if (traceEvent instanceof CallEvent) { + eventType = 'call' eventTarget = `${traceEvent.destinationVerbRef?.module}.${traceEvent.destinationVerbRef?.name}` } else if (traceEvent instanceof IngressEvent) { + eventType = 'ingress' eventTarget = traceEvent.path + } else if (traceEvent instanceof AsyncExecuteEvent) { + eventType = 'async' + eventTarget = `${traceEvent.verbRef?.module}.${traceEvent.verbRef?.name}` + } else if (traceEvent instanceof PubSubPublishEvent) { + eventType = 'publish' + eventTarget = traceEvent.topic + } else if (traceEvent instanceof PubSubConsumeEvent) { + eventType = 'consume' + eventTarget = traceEvent.topic } const barColor = isSelected ? 'bg-green-500' : eventBackgroundColor(event) @@ -52,8 +69,7 @@ const EventBlock = ({ {isHovering && (

      - {event instanceof CallEvent ? 'Call ' : 'Ingress '} - {eventTarget} + {eventType} {eventTarget} {` (${durationInMillis} ms)`}

      @@ -71,19 +87,22 @@ export const TraceGraph = ({ requestKey, selectedEventId }: { requestKey?: strin return } - const requestStartTime = events[0].timeStamp - const traceEvent = events[0].entry.value as TraceEvent - const firstEventDuration = traceEvent.duration - if (requestStartTime === undefined || firstEventDuration === undefined) { - return - } + const traceEvents = events.map((event) => event.entry.value as TraceEvent) + const requestStartTime = Math.min(...traceEvents.map((event) => event.timeStamp?.toDate().getTime() ?? 0)) + const requestEndTime = Math.max( + ...traceEvents.map((event) => { + const eventDuration = event.duration ? durationToMillis(event.duration) : 0 + return (event.timeStamp?.toDate().getTime() ?? 0) + eventDuration + }), + ) + const totalEventDuration = requestEndTime - requestStartTime return (
      {events.map((c, index) => (
      - +
      ))} diff --git a/frontend/console/src/features/traces/TraceGraphHeader.tsx b/frontend/console/src/features/traces/TraceGraphHeader.tsx index 362dd21157..e482aaf99f 100644 --- a/frontend/console/src/features/traces/TraceGraphHeader.tsx +++ b/frontend/console/src/features/traces/TraceGraphHeader.tsx @@ -1,6 +1,7 @@ import { Activity03Icon } from 'hugeicons-react' import { useNavigate } from 'react-router-dom' import { type TraceEvent, useRequestTraceEvents } from '../../api/timeline/use-request-trace-events' +import { durationToMillis } from '../../utils' export const TraceGraphHeader = ({ requestKey, eventId }: { requestKey?: string; eventId: bigint }) => { const navigate = useNavigate() @@ -11,19 +12,20 @@ export const TraceGraphHeader = ({ requestKey, eventId }: { requestKey?: string; 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 + const traceEvents = events.map((event) => event.entry.value as TraceEvent) + const requestStartTime = Math.min(...traceEvents.map((event) => event.timeStamp?.toDate().getTime() ?? 0)) + const requestEndTime = Math.max( + ...traceEvents.map((event) => { + const eventDuration = event.duration ? durationToMillis(event.duration) : 0 + return (event.timeStamp?.toDate().getTime() ?? 0) + eventDuration + }), + ) + const totalEventDuration = requestEndTime - requestStartTime return (
      - Total {totalDurationMillis}ms + Total {totalEventDuration}ms