From 987903616b6e8104c84edd4363e13cac94954d95 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 21 Jul 2026 14:53:19 +0200 Subject: [PATCH 1/2] feat(flow-view): show span info on the edges themselves Trace flow diagram edges were bare smoothstep lines. Each edge now carries a FlowEdgeData payload (duration/min/max, trace share, start offset from parent, x-count, error/missing flags, category accent) and renders through a custom edge component: - default labels only on significant edges (errors, >=5% of trace, combined xN groups) to keep dense traces readable - hover reveals the full pill: +offset -> duration (share), with min-max range for combined groups and "no data" for missing spans - stroke width scales with trace share (same sqrt curve as the card cost rail); error edges red, missing-span edges dashed/unanimated - stroke color moved from a hardcoded dark oklch to a theme token Flow-lab spans get staggered start times so the offset path is exercised in the sandbox. Co-Authored-By: Claude Fable 5 --- apps/web/src/routes/flow-lab.tsx | 21 ++- .../ui/src/components/traces/flow-edge.tsx | 127 ++++++++++++++++++ .../ui/src/components/traces/flow-utils.ts | 71 ++++++++-- .../ui/src/components/traces/flow-view.tsx | 11 +- 4 files changed, 211 insertions(+), 19 deletions(-) create mode 100644 packages/ui/src/components/traces/flow-edge.tsx diff --git a/apps/web/src/routes/flow-lab.tsx b/apps/web/src/routes/flow-lab.tsx index 0a438e36..28d5c116 100644 --- a/apps/web/src/routes/flow-lab.tsx +++ b/apps/web/src/routes/flow-lab.tsx @@ -10,7 +10,12 @@ export const Route = createFileRoute("/flow-lab")({ component: FlowLab, }) -const T0 = "2026-07-21 10:00:00.000" +const T0_MS = new Date("2026-07-21T10:00:00.000Z").getTime() + +/** Trace-relative start time so edges get realistic "+Nms" start offsets. */ +function at(offsetMs: number): string { + return new Date(T0_MS + offsetMs).toISOString().replace("T", " ").replace("Z", "") +} function row(overrides: Partial & { spanId: string; spanName: string }): SpanHierarchyRow { return { @@ -19,7 +24,7 @@ function row(overrides: Partial & { spanId: string; spanName: serviceName: "checkout-api", spanKind: "SPAN_KIND_INTERNAL", durationMs: 20, - startTime: T0, + startTime: at(0), statusCode: "Ok", statusMessage: "", spanAttributes: "{}", @@ -47,6 +52,7 @@ const ROWS: SpanHierarchyRow[] = [ }), row({ spanId: "edge", + startTime: at(2), parentSpanId: "root", spanName: "checkout-edge fetch", serviceName: "edge-router", @@ -61,6 +67,7 @@ const ROWS: SpanHierarchyRow[] = [ }), row({ spanId: "auth", + startTime: at(6), parentSpanId: "root", spanName: "SessionAuthn.verify", serviceName: "identity", @@ -68,12 +75,14 @@ const ROWS: SpanHierarchyRow[] = [ }), row({ spanId: "pricing", + startTime: at(16), parentSpanId: "root", spanName: "PricingEngine.calculateTotals", durationMs: 96, }), row({ spanId: "cache-hit", + startTime: at(18), parentSpanId: "pricing", spanName: "cache.get price-book", durationMs: 2, @@ -86,6 +95,7 @@ const ROWS: SpanHierarchyRow[] = [ }), row({ spanId: "db-orders", + startTime: at(22), parentSpanId: "pricing", spanName: "SELECT orders", spanKind: "SPAN_KIND_CLIENT", @@ -99,6 +109,7 @@ const ROWS: SpanHierarchyRow[] = [ }), row({ spanId: "charge", + startTime: at(120), parentSpanId: "root", spanName: "POST", spanKind: "SPAN_KIND_CLIENT", @@ -111,6 +122,7 @@ const ROWS: SpanHierarchyRow[] = [ }), row({ spanId: "inventory", + startTime: at(340), parentSpanId: "root", spanName: "GET", spanKind: "SPAN_KIND_CLIENT", @@ -124,6 +136,7 @@ const ROWS: SpanHierarchyRow[] = [ }), row({ spanId: "cache-miss", + startTime: at(342), parentSpanId: "inventory", spanName: "cache.get stock", durationMs: 1, @@ -136,6 +149,7 @@ const ROWS: SpanHierarchyRow[] = [ }), row({ spanId: "publish", + startTime: at(430), parentSpanId: "root", spanName: "order.created publish", spanKind: "SPAN_KIND_PRODUCER", @@ -143,6 +157,7 @@ const ROWS: SpanHierarchyRow[] = [ }), row({ spanId: "consume", + startTime: at(438), parentSpanId: "publish", spanName: "order.created process", serviceName: "email-worker", @@ -153,6 +168,7 @@ const ROWS: SpanHierarchyRow[] = [ ...[1, 2, 3].map((i) => row({ spanId: `events-${i}`, + startTime: at(440 + i * 20), parentSpanId: "consume", spanName: "INSERT events", serviceName: "email-worker", @@ -167,6 +183,7 @@ const ROWS: SpanHierarchyRow[] = [ ), row({ spanId: "serialize", + startTime: at(470), parentSpanId: "root", spanName: "serialize response", durationMs: 3, diff --git a/packages/ui/src/components/traces/flow-edge.tsx b/packages/ui/src/components/traces/flow-edge.tsx new file mode 100644 index 00000000..3875814a --- /dev/null +++ b/packages/ui/src/components/traces/flow-edge.tsx @@ -0,0 +1,127 @@ +import { memo, useState } from "react" +import { getSmoothStepPath, type EdgeProps } from "@xyflow/react" + +import { cn } from "../../lib/utils" +import { formatDuration } from "../../lib/format" +import type { FlowEdge } from "./flow-utils" + +/** Share of the trace below which a non-error, non-combined edge stays unlabeled. */ +const LABEL_SHARE_THRESHOLD = 0.05 + +/** Stroke width by trace share — same sqrt scaling as the card cost rail. */ +function strokeWidthForShare(share: number): number { + return Math.min(4, 1.5 + 2.5 * Math.sqrt(Math.max(0, share))) +} + +function formatShare(share: number): string | undefined { + const pct = Math.round(share * 100) + return pct >= 1 ? `${pct}%` : undefined +} + +export const TraceFlowEdge = memo(function TraceFlowEdge({ + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + data, +}: EdgeProps) { + const [hovered, setHovered] = useState(false) + + const [edgePath, labelX, labelY] = getSmoothStepPath({ + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + borderRadius: 8, + }) + + const isError = data?.isError ?? false + const isMissing = data?.isMissing ?? false + const share = data?.share ?? 0 + const count = data?.count ?? 1 + + const baseWidth = strokeWidthForShare(share) + const strokeWidth = hovered ? baseWidth + 0.5 : baseWidth + const stroke = isError ? "var(--severity-error)" : "var(--flow-edge, var(--border))" + + // Default labels only on significant edges; hover reveals the full pill. + const showLabel = hovered || isError || count > 1 || share >= LABEL_SHARE_THRESHOLD + + let label: React.ReactNode = null + if (showLabel && data) { + if (isMissing) { + label = hovered ? no data : null + } else if (hovered) { + const sharePct = formatShare(share) + label = ( + <> + {data.startOffsetMs !== undefined && data.startOffsetMs > 0 && ( + +{formatDuration(data.startOffsetMs)} → + )} + + {formatDuration(data.durationMs)} + + {sharePct && ({sharePct})} + {count > 1 && ( + + {" "} + · ×{count}, {formatDuration(data.minMs)}–{formatDuration(data.maxMs)} + + )} + + ) + } else if (isError) { + label = {formatDuration(data.durationMs)} + } else if (count > 1) { + label = ( + + ×{count} · {formatDuration(data.durationMs)} + + ) + } else { + label = {formatDuration(data.durationMs)} + } + } + + return ( + <> + + {/* Wide invisible hit area so thin edges are hoverable */} + setHovered(true)} + onMouseLeave={() => setHovered(false)} + /> + {label && ( + sourceY ? -16 : 4) - 12} + width={180} + height={24} + className="pointer-events-none overflow-visible" + > +
+ + {label} + +
+
+ )} + + ) +}) diff --git a/packages/ui/src/components/traces/flow-utils.ts b/packages/ui/src/components/traces/flow-utils.ts index 9c1c9d28..42b71cc1 100644 --- a/packages/ui/src/components/traces/flow-utils.ts +++ b/packages/ui/src/components/traces/flow-utils.ts @@ -1,4 +1,5 @@ import type { Node, Edge } from "@xyflow/react" +import { describeSpan } from "../../lib/span-category" import type { SpanNode } from "../../lib/types" export interface AggregatedDuration { @@ -20,8 +21,44 @@ export interface FlowNodeData extends Record { totalDurationMs: number } +export interface FlowEdgeData extends Record { + /** Number of combined child spans this edge fans into (matches the ×N card badge). */ + count: number + /** Total duration across the combined group, ms. */ + durationMs: number + /** min/max across the group; both equal durationMs when count === 1. */ + minMs: number + maxMs: number + /** Child group's share of the whole trace, 0..1. */ + share: number + /** + * Gap between parent start and earliest child start, ms. Omitted when a + * timestamp is unparseable, either span is missing, or the gap is negative + * (clock skew) — never show a misleading offset. + */ + startOffsetMs?: number + isError: boolean + /** Parent or child is a synthesized missing span. */ + isMissing: boolean + /** Category accent text token of the target span, for the label pill. */ + accentText: string +} + export type FlowNode = Node -export type FlowEdge = Edge +export type FlowEdge = Edge + +function computeStartOffsetMs(parentSpan: SpanNode, spans: SpanNode[]): number | undefined { + const parentStart = new Date(parentSpan.startTime).getTime() + if (!Number.isFinite(parentStart)) return undefined + let earliest = Infinity + for (const s of spans) { + const t = new Date(s.startTime).getTime() + if (Number.isFinite(t) && t < earliest) earliest = t + } + if (!Number.isFinite(earliest)) return undefined + const offset = earliest - parentStart + return offset >= 0 ? offset : undefined +} /** * Check if two spans are duplicates (same spanName and serviceName) @@ -147,11 +184,12 @@ export function transformSpansToFlow( // First, combine consecutive duplicates starting from root spans const combinedRoots = combineConsecutiveDuplicates(rootSpans) - function traverse(combinedNode: CombinedNode, parentId?: string) { + function traverse(combinedNode: CombinedNode, parentId?: string, parentSpan?: SpanNode) { const { spans } = combinedNode const nodeId = getCombinedNodeId(spans) const primarySpan = spans[0] // Use first span as the primary representation const count = spans.length + const aggregatedDuration = calculateAggregatedDuration(spans) // Check if any span in the group is selected const isSelected = spans.some((s) => s.spanId === selectedSpanId) @@ -167,30 +205,39 @@ export function transformSpansToFlow( isSelected, count, combinedSpans: spans, - aggregatedDuration: calculateAggregatedDuration(spans), + aggregatedDuration, totalDurationMs, }, }) // Create edge from parent (if any) - if (parentId) { - const isError = hasAnyError(spans) + if (parentId && parentSpan) { + const isMissing = primarySpan.isMissing === true || parentSpan.isMissing === true edges.push({ id: `${parentId}-${nodeId}`, source: parentId, target: nodeId, - ...(isError && { - style: { - stroke: "var(--severity-error)", - strokeWidth: 2, - }, - }), + type: "flowEdge", + data: { + count, + durationMs: aggregatedDuration.total, + minMs: aggregatedDuration.min, + maxMs: aggregatedDuration.max, + share: + totalDurationMs > 0 ? Math.min(1, aggregatedDuration.total / totalDurationMs) : 0, + startOffsetMs: isMissing ? undefined : computeStartOffsetMs(parentSpan, spans), + isError: hasAnyError(spans), + isMissing, + accentText: primarySpan.isMissing + ? "text-muted-foreground" + : describeSpan(primarySpan).category.accent.text, + }, }) } // Traverse children for (const child of combinedNode.children) { - traverse(child, nodeId) + traverse(child, nodeId, primarySpan) } } diff --git a/packages/ui/src/components/traces/flow-view.tsx b/packages/ui/src/components/traces/flow-view.tsx index 223407e2..aa954124 100644 --- a/packages/ui/src/components/traces/flow-view.tsx +++ b/packages/ui/src/components/traces/flow-view.tsx @@ -20,6 +20,7 @@ import { getServiceColor } from "../../lib/colors" import { describeSpan, SPAN_CATEGORIES } from "../../lib/span-category" import { ServiceDot } from "../service-dot" import { FlowSpanNode } from "./flow-node" +import { TraceFlowEdge } from "./flow-edge" import { transformSpansToFlow, getLayoutedElements, @@ -43,13 +44,12 @@ const nodeTypes = { span: FlowSpanNode, } +const edgeTypes = { + flowEdge: TraceFlowEdge, +} + const defaultEdgeOptions = { - type: "smoothstep", animated: true, - style: { - strokeWidth: 2, - stroke: "oklch(0.45 0.02 60)", - }, } export function TraceFlowView({ @@ -175,6 +175,7 @@ export function TraceFlowView({ if (event) userMovedRef.current = true }} nodeTypes={nodeTypes} + edgeTypes={edgeTypes} defaultEdgeOptions={defaultEdgeOptions} nodesDraggable={false} nodesConnectable={false} From d1f5f6efc7447ff2314b2705affa845953d747a6 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 21 Jul 2026 15:29:46 +0200 Subject: [PATCH 2/2] fix(auth): resolve API-key tenants with the key's pinned roles PR #242 pinned creator roles onto MCP keys and added tests asserting a member key cannot create standard keys or revoke other users' keys, but the authorization layers still stamped every resolved API key with the default (root) roles, so those restrictions never applied and the tests fail on main (200 instead of 403). Plumb resolved.roles through the v1 and v2 authorization layers and the MCP tenant resolver, falling back to the defaults for keys without pinned roles. Co-Authored-By: Claude Fable 5 --- apps/api/src/mcp/lib/resolve-tenant.ts | 2 +- apps/api/src/services/ApiAuthorizationLayer.ts | 2 +- apps/api/src/services/ApiAuthorizationV2Layer.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/api/src/mcp/lib/resolve-tenant.ts b/apps/api/src/mcp/lib/resolve-tenant.ts index 79ed47f5..8be14121 100644 --- a/apps/api/src/mcp/lib/resolve-tenant.ts +++ b/apps/api/src/mcp/lib/resolve-tenant.ts @@ -167,7 +167,7 @@ export const resolveMcpTenantContext = Effect.fn("resolveMcpTenantContext")(func return { orgId: validOrgId, userId: validUserId, - roles: apiKeyDefaultRoles, + roles: apiKeyResolved.value.roles ?? apiKeyDefaultRoles, authMode: "self_hosted", ...(actorId ? { actorId } : {}), } as McpTenantContext diff --git a/apps/api/src/services/ApiAuthorizationLayer.ts b/apps/api/src/services/ApiAuthorizationLayer.ts index 994760b9..c89e5eb8 100644 --- a/apps/api/src/services/ApiAuthorizationLayer.ts +++ b/apps/api/src/services/ApiAuthorizationLayer.ts @@ -59,7 +59,7 @@ export const ApiAuthorizationLayer = Layer.effect( const tenant = new CurrentTenant.TenantSchema({ orgId: resolved.orgId, userId: resolved.userId, - roles: apiKeyDefaultRoles, + roles: resolved.roles ?? apiKeyDefaultRoles, authMode: "self_hosted", }) return yield* Effect.provideService(httpEffect, CurrentTenant.Context, tenant) diff --git a/apps/api/src/services/ApiAuthorizationV2Layer.ts b/apps/api/src/services/ApiAuthorizationV2Layer.ts index d1b21614..8a2550ec 100644 --- a/apps/api/src/services/ApiAuthorizationV2Layer.ts +++ b/apps/api/src/services/ApiAuthorizationV2Layer.ts @@ -117,7 +117,7 @@ export const ApiAuthorizationV2Layer = Layer.effect( const tenant = new CurrentTenant.TenantSchema({ orgId: resolved.orgId, userId: resolved.userId, - roles: apiKeyDefaultRoles, + roles: resolved.roles ?? apiKeyDefaultRoles, authMode: "self_hosted", ...(resolved.scopes !== null ? { scopes: resolved.scopes } : {}), })