Home charts execution time per node type on a log scale, from a /observability/node-types rollup

This commit is contained in:
2026-10-15 16:57:07 +02:00
parent fa8b178866
commit 56fa34c2c9
9 changed files with 360 additions and 3 deletions
@@ -86,6 +86,17 @@ class FlowRollup(BaseModel):
last_error_ts: float | None = None
class NodeTypeTiming(BaseModel):
"""How long one kind of node takes to run, across every node of it."""
type: str
#: How many published nodes of this type ran inside the window.
nodes: int
executions: int
avg_ms: float
max_ms: float
class RunRow(BaseModel):
id: str
flow: str
@@ -428,6 +439,58 @@ def read_flow_rollups(
]
@router.get("/node-types", response_model=list[NodeTypeTiming])
def read_node_type_timings(
session: SessionDep,
controller: FlowControllerDep,
hours: Annotated[int, Query(ge=0, le=8760)] = 24,
) -> Any:
"""Execution time per node type, slowest first, to point at the hotspots.
The rollups are kept per node, so the type is joined on from the published
flows. A node deleted since it ran has no type left to count under.
"""
hours = _window_hours(hours)
types = {
f"{flow.name}.{node.id}": node.type
for flow in controller.store.read_all()
for node in flow.nodes
}
statement = (
sa_select(
col(MetricBucket.node),
func.sum(col(MetricBucket.executions)).label("executions"),
func.sum(col(MetricBucket.duration_sum_ms)).label("duration_sum_ms"),
func.max(col(MetricBucket.duration_max_ms)).label("max_ms"),
)
.where(col(MetricBucket.bucket) >= _since(hours))
.group_by(col(MetricBucket.node))
)
totals: dict[str, dict[str, float]] = {}
for row in session.execute(statement):
kind = types.get(row.node)
if kind is None or not row.executions:
continue
entry = totals.setdefault(
kind, {"nodes": 0, "executions": 0, "duration_sum_ms": 0.0, "max_ms": 0.0}
)
entry["nodes"] += 1
entry["executions"] += row.executions
entry["duration_sum_ms"] += row.duration_sum_ms
entry["max_ms"] = max(entry["max_ms"], row.max_ms)
timings = [
NodeTypeTiming(
type=kind,
nodes=int(entry["nodes"]),
executions=int(entry["executions"]),
avg_ms=round(entry["duration_sum_ms"] / entry["executions"], 3),
max_ms=round(entry["max_ms"], 3),
)
for kind, entry in totals.items()
]
return sorted(timings, key=lambda timing: timing.avg_ms, reverse=True)
@router.get("/runs", response_model=RunPage)
def read_runs(
session: SessionDep,
@@ -512,3 +512,59 @@ def test_a_superuser_can_flush_the_collector_now(
)
).all()
assert len(written) == 1
def test_execution_time_is_summed_per_node_type(
client: TestClient, superuser_token_headers: dict[str, str], db: Session
) -> None:
from fluksio.flow.schemas import FlowDef, NodeDef
flow = "node_type_test"
controller = client.app.state.flow_controller
before = controller.store.read_all
controller.store.read_all = lambda: [
FlowDef(
name=flow,
nodes=[
NodeDef(id="a", type="python"),
NodeDef(id="b", type="python"),
NodeDef(id="tick", type="inject"),
],
)
]
now = datetime.now(UTC).replace(second=0, microsecond=0)
for node, executions, total, peak in (
("a", 2, 30.0, 20.0),
("b", 2, 10.0, 6.0),
("tick", 4, 0.4, 0.2),
# Deleted since it ran: nothing to count it under.
("gone", 1, 500.0, 500.0),
):
db.add(
MetricBucket(
flow=flow,
node=f"{flow}.{node}",
bucket=now - timedelta(minutes=2),
executions=executions,
duration_sum_ms=total,
duration_max_ms=peak,
)
)
db.commit()
try:
rows = client.get(
f"{PREFIX}/node-types", headers=superuser_token_headers
).json()
finally:
controller.store.read_all = before
# Slowest first.
assert [row["type"] for row in rows] == ["python", "inject"]
assert rows[0] == {
"type": "python",
"nodes": 2,
"executions": 4,
"avg_ms": 10.0,
"max_ms": 20.0,
}
assert rows[1]["avg_ms"] == 0.1
+6
View File
@@ -58,6 +58,12 @@ Health shows flow status, node load errors, queue depth and event-loop lag.
After you have used batch runs, it also shows running and queued runs.
A degraded instance lists the problems that need attention.
### Execution time per node type
One bar per node type at its average time per run, slowest first, on a log
scale. Each row also shows how often the type ran and its slowest single run,
so a slow or busy kind of node stands out.
### Activity
Choose 1h, 6h, 24h or 7d to review executions and failures, recent runs,
+29
View File
@@ -2657,6 +2657,35 @@ export const NodeTypeInfoSchema = {
description: 'A node type the editor can offer, with its parameter schema.'
} as const;
export const NodeTypeTimingSchema = {
properties: {
type: {
type: 'string',
title: 'Type'
},
nodes: {
type: 'integer',
title: 'Nodes'
},
executions: {
type: 'integer',
title: 'Executions'
},
avg_ms: {
type: 'number',
title: 'Avg Ms'
},
max_ms: {
type: 'number',
title: 'Max Ms'
}
},
type: 'object',
required: ['type', 'nodes', 'executions', 'avg_ms', 'max_ms'],
title: 'NodeTypeTiming',
description: 'How long one kind of node takes to run, across every node of it.'
} as const;
export const OAuthAuthorizeInfoSchema = {
properties: {
client_name: {
File diff suppressed because one or more lines are too long
+17
View File
@@ -919,6 +919,17 @@ export type NodeTypeInfo = {
plugin?: (string | null);
};
/**
* How long one kind of node takes to run, across every node of it.
*/
export type NodeTypeTiming = {
type: string;
nodes: number;
executions: number;
avg_ms: number;
max_ms: number;
};
/**
* What the consent page shows, all of it validated server-side.
*/
@@ -2025,6 +2036,12 @@ export type ObservabilityReadFlowRollupsData = {
export type ObservabilityReadFlowRollupsResponse = (Array<FlowRollup>);
export type ObservabilityReadNodeTypeTimingsData = {
hours?: number;
};
export type ObservabilityReadNodeTypeTimingsResponse = (Array<NodeTypeTiming>);
export type ObservabilityReadRunsData = {
flow?: (string | null);
limit?: number;
@@ -0,0 +1,153 @@
import { useQuery } from "@tanstack/react-query"
import type { NodeTypeTiming } from "@/client"
import type { Range } from "@/components/Common/RangePicker"
import { nodeTypesQueryOptions } from "@/components/Flow/queries"
import { PANEL_SECTION } from "@/components/Flow/SidePanel"
import { Skeleton } from "@/components/ui/skeleton"
import { dur, si } from "@/lib/utils"
import { CARD, nodeTypeTimingsQueryOptions } from "./queries"
/**
* One row's columns: the type, its bar, its average. A phone puts the bar on a
* line of its own under the other two, where it has the card's whole width.
*/
const COLUMNS =
"grid grid-cols-[minmax(0,1fr)_auto] gap-x-3 sm:grid-cols-[12rem_minmax(0,1fr)_5rem]"
/** The fastest the axis reaches down to, in ms: a microsecond. */
const FLOOR = 0.001
/**
* The axis, in whole decades of milliseconds: from below the fastest type to
* above the slowest, two decades at least so a bar has room to differ.
*/
function decades(timings: NodeTypeTiming[]): number[] {
const measured = timings.map((t) => t.avg_ms).filter((ms) => ms >= FLOOR)
if (!measured.length) return []
const low = Math.floor(Math.log10(Math.min(...measured)))
const high = Math.max(Math.ceil(Math.log10(Math.max(...measured))), low + 2)
return Array.from({ length: high - low + 1 }, (_, i) => 10 ** (low + i))
}
/** Where a duration sits along the axis, as a percentage. */
function at(ms: number, axis: number[]): number {
const low = Math.log10(axis[0])
const high = Math.log10(axis[axis.length - 1])
return ((Math.log10(ms) - low) / (high - low)) * 100
}
/**
* How long each kind of node takes per run, on a log scale.
*
* One bar per node type at its average, slowest first, so a hotspot is the top
* row. The scale is logarithmic because node types sit decades apart, a timer
* at microseconds and a model at seconds, and a linear one shows only the
* slowest. A type that ran without a measurable duration has no bar.
*/
export function NodeTypeTimes({ range }: { range: Range }) {
const { data, isPending } = useQuery(nodeTypeTimingsQueryOptions(range))
const { data: types } = useQuery(nodeTypesQueryOptions())
const titles = new Map((types ?? []).map((info) => [info.type, info.title]))
const timings = data ?? []
const axis = decades(timings)
return (
<section className="grid gap-3" data-testid="node-type-times">
<div className="flex flex-wrap items-baseline gap-x-3">
<h2 className={PANEL_SECTION}>Execution time per node type</h2>
<p className="text-muted-foreground text-xs">
average per run over the last {range.label}, log scale
</p>
</div>
<div className={CARD}>
{isPending ? (
<div className="grid gap-3">
<Skeleton className="h-5 w-2/3" />
<Skeleton className="h-5 w-1/2" />
</div>
) : timings.length === 0 ? (
<p className="text-muted-foreground text-sm">Nothing has run yet.</p>
) : (
<div className="grid">
{timings.map((timing) => (
<Row
key={timing.type}
timing={timing}
title={titles.get(timing.type) ?? timing.type}
axis={axis}
/>
))}
{axis.length ? (
<div className={COLUMNS}>
<div className="relative col-span-2 mt-1 h-4 text-muted-foreground text-xs sm:col-span-1 sm:col-start-2">
{axis.map((ms, index) => (
<span
key={ms}
className="absolute top-0 whitespace-nowrap"
style={{
left: `${at(ms, axis)}%`,
// The ends stay inside the track; the rest centre on it.
transform:
index === 0
? undefined
: index === axis.length - 1
? "translateX(-100%)"
: "translateX(-50%)",
}}
>
{dur(ms)}
</span>
))}
</div>
</div>
) : null}
</div>
)}
</div>
</section>
)
}
function Row({
timing,
title,
axis,
}: {
timing: NodeTypeTiming
title: string
axis: number[]
}) {
const width =
timing.avg_ms >= FLOOR && axis.length ? at(timing.avg_ms, axis) : 0
return (
<div className={COLUMNS}>
<div className="col-start-1 row-start-1 min-w-0 pt-1.5 sm:py-1.5">
<p className="truncate text-sm">{title}</p>
<p className="truncate text-muted-foreground text-xs">
{si(timing.executions)} runs · peak {dur(timing.max_ms)}
</p>
</div>
{/* The decade lines run through every row, so together they read as
the grid; the bar starts at the axis's own floor. */}
<div className="relative col-span-2 row-start-2 h-4 sm:col-span-1 sm:col-start-2 sm:row-start-1 sm:h-full">
{axis.map((ms) => (
<span
key={ms}
className="absolute inset-y-0 w-px bg-border"
style={{ left: `${at(ms, axis)}%` }}
/>
))}
<span
className="absolute top-1/2 left-0 h-2.5 -translate-y-1/2 rounded-e-[4px] bg-chart-1"
style={{ width: `${width}%` }}
/>
</div>
<span className="col-start-2 row-start-1 self-center whitespace-nowrap text-right font-mono text-sm sm:col-start-3">
{dur(timing.avg_ms)}
</span>
</div>
)
}
@@ -147,3 +147,10 @@ export const clock = (ts: number) =>
hour: "2-digit",
minute: "2-digit",
})
export const nodeTypeTimingsQueryOptions = (range: Range) => ({
queryKey: ["observability", "node-types", range.hours] as const,
queryFn: () =>
ObservabilityService.readNodeTypeTimings({ hours: range.hours }),
refetchInterval: REFRESH,
})
+4 -2
View File
@@ -12,6 +12,7 @@ import { FlowTable } from "@/components/Health/FlowTable"
import { HealthActivity } from "@/components/Health/HealthActivity"
import { HealthOverview } from "@/components/Health/HealthOverview"
import { LiveIndicator } from "@/components/Health/LiveIndicator"
import { NodeTypeTimes } from "@/components/Health/NodeTypeTimes"
import { Card } from "@/components/ui/card"
export const Route = createFileRoute("/_layout/")({
@@ -35,8 +36,8 @@ const HEADER =
* than living at routes of their own.
*
* Top to bottom it is a widening lens: the whole instance as a graph, what
* has been built on it, whether it is well, then flow by flow and finally
* moment by moment.
* has been built on it, whether it is well, then flow by flow, node type by
* node type, and finally moment by moment.
*/
function Home() {
// The health block's window: one choice, read by the tiles, the flow table,
@@ -91,6 +92,7 @@ function Home() {
<HealthOverview range={range} onRangeChange={setRange} />
<FlowTable range={range} />
<NodeTypeTimes range={range} />
<HealthActivity range={range} />
</div>
)