Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/api/src/mcp/lib/resolve-tenant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/services/ApiAuthorizationLayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/services/ApiAuthorizationV2Layer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } : {}),
})
Expand Down
21 changes: 19 additions & 2 deletions apps/web/src/routes/flow-lab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<SpanHierarchyRow> & { spanId: string; spanName: string }): SpanHierarchyRow {
return {
Expand All @@ -19,7 +24,7 @@ function row(overrides: Partial<SpanHierarchyRow> & { spanId: string; spanName:
serviceName: "checkout-api",
spanKind: "SPAN_KIND_INTERNAL",
durationMs: 20,
startTime: T0,
startTime: at(0),
statusCode: "Ok",
statusMessage: "",
spanAttributes: "{}",
Expand Down Expand Up @@ -47,6 +52,7 @@ const ROWS: SpanHierarchyRow[] = [
}),
row({
spanId: "edge",
startTime: at(2),
parentSpanId: "root",
spanName: "checkout-edge fetch",
serviceName: "edge-router",
Expand All @@ -61,19 +67,22 @@ const ROWS: SpanHierarchyRow[] = [
}),
row({
spanId: "auth",
startTime: at(6),
parentSpanId: "root",
spanName: "SessionAuthn.verify",
serviceName: "identity",
durationMs: 8,
}),
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,
Expand All @@ -86,6 +95,7 @@ const ROWS: SpanHierarchyRow[] = [
}),
row({
spanId: "db-orders",
startTime: at(22),
parentSpanId: "pricing",
spanName: "SELECT orders",
spanKind: "SPAN_KIND_CLIENT",
Expand All @@ -99,6 +109,7 @@ const ROWS: SpanHierarchyRow[] = [
}),
row({
spanId: "charge",
startTime: at(120),
parentSpanId: "root",
spanName: "POST",
spanKind: "SPAN_KIND_CLIENT",
Expand All @@ -111,6 +122,7 @@ const ROWS: SpanHierarchyRow[] = [
}),
row({
spanId: "inventory",
startTime: at(340),
parentSpanId: "root",
spanName: "GET",
spanKind: "SPAN_KIND_CLIENT",
Expand All @@ -124,6 +136,7 @@ const ROWS: SpanHierarchyRow[] = [
}),
row({
spanId: "cache-miss",
startTime: at(342),
parentSpanId: "inventory",
spanName: "cache.get stock",
durationMs: 1,
Expand All @@ -136,13 +149,15 @@ const ROWS: SpanHierarchyRow[] = [
}),
row({
spanId: "publish",
startTime: at(430),
parentSpanId: "root",
spanName: "order.created publish",
spanKind: "SPAN_KIND_PRODUCER",
durationMs: 6,
}),
row({
spanId: "consume",
startTime: at(438),
parentSpanId: "publish",
spanName: "order.created process",
serviceName: "email-worker",
Expand All @@ -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",
Expand All @@ -167,6 +183,7 @@ const ROWS: SpanHierarchyRow[] = [
),
row({
spanId: "serialize",
startTime: at(470),
parentSpanId: "root",
spanName: "serialize response",
durationMs: 3,
Expand Down
127 changes: 127 additions & 0 deletions packages/ui/src/components/traces/flow-edge.tsx
Original file line number Diff line number Diff line change
@@ -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<FlowEdge>) {
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 ? <span className="italic text-muted-foreground/60">no data</span> : null
} else if (hovered) {
const sharePct = formatShare(share)
label = (
<>
{data.startOffsetMs !== undefined && data.startOffsetMs > 0 && (
<span className={data.accentText}>+{formatDuration(data.startOffsetMs)} → </span>
)}
<span className={cn(isError && "text-severity-error")}>
{formatDuration(data.durationMs)}
</span>
{sharePct && <span> ({sharePct})</span>}
{count > 1 && (
<span>
{" "}
· ×{count}, {formatDuration(data.minMs)}–{formatDuration(data.maxMs)}
</span>
)}
</>
)
} else if (isError) {
label = <span className="text-severity-error">{formatDuration(data.durationMs)}</span>
} else if (count > 1) {
label = (
<span>
×{count} · {formatDuration(data.durationMs)}
</span>
)
} else {
label = <span>{formatDuration(data.durationMs)}</span>
}
}

return (
<>
<path
className={cn(!isMissing && "react-flow__edge-path")}
d={edgePath}
fill="none"
stroke={stroke}
strokeWidth={strokeWidth}
strokeDasharray={isMissing ? "4 4" : undefined}
style={{ transition: "stroke-width 150ms" }}
/>
{/* Wide invisible hit area so thin edges are hoverable */}
<path
d={edgePath}
fill="none"
stroke="transparent"
strokeWidth={16}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
/>
{label && (
<foreignObject
x={labelX - 90}
y={labelY + (targetY > sourceY ? -16 : 4) - 12}
width={180}
height={24}
className="pointer-events-none overflow-visible"
>
<div className="flex h-full items-center justify-center">
<span className="whitespace-nowrap rounded border border-border/50 bg-card/90 px-1.5 py-0.5 font-mono text-[10px] tabular-nums text-muted-foreground backdrop-blur-sm">
{label}
</span>
</div>
</foreignObject>
)}
</>
)
})
71 changes: 59 additions & 12 deletions packages/ui/src/components/traces/flow-utils.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -20,8 +21,44 @@ export interface FlowNodeData extends Record<string, unknown> {
totalDurationMs: number
}

export interface FlowEdgeData extends Record<string, unknown> {
/** 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<FlowNodeData, "span">
export type FlowEdge = Edge
export type FlowEdge = Edge<FlowEdgeData>

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)
Expand Down Expand Up @@ -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)
Expand All @@ -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)
}
}

Expand Down
Loading
Loading