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 } : {}), }) 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}