mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
fix(telemetry): updated telemetry, added nested sanitization, added granular trace spans for logs and updated UI (#1627)
* feat(logs): updated telemetry, added nested sanitization, added granular trace spans for logs and updated UI * refactor trace spans into separate components * remove any's from tool defs * updated UI and overlayed spans * cleanup * ack PR comments * stricter type safety * clean
This commit is contained in:
@@ -186,6 +186,20 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
`[${requestId}] Starting controlled async processing of ${createdDocuments.length} documents`
|
||||
)
|
||||
|
||||
// Track bulk document upload
|
||||
try {
|
||||
const { trackPlatformEvent } = await import('@/lib/telemetry/tracer')
|
||||
trackPlatformEvent('platform.knowledge_base.documents_uploaded', {
|
||||
'knowledge_base.id': knowledgeBaseId,
|
||||
'documents.count': createdDocuments.length,
|
||||
'documents.upload_type': 'bulk',
|
||||
'processing.chunk_size': validatedData.processingOptions.chunkSize,
|
||||
'processing.recipe': validatedData.processingOptions.recipe,
|
||||
})
|
||||
} catch (_e) {
|
||||
// Silently fail
|
||||
}
|
||||
|
||||
processDocumentsWithQueue(
|
||||
createdDocuments,
|
||||
knowledgeBaseId,
|
||||
@@ -231,6 +245,20 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
|
||||
const newDocument = await createSingleDocument(validatedData, knowledgeBaseId, requestId)
|
||||
|
||||
// Track single document upload
|
||||
try {
|
||||
const { trackPlatformEvent } = await import('@/lib/telemetry/tracer')
|
||||
trackPlatformEvent('platform.knowledge_base.documents_uploaded', {
|
||||
'knowledge_base.id': knowledgeBaseId,
|
||||
'documents.count': 1,
|
||||
'documents.upload_type': 'single',
|
||||
'document.mime_type': validatedData.mimeType,
|
||||
'document.file_size': validatedData.fileSize,
|
||||
})
|
||||
} catch (_e) {
|
||||
// Silently fail
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: newDocument,
|
||||
|
||||
@@ -214,10 +214,6 @@ describe('Knowledge Search API Route', () => {
|
||||
const response = await POST(req)
|
||||
const data = await response.json()
|
||||
|
||||
if (response.status !== 200) {
|
||||
console.log('Test failed with response:', data)
|
||||
}
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.success).toBe(true)
|
||||
expect(data.data.results).toHaveLength(2)
|
||||
@@ -723,10 +719,6 @@ describe('Knowledge Search API Route', () => {
|
||||
const response = await POST(req)
|
||||
const data = await response.json()
|
||||
|
||||
if (response.status !== 200) {
|
||||
console.log('Tag-only search test error:', data)
|
||||
}
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.success).toBe(true)
|
||||
expect(data.data.results).toHaveLength(2)
|
||||
@@ -783,10 +775,6 @@ describe('Knowledge Search API Route', () => {
|
||||
const response = await POST(req)
|
||||
const data = await response.json()
|
||||
|
||||
if (response.status !== 200) {
|
||||
console.log('Query+tag combination test error:', data)
|
||||
}
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.success).toBe(true)
|
||||
expect(data.data.results).toHaveLength(2)
|
||||
|
||||
@@ -106,6 +106,20 @@ export const POST = withMcpAuth('write')(
|
||||
mcpService.clearCache(workspaceId)
|
||||
|
||||
logger.info(`[${requestId}] Successfully registered MCP server: ${body.name}`)
|
||||
|
||||
// Track MCP server registration
|
||||
try {
|
||||
const { trackPlatformEvent } = await import('@/lib/telemetry/tracer')
|
||||
trackPlatformEvent('platform.mcp.server_added', {
|
||||
'mcp.server_id': serverId,
|
||||
'mcp.server_name': body.name,
|
||||
'mcp.transport': body.transport,
|
||||
'workspace.id': workspaceId,
|
||||
})
|
||||
} catch (_e) {
|
||||
// Silently fail
|
||||
}
|
||||
|
||||
return createMcpSuccessResponse({ serverId }, 201)
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error registering MCP server:`, error)
|
||||
|
||||
@@ -174,6 +174,20 @@ export const POST = withMcpAuth('read')(
|
||||
)
|
||||
}
|
||||
logger.info(`[${requestId}] Successfully executed tool ${toolName} on server ${serverId}`)
|
||||
|
||||
// Track MCP tool execution
|
||||
try {
|
||||
const { trackPlatformEvent } = await import('@/lib/telemetry/tracer')
|
||||
trackPlatformEvent('platform.mcp.tool_executed', {
|
||||
'mcp.server_id': serverId,
|
||||
'mcp.tool_name': toolName,
|
||||
'mcp.execution_status': 'success',
|
||||
'workspace.id': workspaceId,
|
||||
})
|
||||
} catch (_e) {
|
||||
// Silently fail
|
||||
}
|
||||
|
||||
return createMcpSuccessResponse(transformedResult)
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error executing MCP tool:`, error)
|
||||
|
||||
@@ -366,6 +366,19 @@ export async function POST(req: NextRequest) {
|
||||
cronExpression,
|
||||
})
|
||||
|
||||
// Track schedule creation/update
|
||||
try {
|
||||
const { trackPlatformEvent } = await import('@/lib/telemetry/tracer')
|
||||
trackPlatformEvent('platform.schedule.created', {
|
||||
'workflow.id': workflowId,
|
||||
'schedule.type': scheduleType || 'daily',
|
||||
'schedule.timezone': timezone,
|
||||
'schedule.is_custom': scheduleType === 'custom',
|
||||
})
|
||||
} catch (_e) {
|
||||
// Silently fail
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
message: 'Schedule updated',
|
||||
nextRunAt,
|
||||
|
||||
@@ -12,6 +12,7 @@ const ALLOWED_CATEGORIES = [
|
||||
'error',
|
||||
'workflow',
|
||||
'consent',
|
||||
'batch',
|
||||
]
|
||||
|
||||
const DEFAULT_TIMEOUT = 5000 // 5 seconds timeout
|
||||
@@ -132,7 +133,6 @@ async function forwardToCollector(data: any): Promise<boolean> {
|
||||
],
|
||||
}
|
||||
|
||||
// Create explicit AbortController for timeout
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout)
|
||||
|
||||
|
||||
@@ -174,6 +174,23 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
`[${requestId}] Successfully used template: ${id}, created workflow: ${newWorkflowId}, database returned: ${result.id}`
|
||||
)
|
||||
|
||||
// Track template usage
|
||||
try {
|
||||
const { trackPlatformEvent } = await import('@/lib/telemetry/tracer')
|
||||
const templateState = templateData.state as any
|
||||
trackPlatformEvent('platform.template.used', {
|
||||
'template.id': id,
|
||||
'template.name': templateData.name,
|
||||
'workflow.created_id': newWorkflowId,
|
||||
'workflow.blocks_count': templateState?.blocks
|
||||
? Object.keys(templateState.blocks).length
|
||||
: 0,
|
||||
'workspace.id': workspaceId,
|
||||
})
|
||||
} catch (_e) {
|
||||
// Silently fail
|
||||
}
|
||||
|
||||
// Verify the workflow was actually created
|
||||
const verifyWorkflow = await db
|
||||
.select({ id: workflow.id })
|
||||
|
||||
@@ -353,6 +353,31 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
|
||||
logger.info(`[${requestId}] Workflow deployed successfully: ${id}`)
|
||||
|
||||
// Track workflow deployment
|
||||
try {
|
||||
const { trackPlatformEvent } = await import('@/lib/telemetry/tracer')
|
||||
|
||||
// Aggregate block types to understand which blocks are being used
|
||||
const blockTypeCounts: Record<string, number> = {}
|
||||
for (const block of Object.values(currentState.blocks)) {
|
||||
const blockType = (block as any).type || 'unknown'
|
||||
blockTypeCounts[blockType] = (blockTypeCounts[blockType] || 0) + 1
|
||||
}
|
||||
|
||||
trackPlatformEvent('platform.workflow.deployed', {
|
||||
'workflow.id': id,
|
||||
'workflow.name': workflowData!.name,
|
||||
'workflow.blocks_count': Object.keys(currentState.blocks).length,
|
||||
'workflow.edges_count': currentState.edges.length,
|
||||
'workflow.has_loops': Object.keys(currentState.loops).length > 0,
|
||||
'workflow.has_parallels': Object.keys(currentState.parallels).length > 0,
|
||||
'workflow.api_key_type': keyInfo?.type || 'default',
|
||||
'workflow.block_types': JSON.stringify(blockTypeCounts),
|
||||
})
|
||||
} catch (_e) {
|
||||
// Silently fail
|
||||
}
|
||||
|
||||
const responseApiKeyInfo = keyInfo ? `${keyInfo.name} (${keyInfo.type})` : 'Default key'
|
||||
|
||||
return createSuccessResponse({
|
||||
@@ -400,6 +425,17 @@ export async function DELETE(
|
||||
})
|
||||
|
||||
logger.info(`[${requestId}] Workflow undeployed successfully: ${id}`)
|
||||
|
||||
// Track workflow undeployment
|
||||
try {
|
||||
const { trackPlatformEvent } = await import('@/lib/telemetry/tracer')
|
||||
trackPlatformEvent('platform.workflow.undeployed', {
|
||||
'workflow.id': id,
|
||||
})
|
||||
} catch (_e) {
|
||||
// Silently fail
|
||||
}
|
||||
|
||||
return createSuccessResponse({
|
||||
isDeployed: false,
|
||||
deployedAt: null,
|
||||
|
||||
@@ -44,20 +44,20 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
variables: {},
|
||||
})
|
||||
|
||||
const { traceSpans } = buildTraceSpans(result)
|
||||
const { traceSpans, totalDuration } = buildTraceSpans(result)
|
||||
|
||||
if (result.success === false) {
|
||||
const message = result.error || 'Workflow execution failed'
|
||||
await loggingSession.safeCompleteWithError({
|
||||
endedAt: new Date().toISOString(),
|
||||
totalDurationMs: result.metadata?.duration || 0,
|
||||
totalDurationMs: totalDuration || result.metadata?.duration || 0,
|
||||
error: { message },
|
||||
traceSpans,
|
||||
})
|
||||
} else {
|
||||
await loggingSession.safeComplete({
|
||||
endedAt: new Date().toISOString(),
|
||||
totalDurationMs: result.metadata?.duration || 0,
|
||||
totalDurationMs: totalDuration || result.metadata?.duration || 0,
|
||||
finalOutput: result.output || {},
|
||||
traceSpans,
|
||||
})
|
||||
|
||||
@@ -99,6 +99,19 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
logger.info(`[${requestId}] Creating workflow ${workflowId} for user ${session.user.id}`)
|
||||
|
||||
// Track workflow creation
|
||||
try {
|
||||
const { trackPlatformEvent } = await import('@/lib/telemetry/tracer')
|
||||
trackPlatformEvent('platform.workflow.created', {
|
||||
'workflow.id': workflowId,
|
||||
'workflow.name': name,
|
||||
'workflow.has_workspace': !!workspaceId,
|
||||
'workflow.has_folder': !!folderId,
|
||||
})
|
||||
} catch (_e) {
|
||||
// Silently fail
|
||||
}
|
||||
|
||||
await db.insert(workflow).values({
|
||||
id: workflowId,
|
||||
userId: session.user.id,
|
||||
|
||||
@@ -12,7 +12,7 @@ import { FrozenCanvasModal } from '@/app/workspace/[workspaceId]/logs/components
|
||||
import { FileDownload } from '@/app/workspace/[workspaceId]/logs/components/sidebar/components/file-download'
|
||||
import LogMarkdownRenderer from '@/app/workspace/[workspaceId]/logs/components/sidebar/components/markdown-renderer'
|
||||
import { ToolCallsDisplay } from '@/app/workspace/[workspaceId]/logs/components/tool-calls/tool-calls-display'
|
||||
import { TraceSpansDisplay } from '@/app/workspace/[workspaceId]/logs/components/trace-spans/trace-spans-display'
|
||||
import { TraceSpans } from '@/app/workspace/[workspaceId]/logs/components/trace-spans/trace-spans'
|
||||
import { formatDate } from '@/app/workspace/[workspaceId]/logs/utils/format-date'
|
||||
import { formatCost } from '@/providers/utils'
|
||||
import type { WorkflowLog } from '@/stores/logs/filters/types'
|
||||
@@ -32,7 +32,6 @@ interface LogSidebarProps {
|
||||
*/
|
||||
const tryPrettifyJson = (content: string): { isJson: boolean; formatted: string } => {
|
||||
try {
|
||||
// First check if the content looks like JSON (starts with { or [)
|
||||
const trimmed = content.trim()
|
||||
if (
|
||||
!(trimmed.startsWith('{') || trimmed.startsWith('[')) ||
|
||||
@@ -41,12 +40,10 @@ const tryPrettifyJson = (content: string): { isJson: boolean; formatted: string
|
||||
return { isJson: false, formatted: content }
|
||||
}
|
||||
|
||||
// Try to parse the JSON
|
||||
const parsed = JSON.parse(trimmed)
|
||||
const prettified = JSON.stringify(parsed, null, 2)
|
||||
return { isJson: true, formatted: prettified }
|
||||
} catch (_e) {
|
||||
// If parsing fails, it's not valid JSON
|
||||
return { isJson: false, formatted: content }
|
||||
}
|
||||
}
|
||||
@@ -55,7 +52,6 @@ const tryPrettifyJson = (content: string): { isJson: boolean; formatted: string
|
||||
* Formats JSON content for display, handling multiple JSON objects separated by '--'
|
||||
*/
|
||||
const formatJsonContent = (content: string, blockInput?: Record<string, any>): React.ReactNode => {
|
||||
// Look for a pattern like "Block Agent 1 (agent):" to separate system comment from content
|
||||
const blockPattern = /^(Block .+?\(.+?\):)\s*/
|
||||
const match = content.match(blockPattern)
|
||||
|
||||
@@ -74,7 +70,6 @@ const formatJsonContent = (content: string, blockInput?: Record<string, any>): R
|
||||
)
|
||||
}
|
||||
|
||||
// If no system comment pattern found, show the whole content
|
||||
const { isJson, formatted } = tryPrettifyJson(content)
|
||||
|
||||
return (
|
||||
@@ -241,7 +236,6 @@ export function Sidebar({
|
||||
}
|
||||
}, [log?.id])
|
||||
|
||||
// Determine if this is a workflow execution log
|
||||
const isWorkflowExecutionLog = useMemo(() => {
|
||||
if (!log) return false
|
||||
return (
|
||||
@@ -250,8 +244,6 @@ export function Sidebar({
|
||||
)
|
||||
}, [log])
|
||||
|
||||
// Helper to determine if we have cost information to display
|
||||
// All workflow executions now have cost info (base charge + any model costs)
|
||||
const hasCostInfo = useMemo(() => {
|
||||
return isWorkflowExecutionLog && log?.cost
|
||||
}, [log, isWorkflowExecutionLog])
|
||||
@@ -260,18 +252,14 @@ export function Sidebar({
|
||||
return isWorkflowExecutionLog && hasCostInfo
|
||||
}, [isWorkflowExecutionLog, hasCostInfo])
|
||||
|
||||
// Handle trace span expansion state
|
||||
const handleTraceSpanToggle = (expanded: boolean) => {
|
||||
setIsTraceExpanded(expanded)
|
||||
|
||||
// If a trace span is expanded, increase the sidebar width only if it's currently below the expanded width
|
||||
if (expanded) {
|
||||
// Only expand if current width is less than expanded width
|
||||
if (width < EXPANDED_WIDTH) {
|
||||
setWidth(EXPANDED_WIDTH)
|
||||
}
|
||||
} else {
|
||||
// If all trace spans are collapsed, revert to default width only if we're at expanded width
|
||||
if (width === EXPANDED_WIDTH) {
|
||||
setWidth(DEFAULT_WIDTH)
|
||||
}
|
||||
@@ -288,7 +276,6 @@ export function Sidebar({
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (isDragging) {
|
||||
const newWidth = window.innerWidth - e.clientX
|
||||
// Maintain minimum width and respect expansion state
|
||||
const minWidthToUse = isTraceExpanded ? Math.max(MIN_WIDTH, EXPANDED_WIDTH) : MIN_WIDTH
|
||||
setWidth(Math.max(minWidthToUse, Math.min(newWidth, window.innerWidth * 0.8)))
|
||||
}
|
||||
@@ -309,22 +296,18 @@ export function Sidebar({
|
||||
}
|
||||
}, [isDragging, isTraceExpanded, MIN_WIDTH, EXPANDED_WIDTH, width])
|
||||
|
||||
// Handle escape key to close the sidebar
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isOpen) {
|
||||
onClose()
|
||||
}
|
||||
|
||||
// Add keyboard shortcuts for navigation
|
||||
if (isOpen) {
|
||||
// Up arrow key for previous log
|
||||
if (e.key === 'ArrowUp' && hasPrev && onNavigatePrev) {
|
||||
e.preventDefault()
|
||||
handleNavigate(onNavigatePrev)
|
||||
}
|
||||
|
||||
// Down arrow key for next log
|
||||
if (e.key === 'ArrowDown' && hasNext && onNavigateNext) {
|
||||
e.preventDefault()
|
||||
handleNavigate(onNavigateNext)
|
||||
@@ -336,7 +319,6 @@ export function Sidebar({
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [isOpen, onClose, hasPrev, hasNext, onNavigatePrev, onNavigateNext])
|
||||
|
||||
// Handle navigation
|
||||
const handleNavigate = (navigateFunction: () => void) => {
|
||||
navigateFunction()
|
||||
}
|
||||
@@ -530,10 +512,10 @@ export function Sidebar({
|
||||
Workflow State
|
||||
</h3>
|
||||
<Button
|
||||
variant='outline'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => setIsFrozenCanvasOpen(true)}
|
||||
className='w-full justify-start gap-2'
|
||||
className='w-full justify-start gap-2 rounded-md border bg-muted/30 hover:bg-muted/50'
|
||||
>
|
||||
<Eye className='h-4 w-4' />
|
||||
View Snapshot
|
||||
@@ -550,7 +532,7 @@ export function Sidebar({
|
||||
{isWorkflowExecutionLog && log.executionData?.traceSpans && (
|
||||
<div className='w-full'>
|
||||
<div className='w-full overflow-x-hidden'>
|
||||
<TraceSpansDisplay
|
||||
<TraceSpans
|
||||
traceSpans={log.executionData.traceSpans}
|
||||
totalDuration={log.executionData.totalDuration}
|
||||
onExpansionChange={handleTraceSpanToggle}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import type React from 'react'
|
||||
import { transformBlockData } from '@/app/workspace/[workspaceId]/logs/components/trace-spans/utils'
|
||||
|
||||
export function BlockDataDisplay({
|
||||
data,
|
||||
blockType,
|
||||
isInput = false,
|
||||
isError = false,
|
||||
}: {
|
||||
data: unknown
|
||||
blockType?: string
|
||||
isInput?: boolean
|
||||
isError?: boolean
|
||||
}) {
|
||||
if (!data) return null
|
||||
|
||||
const renderValue = (value: unknown, key?: string): React.ReactNode => {
|
||||
if (value === null) return <span className='text-muted-foreground italic'>null</span>
|
||||
if (value === undefined) return <span className='text-muted-foreground italic'>undefined</span>
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return <span className='break-all text-emerald-700 dark:text-emerald-400'>"{value}"</span>
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
return <span className='font-mono text-blue-700 dark:text-blue-400'>{value}</span>
|
||||
}
|
||||
|
||||
if (typeof value === 'boolean') {
|
||||
return (
|
||||
<span className='font-mono text-amber-700 dark:text-amber-400'>{value.toString()}</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) return <span className='text-muted-foreground'>[]</span>
|
||||
return (
|
||||
<div className='space-y-0.5'>
|
||||
<span className='text-muted-foreground'>[</span>
|
||||
<div className='ml-2 space-y-0.5'>
|
||||
{value.map((item, index) => (
|
||||
<div key={index} className='flex min-w-0 gap-1.5'>
|
||||
<span className='flex-shrink-0 font-mono text-slate-600 text-xs dark:text-slate-400'>
|
||||
{index}:
|
||||
</span>
|
||||
<div className='min-w-0 flex-1 overflow-hidden'>{renderValue(item)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<span className='text-muted-foreground'>]</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
const entries = Object.entries(value)
|
||||
if (entries.length === 0) return <span className='text-muted-foreground'>{'{}'}</span>
|
||||
|
||||
return (
|
||||
<div className='space-y-0.5'>
|
||||
{entries.map(([objKey, objValue]) => (
|
||||
<div key={objKey} className='flex min-w-0 gap-1.5'>
|
||||
<span className='flex-shrink-0 font-medium text-indigo-700 dark:text-indigo-400'>
|
||||
{objKey}:
|
||||
</span>
|
||||
<div className='min-w-0 flex-1 overflow-hidden'>{renderValue(objValue, objKey)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <span>{String(value)}</span>
|
||||
}
|
||||
|
||||
const transformedData = transformBlockData(data, blockType || 'unknown', isInput)
|
||||
|
||||
if (isError && typeof data === 'object' && data !== null && 'error' in data) {
|
||||
const errorData = data as { error: string; [key: string]: unknown }
|
||||
return (
|
||||
<div className='space-y-2 text-xs'>
|
||||
<div className='rounded border border-red-200 bg-red-50 p-2 dark:border-red-800 dark:bg-red-950/20'>
|
||||
<div className='mb-1 font-medium text-red-800 dark:text-red-400'>Error</div>
|
||||
<div className='text-red-700 dark:text-red-300'>{errorData.error}</div>
|
||||
</div>
|
||||
{transformedData &&
|
||||
Object.keys(transformedData).filter((key) => key !== 'error' && key !== 'success')
|
||||
.length > 0 && (
|
||||
<div className='space-y-0.5'>
|
||||
{Object.entries(transformedData)
|
||||
.filter(([key]) => key !== 'error' && key !== 'success')
|
||||
.map(([key, value]) => (
|
||||
<div key={key} className='flex gap-1.5'>
|
||||
<span className='font-medium text-indigo-700 dark:text-indigo-400'>{key}:</span>
|
||||
{renderValue(value, key)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-1 overflow-hidden text-xs'>{renderValue(transformedData || data)}</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useState } from 'react'
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react'
|
||||
import { BlockDataDisplay } from '@/app/workspace/[workspaceId]/logs/components/trace-spans'
|
||||
import type { TraceSpan } from '@/stores/logs/filters/types'
|
||||
|
||||
interface CollapsibleInputOutputProps {
|
||||
span: TraceSpan
|
||||
spanId: string
|
||||
depth: number
|
||||
}
|
||||
|
||||
export function CollapsibleInputOutput({ span, spanId, depth }: CollapsibleInputOutputProps) {
|
||||
const [inputExpanded, setInputExpanded] = useState(false)
|
||||
const [outputExpanded, setOutputExpanded] = useState(false)
|
||||
|
||||
const leftMargin = depth * 16 + 8 + 24
|
||||
|
||||
return (
|
||||
<div
|
||||
className='mt-2 mr-4 mb-4 space-y-3 overflow-hidden'
|
||||
style={{ marginLeft: `${leftMargin}px` }}
|
||||
>
|
||||
{span.input && (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setInputExpanded(!inputExpanded)}
|
||||
className='mb-2 flex items-center gap-2 font-medium text-muted-foreground text-xs transition-colors hover:text-foreground'
|
||||
>
|
||||
{inputExpanded ? (
|
||||
<ChevronDown className='h-3 w-3' />
|
||||
) : (
|
||||
<ChevronRight className='h-3 w-3' />
|
||||
)}
|
||||
Input
|
||||
</button>
|
||||
{inputExpanded && (
|
||||
<div className='mb-2 overflow-hidden rounded-md bg-secondary/30 p-3'>
|
||||
<BlockDataDisplay data={span.input} blockType={span.type} isInput={true} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{span.output && (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setOutputExpanded(!outputExpanded)}
|
||||
className='mb-2 flex items-center gap-2 font-medium text-muted-foreground text-xs transition-colors hover:text-foreground'
|
||||
>
|
||||
{outputExpanded ? (
|
||||
<ChevronDown className='h-3 w-3' />
|
||||
) : (
|
||||
<ChevronRight className='h-3 w-3' />
|
||||
)}
|
||||
{span.status === 'error' ? 'Error Details' : 'Output'}
|
||||
</button>
|
||||
{outputExpanded && (
|
||||
<div className='mb-2 overflow-hidden rounded-md bg-secondary/30 p-3'>
|
||||
<BlockDataDisplay
|
||||
data={span.output}
|
||||
blockType={span.type}
|
||||
isInput={false}
|
||||
isError={span.status === 'error'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,725 @@
|
||||
import type React from 'react'
|
||||
import { ChevronDown, ChevronRight, Code, Cpu, ExternalLink } from 'lucide-react'
|
||||
import {
|
||||
AgentIcon,
|
||||
ApiIcon,
|
||||
ChartBarIcon,
|
||||
CodeIcon,
|
||||
ConditionalIcon,
|
||||
ConnectIcon,
|
||||
} from '@/components/icons'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
CollapsibleInputOutput,
|
||||
normalizeChildWorkflowSpan,
|
||||
} from '@/app/workspace/[workspaceId]/logs/components/trace-spans'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import { getProviderIcon } from '@/providers/utils'
|
||||
import type { TraceSpan } from '@/stores/logs/filters/types'
|
||||
import { getTool } from '@/tools/utils'
|
||||
|
||||
interface TraceSpanItemProps {
|
||||
span: TraceSpan
|
||||
depth: number
|
||||
totalDuration: number
|
||||
isLast: boolean
|
||||
parentStartTime: number
|
||||
workflowStartTime: number
|
||||
onToggle: (spanId: string, expanded: boolean, hasSubItems: boolean) => void
|
||||
expandedSpans: Set<string>
|
||||
hasSubItems?: boolean
|
||||
hoveredPercent?: number | null
|
||||
hoveredWorkflowMs?: number | null
|
||||
forwardHover: (clientX: number, clientY: number) => void
|
||||
gapBeforeMs?: number
|
||||
gapBeforePercent?: number
|
||||
showRelativeChip?: boolean
|
||||
chipVisibility?: {
|
||||
model: boolean
|
||||
toolProvider: boolean
|
||||
tokens: boolean
|
||||
cost: boolean
|
||||
relative: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export function TraceSpanItem({
|
||||
span,
|
||||
depth,
|
||||
totalDuration,
|
||||
parentStartTime,
|
||||
workflowStartTime,
|
||||
onToggle,
|
||||
expandedSpans,
|
||||
forwardHover,
|
||||
gapBeforeMs = 0,
|
||||
gapBeforePercent = 0,
|
||||
showRelativeChip = true,
|
||||
chipVisibility = { model: true, toolProvider: true, tokens: true, cost: true, relative: true },
|
||||
}: TraceSpanItemProps): React.ReactNode {
|
||||
const spanId = span.id || `span-${span.name}-${span.startTime}`
|
||||
const expanded = expandedSpans.has(spanId)
|
||||
const hasChildren = span.children && span.children.length > 0
|
||||
const hasToolCalls = span.toolCalls && span.toolCalls.length > 0
|
||||
const hasInputOutput = Boolean(span.input || span.output)
|
||||
const hasNestedItems = hasChildren || hasToolCalls || hasInputOutput
|
||||
|
||||
const spanStartTime = new Date(span.startTime).getTime()
|
||||
const spanEndTime = new Date(span.endTime).getTime()
|
||||
const duration = span.duration || spanEndTime - spanStartTime
|
||||
const startOffset = spanStartTime - parentStartTime
|
||||
|
||||
const relativeStartPercent =
|
||||
totalDuration > 0 ? ((spanStartTime - workflowStartTime) / totalDuration) * 100 : 0
|
||||
|
||||
const actualDurationPercent = totalDuration > 0 ? (duration / totalDuration) * 100 : 0
|
||||
|
||||
const safeStartPercent = Math.min(100, Math.max(0, relativeStartPercent))
|
||||
const safeWidthPercent = Math.max(2, Math.min(100 - safeStartPercent, actualDurationPercent))
|
||||
|
||||
const handleSpanClick = () => {
|
||||
if (hasNestedItems) {
|
||||
onToggle(spanId, !expanded, hasNestedItems)
|
||||
}
|
||||
}
|
||||
|
||||
const getSpanIcon = () => {
|
||||
const type = span.type.toLowerCase()
|
||||
if (hasNestedItems) {
|
||||
return expanded ? <ChevronDown className='h-4 w-4' /> : <ChevronRight className='h-4 w-4' />
|
||||
}
|
||||
if (type === 'agent')
|
||||
return <AgentIcon className='h-3 w-3 text-[var(--brand-primary-hover-hex)]' />
|
||||
if (type === 'evaluator') return <ChartBarIcon className='h-3 w-3 text-[#2FA1FF]' />
|
||||
if (type === 'condition') return <ConditionalIcon className='h-3 w-3 text-[#FF972F]' />
|
||||
if (type === 'router') return <ConnectIcon className='h-3 w-3 text-[#2FA1FF]' />
|
||||
if (type === 'model') return <Cpu className='h-3 w-3 text-[#10a37f]' />
|
||||
if (type === 'function') return <CodeIcon className='h-3 w-3 text-[#FF402F]' />
|
||||
if (type === 'tool') {
|
||||
const toolId = String(span.name || '')
|
||||
const parts = toolId.split('_')
|
||||
for (let i = parts.length; i > 0; i--) {
|
||||
const candidate = parts.slice(0, i).join('_')
|
||||
const block = getBlock(candidate)
|
||||
if (block?.icon) {
|
||||
const Icon = block.icon as React.ComponentType<{
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
}>
|
||||
const color = (block as { bgColor?: string }).bgColor || '#f97316'
|
||||
return <Icon className='h-3 w-3' style={{ color }} />
|
||||
}
|
||||
}
|
||||
return <ExternalLink className='h-3 w-3 text-[#f97316]' />
|
||||
}
|
||||
if (type === 'api') return <ApiIcon className='h-3 w-3 text-[#2F55FF]' />
|
||||
return <Code className='h-3 w-3 text-muted-foreground' />
|
||||
}
|
||||
|
||||
const formatRelativeTime = (ms: number) => {
|
||||
if (ms === 0) return 'start'
|
||||
return `+${ms}ms`
|
||||
}
|
||||
|
||||
const getSpanColor = (type: string) => {
|
||||
switch (type.toLowerCase()) {
|
||||
case 'agent':
|
||||
return 'var(--brand-primary-hover-hex)'
|
||||
case 'provider':
|
||||
return '#818cf8'
|
||||
case 'model':
|
||||
return '#10a37f'
|
||||
case 'function':
|
||||
return '#FF402F'
|
||||
case 'tool':
|
||||
return '#f97316'
|
||||
case 'router':
|
||||
return '#2FA1FF'
|
||||
case 'condition':
|
||||
return '#FF972F'
|
||||
case 'evaluator':
|
||||
return '#2FA1FF'
|
||||
case 'api':
|
||||
return '#2F55FF'
|
||||
default:
|
||||
return '#6b7280'
|
||||
}
|
||||
}
|
||||
|
||||
// Prefer registry-provided block color; fallback to legacy per-type colors
|
||||
const getBlockColor = (type: string) => {
|
||||
try {
|
||||
const block = getBlock(type)
|
||||
const color = (block as { bgColor?: string } | null)?.bgColor
|
||||
if (color) return color as string
|
||||
} catch {}
|
||||
return getSpanColor(type)
|
||||
}
|
||||
const spanColor = getBlockColor(span.type)
|
||||
|
||||
const formatDuration = (ms: number) => {
|
||||
if (ms < 1000) return `${ms}ms`
|
||||
return `${(ms / 1000).toFixed(2)}s`
|
||||
}
|
||||
|
||||
const extractModelName = (spanName: string) => {
|
||||
const modelMatch = spanName.match(/\(([\w.-]+)\)/i)
|
||||
return modelMatch ? modelMatch[1] : ''
|
||||
}
|
||||
|
||||
const formatSpanName = (span: TraceSpan) => {
|
||||
if (span.type === 'tool') {
|
||||
const raw = String(span.name || '')
|
||||
const tool = getTool(raw)
|
||||
const displayName = (() => {
|
||||
if (tool?.name) return tool.name
|
||||
const parts = raw.split('_')
|
||||
const label = parts.slice(1).join(' ')
|
||||
if (label) {
|
||||
return label.replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
}
|
||||
return raw.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
})()
|
||||
return displayName
|
||||
}
|
||||
if (span.type === 'model') {
|
||||
const modelName = extractModelName(span.name)
|
||||
if (span.name.includes('Initial response')) {
|
||||
return (
|
||||
<>
|
||||
Initial response{' '}
|
||||
{modelName && <span className='text-xs opacity-75'>({modelName})</span>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
if (span.name.includes('(iteration')) {
|
||||
return (
|
||||
<>
|
||||
Model response {modelName && <span className='text-xs opacity-75'>({modelName})</span>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
if (span.name.includes('Model Generation')) {
|
||||
return (
|
||||
<>
|
||||
Model Generation{' '}
|
||||
{modelName && <span className='text-xs opacity-75'>({modelName})</span>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
return span.name
|
||||
}
|
||||
|
||||
// Utilities: soften block colors so they are less harsh in light mode and visible in dark mode
|
||||
const hexToRgb = (hex: string) => {
|
||||
const m = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
|
||||
if (!m) return null
|
||||
return {
|
||||
r: Number.parseInt(m[1], 16),
|
||||
g: Number.parseInt(m[2], 16),
|
||||
b: Number.parseInt(m[3], 16),
|
||||
}
|
||||
}
|
||||
|
||||
const rgbToHex = (r: number, g: number, b: number) =>
|
||||
`#${[r, g, b]
|
||||
.map((v) =>
|
||||
Math.max(0, Math.min(255, Math.round(v)))
|
||||
.toString(16)
|
||||
.padStart(2, '0')
|
||||
)
|
||||
.join('')}`
|
||||
|
||||
const softenColor = (hex: string, isDark: boolean, factor = 0.22) => {
|
||||
const rgb = hexToRgb(hex)
|
||||
if (!rgb) return hex
|
||||
// Blend toward white a bit to reduce harshness and increase visibility in dark mode
|
||||
const t = isDark ? factor : factor + 0.08
|
||||
const r = rgb.r + (255 - rgb.r) * t
|
||||
const g = rgb.g + (255 - rgb.g) * t
|
||||
const b = rgb.b + (255 - rgb.b) * t
|
||||
return rgbToHex(r, g, b)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative border-b transition-colors last:border-b-0',
|
||||
expanded ? 'bg-muted/50 dark:bg-accent/30' : 'hover:bg-muted/30 hover:dark:bg-accent/20'
|
||||
)}
|
||||
>
|
||||
{depth > 0 && (
|
||||
<div
|
||||
className='pointer-events-none absolute top-0 bottom-0 border-border/60 border-l'
|
||||
style={{ left: `${depth * 16 + 6}px` }}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center px-2 py-1.5',
|
||||
hasNestedItems ? 'cursor-pointer' : 'cursor-default'
|
||||
)}
|
||||
onClick={handleSpanClick}
|
||||
style={{ paddingLeft: `${depth * 16 + 8}px` }}
|
||||
>
|
||||
<div className='mr-2 flex w-5 flex-shrink-0 items-center justify-center'>
|
||||
{getSpanIcon()}
|
||||
</div>
|
||||
|
||||
<div className='flex min-w-0 flex-1 items-center gap-2 overflow-hidden'>
|
||||
<div
|
||||
className='min-w-0 flex-shrink overflow-hidden'
|
||||
style={{ paddingRight: 'calc(45% + 80px)' }}
|
||||
>
|
||||
<div className='mb-0.5 flex items-center space-x-2'>
|
||||
<span
|
||||
className={cn(
|
||||
'truncate font-medium text-sm',
|
||||
span.status === 'error' && 'text-red-500'
|
||||
)}
|
||||
>
|
||||
{formatSpanName(span)}
|
||||
</span>
|
||||
{chipVisibility.model && span.model && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className='inline-flex cursor-default items-center gap-1 rounded bg-secondary px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground tabular-nums'>
|
||||
{(() => {
|
||||
const model = String(span.model) || ''
|
||||
const IconComp = getProviderIcon(model) as React.ComponentType<{
|
||||
className?: string
|
||||
}> | null
|
||||
return IconComp ? <IconComp className='h-3 w-3' /> : null
|
||||
})()}
|
||||
{String(span.model)}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='top'>Model</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{chipVisibility.toolProvider &&
|
||||
span.type === 'tool' &&
|
||||
(() => {
|
||||
const raw = String(span.name || '')
|
||||
const parts = raw.split('_')
|
||||
let block: ReturnType<typeof getBlock> | null = null
|
||||
for (let i = parts.length; i > 0; i--) {
|
||||
const candidate = parts.slice(0, i).join('_')
|
||||
const b = getBlock(candidate)
|
||||
if (b) {
|
||||
block = b
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!block?.icon) return null
|
||||
const Icon = block.icon as React.ComponentType<{ className?: string }>
|
||||
return (
|
||||
<span className='inline-flex items-center gap-1 rounded bg-secondary px-1.5 py-0.5 text-[10px] text-muted-foreground'>
|
||||
<Icon className='h-3 w-3 text-muted-foreground' />
|
||||
</span>
|
||||
)
|
||||
})()}
|
||||
{chipVisibility.tokens && span.tokens && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className='cursor-default rounded bg-secondary px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground tabular-nums'>
|
||||
{(() => {
|
||||
const t = span.tokens
|
||||
const total =
|
||||
typeof t === 'number'
|
||||
? t
|
||||
: (t.total ?? (t.input || 0) + (t.output || 0))
|
||||
return `T:${total}`
|
||||
})()}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='top'>
|
||||
{(() => {
|
||||
const t = span.tokens
|
||||
if (typeof t === 'number') return <span>{t} tokens</span>
|
||||
const hasIn = typeof t.input === 'number'
|
||||
const hasOut = typeof t.output === 'number'
|
||||
const input = hasIn ? t.input : undefined
|
||||
const output = hasOut ? t.output : undefined
|
||||
const total =
|
||||
t.total ??
|
||||
(hasIn && hasOut ? (t.input || 0) + (t.output || 0) : undefined)
|
||||
|
||||
if (hasIn || hasOut) {
|
||||
return (
|
||||
<span className='font-normal text-xs'>
|
||||
{`${hasIn ? input : '—'} in / ${hasOut ? output : '—'} out`}
|
||||
{typeof total === 'number' ? ` (total ${total})` : ''}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (typeof total === 'number')
|
||||
return <span className='font-normal text-xs'>Total {total} tokens</span>
|
||||
return <span className='font-normal text-xs'>Tokens unavailable</span>
|
||||
})()}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{chipVisibility.cost && span.cost?.total !== undefined && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className='cursor-default rounded bg-secondary px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground tabular-nums'>
|
||||
{(() => {
|
||||
try {
|
||||
const { formatCost } = require('@/providers/utils')
|
||||
return formatCost(Number(span.cost.total) || 0)
|
||||
} catch {
|
||||
return `$${Number.parseFloat(String(span.cost.total)).toFixed(4)}`
|
||||
}
|
||||
})()}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='top'>
|
||||
{(() => {
|
||||
const c = span.cost || {}
|
||||
const input = typeof c.input === 'number' ? c.input : undefined
|
||||
const output = typeof c.output === 'number' ? c.output : undefined
|
||||
const total =
|
||||
typeof c.total === 'number'
|
||||
? c.total
|
||||
: typeof input === 'number' && typeof output === 'number'
|
||||
? input + output
|
||||
: undefined
|
||||
let formatCostFn: (v: number) => string = (v: number) =>
|
||||
`$${Number(v).toFixed(4)}`
|
||||
try {
|
||||
formatCostFn = require('@/providers/utils').formatCost as (
|
||||
v: number
|
||||
) => string
|
||||
} catch {}
|
||||
return (
|
||||
<div className='space-y-0.5'>
|
||||
{typeof input === 'number' && (
|
||||
<div className='text-xs'>Input: {formatCostFn(input)}</div>
|
||||
)}
|
||||
{typeof output === 'number' && (
|
||||
<div className='text-xs'>Output: {formatCostFn(output)}</div>
|
||||
)}
|
||||
{typeof total === 'number' && (
|
||||
<div className='border-t pt-0.5 text-xs'>
|
||||
Total: {formatCostFn(total)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{showRelativeChip && depth > 0 && (
|
||||
<span className='inline-flex items-center rounded bg-secondary px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground tabular-nums'>
|
||||
{span.relativeStartMs !== undefined
|
||||
? `+${span.relativeStartMs}ms`
|
||||
: formatRelativeTime(startOffset)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className='block text-muted-foreground text-xs'>{formatDuration(duration)}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className='absolute right-[73px] hidden h-full items-center sm:flex'
|
||||
style={{ width: 'calc(45% - 73px)', pointerEvents: 'none' }}
|
||||
>
|
||||
<div
|
||||
className='relative h-2 w-full overflow-visible rounded-full bg-accent/30'
|
||||
style={{ pointerEvents: 'auto' }}
|
||||
onPointerMove={(e) => forwardHover(e.clientX, e.clientY)}
|
||||
>
|
||||
{gapBeforeMs > 5 && (
|
||||
<div
|
||||
className='absolute h-full border-yellow-500/40 border-r border-l bg-yellow-500/20'
|
||||
style={{
|
||||
left: `${Math.max(0, safeStartPercent - gapBeforePercent)}%`,
|
||||
width: `${gapBeforePercent}%`,
|
||||
zIndex: 4,
|
||||
}}
|
||||
title={`${gapBeforeMs.toFixed(0)}ms between blocks`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(() => {
|
||||
const providerTiming = span.providerTiming
|
||||
const hasSegs =
|
||||
Array.isArray(providerTiming?.segments) && providerTiming.segments.length > 0
|
||||
const type = String(span.type || '').toLowerCase()
|
||||
const isDark =
|
||||
typeof document !== 'undefined' &&
|
||||
document.documentElement.classList.contains('dark')
|
||||
// Base rail: keep workflow neutral so overlays stand out; otherwise use block color
|
||||
const neutralRail = isDark
|
||||
? 'rgba(148, 163, 184, 0.28)'
|
||||
: 'rgba(148, 163, 184, 0.32)'
|
||||
const baseColor = type === 'workflow' ? neutralRail : softenColor(spanColor, isDark)
|
||||
const isFlatBase = type !== 'workflow'
|
||||
return (
|
||||
<div
|
||||
className='absolute h-full'
|
||||
style={{
|
||||
left: `${safeStartPercent}%`,
|
||||
width: `${safeWidthPercent}%`,
|
||||
backgroundColor: baseColor,
|
||||
borderRadius: isFlatBase ? 0 : 9999,
|
||||
zIndex: 5,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Workflow-level overlay of child spans (no duplication of agent's model/streaming) */}
|
||||
{(() => {
|
||||
if (String(span.type || '').toLowerCase() !== 'workflow') return null
|
||||
const children = (span.children || []) as TraceSpan[]
|
||||
if (!children.length) return null
|
||||
// Build overlay segments (exclude agent-internal pieces like model/streaming)
|
||||
const overlay = children
|
||||
.filter(
|
||||
(c) => c.type !== 'model' && c.name?.toLowerCase() !== 'streaming response'
|
||||
)
|
||||
.map((c) => ({
|
||||
startMs: new Date(c.startTime).getTime(),
|
||||
endMs: new Date(c.endTime).getTime(),
|
||||
type: String(c.type || ''),
|
||||
name: c.name || '',
|
||||
}))
|
||||
.sort((a, b) => a.startMs - b.startMs)
|
||||
|
||||
if (!overlay.length) return null
|
||||
|
||||
const render: React.ReactNode[] = []
|
||||
const isDark = document?.documentElement?.classList?.contains('dark') ?? false
|
||||
const msToPercent = (ms: number) =>
|
||||
totalDuration > 0 ? (ms / totalDuration) * 100 : 0
|
||||
|
||||
for (let i = 0; i < overlay.length; i++) {
|
||||
const seg = overlay[i]
|
||||
const prevEnd = i > 0 ? overlay[i - 1].endMs : undefined
|
||||
// Render gap between previous and current overlay segment (like in row-level spans)
|
||||
if (prevEnd && seg.startMs - prevEnd > 5) {
|
||||
const gapStartPercent = msToPercent(prevEnd - workflowStartTime)
|
||||
const gapWidthPercent = msToPercent(seg.startMs - prevEnd)
|
||||
render.push(
|
||||
<div
|
||||
key={`wf-gap-${i}`}
|
||||
className='absolute h-full border-yellow-500/40 border-r border-l bg-yellow-500/20'
|
||||
style={{
|
||||
left: `${Math.max(0, Math.min(100, gapStartPercent))}%`,
|
||||
width: `${Math.max(0.1, Math.min(100, gapWidthPercent))}%`,
|
||||
zIndex: 8,
|
||||
}}
|
||||
title={`${Math.round(seg.startMs - prevEnd)}ms between blocks`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const segStartPercent = msToPercent(seg.startMs - workflowStartTime)
|
||||
const segWidthPercent = msToPercent(seg.endMs - seg.startMs)
|
||||
const childColor = softenColor(getBlockColor(seg.type), isDark, 0.18)
|
||||
render.push(
|
||||
<div
|
||||
key={`wfseg-${i}`}
|
||||
className='absolute h-full'
|
||||
style={{
|
||||
left: `${Math.max(0, Math.min(100, segStartPercent))}%`,
|
||||
width: `${Math.max(0.1, Math.min(100, segWidthPercent))}%`,
|
||||
backgroundColor: childColor,
|
||||
opacity: 1,
|
||||
zIndex: 6,
|
||||
}}
|
||||
title={`${seg.type}${seg.name ? `: ${seg.name}` : ''} - ${Math.round(
|
||||
seg.endMs - seg.startMs
|
||||
)}ms`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return render
|
||||
})()}
|
||||
|
||||
{(() => {
|
||||
const providerTiming = span.providerTiming
|
||||
const segments: Array<{
|
||||
type: string
|
||||
startTime: string | number
|
||||
endTime: string | number
|
||||
name?: string
|
||||
}> = []
|
||||
|
||||
const isWorkflow = String(span.type || '').toLowerCase() === 'workflow'
|
||||
|
||||
// For workflow rows, avoid duplicating model/streaming info on the base rail –
|
||||
// those are already represented inside Agent. Only show provider timing if present.
|
||||
if (
|
||||
!hasChildren &&
|
||||
providerTiming?.segments &&
|
||||
Array.isArray(providerTiming.segments)
|
||||
) {
|
||||
providerTiming.segments.forEach((seg) =>
|
||||
segments.push({
|
||||
type: seg.type || 'segment',
|
||||
startTime: seg.startTime,
|
||||
endTime: seg.endTime,
|
||||
name: seg.name,
|
||||
})
|
||||
)
|
||||
}
|
||||
if (!segments.length || safeWidthPercent <= 0) return null
|
||||
|
||||
return segments
|
||||
.sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime())
|
||||
.map((seg, index) => {
|
||||
const startMs = new Date(seg.startTime).getTime()
|
||||
const endMs = new Date(seg.endTime).getTime()
|
||||
const segDuration = endMs - startMs
|
||||
|
||||
// Calculate position on the GLOBAL workflow timeline
|
||||
// This ensures overlay segments align with their corresponding child rows
|
||||
const segmentStartPercent =
|
||||
totalDuration > 0 ? ((startMs - workflowStartTime) / totalDuration) * 100 : 0
|
||||
const segmentWidthPercent =
|
||||
totalDuration > 0 ? (segDuration / totalDuration) * 100 : 0
|
||||
|
||||
const color = seg.type === 'tool' ? getSpanColor('tool') : getSpanColor('model')
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${seg.type}-${index}`}
|
||||
className='absolute h-full'
|
||||
style={{
|
||||
left: `${Math.max(0, Math.min(100, segmentStartPercent))}%`,
|
||||
width: `${Math.max(0.1, Math.min(100, segmentWidthPercent))}%`,
|
||||
backgroundColor: color,
|
||||
zIndex: 6,
|
||||
}}
|
||||
title={`${seg.type}${seg.name ? `: ${seg.name}` : ''} - ${Math.round(segDuration)}ms`}
|
||||
/>
|
||||
)
|
||||
})
|
||||
})()}
|
||||
<div className='absolute inset-x-0 inset-y-[-12px] cursor-crosshair' />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className='absolute right-3.5 w-[65px] flex-shrink-0 text-right font-mono text-muted-foreground text-xs tabular-nums'>
|
||||
{`${duration}ms`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div>
|
||||
{(span.input || span.output) && (
|
||||
<CollapsibleInputOutput span={span} spanId={spanId} depth={depth} />
|
||||
)}
|
||||
|
||||
{hasChildren && (
|
||||
<div>
|
||||
{span.children?.map((childSpan, index) => {
|
||||
const enrichedChildSpan = normalizeChildWorkflowSpan(childSpan)
|
||||
|
||||
const childHasSubItems = Boolean(
|
||||
(enrichedChildSpan.children && enrichedChildSpan.children.length > 0) ||
|
||||
(enrichedChildSpan.toolCalls && enrichedChildSpan.toolCalls.length > 0) ||
|
||||
enrichedChildSpan.input ||
|
||||
enrichedChildSpan.output
|
||||
)
|
||||
|
||||
let childGapMs = 0
|
||||
let childGapPercent = 0
|
||||
if (index > 0 && span.children) {
|
||||
const prevChild = span.children[index - 1]
|
||||
const prevEndTime = new Date(prevChild.endTime).getTime()
|
||||
const currentStartTime = new Date(enrichedChildSpan.startTime).getTime()
|
||||
childGapMs = currentStartTime - prevEndTime
|
||||
if (childGapMs > 0 && totalDuration > 0) {
|
||||
childGapPercent = (childGapMs / totalDuration) * 100
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<TraceSpanItem
|
||||
key={index}
|
||||
span={enrichedChildSpan}
|
||||
depth={depth + 1}
|
||||
totalDuration={totalDuration}
|
||||
isLast={index === (span.children?.length || 0) - 1}
|
||||
parentStartTime={spanStartTime}
|
||||
workflowStartTime={workflowStartTime}
|
||||
onToggle={onToggle}
|
||||
expandedSpans={expandedSpans}
|
||||
hasSubItems={childHasSubItems}
|
||||
forwardHover={forwardHover}
|
||||
gapBeforeMs={childGapMs}
|
||||
gapBeforePercent={childGapPercent}
|
||||
showRelativeChip={chipVisibility.relative}
|
||||
chipVisibility={chipVisibility}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasToolCalls && (
|
||||
<div>
|
||||
{span.toolCalls?.map((toolCall, index) => {
|
||||
const toolStartTime = toolCall.startTime
|
||||
? new Date(toolCall.startTime).getTime()
|
||||
: spanStartTime
|
||||
const toolEndTime = toolCall.endTime
|
||||
? new Date(toolCall.endTime).getTime()
|
||||
: toolStartTime + (toolCall.duration || 0)
|
||||
|
||||
const toolSpan: TraceSpan = {
|
||||
id: `${spanId}-tool-${index}`,
|
||||
name: toolCall.name,
|
||||
type: 'tool',
|
||||
duration: toolCall.duration || toolEndTime - toolStartTime,
|
||||
startTime: new Date(toolStartTime).toISOString(),
|
||||
endTime: new Date(toolEndTime).toISOString(),
|
||||
status: toolCall.error ? 'error' : 'success',
|
||||
input: toolCall.input,
|
||||
output: toolCall.error
|
||||
? { error: toolCall.error, ...(toolCall.output || {}) }
|
||||
: toolCall.output,
|
||||
}
|
||||
|
||||
const hasToolCallData = Boolean(toolCall.input || toolCall.output || toolCall.error)
|
||||
|
||||
return (
|
||||
<TraceSpanItem
|
||||
key={`tool-${index}`}
|
||||
span={toolSpan}
|
||||
depth={depth + 1}
|
||||
totalDuration={totalDuration}
|
||||
isLast={index === (span.toolCalls?.length || 0) - 1}
|
||||
parentStartTime={spanStartTime}
|
||||
workflowStartTime={workflowStartTime}
|
||||
onToggle={onToggle}
|
||||
expandedSpans={expandedSpans}
|
||||
hasSubItems={hasToolCallData}
|
||||
forwardHover={forwardHover}
|
||||
showRelativeChip={chipVisibility.relative}
|
||||
chipVisibility={chipVisibility}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { BlockDataDisplay } from './components/block-data-display'
|
||||
export { CollapsibleInputOutput } from './components/collapsible-input-output'
|
||||
export { TraceSpanItem } from './components/trace-span-item'
|
||||
export * from './utils'
|
||||
@@ -1,755 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { ChevronDown, ChevronRight, Code, Cpu, ExternalLink } from 'lucide-react'
|
||||
import {
|
||||
AgentIcon,
|
||||
ApiIcon,
|
||||
ChartBarIcon,
|
||||
CodeIcon,
|
||||
ConditionalIcon,
|
||||
ConnectIcon,
|
||||
} from '@/components/icons'
|
||||
import { cn, redactApiKeys } from '@/lib/utils'
|
||||
import type { TraceSpan } from '@/stores/logs/filters/types'
|
||||
|
||||
function getSpanKey(span: TraceSpan): string {
|
||||
if (span.id) {
|
||||
return span.id
|
||||
}
|
||||
|
||||
const name = span.name || 'span'
|
||||
const start = span.startTime || 'unknown-start'
|
||||
const end = span.endTime || 'unknown-end'
|
||||
|
||||
return `${name}|${start}|${end}`
|
||||
}
|
||||
|
||||
function mergeTraceSpanChildren(...groups: TraceSpan[][]): TraceSpan[] {
|
||||
const merged: TraceSpan[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
groups.forEach((group) => {
|
||||
group.forEach((child) => {
|
||||
const key = getSpanKey(child)
|
||||
if (seen.has(key)) {
|
||||
return
|
||||
}
|
||||
seen.add(key)
|
||||
merged.push(child)
|
||||
})
|
||||
})
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
function normalizeChildWorkflowSpan(span: TraceSpan): TraceSpan {
|
||||
const enrichedSpan: TraceSpan = { ...span }
|
||||
|
||||
if (enrichedSpan.output && typeof enrichedSpan.output === 'object') {
|
||||
enrichedSpan.output = { ...enrichedSpan.output }
|
||||
}
|
||||
|
||||
const normalizedChildren = Array.isArray(span.children)
|
||||
? span.children.map((childSpan) => normalizeChildWorkflowSpan(childSpan))
|
||||
: []
|
||||
|
||||
const outputChildSpans = Array.isArray(span.output?.childTraceSpans)
|
||||
? (span.output!.childTraceSpans as TraceSpan[]).map((childSpan) =>
|
||||
normalizeChildWorkflowSpan(childSpan)
|
||||
)
|
||||
: []
|
||||
|
||||
const mergedChildren = mergeTraceSpanChildren(normalizedChildren, outputChildSpans)
|
||||
|
||||
if (enrichedSpan.output && 'childTraceSpans' in enrichedSpan.output) {
|
||||
const { childTraceSpans, ...cleanOutput } = enrichedSpan.output as {
|
||||
childTraceSpans?: TraceSpan[]
|
||||
} & Record<string, unknown>
|
||||
enrichedSpan.output = cleanOutput
|
||||
}
|
||||
|
||||
enrichedSpan.children = mergedChildren.length > 0 ? mergedChildren : undefined
|
||||
|
||||
return enrichedSpan
|
||||
}
|
||||
|
||||
interface TraceSpansDisplayProps {
|
||||
traceSpans?: TraceSpan[]
|
||||
totalDuration?: number
|
||||
onExpansionChange?: (expanded: boolean) => void
|
||||
}
|
||||
|
||||
// Transform raw block data into clean, user-friendly format
|
||||
function transformBlockData(data: any, blockType: string, isInput: boolean) {
|
||||
if (!data) return null
|
||||
|
||||
// For input data, filter out sensitive information
|
||||
if (isInput) {
|
||||
const cleanInput = redactApiKeys(data)
|
||||
|
||||
// Remove null/undefined values for cleaner display
|
||||
Object.keys(cleanInput).forEach((key) => {
|
||||
if (cleanInput[key] === null || cleanInput[key] === undefined) {
|
||||
delete cleanInput[key]
|
||||
}
|
||||
})
|
||||
|
||||
return cleanInput
|
||||
}
|
||||
|
||||
// For output data, extract meaningful information based on block type
|
||||
if (data.response) {
|
||||
const response = data.response
|
||||
|
||||
switch (blockType) {
|
||||
case 'agent':
|
||||
return {
|
||||
content: response.content,
|
||||
model: data.model,
|
||||
tokens: data.tokens,
|
||||
toolCalls: response.toolCalls,
|
||||
...(data.cost && { cost: data.cost }),
|
||||
}
|
||||
|
||||
case 'function':
|
||||
return {
|
||||
result: response.result,
|
||||
stdout: response.stdout,
|
||||
...(response.executionTime && { executionTime: `${response.executionTime}ms` }),
|
||||
}
|
||||
|
||||
case 'api':
|
||||
return {
|
||||
data: response.data,
|
||||
status: response.status,
|
||||
headers: response.headers,
|
||||
}
|
||||
|
||||
case 'tool':
|
||||
// For tool calls, show the result data directly
|
||||
return response
|
||||
|
||||
default:
|
||||
// For other block types, show the response content
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
// Collapsible Input/Output component
|
||||
interface CollapsibleInputOutputProps {
|
||||
span: TraceSpan
|
||||
spanId: string
|
||||
depth: number
|
||||
}
|
||||
|
||||
function CollapsibleInputOutput({ span, spanId, depth }: CollapsibleInputOutputProps) {
|
||||
const [inputExpanded, setInputExpanded] = useState(false)
|
||||
const [outputExpanded, setOutputExpanded] = useState(false)
|
||||
|
||||
// Calculate the left margin based on depth to match the parent span's indentation
|
||||
const leftMargin = depth * 16 + 8 + 24 // Base depth indentation + icon width + extra padding
|
||||
|
||||
return (
|
||||
<div
|
||||
className='mt-2 mr-4 mb-4 space-y-3 overflow-hidden'
|
||||
style={{ marginLeft: `${leftMargin}px` }}
|
||||
>
|
||||
{/* Input Data - Collapsible */}
|
||||
{span.input && (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setInputExpanded(!inputExpanded)}
|
||||
className='mb-2 flex items-center gap-2 font-medium text-muted-foreground text-xs transition-colors hover:text-foreground'
|
||||
>
|
||||
{inputExpanded ? (
|
||||
<ChevronDown className='h-3 w-3' />
|
||||
) : (
|
||||
<ChevronRight className='h-3 w-3' />
|
||||
)}
|
||||
Input
|
||||
</button>
|
||||
{inputExpanded && (
|
||||
<div className='mb-2 overflow-hidden rounded-md bg-secondary/30 p-3'>
|
||||
<BlockDataDisplay data={span.input} blockType={span.type} isInput={true} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Output Data - Collapsible */}
|
||||
{span.output && (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setOutputExpanded(!outputExpanded)}
|
||||
className='mb-2 flex items-center gap-2 font-medium text-muted-foreground text-xs transition-colors hover:text-foreground'
|
||||
>
|
||||
{outputExpanded ? (
|
||||
<ChevronDown className='h-3 w-3' />
|
||||
) : (
|
||||
<ChevronRight className='h-3 w-3' />
|
||||
)}
|
||||
{span.status === 'error' ? 'Error Details' : 'Output'}
|
||||
</button>
|
||||
{outputExpanded && (
|
||||
<div className='mb-2 overflow-hidden rounded-md bg-secondary/30 p-3'>
|
||||
<BlockDataDisplay
|
||||
data={span.output}
|
||||
blockType={span.type}
|
||||
isInput={false}
|
||||
isError={span.status === 'error'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Component to display block input/output data in a clean, readable format
|
||||
function BlockDataDisplay({
|
||||
data,
|
||||
blockType,
|
||||
isInput = false,
|
||||
isError = false,
|
||||
}: {
|
||||
data: any
|
||||
blockType?: string
|
||||
isInput?: boolean
|
||||
isError?: boolean
|
||||
}) {
|
||||
if (!data) return null
|
||||
|
||||
// Handle different data types
|
||||
const renderValue = (value: any, key?: string): React.ReactNode => {
|
||||
if (value === null) return <span className='text-muted-foreground italic'>null</span>
|
||||
if (value === undefined) return <span className='text-muted-foreground italic'>undefined</span>
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return <span className='break-all text-emerald-700 dark:text-emerald-400'>"{value}"</span>
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
return <span className='font-mono text-blue-700 dark:text-blue-400'>{value}</span>
|
||||
}
|
||||
|
||||
if (typeof value === 'boolean') {
|
||||
return (
|
||||
<span className='font-mono text-amber-700 dark:text-amber-400'>{value.toString()}</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) return <span className='text-muted-foreground'>[]</span>
|
||||
return (
|
||||
<div className='space-y-0.5'>
|
||||
<span className='text-muted-foreground'>[</span>
|
||||
<div className='ml-2 space-y-0.5'>
|
||||
{value.map((item, index) => (
|
||||
<div key={index} className='flex min-w-0 gap-1.5'>
|
||||
<span className='flex-shrink-0 font-mono text-slate-600 text-xs dark:text-slate-400'>
|
||||
{index}:
|
||||
</span>
|
||||
<div className='min-w-0 flex-1 overflow-hidden'>{renderValue(item)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<span className='text-muted-foreground'>]</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
const entries = Object.entries(value)
|
||||
if (entries.length === 0) return <span className='text-muted-foreground'>{'{}'}</span>
|
||||
|
||||
return (
|
||||
<div className='space-y-0.5'>
|
||||
{entries.map(([objKey, objValue]) => (
|
||||
<div key={objKey} className='flex min-w-0 gap-1.5'>
|
||||
<span className='flex-shrink-0 font-medium text-indigo-700 dark:text-indigo-400'>
|
||||
{objKey}:
|
||||
</span>
|
||||
<div className='min-w-0 flex-1 overflow-hidden'>{renderValue(objValue, objKey)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <span>{String(value)}</span>
|
||||
}
|
||||
|
||||
// Transform the data for better display
|
||||
const transformedData = transformBlockData(data, blockType || 'unknown', isInput)
|
||||
|
||||
// Special handling for error output
|
||||
if (isError && data.error) {
|
||||
return (
|
||||
<div className='space-y-2 text-xs'>
|
||||
<div className='rounded border border-red-200 bg-red-50 p-2 dark:border-red-800 dark:bg-red-950/20'>
|
||||
<div className='mb-1 font-medium text-red-800 dark:text-red-400'>Error</div>
|
||||
<div className='text-red-700 dark:text-red-300'>{data.error}</div>
|
||||
</div>
|
||||
{/* Show other output data if available */}
|
||||
{transformedData &&
|
||||
Object.keys(transformedData).filter((key) => key !== 'error' && key !== 'success')
|
||||
.length > 0 && (
|
||||
<div className='space-y-0.5'>
|
||||
{Object.entries(transformedData)
|
||||
.filter(([key]) => key !== 'error' && key !== 'success')
|
||||
.map(([key, value]) => (
|
||||
<div key={key} className='flex gap-1.5'>
|
||||
<span className='font-medium text-indigo-700 dark:text-indigo-400'>{key}:</span>
|
||||
{renderValue(value, key)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-1 overflow-hidden text-xs'>{renderValue(transformedData || data)}</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function TraceSpansDisplay({
|
||||
traceSpans,
|
||||
totalDuration = 0,
|
||||
onExpansionChange,
|
||||
}: TraceSpansDisplayProps) {
|
||||
// Keep track of expanded spans
|
||||
const [expandedSpans, setExpandedSpans] = useState<Set<string>>(new Set())
|
||||
|
||||
// Early return after all hooks
|
||||
if (!traceSpans || traceSpans.length === 0) {
|
||||
return <div className='text-muted-foreground text-sm'>No trace data available</div>
|
||||
}
|
||||
|
||||
// Find the earliest start time among all spans to be the workflow start time
|
||||
const workflowStartTime = traceSpans.reduce((earliest, span) => {
|
||||
const startTime = new Date(span.startTime).getTime()
|
||||
return startTime < earliest ? startTime : earliest
|
||||
}, Number.POSITIVE_INFINITY)
|
||||
|
||||
// Find the latest end time among all spans
|
||||
const workflowEndTime = traceSpans.reduce((latest, span) => {
|
||||
const endTime = span.endTime ? new Date(span.endTime).getTime() : 0
|
||||
return endTime > latest ? endTime : latest
|
||||
}, 0)
|
||||
|
||||
// Calculate the actual total workflow duration from start to end
|
||||
// This ensures parallel spans are represented correctly in the timeline
|
||||
const actualTotalDuration = workflowEndTime - workflowStartTime
|
||||
|
||||
// Handle span toggling
|
||||
const handleSpanToggle = (spanId: string, expanded: boolean, hasSubItems: boolean) => {
|
||||
const newExpandedSpans = new Set(expandedSpans)
|
||||
if (expanded) {
|
||||
newExpandedSpans.add(spanId)
|
||||
} else {
|
||||
newExpandedSpans.delete(spanId)
|
||||
}
|
||||
setExpandedSpans(newExpandedSpans)
|
||||
|
||||
// Only notify parent component if this span has children or tool calls
|
||||
if (onExpansionChange && hasSubItems) {
|
||||
onExpansionChange(newExpandedSpans.size > 0)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='w-full'>
|
||||
<div className='mb-2 flex items-center justify-between'>
|
||||
<div className='font-medium text-muted-foreground text-xs'>Workflow Execution</div>
|
||||
</div>
|
||||
<div className='w-full overflow-hidden rounded-md border shadow-sm'>
|
||||
{traceSpans.map((span, index) => {
|
||||
const normalizedSpan = normalizeChildWorkflowSpan(span)
|
||||
const hasSubItems = Boolean(
|
||||
(normalizedSpan.children && normalizedSpan.children.length > 0) ||
|
||||
(normalizedSpan.toolCalls && normalizedSpan.toolCalls.length > 0) ||
|
||||
normalizedSpan.input ||
|
||||
normalizedSpan.output
|
||||
)
|
||||
return (
|
||||
<TraceSpanItem
|
||||
key={index}
|
||||
span={normalizedSpan}
|
||||
depth={0}
|
||||
totalDuration={
|
||||
actualTotalDuration !== undefined ? actualTotalDuration : totalDuration
|
||||
}
|
||||
isLast={index === traceSpans.length - 1}
|
||||
parentStartTime={new Date(normalizedSpan.startTime).getTime()}
|
||||
workflowStartTime={workflowStartTime}
|
||||
onToggle={handleSpanToggle}
|
||||
expandedSpans={expandedSpans}
|
||||
hasSubItems={hasSubItems}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface TraceSpanItemProps {
|
||||
span: TraceSpan
|
||||
depth: number
|
||||
totalDuration: number
|
||||
isLast: boolean
|
||||
parentStartTime: number // Start time of the parent span for offset calculation
|
||||
workflowStartTime: number // Start time of the entire workflow
|
||||
onToggle: (spanId: string, expanded: boolean, hasSubItems: boolean) => void
|
||||
expandedSpans: Set<string>
|
||||
hasSubItems?: boolean
|
||||
}
|
||||
|
||||
function TraceSpanItem({
|
||||
span,
|
||||
depth,
|
||||
totalDuration,
|
||||
isLast,
|
||||
parentStartTime,
|
||||
workflowStartTime,
|
||||
onToggle,
|
||||
expandedSpans,
|
||||
hasSubItems = false,
|
||||
}: TraceSpanItemProps): React.ReactNode {
|
||||
const spanId = span.id || `span-${span.name}-${span.startTime}`
|
||||
const expanded = expandedSpans.has(spanId)
|
||||
const hasChildren = span.children && span.children.length > 0
|
||||
const hasToolCalls = span.toolCalls && span.toolCalls.length > 0
|
||||
const hasInputOutput = Boolean(span.input || span.output)
|
||||
const hasNestedItems = hasChildren || hasToolCalls || hasInputOutput
|
||||
|
||||
// Calculate timing information
|
||||
const spanStartTime = new Date(span.startTime).getTime()
|
||||
const spanEndTime = new Date(span.endTime).getTime()
|
||||
const duration = span.duration || spanEndTime - spanStartTime
|
||||
const startOffset = spanStartTime - parentStartTime // Time from parent start to this span's start
|
||||
|
||||
// Calculate the position relative to the workflow start time for accurate timeline visualization
|
||||
// For parallel execution, this ensures spans align correctly based on their actual start time
|
||||
const relativeStartPercent =
|
||||
totalDuration > 0 ? ((spanStartTime - workflowStartTime) / totalDuration) * 100 : 0
|
||||
|
||||
// Calculate width based on the span's actual duration relative to total workflow duration
|
||||
const actualDurationPercent = totalDuration > 0 ? (duration / totalDuration) * 100 : 0
|
||||
|
||||
// Ensure values are within valid range
|
||||
const safeStartPercent = Math.min(100, Math.max(0, relativeStartPercent))
|
||||
const safeWidthPercent = Math.max(2, Math.min(100 - safeStartPercent, actualDurationPercent))
|
||||
|
||||
// Handle click to expand/collapse this span
|
||||
const handleSpanClick = () => {
|
||||
if (hasNestedItems) {
|
||||
onToggle(spanId, !expanded, hasNestedItems)
|
||||
}
|
||||
}
|
||||
|
||||
// Get appropriate icon based on span type
|
||||
const getSpanIcon = () => {
|
||||
const type = span.type.toLowerCase()
|
||||
|
||||
// Expand/collapse for spans with children
|
||||
if (hasNestedItems) {
|
||||
return expanded ? <ChevronDown className='h-4 w-4' /> : <ChevronRight className='h-4 w-4' />
|
||||
}
|
||||
|
||||
// Block type specific icons
|
||||
if (type === 'agent') {
|
||||
return <AgentIcon className='h-3 w-3 text-[var(--brand-primary-hover-hex)]' />
|
||||
}
|
||||
|
||||
if (type === 'evaluator') {
|
||||
return <ChartBarIcon className='h-3 w-3 text-[#2FA1FF]' />
|
||||
}
|
||||
|
||||
if (type === 'condition') {
|
||||
return <ConditionalIcon className='h-3 w-3 text-[#FF972F]' />
|
||||
}
|
||||
|
||||
if (type === 'router') {
|
||||
return <ConnectIcon className='h-3 w-3 text-[#2FA1FF]' />
|
||||
}
|
||||
|
||||
if (type === 'model') {
|
||||
return <Cpu className='h-3 w-3 text-[#10a37f]' />
|
||||
}
|
||||
|
||||
if (type === 'function') {
|
||||
return <CodeIcon className='h-3 w-3 text-[#FF402F]' />
|
||||
}
|
||||
|
||||
if (type === 'tool') {
|
||||
return <ExternalLink className='h-3 w-3 text-[#f97316]' />
|
||||
}
|
||||
|
||||
if (type === 'api') {
|
||||
return <ApiIcon className='h-3 w-3 text-[#2F55FF]' />
|
||||
}
|
||||
|
||||
return <Code className='h-3 w-3 text-muted-foreground' />
|
||||
}
|
||||
|
||||
// Format milliseconds as +XXms for relative timing
|
||||
const formatRelativeTime = (ms: number) => {
|
||||
if (ms === 0) return 'start'
|
||||
return `+${ms}ms`
|
||||
}
|
||||
|
||||
// Get color based on span type
|
||||
const getSpanColor = (type: string) => {
|
||||
switch (type.toLowerCase()) {
|
||||
case 'agent':
|
||||
return 'var(--brand-primary-hover-hex)' // Purple from AgentBlock
|
||||
case 'provider':
|
||||
return '#818cf8' // Indigo for provider
|
||||
case 'model':
|
||||
return '#10a37f' // Green from OpenAIBlock
|
||||
case 'function':
|
||||
return '#FF402F' // Orange-red from FunctionBlock
|
||||
case 'tool':
|
||||
return '#f97316' // Orange for tools
|
||||
case 'router':
|
||||
return '#2FA1FF' // Blue from RouterBlock
|
||||
case 'condition':
|
||||
return '#FF972F' // Orange from ConditionBlock
|
||||
case 'evaluator':
|
||||
return '#2FA1FF' // Blue from EvaluatorBlock
|
||||
case 'api':
|
||||
return '#2F55FF' // Blue from ApiBlock
|
||||
default:
|
||||
return '#6b7280' // Gray for others
|
||||
}
|
||||
}
|
||||
|
||||
const spanColor = getSpanColor(span.type)
|
||||
|
||||
// Format duration to be more readable
|
||||
const formatDuration = (ms: number) => {
|
||||
if (ms < 1000) return `${ms}ms`
|
||||
return `${(ms / 1000).toFixed(2)}s`
|
||||
}
|
||||
|
||||
// Extract model name from span name using a more general pattern
|
||||
const extractModelName = (spanName: string) => {
|
||||
// Try to match model name in parentheses
|
||||
const modelMatch = spanName.match(/\(([\w.-]+)\)/i)
|
||||
return modelMatch ? modelMatch[1] : ''
|
||||
}
|
||||
|
||||
// Format span name for display
|
||||
const formatSpanName = (span: TraceSpan) => {
|
||||
if (span.type === 'model') {
|
||||
const modelName = extractModelName(span.name)
|
||||
|
||||
if (span.name.includes('Initial response')) {
|
||||
return (
|
||||
<>
|
||||
Initial response{' '}
|
||||
{modelName && <span className='text-xs opacity-75'>({modelName})</span>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
if (span.name.includes('(iteration')) {
|
||||
const iterMatch = span.name.match(/\(iteration (\d+)\)/)
|
||||
const iterNum = iterMatch ? iterMatch[1] : ''
|
||||
return (
|
||||
<>
|
||||
Model response{' '}
|
||||
{iterNum && <span className='text-xs opacity-75'>(iteration {iterNum})</span>}{' '}
|
||||
{modelName && <span className='text-xs opacity-75'>({modelName})</span>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
if (span.name.includes('Model Generation')) {
|
||||
return (
|
||||
<>
|
||||
Model Generation{' '}
|
||||
{modelName && <span className='text-xs opacity-75'>({modelName})</span>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return span.name
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'border-b transition-colors last:border-b-0',
|
||||
expanded ? 'bg-accent/30' : 'hover:bg-accent/20'
|
||||
)}
|
||||
>
|
||||
{/* Span header */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center px-2 py-1.5',
|
||||
hasNestedItems ? 'cursor-pointer' : 'cursor-default'
|
||||
)}
|
||||
onClick={handleSpanClick}
|
||||
style={{ paddingLeft: `${depth * 16 + 8}px` }}
|
||||
>
|
||||
<div className='mr-2 flex w-5 flex-shrink-0 items-center justify-center'>
|
||||
{getSpanIcon()}
|
||||
</div>
|
||||
|
||||
<div className='flex min-w-0 flex-1 items-center gap-2 overflow-hidden'>
|
||||
<div className='min-w-0 flex-shrink overflow-hidden'>
|
||||
<div className='mb-0.5 flex items-center space-x-2'>
|
||||
<span
|
||||
className={cn(
|
||||
'truncate font-medium text-sm',
|
||||
span.status === 'error' && 'text-red-500'
|
||||
)}
|
||||
>
|
||||
{formatSpanName(span)}
|
||||
</span>
|
||||
{depth > 0 && (
|
||||
<span className='flex-shrink-0 whitespace-nowrap text-muted-foreground text-xs'>
|
||||
{span.relativeStartMs !== undefined
|
||||
? `+${span.relativeStartMs}ms`
|
||||
: formatRelativeTime(startOffset)}
|
||||
</span>
|
||||
)}
|
||||
{depth === 0 && (
|
||||
<span
|
||||
className='flex-shrink-0 whitespace-nowrap text-muted-foreground text-xs'
|
||||
title={`Start time: ${new Date(span.startTime).toLocaleTimeString()}`}
|
||||
>
|
||||
{new Date(span.startTime).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className='block text-muted-foreground text-xs'>{formatDuration(duration)}</span>
|
||||
</div>
|
||||
|
||||
<div className='ml-auto flex w-[40%] flex-shrink-0 items-center gap-2'>
|
||||
{/* Timeline visualization - responsive width based on container size */}
|
||||
<div className='relative hidden h-2 min-w-[15%] flex-1 flex-shrink-0 overflow-hidden rounded-full bg-accent/40 sm:block'>
|
||||
<div
|
||||
className='absolute h-full rounded-full'
|
||||
style={{
|
||||
left: `${safeStartPercent}%`,
|
||||
width: `${safeWidthPercent}%`,
|
||||
backgroundColor: spanColor,
|
||||
}}
|
||||
title={`Start: ${new Date(span.startTime).toISOString()}, End: ${new Date(span.endTime).toISOString()}, Duration: ${duration}ms`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Duration text - always show in ms */}
|
||||
<span className='w-[65px] flex-shrink-0 text-right font-mono text-muted-foreground text-xs tabular-nums'>
|
||||
{`${duration}ms`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded content */}
|
||||
{expanded && (
|
||||
<div>
|
||||
{/* Block Input/Output Data - Collapsible */}
|
||||
{(span.input || span.output) && (
|
||||
<CollapsibleInputOutput span={span} spanId={spanId} depth={depth} />
|
||||
)}
|
||||
|
||||
{/* Children and tool calls */}
|
||||
{/* Render child spans */}
|
||||
{hasChildren && (
|
||||
<div>
|
||||
{span.children?.map((childSpan, index) => {
|
||||
const enrichedChildSpan = normalizeChildWorkflowSpan(childSpan)
|
||||
|
||||
const childHasSubItems = Boolean(
|
||||
(enrichedChildSpan.children && enrichedChildSpan.children.length > 0) ||
|
||||
(enrichedChildSpan.toolCalls && enrichedChildSpan.toolCalls.length > 0) ||
|
||||
enrichedChildSpan.input ||
|
||||
enrichedChildSpan.output
|
||||
)
|
||||
|
||||
return (
|
||||
<TraceSpanItem
|
||||
key={index}
|
||||
span={enrichedChildSpan}
|
||||
depth={depth + 1}
|
||||
totalDuration={totalDuration}
|
||||
isLast={index === (span.children?.length || 0) - 1}
|
||||
parentStartTime={spanStartTime}
|
||||
workflowStartTime={workflowStartTime}
|
||||
onToggle={onToggle}
|
||||
expandedSpans={expandedSpans}
|
||||
hasSubItems={childHasSubItems}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Render tool calls as spans */}
|
||||
{hasToolCalls && (
|
||||
<div>
|
||||
{span.toolCalls?.map((toolCall, index) => {
|
||||
// Create a pseudo-span for each tool call
|
||||
const toolStartTime = toolCall.startTime
|
||||
? new Date(toolCall.startTime).getTime()
|
||||
: spanStartTime
|
||||
const toolEndTime = toolCall.endTime
|
||||
? new Date(toolCall.endTime).getTime()
|
||||
: toolStartTime + (toolCall.duration || 0)
|
||||
|
||||
const toolSpan: TraceSpan = {
|
||||
id: `${spanId}-tool-${index}`,
|
||||
name: toolCall.name,
|
||||
type: 'tool',
|
||||
duration: toolCall.duration || toolEndTime - toolStartTime,
|
||||
startTime: new Date(toolStartTime).toISOString(),
|
||||
endTime: new Date(toolEndTime).toISOString(),
|
||||
status: toolCall.error ? 'error' : 'success',
|
||||
// Include tool call arguments as input and result as output
|
||||
input: toolCall.input,
|
||||
output: toolCall.error
|
||||
? { error: toolCall.error, ...(toolCall.output || {}) }
|
||||
: toolCall.output,
|
||||
}
|
||||
|
||||
// Tool calls now have input/output data to display
|
||||
const hasToolCallData = Boolean(toolCall.input || toolCall.output || toolCall.error)
|
||||
|
||||
return (
|
||||
<TraceSpanItem
|
||||
key={`tool-${index}`}
|
||||
span={toolSpan}
|
||||
depth={depth + 1}
|
||||
totalDuration={totalDuration}
|
||||
isLast={index === (span.toolCalls?.length || 0) - 1}
|
||||
parentStartTime={spanStartTime}
|
||||
workflowStartTime={workflowStartTime}
|
||||
onToggle={onToggle}
|
||||
expandedSpans={expandedSpans}
|
||||
hasSubItems={hasToolCallData}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Maximize2, Minimize2 } from 'lucide-react'
|
||||
import {
|
||||
formatDurationDisplay,
|
||||
normalizeChildWorkflowSpan,
|
||||
TraceSpanItem,
|
||||
} from '@/app/workspace/[workspaceId]/logs/components/trace-spans'
|
||||
import type { TraceSpan } from '@/stores/logs/filters/types'
|
||||
|
||||
interface TraceSpansProps {
|
||||
traceSpans?: TraceSpan[]
|
||||
totalDuration?: number
|
||||
onExpansionChange?: (expanded: boolean) => void
|
||||
}
|
||||
|
||||
export function TraceSpans({ traceSpans, totalDuration = 0, onExpansionChange }: TraceSpansProps) {
|
||||
const [expandedSpans, setExpandedSpans] = useState<Set<string>>(new Set())
|
||||
const [typeFilters, setTypeFilters] = useState<Record<string, boolean>>({})
|
||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||
const timelineHitboxRef = useRef<HTMLDivElement | null>(null)
|
||||
const [hoveredPercent, setHoveredPercent] = useState<number | null>(null)
|
||||
const [hoveredWorkflowMs, setHoveredWorkflowMs] = useState<number | null>(null)
|
||||
const [hoveredX, setHoveredX] = useState<number | null>(null)
|
||||
const [containerWidth, setContainerWidth] = useState<number>(0)
|
||||
|
||||
type ChipVisibility = {
|
||||
model: boolean
|
||||
toolProvider: boolean
|
||||
tokens: boolean
|
||||
cost: boolean
|
||||
relative: boolean
|
||||
}
|
||||
|
||||
const chipVisibility: ChipVisibility = useMemo(() => {
|
||||
const leftBudget = containerWidth * 0.55
|
||||
return {
|
||||
model: leftBudget >= 300, // first to reveal
|
||||
toolProvider: leftBudget >= 300, // alongside model
|
||||
tokens: leftBudget >= 380, // then tokens
|
||||
cost: leftBudget >= 460, // then cost
|
||||
relative: leftBudget >= 540, // finally relative timing
|
||||
}
|
||||
}, [containerWidth])
|
||||
|
||||
if (!traceSpans || traceSpans.length === 0) {
|
||||
return <div className='text-muted-foreground text-sm'>No trace data available</div>
|
||||
}
|
||||
|
||||
const workflowStartTime = traceSpans.reduce((earliest, span) => {
|
||||
const startTime = new Date(span.startTime).getTime()
|
||||
return startTime < earliest ? startTime : earliest
|
||||
}, Number.POSITIVE_INFINITY)
|
||||
|
||||
const workflowEndTime = traceSpans.reduce((latest, span) => {
|
||||
const endTime = span.endTime ? new Date(span.endTime).getTime() : 0
|
||||
return endTime > latest ? endTime : latest
|
||||
}, 0)
|
||||
|
||||
const actualTotalDuration = workflowEndTime - workflowStartTime
|
||||
|
||||
const handleSpanToggle = (spanId: string, expanded: boolean, hasSubItems: boolean) => {
|
||||
const newExpandedSpans = new Set(expandedSpans)
|
||||
if (expanded) {
|
||||
newExpandedSpans.add(spanId)
|
||||
} else {
|
||||
newExpandedSpans.delete(spanId)
|
||||
}
|
||||
setExpandedSpans(newExpandedSpans)
|
||||
|
||||
if (onExpansionChange && hasSubItems) {
|
||||
onExpansionChange(newExpandedSpans.size > 0)
|
||||
}
|
||||
}
|
||||
|
||||
const availableTypes = useMemo(() => {
|
||||
const set = new Set<string>()
|
||||
const visit = (spans?: TraceSpan[]) => {
|
||||
if (!spans) return
|
||||
for (const s of spans) {
|
||||
if (s?.type) {
|
||||
const tl = s.type.toLowerCase()
|
||||
if (tl !== 'workflow') set.add(tl) // Never expose 'workflow' as a filter
|
||||
}
|
||||
if (s?.children?.length) visit(s.children)
|
||||
if (s?.toolCalls?.length) set.add('tool')
|
||||
}
|
||||
}
|
||||
visit(traceSpans)
|
||||
return Array.from(set).sort()
|
||||
}, [traceSpans])
|
||||
|
||||
const effectiveTypeFilters = useMemo(() => {
|
||||
if (!availableTypes.length) return {}
|
||||
if (Object.keys(typeFilters).length === 0) {
|
||||
const all: Record<string, boolean> = {}
|
||||
availableTypes.forEach((t) => (all[t] = true))
|
||||
return all
|
||||
}
|
||||
const merged = { ...typeFilters }
|
||||
availableTypes.forEach((t) => {
|
||||
if (merged[t] === undefined) merged[t] = true
|
||||
})
|
||||
return merged
|
||||
}, [availableTypes, typeFilters])
|
||||
|
||||
const toggleAll = (expand: boolean) => {
|
||||
if (!traceSpans) return
|
||||
const next = new Set<string>()
|
||||
if (expand) {
|
||||
const collect = (spans: TraceSpan[]) => {
|
||||
for (const s of spans) {
|
||||
const id = s.id || `span-${s.name}-${s.startTime}`
|
||||
next.add(id)
|
||||
if (s.children?.length) collect(s.children)
|
||||
if (s?.toolCalls?.length) next.add(`${id}-tools`)
|
||||
}
|
||||
}
|
||||
collect(traceSpans)
|
||||
}
|
||||
setExpandedSpans(next)
|
||||
onExpansionChange?.(expand)
|
||||
}
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const allowed = new Set(
|
||||
Object.entries(effectiveTypeFilters)
|
||||
.filter(([, v]) => v)
|
||||
.map(([k]) => k)
|
||||
)
|
||||
const filterTree = (spans: TraceSpan[]): TraceSpan[] =>
|
||||
spans
|
||||
.map((s) => ({ ...s }))
|
||||
.filter((s) => {
|
||||
const tl = s.type?.toLowerCase?.() || ''
|
||||
if (tl === 'workflow') return true
|
||||
return allowed.has(tl)
|
||||
})
|
||||
.map((s) => ({
|
||||
...s,
|
||||
children: s.children ? filterTree(s.children) : undefined,
|
||||
}))
|
||||
return traceSpans ? filterTree(traceSpans) : []
|
||||
}, [traceSpans, effectiveTypeFilters])
|
||||
|
||||
const forwardHover = useCallback(
|
||||
(clientX: number, clientY: number) => {
|
||||
if (!timelineHitboxRef.current || !containerRef.current) return
|
||||
|
||||
const railRect = timelineHitboxRef.current.getBoundingClientRect()
|
||||
const containerRect = containerRef.current.getBoundingClientRect()
|
||||
|
||||
const withinX = clientX >= railRect.left && clientX <= railRect.right
|
||||
const withinY = clientY >= railRect.top && clientY <= railRect.bottom
|
||||
|
||||
if (!withinX || !withinY) {
|
||||
setHoveredPercent(null)
|
||||
setHoveredWorkflowMs(null)
|
||||
setHoveredX(null)
|
||||
return
|
||||
}
|
||||
|
||||
const clamped = Math.max(0, Math.min(1, (clientX - railRect.left) / railRect.width))
|
||||
setHoveredPercent(clamped * 100)
|
||||
setHoveredWorkflowMs(workflowStartTime + clamped * actualTotalDuration)
|
||||
setHoveredX(railRect.left + clamped * railRect.width - containerRect.left)
|
||||
},
|
||||
[actualTotalDuration, workflowStartTime]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const handleMove = (event: MouseEvent) => {
|
||||
forwardHover(event.clientX, event.clientY)
|
||||
}
|
||||
|
||||
window.addEventListener('pointermove', handleMove)
|
||||
return () => window.removeEventListener('pointermove', handleMove)
|
||||
}, [forwardHover])
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return
|
||||
const el = containerRef.current
|
||||
const ro = new ResizeObserver((entries: ResizeObserverEntry[]) => {
|
||||
const width = entries?.[0]?.contentRect?.width || el.clientWidth
|
||||
setContainerWidth(width)
|
||||
})
|
||||
ro.observe(el)
|
||||
setContainerWidth(el.clientWidth)
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className='w-full'>
|
||||
<div className='mb-2 flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='font-medium text-muted-foreground text-xs'>Workflow Execution</div>
|
||||
</div>
|
||||
<div className='flex items-center gap-1'>
|
||||
{(() => {
|
||||
const anyExpanded = expandedSpans.size > 0
|
||||
return (
|
||||
<button
|
||||
onClick={() => toggleAll(!anyExpanded)}
|
||||
className='rounded px-2 py-1 text-muted-foreground text-xs transition-colors hover:bg-accent'
|
||||
title={anyExpanded ? 'Collapse all' : 'Expand all'}
|
||||
>
|
||||
{anyExpanded ? (
|
||||
<>
|
||||
<Minimize2 className='mr-1 inline h-3.5 w-3.5' /> Collapse
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Maximize2 className='mr-1 inline h-3.5 w-3.5' /> Expand
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className='relative w-full overflow-hidden rounded-md border shadow-sm'
|
||||
onMouseLeave={() => {
|
||||
setHoveredPercent(null)
|
||||
setHoveredWorkflowMs(null)
|
||||
setHoveredX(null)
|
||||
}}
|
||||
>
|
||||
{filtered.map((span, index) => {
|
||||
const normalizedSpan = normalizeChildWorkflowSpan(span)
|
||||
const hasSubItems = Boolean(
|
||||
(normalizedSpan.children && normalizedSpan.children.length > 0) ||
|
||||
(normalizedSpan.toolCalls && normalizedSpan.toolCalls.length > 0) ||
|
||||
normalizedSpan.input ||
|
||||
normalizedSpan.output
|
||||
)
|
||||
|
||||
// Calculate gap from previous span (for sequential execution visualization)
|
||||
let gapMs = 0
|
||||
let gapPercent = 0
|
||||
if (index > 0) {
|
||||
const prevSpan = filtered[index - 1]
|
||||
const prevEndTime = new Date(prevSpan.endTime).getTime()
|
||||
const currentStartTime = new Date(normalizedSpan.startTime).getTime()
|
||||
gapMs = currentStartTime - prevEndTime
|
||||
if (gapMs > 0 && actualTotalDuration > 0) {
|
||||
gapPercent = (gapMs / actualTotalDuration) * 100
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<TraceSpanItem
|
||||
key={index}
|
||||
span={normalizedSpan}
|
||||
depth={0}
|
||||
totalDuration={
|
||||
actualTotalDuration !== undefined ? actualTotalDuration : totalDuration
|
||||
}
|
||||
isLast={index === traceSpans.length - 1}
|
||||
parentStartTime={new Date(normalizedSpan.startTime).getTime()}
|
||||
workflowStartTime={workflowStartTime}
|
||||
onToggle={handleSpanToggle}
|
||||
expandedSpans={expandedSpans}
|
||||
hasSubItems={hasSubItems}
|
||||
hoveredPercent={hoveredPercent}
|
||||
hoveredWorkflowMs={hoveredWorkflowMs}
|
||||
forwardHover={forwardHover}
|
||||
gapBeforeMs={gapMs}
|
||||
gapBeforePercent={gapPercent}
|
||||
showRelativeChip={chipVisibility.relative}
|
||||
chipVisibility={chipVisibility}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Global crosshair spanning all rows with visible time label */}
|
||||
{hoveredPercent !== null && hoveredX !== null && (
|
||||
<>
|
||||
<div
|
||||
className='pointer-events-none absolute inset-y-0 w-px bg-black/30 dark:bg-white/45'
|
||||
style={{ left: hoveredX, zIndex: 20 }}
|
||||
/>
|
||||
<div
|
||||
className='-translate-x-1/2 pointer-events-none absolute top-1 rounded bg-popover px-1.5 py-0.5 text-[10px] text-foreground shadow'
|
||||
style={{ left: hoveredX, zIndex: 20 }}
|
||||
>
|
||||
{formatDurationDisplay(Math.max(0, (hoveredWorkflowMs || 0) - workflowStartTime))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Hover capture area - aligned to timeline bars, not extending to edge */}
|
||||
<div
|
||||
ref={timelineHitboxRef}
|
||||
className='pointer-events-auto absolute inset-y-0 right-[73px] w-[calc(45%-73px)]'
|
||||
onPointerMove={(e) => forwardHover(e.clientX, e.clientY)}
|
||||
onPointerLeave={() => {
|
||||
setHoveredPercent(null)
|
||||
setHoveredWorkflowMs(null)
|
||||
setHoveredX(null)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { redactApiKeys } from '@/lib/utils'
|
||||
import type { TraceSpan } from '@/stores/logs/filters/types'
|
||||
|
||||
export function getSpanKey(span: TraceSpan): string {
|
||||
if (span.id) {
|
||||
return span.id
|
||||
}
|
||||
|
||||
const name = span.name || 'span'
|
||||
const start = span.startTime || 'unknown-start'
|
||||
const end = span.endTime || 'unknown-end'
|
||||
|
||||
return `${name}|${start}|${end}`
|
||||
}
|
||||
|
||||
export function mergeTraceSpanChildren(...groups: TraceSpan[][]): TraceSpan[] {
|
||||
const merged: TraceSpan[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
groups.forEach((group) => {
|
||||
group.forEach((child) => {
|
||||
const key = getSpanKey(child)
|
||||
if (seen.has(key)) {
|
||||
return
|
||||
}
|
||||
seen.add(key)
|
||||
merged.push(child)
|
||||
})
|
||||
})
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
export function normalizeChildWorkflowSpan(span: TraceSpan): TraceSpan {
|
||||
const enrichedSpan: TraceSpan = { ...span }
|
||||
|
||||
if (enrichedSpan.output && typeof enrichedSpan.output === 'object') {
|
||||
enrichedSpan.output = { ...enrichedSpan.output }
|
||||
}
|
||||
|
||||
const normalizedChildren = Array.isArray(span.children)
|
||||
? span.children.map((childSpan) => normalizeChildWorkflowSpan(childSpan))
|
||||
: []
|
||||
|
||||
const outputChildSpans = Array.isArray(span.output?.childTraceSpans)
|
||||
? (span.output!.childTraceSpans as TraceSpan[]).map((childSpan) =>
|
||||
normalizeChildWorkflowSpan(childSpan)
|
||||
)
|
||||
: []
|
||||
|
||||
const mergedChildren = mergeTraceSpanChildren(normalizedChildren, outputChildSpans)
|
||||
|
||||
if (enrichedSpan.output && 'childTraceSpans' in enrichedSpan.output) {
|
||||
const { childTraceSpans, ...cleanOutput } = enrichedSpan.output as {
|
||||
childTraceSpans?: TraceSpan[]
|
||||
} & Record<string, unknown>
|
||||
enrichedSpan.output = cleanOutput
|
||||
}
|
||||
|
||||
enrichedSpan.children = mergedChildren.length > 0 ? mergedChildren : undefined
|
||||
|
||||
return enrichedSpan
|
||||
}
|
||||
|
||||
export function transformBlockData(data: unknown, blockType: string, isInput: boolean) {
|
||||
if (!data) return null
|
||||
|
||||
if (isInput) {
|
||||
const cleanInput = redactApiKeys(data)
|
||||
|
||||
Object.keys(cleanInput).forEach((key) => {
|
||||
if (cleanInput[key] === null || cleanInput[key] === undefined) {
|
||||
delete cleanInput[key]
|
||||
}
|
||||
})
|
||||
|
||||
return cleanInput
|
||||
}
|
||||
|
||||
if (typeof data === 'object' && data !== null && 'response' in data) {
|
||||
const dataWithResponse = data as Record<string, unknown>
|
||||
const response = dataWithResponse.response as Record<string, unknown>
|
||||
|
||||
switch (blockType) {
|
||||
case 'agent':
|
||||
return {
|
||||
content: response.content,
|
||||
model: 'model' in dataWithResponse ? dataWithResponse.model : undefined,
|
||||
tokens: 'tokens' in dataWithResponse ? dataWithResponse.tokens : undefined,
|
||||
toolCalls: response.toolCalls,
|
||||
...('cost' in dataWithResponse && dataWithResponse.cost
|
||||
? { cost: dataWithResponse.cost }
|
||||
: {}),
|
||||
}
|
||||
|
||||
case 'function':
|
||||
return {
|
||||
result: response.result,
|
||||
stdout: response.stdout,
|
||||
...('executionTime' in response && response.executionTime
|
||||
? { executionTime: `${response.executionTime}ms` }
|
||||
: {}),
|
||||
}
|
||||
|
||||
case 'api':
|
||||
return {
|
||||
data: response.data,
|
||||
status: response.status,
|
||||
headers: response.headers,
|
||||
}
|
||||
|
||||
case 'tool':
|
||||
return response
|
||||
|
||||
default:
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export function formatDurationDisplay(ms: number): string {
|
||||
if (ms < 1000) {
|
||||
return `${ms.toFixed(0)}ms`
|
||||
}
|
||||
return `${(ms / 1000).toFixed(2)}s`
|
||||
}
|
||||
@@ -68,8 +68,6 @@ export const SubBlock = memo(
|
||||
}: SubBlockProps) {
|
||||
const [isValidJson, setIsValidJson] = useState(true)
|
||||
|
||||
// Removed debug logging for performance
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { useEffect } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { BaseEdge, EdgeLabelRenderer, type EdgeProps, getSmoothStepPath } from 'reactflow'
|
||||
import type { EdgeDiffStatus } from '@/lib/workflows/diff/types'
|
||||
import { useWorkflowDiffStore } from '@/stores/workflow-diff'
|
||||
import { useCurrentWorkflow } from '../../hooks'
|
||||
|
||||
interface WorkflowEdgeProps extends EdgeProps {
|
||||
sourceHandle?: string | null
|
||||
@@ -38,110 +36,45 @@ export const WorkflowEdge = ({
|
||||
offset: isHorizontal ? 30 : 20,
|
||||
})
|
||||
|
||||
// Use the directly provided isSelected flag instead of computing it
|
||||
const isSelected = data?.isSelected ?? false
|
||||
const isInsideLoop = data?.isInsideLoop ?? false
|
||||
const parentLoopId = data?.parentLoopId
|
||||
|
||||
// Get edge diff status
|
||||
const diffAnalysis = useWorkflowDiffStore((state) => state.diffAnalysis)
|
||||
const isShowingDiff = useWorkflowDiffStore((state) => state.isShowingDiff)
|
||||
const isDiffReady = useWorkflowDiffStore((state) => state.isDiffReady)
|
||||
const currentWorkflow = useCurrentWorkflow()
|
||||
|
||||
// Generate edge identifier using block IDs to match diff analysis from sim agent
|
||||
// This must exactly match the logic used by the sim agent diff analysis
|
||||
const generateEdgeIdentity = (
|
||||
sourceId: string,
|
||||
targetId: string,
|
||||
sourceHandle?: string | null,
|
||||
targetHandle?: string | null
|
||||
): string => {
|
||||
// The diff analysis generates edge identifiers in the format: sourceId-sourceHandle-targetId-targetHandle
|
||||
// Use actual handle names, defaulting to 'source' and 'target' if not provided
|
||||
const actualSourceHandle = sourceHandle || 'source'
|
||||
const actualTargetHandle = targetHandle || 'target'
|
||||
return `${sourceId}-${actualSourceHandle}-${targetId}-${actualTargetHandle}`
|
||||
}
|
||||
|
||||
// Generate edge identifier using the exact same logic as the diff engine
|
||||
const edgeIdentifier = generateEdgeIdentity(source, target, sourceHandle, targetHandle)
|
||||
|
||||
// Debug logging to understand what's happening
|
||||
useEffect(() => {
|
||||
if (edgeIdentifier && diffAnalysis?.edge_diff) {
|
||||
console.log(`[Edge Debug] Edge ${id}:`, {
|
||||
edgeIdentifier,
|
||||
sourceHandle,
|
||||
targetHandle,
|
||||
sourceBlockId: source,
|
||||
targetBlockId: target,
|
||||
isShowingDiff,
|
||||
isDiffMode: currentWorkflow.isDiffMode,
|
||||
edgeDiffAnalysis: diffAnalysis.edge_diff,
|
||||
// Show actual array contents to see why matching fails
|
||||
newEdgesArray: diffAnalysis.edge_diff.new_edges,
|
||||
deletedEdgesArray: diffAnalysis.edge_diff.deleted_edges,
|
||||
unchangedEdgesArray: diffAnalysis.edge_diff.unchanged_edges,
|
||||
// Check if this edge matches any in the diff analysis
|
||||
matchesNew: diffAnalysis.edge_diff.new_edges.includes(edgeIdentifier),
|
||||
matchesDeleted: diffAnalysis.edge_diff.deleted_edges.includes(edgeIdentifier),
|
||||
matchesUnchanged: diffAnalysis.edge_diff.unchanged_edges.includes(edgeIdentifier),
|
||||
})
|
||||
}
|
||||
}, [
|
||||
edgeIdentifier,
|
||||
diffAnalysis,
|
||||
isShowingDiff,
|
||||
id,
|
||||
sourceHandle,
|
||||
targetHandle,
|
||||
source,
|
||||
target,
|
||||
currentWorkflow.isDiffMode,
|
||||
])
|
||||
|
||||
// One-time debug log of full diff analysis
|
||||
useEffect(() => {
|
||||
if (diffAnalysis && id === Object.keys(currentWorkflow.blocks)[0]) {
|
||||
// Only log once per diff
|
||||
console.log('[Full Diff Analysis]:', {
|
||||
edge_diff: diffAnalysis.edge_diff,
|
||||
new_blocks: diffAnalysis.new_blocks,
|
||||
edited_blocks: diffAnalysis.edited_blocks,
|
||||
deleted_blocks: diffAnalysis.deleted_blocks,
|
||||
isShowingDiff,
|
||||
currentWorkflowEdgeCount: currentWorkflow.edges.length,
|
||||
currentWorkflowBlockCount: Object.keys(currentWorkflow.blocks).length,
|
||||
})
|
||||
}
|
||||
}, [diffAnalysis, id, currentWorkflow.blocks, currentWorkflow.edges, isShowingDiff])
|
||||
|
||||
// Determine edge diff status
|
||||
let edgeDiffStatus: EdgeDiffStatus = null
|
||||
|
||||
// Check if edge is directly marked as deleted (for reconstructed edges)
|
||||
if (data?.isDeleted) {
|
||||
edgeDiffStatus = 'deleted'
|
||||
}
|
||||
// Only attempt to determine diff status if all required data is available
|
||||
else if (diffAnalysis?.edge_diff && edgeIdentifier && isDiffReady) {
|
||||
} else if (diffAnalysis?.edge_diff && edgeIdentifier && isDiffReady) {
|
||||
if (isShowingDiff) {
|
||||
// In diff view, show new edges
|
||||
if (diffAnalysis.edge_diff.new_edges.includes(edgeIdentifier)) {
|
||||
edgeDiffStatus = 'new'
|
||||
} else if (diffAnalysis.edge_diff.unchanged_edges.includes(edgeIdentifier)) {
|
||||
edgeDiffStatus = 'unchanged'
|
||||
}
|
||||
} else {
|
||||
// In original workflow, show deleted edges
|
||||
if (diffAnalysis.edge_diff.deleted_edges.includes(edgeIdentifier)) {
|
||||
edgeDiffStatus = 'deleted'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merge any style props passed from parent with diff highlighting
|
||||
const getEdgeColor = () => {
|
||||
if (edgeDiffStatus === 'new') return '#22c55e' // Green for new edges
|
||||
if (edgeDiffStatus === 'deleted') return '#ef4444' // Red for deleted edges
|
||||
|
||||
@@ -406,11 +406,14 @@ export function useWorkflowExecution() {
|
||||
}
|
||||
}
|
||||
|
||||
const streamCompletionTimes = new Map<string, number>()
|
||||
|
||||
const onStream = async (streamingExecution: StreamingExecution) => {
|
||||
const promise = (async () => {
|
||||
if (!streamingExecution.stream) return
|
||||
const reader = streamingExecution.stream.getReader()
|
||||
const blockId = (streamingExecution.execution as any)?.blockId
|
||||
const streamStartTime = Date.now()
|
||||
let isFirstChunk = true
|
||||
|
||||
if (blockId) {
|
||||
@@ -420,6 +423,10 @@ export function useWorkflowExecution() {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) {
|
||||
// Record when this stream completed
|
||||
if (blockId) {
|
||||
streamCompletionTimes.set(blockId, Date.now())
|
||||
}
|
||||
break
|
||||
}
|
||||
const chunk = new TextDecoder().decode(value)
|
||||
@@ -517,6 +524,25 @@ export function useWorkflowExecution() {
|
||||
result.metadata = { duration: 0, startTime: new Date().toISOString() }
|
||||
}
|
||||
;(result.metadata as any).source = 'chat'
|
||||
|
||||
// Update block logs with actual stream completion times
|
||||
if (result.logs && streamCompletionTimes.size > 0) {
|
||||
const streamCompletionEndTime = new Date(
|
||||
Math.max(...Array.from(streamCompletionTimes.values()))
|
||||
).toISOString()
|
||||
|
||||
result.logs.forEach((log: BlockLog) => {
|
||||
if (streamCompletionTimes.has(log.blockId)) {
|
||||
const completionTime = streamCompletionTimes.get(log.blockId)!
|
||||
const startTime = new Date(log.startedAt).getTime()
|
||||
|
||||
// Update the log with actual stream completion time
|
||||
log.endedAt = new Date(completionTime).toISOString()
|
||||
log.durationMs = completionTime - startTime
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Update streamed content and apply tokenization
|
||||
if (result.logs) {
|
||||
result.logs.forEach((log: BlockLog) => {
|
||||
|
||||
@@ -242,6 +242,7 @@ export class Executor {
|
||||
): Promise<ExecutionResult | StreamingExecution> {
|
||||
const { setIsExecuting, setIsDebugging, setPendingBlocks, reset } = useExecutionStore.getState()
|
||||
const startTime = new Date()
|
||||
const executorStartMs = startTime.getTime()
|
||||
let finalOutput: NormalizedBlockOutput = {}
|
||||
|
||||
// Track workflow execution start
|
||||
@@ -252,7 +253,9 @@ export class Executor {
|
||||
startTime: startTime.toISOString(),
|
||||
})
|
||||
|
||||
const beforeValidation = Date.now()
|
||||
this.validateWorkflow(startBlockId)
|
||||
const validationTime = Date.now() - beforeValidation
|
||||
|
||||
const context = this.createExecutionContext(workflowId, startTime, startBlockId)
|
||||
|
||||
@@ -268,10 +271,13 @@ export class Executor {
|
||||
|
||||
let hasMoreLayers = true
|
||||
let iteration = 0
|
||||
const firstBlockExecutionTime: number | null = null
|
||||
const maxIterations = 500 // Safety limit for infinite loops
|
||||
|
||||
while (hasMoreLayers && iteration < maxIterations && !this.isCancelled) {
|
||||
const iterationStart = Date.now()
|
||||
const nextLayer = this.getNextExecutionLayer(context)
|
||||
const getNextLayerTime = Date.now() - iterationStart
|
||||
|
||||
if (this.isDebugging) {
|
||||
// In debug mode, update the pending blocks and wait for user interaction
|
||||
@@ -405,6 +411,7 @@ export class Executor {
|
||||
if (normalizedOutputs.length > 0) {
|
||||
finalOutput = normalizedOutputs[normalizedOutputs.length - 1]
|
||||
}
|
||||
|
||||
// Process loop iterations - this will activate external paths when loops complete
|
||||
await this.loopManager.processLoopIterations(context)
|
||||
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
/**
|
||||
* Sim Telemetry - Client-side Instrumentation
|
||||
*
|
||||
* This file initializes client-side telemetry when the app loads in the browser.
|
||||
* It respects the user's telemetry preferences stored in localStorage.
|
||||
*
|
||||
*/
|
||||
|
||||
import { env } from './lib/env'
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
const TELEMETRY_STATUS_KEY = 'simstudio-telemetry-status'
|
||||
const BATCH_INTERVAL_MS = 10000 // Send batches every 10 seconds
|
||||
const MAX_BATCH_SIZE = 50 // Max events per batch
|
||||
let telemetryEnabled = true
|
||||
const eventBatch: any[] = []
|
||||
let batchTimer: NodeJS.Timeout | null = null
|
||||
|
||||
try {
|
||||
if (env.NEXT_TELEMETRY_DISABLED === '1') {
|
||||
@@ -26,216 +27,221 @@ if (typeof window !== 'undefined') {
|
||||
}
|
||||
|
||||
/**
|
||||
* Safe serialize function to ensure we don't include circular references or invalid data
|
||||
* Add event to batch and schedule flush
|
||||
*/
|
||||
function safeSerialize(obj: any): any {
|
||||
if (obj === null || obj === undefined) return null
|
||||
if (typeof obj !== 'object') return obj
|
||||
function addToBatch(event: any): void {
|
||||
if (!telemetryEnabled) return
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map((item) => safeSerialize(item))
|
||||
eventBatch.push(event)
|
||||
|
||||
if (eventBatch.length >= MAX_BATCH_SIZE) {
|
||||
flushBatch()
|
||||
} else if (!batchTimer) {
|
||||
batchTimer = setTimeout(flushBatch, BATCH_INTERVAL_MS)
|
||||
}
|
||||
|
||||
const result: Record<string, any> = {}
|
||||
|
||||
for (const key in obj) {
|
||||
if (key in obj) {
|
||||
const value = obj[key]
|
||||
if (
|
||||
value === undefined ||
|
||||
value === null ||
|
||||
typeof value === 'function' ||
|
||||
typeof value === 'symbol'
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
result[key] = safeSerialize(value)
|
||||
} catch (_e) {
|
||||
try {
|
||||
result[key] = String(value)
|
||||
} catch (_e2) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize event data to remove sensitive information
|
||||
*/
|
||||
function sanitizeEvent(event: any): any {
|
||||
const patterns = ['password', 'token', 'secret', 'key', 'auth', 'credential', 'private']
|
||||
const sensitiveRe = new RegExp(patterns.join('|'), 'i')
|
||||
|
||||
const scrubString = (s: string) => (s && sensitiveRe.test(s) ? '[redacted]' : s)
|
||||
|
||||
if (event == null) return event
|
||||
if (typeof event === 'string') return scrubString(event)
|
||||
if (typeof event !== 'object') return event
|
||||
|
||||
if (Array.isArray(event)) {
|
||||
return event.map((item) => sanitizeEvent(item))
|
||||
}
|
||||
|
||||
const sanitized: Record<string, unknown> = {}
|
||||
for (const [key, value] of Object.entries(event)) {
|
||||
const lowerKey = key.toLowerCase()
|
||||
if (patterns.some((p) => lowerKey.includes(p))) continue
|
||||
|
||||
if (typeof value === 'string') sanitized[key] = scrubString(value)
|
||||
else if (Array.isArray(value)) sanitized[key] = value.map((v) => sanitizeEvent(v))
|
||||
else if (value && typeof value === 'object') sanitized[key] = sanitizeEvent(value)
|
||||
else sanitized[key] = value
|
||||
}
|
||||
|
||||
return sanitized
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush batch of events to server
|
||||
*/
|
||||
function flushBatch(): void {
|
||||
if (eventBatch.length === 0) return
|
||||
|
||||
const batch = eventBatch.splice(0, eventBatch.length)
|
||||
if (batchTimer) {
|
||||
clearTimeout(batchTimer)
|
||||
batchTimer = null
|
||||
}
|
||||
|
||||
const sanitizedBatch = batch.map(sanitizeEvent)
|
||||
|
||||
const payload = JSON.stringify({
|
||||
category: 'batch',
|
||||
action: 'client_events',
|
||||
events: sanitizedBatch,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
|
||||
const payloadSize = new Blob([payload]).size
|
||||
const MAX_BEACON_SIZE = 64 * 1024 // 64KB
|
||||
|
||||
if (navigator.sendBeacon && payloadSize < MAX_BEACON_SIZE) {
|
||||
const sent = navigator.sendBeacon('/api/telemetry', payload)
|
||||
|
||||
if (!sent) {
|
||||
fetch('/api/telemetry', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: payload,
|
||||
keepalive: true,
|
||||
}).catch(() => {
|
||||
// Silently fail
|
||||
})
|
||||
}
|
||||
} else {
|
||||
fetch('/api/telemetry', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: payload,
|
||||
keepalive: true,
|
||||
}).catch(() => {
|
||||
// Silently fail
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('beforeunload', flushBatch)
|
||||
window.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
flushBatch()
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Global event tracking function
|
||||
*/
|
||||
|
||||
;(window as any).__SIM_TELEMETRY_ENABLED = telemetryEnabled
|
||||
;(window as any).__SIM_TRACK_EVENT = (eventName: string, properties?: any) => {
|
||||
if (!telemetryEnabled) return
|
||||
|
||||
const safeProps = properties || {}
|
||||
|
||||
const payload = {
|
||||
addToBatch({
|
||||
category: 'feature_usage',
|
||||
action: eventName || 'unknown_event',
|
||||
action: eventName,
|
||||
timestamp: Date.now(),
|
||||
...safeSerialize(safeProps),
|
||||
}
|
||||
|
||||
fetch('/api/telemetry', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
}).catch(() => {
|
||||
// Silently fail if sending metrics fails
|
||||
...(properties || {}),
|
||||
})
|
||||
}
|
||||
|
||||
if (telemetryEnabled) {
|
||||
performance.mark('sim-studio-init')
|
||||
const shouldTrackVitals = Math.random() < 0.1
|
||||
|
||||
let telemetryConfig
|
||||
try {
|
||||
telemetryConfig = (window as any).__SIM_STUDIO_TELEMETRY_CONFIG || {
|
||||
clientSide: { enabled: true },
|
||||
}
|
||||
} catch (_e) {
|
||||
telemetryConfig = { clientSide: { enabled: true } }
|
||||
if (shouldTrackVitals) {
|
||||
window.addEventListener(
|
||||
'load',
|
||||
() => {
|
||||
if (typeof PerformanceObserver !== 'undefined') {
|
||||
const lcpObserver = new PerformanceObserver((list) => {
|
||||
const entries = list.getEntries()
|
||||
const lastEntry = entries[entries.length - 1]
|
||||
|
||||
if (lastEntry) {
|
||||
addToBatch({
|
||||
category: 'performance',
|
||||
action: 'web_vital',
|
||||
label: 'LCP',
|
||||
value: (lastEntry as any).startTime || 0,
|
||||
entryType: 'largest-contentful-paint',
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
lcpObserver.disconnect()
|
||||
})
|
||||
|
||||
let clsValue = 0
|
||||
const clsObserver = new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
if (!(entry as any).hadRecentInput) {
|
||||
clsValue += (entry as any).value || 0
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const fidObserver = new PerformanceObserver((list) => {
|
||||
const entries = list.getEntries()
|
||||
|
||||
for (const entry of entries) {
|
||||
const fidValue =
|
||||
((entry as any).processingStart || 0) - ((entry as any).startTime || 0)
|
||||
|
||||
addToBatch({
|
||||
category: 'performance',
|
||||
action: 'web_vital',
|
||||
label: 'FID',
|
||||
value: fidValue,
|
||||
entryType: 'first-input',
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
fidObserver.disconnect()
|
||||
})
|
||||
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (clsValue > 0) {
|
||||
addToBatch({
|
||||
category: 'performance',
|
||||
action: 'web_vital',
|
||||
label: 'CLS',
|
||||
value: clsValue,
|
||||
entryType: 'layout-shift',
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
clsObserver.disconnect()
|
||||
})
|
||||
|
||||
lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true })
|
||||
clsObserver.observe({ type: 'layout-shift', buffered: true })
|
||||
fidObserver.observe({ type: 'first-input', buffered: true })
|
||||
}
|
||||
},
|
||||
{ once: true }
|
||||
)
|
||||
}
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
performance.mark('sim-studio-loaded')
|
||||
performance.measure('page-load', 'sim-studio-init', 'sim-studio-loaded')
|
||||
|
||||
if (typeof PerformanceObserver !== 'undefined') {
|
||||
const lcpObserver = new PerformanceObserver((list) => {
|
||||
const entries = list.getEntries()
|
||||
|
||||
entries.forEach((entry) => {
|
||||
const value =
|
||||
entry.entryType === 'largest-contentful-paint'
|
||||
? (entry as any).startTime
|
||||
: (entry as any).value || 0
|
||||
|
||||
// Ensure we have non-null values for all fields
|
||||
const metric = {
|
||||
name: entry.name || 'unknown',
|
||||
value: value || 0,
|
||||
entryType: entry.entryType || 'unknown',
|
||||
}
|
||||
|
||||
if (telemetryEnabled && telemetryConfig?.clientSide?.enabled) {
|
||||
const safePayload = {
|
||||
category: 'performance',
|
||||
action: 'web_vital',
|
||||
label: metric.name,
|
||||
value: metric.value,
|
||||
entryType: metric.entryType,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
|
||||
fetch('/api/telemetry', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(safePayload),
|
||||
}).catch(() => {
|
||||
// Silently fail if sending metrics fails
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
lcpObserver.disconnect()
|
||||
window.addEventListener('error', (event) => {
|
||||
if (telemetryEnabled && !event.defaultPrevented) {
|
||||
addToBatch({
|
||||
category: 'error',
|
||||
action: 'unhandled_error',
|
||||
message: event.error?.message || event.message || 'Unknown error',
|
||||
url: window.location.pathname,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
|
||||
const clsObserver = new PerformanceObserver((list) => {
|
||||
const entries = list.getEntries()
|
||||
let clsValue = 0
|
||||
|
||||
entries.forEach((entry) => {
|
||||
clsValue += (entry as any).value || 0
|
||||
})
|
||||
|
||||
if (telemetryEnabled && telemetryConfig?.clientSide?.enabled) {
|
||||
const safePayload = {
|
||||
category: 'performance',
|
||||
action: 'web_vital',
|
||||
label: 'CLS',
|
||||
value: clsValue || 0,
|
||||
entryType: 'layout-shift',
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
|
||||
fetch('/api/telemetry', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(safePayload),
|
||||
}).catch(() => {
|
||||
// Silently fail if sending metrics fails
|
||||
})
|
||||
}
|
||||
|
||||
clsObserver.disconnect()
|
||||
})
|
||||
|
||||
const fidObserver = new PerformanceObserver((list) => {
|
||||
const entries = list.getEntries()
|
||||
|
||||
entries.forEach((entry) => {
|
||||
const processingStart = (entry as any).processingStart || 0
|
||||
const startTime = (entry as any).startTime || 0
|
||||
|
||||
const metric = {
|
||||
name: entry.name || 'unknown',
|
||||
value: processingStart - startTime,
|
||||
entryType: entry.entryType || 'unknown',
|
||||
}
|
||||
|
||||
if (telemetryEnabled && telemetryConfig?.clientSide?.enabled) {
|
||||
const safePayload = {
|
||||
category: 'performance',
|
||||
action: 'web_vital',
|
||||
label: 'FID',
|
||||
value: metric.value,
|
||||
entryType: metric.entryType,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
|
||||
fetch('/api/telemetry', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(safePayload),
|
||||
}).catch(() => {
|
||||
// Silently fail if sending metrics fails
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
fidObserver.disconnect()
|
||||
})
|
||||
|
||||
lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true })
|
||||
clsObserver.observe({ type: 'layout-shift', buffered: true })
|
||||
fidObserver.observe({ type: 'first-input', buffered: true })
|
||||
}
|
||||
})
|
||||
|
||||
window.addEventListener('error', (event) => {
|
||||
if (telemetryEnabled && telemetryConfig?.clientSide?.enabled) {
|
||||
const errorDetails = {
|
||||
message: event.error?.message || 'Unknown error',
|
||||
stack: event.error?.stack?.split('\n')[0] || '',
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
if (telemetryEnabled) {
|
||||
addToBatch({
|
||||
category: 'error',
|
||||
action: 'unhandled_rejection',
|
||||
message: event.reason?.message || String(event.reason) || 'Unhandled promise rejection',
|
||||
url: window.location.pathname,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
|
||||
const safePayload = {
|
||||
category: 'error',
|
||||
action: 'client_error',
|
||||
label: errorDetails.message,
|
||||
stack: errorDetails.stack,
|
||||
url: errorDetails.url,
|
||||
timestamp: errorDetails.timestamp,
|
||||
}
|
||||
|
||||
fetch('/api/telemetry', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(safePayload),
|
||||
}).catch(() => {
|
||||
// Silently fail if sending error fails
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -11,10 +11,6 @@ const logger = createLogger('EdgeInstrumentation')
|
||||
|
||||
export async function register() {
|
||||
try {
|
||||
// Only Web API compatible code here
|
||||
// No Node.js APIs like process.on, crypto, fs, etc.
|
||||
|
||||
// Future: Add Edge Runtime compatible telemetry here
|
||||
logger.info('Edge Runtime instrumentation initialized')
|
||||
} catch (error) {
|
||||
logger.error('Failed to initialize Edge Runtime instrumentation', error)
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
/**
|
||||
* Sim Telemetry - Server-side Instrumentation
|
||||
*
|
||||
* This file contains all server-side instrumentation logic.
|
||||
* Sim OpenTelemetry - Server-side Instrumentation
|
||||
*/
|
||||
|
||||
import { DiagConsoleLogger, DiagLogLevel, diag } from '@opentelemetry/api'
|
||||
import { env } from './lib/env'
|
||||
import { createLogger } from './lib/logs/console/logger.ts'
|
||||
import { createLogger } from './lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('OtelInstrumentation')
|
||||
diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.ERROR)
|
||||
|
||||
const logger = createLogger('OTelInstrumentation')
|
||||
|
||||
const DEFAULT_TELEMETRY_CONFIG = {
|
||||
endpoint: env.TELEMETRY_ENDPOINT || 'https://telemetry.simstudio.ai/v1/traces',
|
||||
@@ -15,68 +16,97 @@ const DEFAULT_TELEMETRY_CONFIG = {
|
||||
serviceVersion: '0.1.0',
|
||||
serverSide: { enabled: true },
|
||||
batchSettings: {
|
||||
maxQueueSize: 100,
|
||||
maxExportBatchSize: 10,
|
||||
maxQueueSize: 2048,
|
||||
maxExportBatchSize: 512,
|
||||
scheduledDelayMillis: 5000,
|
||||
exportTimeoutMillis: 30000,
|
||||
},
|
||||
}
|
||||
|
||||
// Initialize OpenTelemetry
|
||||
/**
|
||||
* Initialize OpenTelemetry SDK with proper configuration
|
||||
*/
|
||||
async function initializeOpenTelemetry() {
|
||||
try {
|
||||
if (env.NEXT_TELEMETRY_DISABLED === '1') {
|
||||
logger.info('OpenTelemetry telemetry disabled via environment variable')
|
||||
logger.info('OpenTelemetry disabled via NEXT_TELEMETRY_DISABLED=1')
|
||||
return
|
||||
}
|
||||
|
||||
let telemetryConfig
|
||||
try {
|
||||
// Use dynamic import for ES modules
|
||||
telemetryConfig = (await import('./telemetry.config.ts')).default
|
||||
} catch (_e) {
|
||||
telemetryConfig = (await import('./telemetry.config')).default
|
||||
} catch {
|
||||
telemetryConfig = DEFAULT_TELEMETRY_CONFIG
|
||||
}
|
||||
|
||||
if (telemetryConfig.serverSide?.enabled === false) {
|
||||
logger.info('Server-side OpenTelemetry instrumentation is disabled in config')
|
||||
logger.info('Server-side OpenTelemetry disabled in config')
|
||||
return
|
||||
}
|
||||
|
||||
// Dynamic imports for server-side libraries
|
||||
const { NodeSDK } = await import('@opentelemetry/sdk-node')
|
||||
const { resourceFromAttributes } = await import('@opentelemetry/resources')
|
||||
const { SemanticResourceAttributes } = await import('@opentelemetry/semantic-conventions')
|
||||
const { defaultResource, resourceFromAttributes } = await import('@opentelemetry/resources')
|
||||
const { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION, ATTR_DEPLOYMENT_ENVIRONMENT } = await import(
|
||||
'@opentelemetry/semantic-conventions/incubating'
|
||||
)
|
||||
const { OTLPTraceExporter } = await import('@opentelemetry/exporter-trace-otlp-http')
|
||||
const { BatchSpanProcessor } = await import('@opentelemetry/sdk-trace-node')
|
||||
const { ParentBasedSampler, TraceIdRatioBasedSampler } = await import(
|
||||
'@opentelemetry/sdk-trace-base'
|
||||
)
|
||||
|
||||
const exporter = new OTLPTraceExporter({
|
||||
url: telemetryConfig.endpoint,
|
||||
headers: {},
|
||||
timeoutMillis: telemetryConfig.batchSettings.exportTimeoutMillis,
|
||||
})
|
||||
|
||||
const configResource = resourceFromAttributes({
|
||||
[SemanticResourceAttributes.SERVICE_NAME]: telemetryConfig.serviceName,
|
||||
[SemanticResourceAttributes.SERVICE_VERSION]: telemetryConfig.serviceVersion,
|
||||
[SemanticResourceAttributes.DEPLOYMENT_ENVIRONMENT]: env.NODE_ENV,
|
||||
const spanProcessor = new BatchSpanProcessor(exporter, {
|
||||
maxQueueSize: telemetryConfig.batchSettings.maxQueueSize,
|
||||
maxExportBatchSize: telemetryConfig.batchSettings.maxExportBatchSize,
|
||||
scheduledDelayMillis: telemetryConfig.batchSettings.scheduledDelayMillis,
|
||||
exportTimeoutMillis: telemetryConfig.batchSettings.exportTimeoutMillis,
|
||||
})
|
||||
|
||||
const resource = defaultResource().merge(
|
||||
resourceFromAttributes({
|
||||
[ATTR_SERVICE_NAME]: telemetryConfig.serviceName,
|
||||
[ATTR_SERVICE_VERSION]: telemetryConfig.serviceVersion,
|
||||
[ATTR_DEPLOYMENT_ENVIRONMENT]: env.NODE_ENV || 'development',
|
||||
'service.namespace': 'sim-ai-platform',
|
||||
'telemetry.sdk.name': 'opentelemetry',
|
||||
'telemetry.sdk.language': 'nodejs',
|
||||
'telemetry.sdk.version': '1.0.0',
|
||||
})
|
||||
)
|
||||
|
||||
const sampler = new ParentBasedSampler({
|
||||
root: new TraceIdRatioBasedSampler(0.1), // 10% sampling for root spans
|
||||
})
|
||||
|
||||
const sdk = new NodeSDK({
|
||||
resource: configResource,
|
||||
resource,
|
||||
spanProcessor,
|
||||
sampler,
|
||||
traceExporter: exporter,
|
||||
})
|
||||
|
||||
sdk.start()
|
||||
|
||||
const shutdownHandler = async () => {
|
||||
await sdk
|
||||
.shutdown()
|
||||
.then(() => logger.info('OpenTelemetry SDK shut down successfully'))
|
||||
.catch((err) => logger.error('Error shutting down OpenTelemetry SDK', err))
|
||||
try {
|
||||
await sdk.shutdown()
|
||||
logger.info('OpenTelemetry SDK shut down successfully')
|
||||
} catch (err) {
|
||||
logger.error('Error shutting down OpenTelemetry SDK', err)
|
||||
}
|
||||
}
|
||||
|
||||
process.on('SIGTERM', shutdownHandler)
|
||||
process.on('SIGINT', shutdownHandler)
|
||||
|
||||
logger.info('OpenTelemetry instrumentation initialized for server-side telemetry')
|
||||
logger.info('OpenTelemetry instrumentation initialized')
|
||||
} catch (error) {
|
||||
logger.error('Failed to initialize OpenTelemetry instrumentation', error)
|
||||
}
|
||||
|
||||
@@ -1,32 +1,28 @@
|
||||
/**
|
||||
* OpenTelemetry Instrumentation Entry Point
|
||||
*
|
||||
* This is the main entry point for OpenTelemetry instrumentation.
|
||||
* It delegates to runtime-specific instrumentation modules.
|
||||
*/
|
||||
export async function register() {
|
||||
console.log('[Main Instrumentation] register() called, environment:', {
|
||||
NEXT_RUNTIME: process.env.NEXT_RUNTIME,
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
})
|
||||
|
||||
// Load Node.js-specific instrumentation
|
||||
if (process.env.NEXT_RUNTIME === 'nodejs') {
|
||||
console.log('[Main Instrumentation] Loading Node.js instrumentation...')
|
||||
const nodeInstrumentation = await import('./instrumentation-node')
|
||||
if (nodeInstrumentation.register) {
|
||||
console.log('[Main Instrumentation] Calling Node.js register()...')
|
||||
await nodeInstrumentation.register()
|
||||
}
|
||||
}
|
||||
|
||||
// Load Edge Runtime-specific instrumentation
|
||||
if (process.env.NEXT_RUNTIME === 'edge') {
|
||||
console.log('[Main Instrumentation] Loading Edge Runtime instrumentation...')
|
||||
const edgeInstrumentation = await import('./instrumentation-edge')
|
||||
if (edgeInstrumentation.register) {
|
||||
console.log('[Main Instrumentation] Calling Edge Runtime register()...')
|
||||
await edgeInstrumentation.register()
|
||||
}
|
||||
}
|
||||
|
||||
// Load client instrumentation if we're on the client
|
||||
if (typeof window !== 'undefined') {
|
||||
console.log('[Main Instrumentation] Loading client instrumentation...')
|
||||
await import('./instrumentation-client')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,17 +111,50 @@ export class LoggingSession {
|
||||
|
||||
try {
|
||||
const costSummary = calculateCostSummary(traceSpans || [])
|
||||
const endTime = endedAt || new Date().toISOString()
|
||||
const duration = totalDurationMs || 0
|
||||
|
||||
await executionLogger.completeWorkflowExecution({
|
||||
executionId: this.executionId,
|
||||
endedAt: endedAt || new Date().toISOString(),
|
||||
totalDurationMs: totalDurationMs || 0,
|
||||
endedAt: endTime,
|
||||
totalDurationMs: duration,
|
||||
costSummary,
|
||||
finalOutput: finalOutput || {},
|
||||
traceSpans: traceSpans || [],
|
||||
workflowInput,
|
||||
})
|
||||
|
||||
// Track workflow execution outcome
|
||||
if (traceSpans && traceSpans.length > 0) {
|
||||
try {
|
||||
const { trackPlatformEvent } = await import('@/lib/telemetry/tracer')
|
||||
|
||||
// Determine status from trace spans
|
||||
const hasErrors = traceSpans.some((span: any) => {
|
||||
const checkForErrors = (s: any): boolean => {
|
||||
if (s.status === 'error') return true
|
||||
if (s.children && Array.isArray(s.children)) {
|
||||
return s.children.some(checkForErrors)
|
||||
}
|
||||
return false
|
||||
}
|
||||
return checkForErrors(span)
|
||||
})
|
||||
|
||||
trackPlatformEvent('platform.workflow.executed', {
|
||||
'workflow.id': this.workflowId,
|
||||
'execution.duration_ms': duration,
|
||||
'execution.status': hasErrors ? 'error' : 'success',
|
||||
'execution.trigger': this.triggerType,
|
||||
'execution.blocks_executed': traceSpans.length,
|
||||
'execution.has_errors': hasErrors,
|
||||
'execution.total_cost': costSummary.totalCost || 0,
|
||||
})
|
||||
} catch (_e) {
|
||||
// Silently fail
|
||||
}
|
||||
}
|
||||
|
||||
if (this.requestId) {
|
||||
logger.debug(`[${this.requestId}] Completed logging for execution ${this.executionId}`)
|
||||
}
|
||||
@@ -168,15 +201,33 @@ export class LoggingSession {
|
||||
output: { error: message },
|
||||
}
|
||||
|
||||
const spans = hasProvidedSpans ? traceSpans : [errorSpan]
|
||||
|
||||
await executionLogger.completeWorkflowExecution({
|
||||
executionId: this.executionId,
|
||||
endedAt: endTime.toISOString(),
|
||||
totalDurationMs: Math.max(1, durationMs),
|
||||
costSummary,
|
||||
finalOutput: { error: message },
|
||||
traceSpans: hasProvidedSpans ? traceSpans : [errorSpan],
|
||||
traceSpans: spans,
|
||||
})
|
||||
|
||||
// Track workflow execution error outcome
|
||||
try {
|
||||
const { trackPlatformEvent } = await import('@/lib/telemetry/tracer')
|
||||
trackPlatformEvent('platform.workflow.executed', {
|
||||
'workflow.id': this.workflowId,
|
||||
'execution.duration_ms': Math.max(1, durationMs),
|
||||
'execution.status': 'error',
|
||||
'execution.trigger': this.triggerType,
|
||||
'execution.blocks_executed': spans.length,
|
||||
'execution.has_errors': true,
|
||||
'execution.error_message': message,
|
||||
})
|
||||
} catch (_e) {
|
||||
// Silently fail
|
||||
}
|
||||
|
||||
if (this.requestId) {
|
||||
logger.debug(`[${this.requestId}] Completed logging for execution ${this.executionId}`)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import type { TraceSpan } from '@/lib/logs/types'
|
||||
import type { ToolCall, TraceSpan } from '@/lib/logs/types'
|
||||
import { isWorkflowBlockType } from '@/executor/consts'
|
||||
import type { ExecutionResult } from '@/executor/types'
|
||||
|
||||
@@ -58,29 +58,21 @@ function mergeTraceSpanChildren(...childGroups: TraceSpan[][]): TraceSpan[] {
|
||||
return merged
|
||||
}
|
||||
|
||||
// Helper function to build a tree of trace spans from execution logs
|
||||
export function buildTraceSpans(result: ExecutionResult): {
|
||||
traceSpans: TraceSpan[]
|
||||
totalDuration: number
|
||||
} {
|
||||
// If no logs, return empty spans
|
||||
if (!result.logs || result.logs.length === 0) {
|
||||
return { traceSpans: [], totalDuration: 0 }
|
||||
}
|
||||
|
||||
// Store all spans as a map for faster lookup
|
||||
const spanMap = new Map<string, TraceSpan>()
|
||||
|
||||
// Create a map to track parent-child relationships from workflow structure
|
||||
// This helps distinguish between actual parent-child relationships vs parallel execution
|
||||
const parentChildMap = new Map<string, string>()
|
||||
|
||||
// If we have workflow information in the logs, extract parent-child relationships
|
||||
// Define connection type inline for now
|
||||
type Connection = { source: string; target: string }
|
||||
const workflowConnections: Connection[] = result.metadata?.workflowConnections || []
|
||||
if (workflowConnections.length > 0) {
|
||||
// Build the connection map from workflow connections
|
||||
workflowConnections.forEach((conn: Connection) => {
|
||||
if (conn.source && conn.target) {
|
||||
parentChildMap.set(conn.target, conn.source)
|
||||
@@ -88,21 +80,15 @@ export function buildTraceSpans(result: ExecutionResult): {
|
||||
})
|
||||
}
|
||||
|
||||
// First pass: Create spans for each block
|
||||
result.logs.forEach((log) => {
|
||||
// Skip logs that don't have block execution information
|
||||
if (!log.blockId || !log.blockType) return
|
||||
|
||||
// Create a unique ID for this span using blockId and timestamp
|
||||
const spanId = `${log.blockId}-${new Date(log.startedAt).getTime()}`
|
||||
|
||||
// Extract duration if available
|
||||
const duration = log.durationMs || 0
|
||||
|
||||
// Create the span
|
||||
let output = log.output || {}
|
||||
|
||||
// If there's an error, include it in the output
|
||||
if (log.error) {
|
||||
output = {
|
||||
...output,
|
||||
@@ -110,7 +96,6 @@ export function buildTraceSpans(result: ExecutionResult): {
|
||||
}
|
||||
}
|
||||
|
||||
// Use block name consistently for all block types
|
||||
const displayName = log.blockName || log.blockId
|
||||
|
||||
const span: TraceSpan = {
|
||||
@@ -122,21 +107,26 @@ export function buildTraceSpans(result: ExecutionResult): {
|
||||
endTime: log.endedAt,
|
||||
status: log.error ? 'error' : 'success',
|
||||
children: [],
|
||||
// Store the block ID for later use in identifying direct parent-child relationships
|
||||
blockId: log.blockId,
|
||||
// Include block input/output data
|
||||
input: log.input || {},
|
||||
output: output,
|
||||
}
|
||||
|
||||
// Add provider timing data if it exists
|
||||
if (log.output?.providerTiming) {
|
||||
const providerTiming = log.output.providerTiming
|
||||
const providerTiming = log.output.providerTiming as {
|
||||
duration: number
|
||||
startTime: string
|
||||
endTime: string
|
||||
timeSegments?: Array<{
|
||||
type: string
|
||||
name?: string
|
||||
startTime: string | number
|
||||
endTime: string | number
|
||||
duration: number
|
||||
}>
|
||||
}
|
||||
|
||||
// Store provider timing as metadata instead of creating child spans
|
||||
// This keeps the UI cleaner while preserving timing information
|
||||
|
||||
;(span as any).providerTiming = {
|
||||
span.providerTiming = {
|
||||
duration: providerTiming.duration,
|
||||
startTime: providerTiming.startTime,
|
||||
endTime: providerTiming.endTime,
|
||||
@@ -144,22 +134,48 @@ export function buildTraceSpans(result: ExecutionResult): {
|
||||
}
|
||||
}
|
||||
|
||||
// Always add cost, token, and model information if available (regardless of provider timing)
|
||||
if (log.output?.cost) {
|
||||
;(span as any).cost = log.output.cost
|
||||
span.cost = log.output.cost as {
|
||||
input?: number
|
||||
output?: number
|
||||
total?: number
|
||||
}
|
||||
}
|
||||
|
||||
if (log.output?.tokens) {
|
||||
;(span as any).tokens = log.output.tokens
|
||||
const t = log.output.tokens as
|
||||
| number
|
||||
| {
|
||||
input?: number
|
||||
output?: number
|
||||
total?: number
|
||||
prompt?: number
|
||||
completion?: number
|
||||
}
|
||||
if (typeof t === 'number') {
|
||||
span.tokens = t
|
||||
} else if (typeof t === 'object') {
|
||||
const input = t.input ?? t.prompt
|
||||
const output = t.output ?? t.completion
|
||||
const total =
|
||||
t.total ??
|
||||
(typeof input === 'number' || typeof output === 'number'
|
||||
? (input || 0) + (output || 0)
|
||||
: undefined)
|
||||
span.tokens = {
|
||||
...(typeof input === 'number' ? { input } : {}),
|
||||
...(typeof output === 'number' ? { output } : {}),
|
||||
...(typeof total === 'number' ? { total } : {}),
|
||||
}
|
||||
} else {
|
||||
span.tokens = t
|
||||
}
|
||||
}
|
||||
|
||||
if (log.output?.model) {
|
||||
;(span as any).model = log.output.model
|
||||
span.model = log.output.model as string
|
||||
}
|
||||
|
||||
// Enhanced approach: Use timeSegments for sequential flow if available
|
||||
// This provides the actual model→tool→model execution sequence
|
||||
// Skip for workflow blocks since they will be processed via output.childTraceSpans at the end
|
||||
if (
|
||||
!isWorkflowBlockType(log.blockType) &&
|
||||
log.output?.providerTiming?.timeSegments &&
|
||||
@@ -168,70 +184,78 @@ export function buildTraceSpans(result: ExecutionResult): {
|
||||
const timeSegments = log.output.providerTiming.timeSegments
|
||||
const toolCallsData = log.output?.toolCalls?.list || log.output?.toolCalls || []
|
||||
|
||||
// Create child spans for each time segment
|
||||
span.children = timeSegments.map((segment: any, index: number) => {
|
||||
const segmentStartTime = new Date(segment.startTime).toISOString()
|
||||
const segmentEndTime = new Date(segment.endTime).toISOString()
|
||||
span.children = timeSegments.map(
|
||||
(
|
||||
segment: {
|
||||
type: string
|
||||
name?: string
|
||||
startTime: string | number
|
||||
endTime: string | number
|
||||
duration: number
|
||||
},
|
||||
index: number
|
||||
) => {
|
||||
const segmentStartTime = new Date(segment.startTime).toISOString()
|
||||
let segmentEndTime = new Date(segment.endTime).toISOString()
|
||||
let segmentDuration = segment.duration
|
||||
|
||||
if (segment.type === 'tool') {
|
||||
// Find matching tool call data for this segment
|
||||
const matchingToolCall = toolCallsData.find(
|
||||
(tc: any) => tc.name === segment.name || stripCustomToolPrefix(tc.name) === segment.name
|
||||
)
|
||||
if (segment.name?.toLowerCase().includes('streaming') && log.endedAt) {
|
||||
const blockEndTime = new Date(log.endedAt).getTime()
|
||||
const segmentEndTimeMs = new Date(segment.endTime).getTime()
|
||||
|
||||
if (blockEndTime > segmentEndTimeMs) {
|
||||
segmentEndTime = log.endedAt
|
||||
segmentDuration = blockEndTime - new Date(segment.startTime).getTime()
|
||||
}
|
||||
}
|
||||
|
||||
if (segment.type === 'tool') {
|
||||
const matchingToolCall = toolCallsData.find(
|
||||
(tc: { name?: string; [key: string]: unknown }) =>
|
||||
tc.name === segment.name || stripCustomToolPrefix(tc.name || '') === segment.name
|
||||
)
|
||||
|
||||
return {
|
||||
id: `${span.id}-segment-${index}`,
|
||||
name: stripCustomToolPrefix(segment.name || ''),
|
||||
type: 'tool',
|
||||
duration: segment.duration,
|
||||
startTime: segmentStartTime,
|
||||
endTime: segmentEndTime,
|
||||
status: matchingToolCall?.error ? 'error' : 'success',
|
||||
input: matchingToolCall?.arguments || matchingToolCall?.input,
|
||||
output: matchingToolCall?.error
|
||||
? {
|
||||
error: matchingToolCall.error,
|
||||
...(matchingToolCall.result || matchingToolCall.output || {}),
|
||||
}
|
||||
: matchingToolCall?.result || matchingToolCall?.output,
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: `${span.id}-segment-${index}`,
|
||||
name: stripCustomToolPrefix(segment.name),
|
||||
type: 'tool',
|
||||
duration: segment.duration,
|
||||
name: segment.name,
|
||||
type: 'model',
|
||||
duration: segmentDuration,
|
||||
startTime: segmentStartTime,
|
||||
endTime: segmentEndTime,
|
||||
status: matchingToolCall?.error ? 'error' : 'success',
|
||||
input: matchingToolCall?.arguments || matchingToolCall?.input,
|
||||
output: matchingToolCall?.error
|
||||
? {
|
||||
error: matchingToolCall.error,
|
||||
...(matchingToolCall.result || matchingToolCall.output || {}),
|
||||
}
|
||||
: matchingToolCall?.result || matchingToolCall?.output,
|
||||
status: 'success',
|
||||
}
|
||||
}
|
||||
// Model segment
|
||||
return {
|
||||
id: `${span.id}-segment-${index}`,
|
||||
name: segment.name,
|
||||
type: 'model',
|
||||
duration: segment.duration,
|
||||
startTime: segmentStartTime,
|
||||
endTime: segmentEndTime,
|
||||
status: 'success',
|
||||
}
|
||||
})
|
||||
)
|
||||
} else {
|
||||
// Fallback: Extract tool calls using the original approach for backwards compatibility
|
||||
// Tool calls handling for different formats:
|
||||
// 1. Standard format in response.toolCalls.list
|
||||
// 2. Direct toolCalls array in response
|
||||
// 3. Streaming response formats with executionData
|
||||
|
||||
// Check all possible paths for toolCalls
|
||||
let toolCallsList = null
|
||||
|
||||
// Wrap extraction in try-catch to handle unexpected toolCalls formats
|
||||
try {
|
||||
if (log.output?.toolCalls?.list) {
|
||||
// Standard format with list property
|
||||
toolCallsList = log.output.toolCalls.list
|
||||
} else if (Array.isArray(log.output?.toolCalls)) {
|
||||
// Direct array format
|
||||
toolCallsList = log.output.toolCalls
|
||||
} else if (log.output?.executionData?.output?.toolCalls) {
|
||||
// Streaming format with executionData
|
||||
const tcObj = log.output.executionData.output.toolCalls
|
||||
toolCallsList = Array.isArray(tcObj) ? tcObj : tcObj.list || []
|
||||
}
|
||||
|
||||
// Validate that toolCallsList is actually an array before processing
|
||||
if (toolCallsList && !Array.isArray(toolCallsList)) {
|
||||
logger.warn(`toolCallsList is not an array: ${typeof toolCallsList}`, {
|
||||
blockId: log.blockId,
|
||||
@@ -241,36 +265,56 @@ export function buildTraceSpans(result: ExecutionResult): {
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error extracting toolCalls from block ${log.blockId}:`, error)
|
||||
toolCallsList = [] // Set to empty array as fallback
|
||||
toolCallsList = []
|
||||
}
|
||||
|
||||
if (toolCallsList && toolCallsList.length > 0) {
|
||||
span.toolCalls = toolCallsList
|
||||
.map((tc: any) => {
|
||||
// Add null check for each tool call
|
||||
if (!tc) return null
|
||||
const processedToolCalls: ToolCall[] = []
|
||||
|
||||
try {
|
||||
return {
|
||||
name: stripCustomToolPrefix(tc.name || 'unnamed-tool'),
|
||||
duration: tc.duration || 0,
|
||||
startTime: tc.startTime || log.startedAt,
|
||||
endTime: tc.endTime || log.endedAt,
|
||||
status: tc.error ? 'error' : 'success',
|
||||
input: tc.arguments || tc.input,
|
||||
output: tc.result || tc.output,
|
||||
error: tc.error,
|
||||
}
|
||||
} catch (tcError) {
|
||||
logger.error(`Error processing tool call in block ${log.blockId}:`, tcError)
|
||||
return null
|
||||
for (const tc of toolCallsList as Array<{
|
||||
name?: string
|
||||
duration?: number
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
error?: string
|
||||
arguments?: Record<string, unknown>
|
||||
input?: Record<string, unknown>
|
||||
result?: Record<string, unknown>
|
||||
output?: Record<string, unknown>
|
||||
}>) {
|
||||
if (!tc) continue
|
||||
|
||||
try {
|
||||
const toolCall: ToolCall = {
|
||||
name: stripCustomToolPrefix(tc.name || 'unnamed-tool'),
|
||||
duration: tc.duration || 0,
|
||||
startTime: tc.startTime || log.startedAt,
|
||||
endTime: tc.endTime || log.endedAt,
|
||||
status: tc.error ? 'error' : 'success',
|
||||
}
|
||||
})
|
||||
.filter(Boolean) // Remove any null entries from failed processing
|
||||
|
||||
if (tc.arguments || tc.input) {
|
||||
toolCall.input = tc.arguments || tc.input
|
||||
}
|
||||
|
||||
if (tc.result || tc.output) {
|
||||
toolCall.output = tc.result || tc.output
|
||||
}
|
||||
|
||||
if (tc.error) {
|
||||
toolCall.error = tc.error
|
||||
}
|
||||
|
||||
processedToolCalls.push(toolCall)
|
||||
} catch (tcError) {
|
||||
logger.error(`Error processing tool call in block ${log.blockId}:`, tcError)
|
||||
}
|
||||
}
|
||||
|
||||
span.toolCalls = processedToolCalls
|
||||
}
|
||||
}
|
||||
|
||||
// Handle child workflow spans for workflow blocks - process at the end to avoid being overwritten
|
||||
if (
|
||||
isWorkflowBlockType(log.blockType) &&
|
||||
log.output?.childTraceSpans &&
|
||||
@@ -281,14 +325,9 @@ export function buildTraceSpans(result: ExecutionResult): {
|
||||
span.children = mergeTraceSpanChildren(span.children || [], flattenedChildren)
|
||||
}
|
||||
|
||||
// Store in map
|
||||
spanMap.set(spanId, span)
|
||||
})
|
||||
|
||||
// Second pass: Build a flat hierarchy for sequential workflow execution
|
||||
// For most workflows, blocks execute sequentially and should be shown at the same level
|
||||
// Only nest blocks that are truly hierarchical (like subflows, loops, etc.)
|
||||
|
||||
const sortedLogs = [...result.logs].sort((a, b) => {
|
||||
const aTime = new Date(a.startedAt).getTime()
|
||||
const bTime = new Date(b.startedAt).getTime()
|
||||
@@ -297,8 +336,6 @@ export function buildTraceSpans(result: ExecutionResult): {
|
||||
|
||||
const rootSpans: TraceSpan[] = []
|
||||
|
||||
// For now, treat all blocks as top-level spans in execution order
|
||||
// This gives a cleaner, more intuitive view of workflow execution
|
||||
sortedLogs.forEach((log) => {
|
||||
if (!log.blockId) return
|
||||
|
||||
@@ -310,10 +347,8 @@ export function buildTraceSpans(result: ExecutionResult): {
|
||||
})
|
||||
|
||||
if (rootSpans.length === 0 && workflowConnections.length === 0) {
|
||||
// Track parent spans using a stack
|
||||
const spanStack: TraceSpan[] = []
|
||||
|
||||
// Process logs to build time-based hierarchy (original approach)
|
||||
sortedLogs.forEach((log) => {
|
||||
if (!log.blockId || !log.blockType) return
|
||||
|
||||
@@ -321,20 +356,16 @@ export function buildTraceSpans(result: ExecutionResult): {
|
||||
const span = spanMap.get(spanId)
|
||||
if (!span) return
|
||||
|
||||
// If we have a non-empty stack, check if this span should be a child
|
||||
if (spanStack.length > 0) {
|
||||
const potentialParent = spanStack[spanStack.length - 1]
|
||||
const parentStartTime = new Date(potentialParent.startTime).getTime()
|
||||
const parentEndTime = new Date(potentialParent.endTime).getTime()
|
||||
const spanStartTime = new Date(span.startTime).getTime()
|
||||
|
||||
// If this span starts after the parent starts and the parent is still on the stack,
|
||||
// we'll assume it's a child span
|
||||
if (spanStartTime >= parentStartTime && spanStartTime <= parentEndTime) {
|
||||
if (!potentialParent.children) potentialParent.children = []
|
||||
potentialParent.children.push(span)
|
||||
} else {
|
||||
// This span doesn't belong to the current parent, pop from stack
|
||||
while (
|
||||
spanStack.length > 0 &&
|
||||
new Date(spanStack[spanStack.length - 1].endTime).getTime() < spanStartTime
|
||||
@@ -342,22 +373,18 @@ export function buildTraceSpans(result: ExecutionResult): {
|
||||
spanStack.pop()
|
||||
}
|
||||
|
||||
// Check if we still have a parent
|
||||
if (spanStack.length > 0) {
|
||||
const newParent = spanStack[spanStack.length - 1]
|
||||
if (!newParent.children) newParent.children = []
|
||||
newParent.children.push(span)
|
||||
} else {
|
||||
// No parent, this is a root span
|
||||
rootSpans.push(span)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Empty stack, this is a root span
|
||||
rootSpans.push(span)
|
||||
}
|
||||
|
||||
// Check if this span could be a parent to future spans
|
||||
if (log.blockType === 'agent' || isWorkflowBlockType(log.blockType)) {
|
||||
spanStack.push(span)
|
||||
}
|
||||
@@ -383,6 +410,16 @@ export function buildTraceSpans(result: ExecutionResult): {
|
||||
|
||||
const actualWorkflowDuration = latestEnd - earliestStart
|
||||
|
||||
const addRelativeTimestamps = (spans: TraceSpan[], workflowStartMs: number) => {
|
||||
spans.forEach((span) => {
|
||||
span.relativeStartMs = new Date(span.startTime).getTime() - workflowStartMs
|
||||
if (span.children && span.children.length > 0) {
|
||||
addRelativeTimestamps(span.children, workflowStartMs)
|
||||
}
|
||||
})
|
||||
}
|
||||
addRelativeTimestamps(groupedRootSpans, earliestStart)
|
||||
|
||||
const hasErrors = groupedRootSpans.some((span) => {
|
||||
if (span.status === 'error') return true
|
||||
const checkChildren = (children: TraceSpan[] = []): boolean => {
|
||||
|
||||
@@ -35,8 +35,8 @@ export interface ToolCall {
|
||||
startTime: string
|
||||
endTime: string
|
||||
status: 'success' | 'error'
|
||||
input: Record<string, unknown>
|
||||
output: Record<string, unknown>
|
||||
input?: Record<string, unknown>
|
||||
output?: Record<string, unknown>
|
||||
error?: string
|
||||
}
|
||||
|
||||
@@ -133,6 +133,27 @@ export interface WorkflowExecutionLog {
|
||||
export type WorkflowExecutionLogInsert = Omit<WorkflowExecutionLog, 'id' | 'createdAt'>
|
||||
export type WorkflowExecutionLogSelect = WorkflowExecutionLog
|
||||
|
||||
export interface TokenInfo {
|
||||
input?: number
|
||||
output?: number
|
||||
total?: number
|
||||
prompt?: number
|
||||
completion?: number
|
||||
}
|
||||
|
||||
export interface ProviderTiming {
|
||||
duration: number
|
||||
startTime: string
|
||||
endTime: string
|
||||
segments: Array<{
|
||||
type: string
|
||||
name?: string
|
||||
startTime: string | number
|
||||
endTime: string | number
|
||||
duration: number
|
||||
}>
|
||||
}
|
||||
|
||||
export interface TraceSpan {
|
||||
id: string
|
||||
name: string
|
||||
@@ -143,11 +164,18 @@ export interface TraceSpan {
|
||||
children?: TraceSpan[]
|
||||
toolCalls?: ToolCall[]
|
||||
status?: 'success' | 'error'
|
||||
tokens?: number
|
||||
tokens?: number | TokenInfo
|
||||
relativeStartMs?: number
|
||||
blockId?: string
|
||||
input?: Record<string, unknown>
|
||||
output?: Record<string, unknown>
|
||||
model?: string
|
||||
cost?: {
|
||||
input?: number
|
||||
output?: number
|
||||
total?: number
|
||||
}
|
||||
providerTiming?: ProviderTiming
|
||||
}
|
||||
|
||||
export interface WorkflowExecutionSummary {
|
||||
|
||||
@@ -1,284 +0,0 @@
|
||||
/**
|
||||
* Sim Telemetry
|
||||
*
|
||||
* This file can be customized in forked repositories:
|
||||
* - Set TELEMETRY_ENDPOINT in telemetry.config.ts to your collector
|
||||
* - Modify allowed event categories as needed
|
||||
* - Edit disclosure text to match your privacy policy
|
||||
*
|
||||
* Please maintain ethical telemetry practices if modified.
|
||||
*/
|
||||
import { DiagConsoleLogger, DiagLogLevel, diag } from '@opentelemetry/api'
|
||||
import { env } from '@/lib/env'
|
||||
import { isProd } from '@/lib/environment'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.ERROR)
|
||||
|
||||
const logger = createLogger('Telemetry')
|
||||
|
||||
export type TelemetryEvent = {
|
||||
name: string
|
||||
properties?: Record<string, any>
|
||||
}
|
||||
|
||||
export type TelemetryStatus = {
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
const TELEMETRY_STATUS_KEY = 'simstudio-telemetry-status'
|
||||
|
||||
let telemetryConfig = {
|
||||
endpoint: env.TELEMETRY_ENDPOINT || 'https://telemetry.simstudio.ai/v1/traces',
|
||||
serviceName: 'sim-studio',
|
||||
serviceVersion: '0.1.0',
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined' && (window as any).__SIM_STUDIO_TELEMETRY_CONFIG) {
|
||||
telemetryConfig = { ...telemetryConfig, ...(window as any).__SIM_STUDIO_TELEMETRY_CONFIG }
|
||||
}
|
||||
|
||||
let telemetryInitialized = false
|
||||
|
||||
/**
|
||||
* Gets the current telemetry status from localStorage
|
||||
*/
|
||||
export function getTelemetryStatus(): TelemetryStatus {
|
||||
if (typeof window === 'undefined') {
|
||||
return { enabled: true }
|
||||
}
|
||||
|
||||
try {
|
||||
if (env.NEXT_TELEMETRY_DISABLED === '1') {
|
||||
return { enabled: false }
|
||||
}
|
||||
|
||||
const stored = localStorage.getItem(TELEMETRY_STATUS_KEY)
|
||||
return stored ? JSON.parse(stored) : { enabled: true }
|
||||
} catch (error) {
|
||||
logger.error('Failed to get telemetry status from localStorage', error)
|
||||
return { enabled: true }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the telemetry status in localStorage
|
||||
*/
|
||||
export function setTelemetryStatus(status: TelemetryStatus): void {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
localStorage.setItem(TELEMETRY_STATUS_KEY, JSON.stringify(status))
|
||||
|
||||
if (status.enabled && !telemetryInitialized) {
|
||||
initializeClientTelemetry()
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to set telemetry status in localStorage', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disables telemetry
|
||||
*/
|
||||
export function disableTelemetry(): void {
|
||||
const currentStatus = getTelemetryStatus()
|
||||
if (currentStatus.enabled) {
|
||||
trackEvent('consent', 'opt_out')
|
||||
}
|
||||
|
||||
setTelemetryStatus({ enabled: false })
|
||||
logger.info('Telemetry disabled')
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables telemetry
|
||||
*/
|
||||
export function enableTelemetry(): void {
|
||||
if (env.NEXT_TELEMETRY_DISABLED === '1') {
|
||||
logger.info('Telemetry disabled by environment variable, cannot enable')
|
||||
return
|
||||
}
|
||||
|
||||
const currentStatus = getTelemetryStatus()
|
||||
if (!currentStatus.enabled) {
|
||||
trackEvent('consent', 'opt_in')
|
||||
}
|
||||
|
||||
setTelemetryStatus({ enabled: true })
|
||||
logger.info('Telemetry enabled')
|
||||
|
||||
if (!telemetryInitialized) {
|
||||
initializeClientTelemetry()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize client-side telemetry without OpenTelemetry SDK
|
||||
* This approach uses direct event tracking instead of the OTel SDK
|
||||
* to avoid TypeScript compatibility issues while still collecting useful data
|
||||
*/
|
||||
function initializeClientTelemetry(): void {
|
||||
if (typeof window === 'undefined' || telemetryInitialized) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const clientSideEnabled =
|
||||
(window as any).__SIM_STUDIO_TELEMETRY_CONFIG?.clientSide?.enabled !== false
|
||||
|
||||
if (!clientSideEnabled) {
|
||||
logger.info('Client-side telemetry disabled in configuration')
|
||||
return
|
||||
}
|
||||
|
||||
if (isProd) {
|
||||
trackEvent('page_view', window.location.pathname)
|
||||
|
||||
if (typeof window.history !== 'undefined') {
|
||||
const originalPushState = window.history.pushState
|
||||
window.history.pushState = function (...args) {
|
||||
const result = originalPushState.apply(this, args)
|
||||
trackEvent('page_view', window.location.pathname)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window.performance !== 'undefined') {
|
||||
window.addEventListener('load', () => {
|
||||
setTimeout(() => {
|
||||
if (performance.getEntriesByType) {
|
||||
const navigationTiming = performance.getEntriesByType(
|
||||
'navigation'
|
||||
)[0] as PerformanceNavigationTiming
|
||||
if (navigationTiming) {
|
||||
trackEvent(
|
||||
'performance',
|
||||
'page_load',
|
||||
window.location.pathname,
|
||||
navigationTiming.loadEventEnd - navigationTiming.startTime
|
||||
)
|
||||
}
|
||||
|
||||
const lcpEntries = performance
|
||||
.getEntriesByType('paint')
|
||||
.filter((entry) => entry.name === 'largest-contentful-paint')
|
||||
if (lcpEntries.length > 0) {
|
||||
trackEvent(
|
||||
'performance',
|
||||
'largest_contentful_paint',
|
||||
window.location.pathname,
|
||||
lcpEntries[0].startTime
|
||||
)
|
||||
}
|
||||
}
|
||||
}, 0)
|
||||
})
|
||||
}
|
||||
|
||||
document.addEventListener(
|
||||
'click',
|
||||
(e) => {
|
||||
let target = e.target as HTMLElement | null
|
||||
let telemetryAction = null
|
||||
|
||||
while (target && !telemetryAction) {
|
||||
telemetryAction = target.getAttribute('data-telemetry')
|
||||
if (!telemetryAction) {
|
||||
target = target.parentElement
|
||||
}
|
||||
}
|
||||
|
||||
if (telemetryAction) {
|
||||
trackEvent('feature_usage', telemetryAction)
|
||||
}
|
||||
},
|
||||
{ passive: true }
|
||||
)
|
||||
|
||||
document.addEventListener(
|
||||
'submit',
|
||||
(e) => {
|
||||
const form = e.target as HTMLFormElement
|
||||
const telemetryAction = form.getAttribute('data-telemetry')
|
||||
if (telemetryAction) {
|
||||
trackEvent('feature_usage', telemetryAction)
|
||||
}
|
||||
},
|
||||
{ passive: true }
|
||||
)
|
||||
|
||||
window.addEventListener(
|
||||
'error',
|
||||
(event) => {
|
||||
const errorDetails = {
|
||||
message: event.error?.message || 'Unknown error',
|
||||
stack: event.error?.stack?.split('\n')[0] || '',
|
||||
url: window.location.pathname,
|
||||
}
|
||||
trackEvent('error', 'client_error', errorDetails.message)
|
||||
},
|
||||
{ passive: true }
|
||||
)
|
||||
|
||||
window.addEventListener(
|
||||
'unhandledrejection',
|
||||
(event) => {
|
||||
const errorDetails = {
|
||||
message: event.reason?.message || String(event.reason) || 'Unhandled promise rejection',
|
||||
url: window.location.pathname,
|
||||
}
|
||||
trackEvent('error', 'unhandled_rejection', errorDetails.message)
|
||||
},
|
||||
{ passive: true }
|
||||
)
|
||||
|
||||
logger.info('Enhanced client-side telemetry initialized')
|
||||
telemetryInitialized = true
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to initialize client-side telemetry', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a telemetry event
|
||||
*/
|
||||
export async function trackEvent(
|
||||
category: string,
|
||||
action: string,
|
||||
label?: string,
|
||||
value?: number
|
||||
): Promise<void> {
|
||||
const status = getTelemetryStatus()
|
||||
|
||||
if (!status.enabled) return
|
||||
|
||||
try {
|
||||
if (isProd) {
|
||||
await fetch('/api/telemetry', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
category,
|
||||
action,
|
||||
label,
|
||||
value,
|
||||
timestamp: new Date().toISOString(),
|
||||
service: telemetryConfig.serviceName,
|
||||
version: telemetryConfig.serviceVersion,
|
||||
userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : undefined,
|
||||
url: typeof window !== 'undefined' ? window.location.pathname : undefined,
|
||||
}),
|
||||
})
|
||||
} else {
|
||||
if (category === 'consent') {
|
||||
logger.debug('Telemetry consent change', { action })
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to track telemetry event', error)
|
||||
}
|
||||
}
|
||||
451
apps/sim/lib/telemetry/tracer.ts
Normal file
451
apps/sim/lib/telemetry/tracer.ts
Normal file
@@ -0,0 +1,451 @@
|
||||
/**
|
||||
* OpenTelemetry Integration for Sim Execution Pipeline
|
||||
*
|
||||
* This module integrates OpenTelemetry tracing with the existing execution logging system.
|
||||
* It converts TraceSpans and BlockLogs into proper OpenTelemetry spans with semantic conventions.
|
||||
*
|
||||
* Architecture:
|
||||
* - LoggingSession tracks workflow execution start/complete
|
||||
* - Executor generates BlockLogs for each block execution
|
||||
* - TraceSpans are built from BlockLogs
|
||||
* - This module converts TraceSpans -> OpenTelemetry Spans
|
||||
*
|
||||
* Integration Points:
|
||||
* 1. LoggingSession.start() -> Create root workflow span
|
||||
* 2. LoggingSession.complete() -> End workflow span with all block spans as children
|
||||
* 3. Block execution -> Create span for each block type with proper attributes
|
||||
*/
|
||||
|
||||
import { context, type Span, SpanStatusCode, trace } from '@opentelemetry/api'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import type { TraceSpan } from '@/lib/logs/types'
|
||||
|
||||
/**
|
||||
* GenAI Semantic Convention Attributes
|
||||
*/
|
||||
const GenAIAttributes = {
|
||||
// System attributes
|
||||
SYSTEM: 'gen_ai.system',
|
||||
REQUEST_MODEL: 'gen_ai.request.model',
|
||||
RESPONSE_MODEL: 'gen_ai.response.model',
|
||||
|
||||
// Token usage
|
||||
USAGE_INPUT_TOKENS: 'gen_ai.usage.input_tokens',
|
||||
USAGE_OUTPUT_TOKENS: 'gen_ai.usage.output_tokens',
|
||||
USAGE_TOTAL_TOKENS: 'gen_ai.usage.total_tokens',
|
||||
|
||||
// Request/Response
|
||||
REQUEST_TEMPERATURE: 'gen_ai.request.temperature',
|
||||
REQUEST_TOP_P: 'gen_ai.request.top_p',
|
||||
REQUEST_MAX_TOKENS: 'gen_ai.request.max_tokens',
|
||||
RESPONSE_FINISH_REASON: 'gen_ai.response.finish_reason',
|
||||
|
||||
// Agent-specific
|
||||
AGENT_ID: 'gen_ai.agent.id',
|
||||
AGENT_NAME: 'gen_ai.agent.name',
|
||||
AGENT_TASK: 'gen_ai.agent.task',
|
||||
|
||||
// Workflow-specific
|
||||
WORKFLOW_ID: 'gen_ai.workflow.id',
|
||||
WORKFLOW_NAME: 'gen_ai.workflow.name',
|
||||
WORKFLOW_VERSION: 'gen_ai.workflow.version',
|
||||
WORKFLOW_EXECUTION_ID: 'gen_ai.workflow.execution_id',
|
||||
|
||||
// Tool-specific
|
||||
TOOL_NAME: 'gen_ai.tool.name',
|
||||
TOOL_DESCRIPTION: 'gen_ai.tool.description',
|
||||
|
||||
// Cost tracking
|
||||
COST_TOTAL: 'gen_ai.cost.total',
|
||||
COST_INPUT: 'gen_ai.cost.input',
|
||||
COST_OUTPUT: 'gen_ai.cost.output',
|
||||
}
|
||||
|
||||
const logger = createLogger('OTelIntegration')
|
||||
|
||||
// Lazy-load tracer
|
||||
let _tracer: ReturnType<typeof trace.getTracer> | null = null
|
||||
|
||||
function getTracer() {
|
||||
if (!_tracer) {
|
||||
_tracer = trace.getTracer('sim-ai-platform', '1.0.0')
|
||||
}
|
||||
return _tracer
|
||||
}
|
||||
|
||||
/**
|
||||
* Map block types to OpenTelemetry span kinds and semantic conventions
|
||||
*/
|
||||
const BLOCK_TYPE_MAPPING: Record<
|
||||
string,
|
||||
{
|
||||
spanName: string
|
||||
spanKind: string
|
||||
getAttributes: (traceSpan: TraceSpan) => Record<string, string | number | boolean | undefined>
|
||||
}
|
||||
> = {
|
||||
agent: {
|
||||
spanName: 'gen_ai.agent.execute',
|
||||
spanKind: 'gen_ai.agent',
|
||||
getAttributes: (span) => {
|
||||
const attrs: Record<string, string | number | boolean | undefined> = {
|
||||
[GenAIAttributes.AGENT_ID]: span.blockId || span.id,
|
||||
[GenAIAttributes.AGENT_NAME]: span.name,
|
||||
}
|
||||
|
||||
if (span.model) {
|
||||
attrs[GenAIAttributes.REQUEST_MODEL] = span.model
|
||||
}
|
||||
|
||||
if (span.tokens) {
|
||||
if (typeof span.tokens === 'number') {
|
||||
attrs[GenAIAttributes.USAGE_TOTAL_TOKENS] = span.tokens
|
||||
} else {
|
||||
attrs[GenAIAttributes.USAGE_INPUT_TOKENS] = span.tokens.input || span.tokens.prompt || 0
|
||||
attrs[GenAIAttributes.USAGE_OUTPUT_TOKENS] =
|
||||
span.tokens.output || span.tokens.completion || 0
|
||||
attrs[GenAIAttributes.USAGE_TOTAL_TOKENS] = span.tokens.total || 0
|
||||
}
|
||||
}
|
||||
|
||||
if (span.cost) {
|
||||
attrs[GenAIAttributes.COST_INPUT] = span.cost.input || 0
|
||||
attrs[GenAIAttributes.COST_OUTPUT] = span.cost.output || 0
|
||||
attrs[GenAIAttributes.COST_TOTAL] = span.cost.total || 0
|
||||
}
|
||||
|
||||
return attrs
|
||||
},
|
||||
},
|
||||
workflow: {
|
||||
spanName: 'gen_ai.workflow.execute',
|
||||
spanKind: 'gen_ai.workflow',
|
||||
getAttributes: (span) => ({
|
||||
[GenAIAttributes.WORKFLOW_ID]: span.blockId || 'root',
|
||||
[GenAIAttributes.WORKFLOW_NAME]: span.name,
|
||||
}),
|
||||
},
|
||||
tool: {
|
||||
spanName: 'gen_ai.tool.call',
|
||||
spanKind: 'gen_ai.tool',
|
||||
getAttributes: (span) => ({
|
||||
[GenAIAttributes.TOOL_NAME]: span.name,
|
||||
'tool.id': span.id,
|
||||
'tool.duration_ms': span.duration,
|
||||
}),
|
||||
},
|
||||
model: {
|
||||
spanName: 'gen_ai.model.request',
|
||||
spanKind: 'gen_ai.model',
|
||||
getAttributes: (span) => ({
|
||||
'model.name': span.name,
|
||||
'model.id': span.id,
|
||||
'model.duration_ms': span.duration,
|
||||
}),
|
||||
},
|
||||
api: {
|
||||
spanName: 'http.client.request',
|
||||
spanKind: 'http.client',
|
||||
getAttributes: (span) => {
|
||||
const input = span.input as { method?: string; url?: string } | undefined
|
||||
const output = span.output as { status?: number } | undefined
|
||||
return {
|
||||
'http.request.method': input?.method || 'GET',
|
||||
'http.request.url': input?.url || '',
|
||||
'http.response.status_code': output?.status || 0,
|
||||
'block.id': span.blockId,
|
||||
'block.name': span.name,
|
||||
}
|
||||
},
|
||||
},
|
||||
function: {
|
||||
spanName: 'function.execute',
|
||||
spanKind: 'function',
|
||||
getAttributes: (span) => ({
|
||||
'function.name': span.name,
|
||||
'function.id': span.blockId,
|
||||
'function.execution_time_ms': span.duration,
|
||||
}),
|
||||
},
|
||||
router: {
|
||||
spanName: 'router.evaluate',
|
||||
spanKind: 'router',
|
||||
getAttributes: (span) => {
|
||||
const output = span.output as { selectedPath?: { blockId?: string } } | undefined
|
||||
return {
|
||||
'router.name': span.name,
|
||||
'router.id': span.blockId,
|
||||
'router.selected_path': output?.selectedPath?.blockId,
|
||||
}
|
||||
},
|
||||
},
|
||||
condition: {
|
||||
spanName: 'condition.evaluate',
|
||||
spanKind: 'condition',
|
||||
getAttributes: (span) => {
|
||||
const output = span.output as { conditionResult?: boolean | string } | undefined
|
||||
return {
|
||||
'condition.name': span.name,
|
||||
'condition.id': span.blockId,
|
||||
'condition.result': output?.conditionResult,
|
||||
}
|
||||
},
|
||||
},
|
||||
loop: {
|
||||
spanName: 'loop.execute',
|
||||
spanKind: 'loop',
|
||||
getAttributes: (span) => ({
|
||||
'loop.name': span.name,
|
||||
'loop.id': span.blockId,
|
||||
'loop.iterations': span.children?.length || 0,
|
||||
}),
|
||||
},
|
||||
parallel: {
|
||||
spanName: 'parallel.execute',
|
||||
spanKind: 'parallel',
|
||||
getAttributes: (span) => ({
|
||||
'parallel.name': span.name,
|
||||
'parallel.id': span.blockId,
|
||||
'parallel.branches': span.children?.length || 0,
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a TraceSpan to an OpenTelemetry span
|
||||
* Creates a proper OTel span with all the metadata from the trace span
|
||||
*/
|
||||
export function createOTelSpanFromTraceSpan(traceSpan: TraceSpan, parentSpan?: Span): Span | null {
|
||||
try {
|
||||
const tracer = getTracer()
|
||||
|
||||
const blockMapping = BLOCK_TYPE_MAPPING[traceSpan.type] || {
|
||||
spanName: `block.${traceSpan.type}`,
|
||||
spanKind: 'internal',
|
||||
getAttributes: (span: TraceSpan) => ({
|
||||
'block.type': span.type,
|
||||
'block.id': span.blockId,
|
||||
'block.name': span.name,
|
||||
}),
|
||||
}
|
||||
|
||||
const attributes = {
|
||||
...blockMapping.getAttributes(traceSpan),
|
||||
'span.type': traceSpan.type,
|
||||
'span.duration_ms': traceSpan.duration,
|
||||
'span.status': traceSpan.status,
|
||||
}
|
||||
|
||||
const ctx = parentSpan ? trace.setSpan(context.active(), parentSpan) : context.active()
|
||||
|
||||
const span = tracer.startSpan(
|
||||
blockMapping.spanName,
|
||||
{
|
||||
attributes,
|
||||
startTime: new Date(traceSpan.startTime),
|
||||
},
|
||||
ctx
|
||||
)
|
||||
|
||||
if (traceSpan.status === 'error') {
|
||||
const errorMessage =
|
||||
typeof traceSpan.output?.error === 'string'
|
||||
? traceSpan.output.error
|
||||
: 'Block execution failed'
|
||||
|
||||
span.setStatus({
|
||||
code: SpanStatusCode.ERROR,
|
||||
message: errorMessage,
|
||||
})
|
||||
|
||||
if (errorMessage && errorMessage !== 'Block execution failed') {
|
||||
span.recordException(new Error(errorMessage))
|
||||
}
|
||||
} else {
|
||||
span.setStatus({ code: SpanStatusCode.OK })
|
||||
}
|
||||
|
||||
if (traceSpan.children && traceSpan.children.length > 0) {
|
||||
for (const childTraceSpan of traceSpan.children) {
|
||||
createOTelSpanFromTraceSpan(childTraceSpan, span)
|
||||
}
|
||||
}
|
||||
|
||||
if (traceSpan.toolCalls && traceSpan.toolCalls.length > 0) {
|
||||
for (const toolCall of traceSpan.toolCalls) {
|
||||
const toolSpan = tracer.startSpan(
|
||||
'gen_ai.tool.call',
|
||||
{
|
||||
attributes: {
|
||||
[GenAIAttributes.TOOL_NAME]: toolCall.name,
|
||||
'tool.status': toolCall.status,
|
||||
'tool.duration_ms': toolCall.duration || 0,
|
||||
},
|
||||
startTime: new Date(toolCall.startTime),
|
||||
},
|
||||
trace.setSpan(context.active(), span)
|
||||
)
|
||||
|
||||
if (toolCall.status === 'error') {
|
||||
toolSpan.setStatus({
|
||||
code: SpanStatusCode.ERROR,
|
||||
message: toolCall.error || 'Tool call failed',
|
||||
})
|
||||
if (toolCall.error) {
|
||||
toolSpan.recordException(new Error(toolCall.error))
|
||||
}
|
||||
} else {
|
||||
toolSpan.setStatus({ code: SpanStatusCode.OK })
|
||||
}
|
||||
|
||||
toolSpan.end(new Date(toolCall.endTime))
|
||||
}
|
||||
}
|
||||
|
||||
span.end(new Date(traceSpan.endTime))
|
||||
|
||||
return span
|
||||
} catch (error) {
|
||||
logger.error('Failed to create OTel span from trace span', {
|
||||
error,
|
||||
traceSpanId: traceSpan.id,
|
||||
traceSpanType: traceSpan.type,
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create OpenTelemetry spans for an entire workflow execution
|
||||
* This is called from LoggingSession.complete() with the final trace spans
|
||||
*/
|
||||
export function createOTelSpansForWorkflowExecution(params: {
|
||||
workflowId: string
|
||||
workflowName?: string
|
||||
executionId: string
|
||||
traceSpans: TraceSpan[]
|
||||
trigger: string
|
||||
startTime: string
|
||||
endTime: string
|
||||
totalDurationMs: number
|
||||
status: 'success' | 'error'
|
||||
error?: string
|
||||
}): void {
|
||||
try {
|
||||
const tracer = getTracer()
|
||||
|
||||
const rootSpan = tracer.startSpan(
|
||||
'gen_ai.workflow.execute',
|
||||
{
|
||||
attributes: {
|
||||
[GenAIAttributes.WORKFLOW_ID]: params.workflowId,
|
||||
[GenAIAttributes.WORKFLOW_NAME]: params.workflowName || params.workflowId,
|
||||
[GenAIAttributes.WORKFLOW_EXECUTION_ID]: params.executionId,
|
||||
'workflow.trigger': params.trigger,
|
||||
'workflow.duration_ms': params.totalDurationMs,
|
||||
},
|
||||
startTime: new Date(params.startTime),
|
||||
},
|
||||
context.active()
|
||||
)
|
||||
|
||||
if (params.status === 'error') {
|
||||
rootSpan.setStatus({
|
||||
code: SpanStatusCode.ERROR,
|
||||
message: params.error || 'Workflow execution failed',
|
||||
})
|
||||
if (params.error) {
|
||||
rootSpan.recordException(new Error(params.error))
|
||||
}
|
||||
} else {
|
||||
rootSpan.setStatus({ code: SpanStatusCode.OK })
|
||||
}
|
||||
|
||||
for (const traceSpan of params.traceSpans) {
|
||||
createOTelSpanFromTraceSpan(traceSpan, rootSpan)
|
||||
}
|
||||
|
||||
rootSpan.end(new Date(params.endTime))
|
||||
|
||||
logger.debug('Created OTel spans for workflow execution', {
|
||||
workflowId: params.workflowId,
|
||||
executionId: params.executionId,
|
||||
spanCount: params.traceSpans.length,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to create OTel spans for workflow execution', {
|
||||
error,
|
||||
workflowId: params.workflowId,
|
||||
executionId: params.executionId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a real-time OpenTelemetry span for a block execution
|
||||
* Can be called from block handlers during execution for real-time tracing
|
||||
*/
|
||||
export async function traceBlockExecution<T>(
|
||||
blockType: string,
|
||||
blockId: string,
|
||||
blockName: string,
|
||||
fn: (span: Span) => Promise<T>
|
||||
): Promise<T> {
|
||||
const tracer = getTracer()
|
||||
|
||||
const blockMapping = BLOCK_TYPE_MAPPING[blockType] || {
|
||||
spanName: `block.${blockType}`,
|
||||
spanKind: 'internal',
|
||||
getAttributes: () => ({}),
|
||||
}
|
||||
|
||||
return tracer.startActiveSpan(
|
||||
blockMapping.spanName,
|
||||
{
|
||||
attributes: {
|
||||
'block.type': blockType,
|
||||
'block.id': blockId,
|
||||
'block.name': blockName,
|
||||
},
|
||||
},
|
||||
async (span) => {
|
||||
try {
|
||||
const result = await fn(span)
|
||||
span.setStatus({ code: SpanStatusCode.OK })
|
||||
return result
|
||||
} catch (error) {
|
||||
span.setStatus({
|
||||
code: SpanStatusCode.ERROR,
|
||||
message: error instanceof Error ? error.message : 'Block execution failed',
|
||||
})
|
||||
span.recordException(error instanceof Error ? error : new Error(String(error)))
|
||||
throw error
|
||||
} finally {
|
||||
span.end()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Track platform events (workflow creation, knowledge base operations, etc.)
|
||||
*/
|
||||
export function trackPlatformEvent(
|
||||
eventName: string,
|
||||
attributes: Record<string, string | number | boolean>
|
||||
): void {
|
||||
try {
|
||||
const tracer = getTracer()
|
||||
const span = tracer.startSpan(eventName, {
|
||||
attributes: {
|
||||
...attributes,
|
||||
'event.name': eventName,
|
||||
'event.timestamp': Date.now(),
|
||||
},
|
||||
})
|
||||
span.setStatus({ code: SpanStatusCode.OK })
|
||||
span.end()
|
||||
} catch (error) {
|
||||
// Silently fail
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ export async function createStreamingResponse(
|
||||
try {
|
||||
const streamedContent = new Map<string, string>()
|
||||
const processedOutputs = new Set<string>()
|
||||
const streamCompletionTimes = new Map<string, number>()
|
||||
|
||||
const sendChunk = (blockId: string, content: string) => {
|
||||
const separator = processedOutputs.size > 0 ? '\n\n' : ''
|
||||
@@ -58,7 +59,11 @@ export async function createStreamingResponse(
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
if (done) {
|
||||
// Record when this stream completed
|
||||
streamCompletionTimes.set(blockId, Date.now())
|
||||
break
|
||||
}
|
||||
|
||||
const textChunk = decoder.decode(value, { stream: true })
|
||||
streamedContent.set(blockId, (streamedContent.get(blockId) || '') + textChunk)
|
||||
@@ -124,6 +129,15 @@ export async function createStreamingResponse(
|
||||
result.logs = result.logs.map((log: any) => {
|
||||
if (streamedContent.has(log.blockId)) {
|
||||
const content = streamedContent.get(log.blockId)
|
||||
|
||||
// Update timing to reflect actual stream completion
|
||||
if (streamCompletionTimes.has(log.blockId)) {
|
||||
const completionTime = streamCompletionTimes.get(log.blockId)!
|
||||
const startTime = new Date(log.startedAt).getTime()
|
||||
log.endedAt = new Date(completionTime).toISOString()
|
||||
log.durationMs = completionTime - startTime
|
||||
}
|
||||
|
||||
if (log.output && content) {
|
||||
return { ...log, output: { ...log.output, content } }
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
|
||||
name: 'OpenAI',
|
||||
description: "OpenAI's models",
|
||||
defaultModel: 'gpt-4o',
|
||||
modelPatterns: [/^gpt/, /^o1/],
|
||||
modelPatterns: [/^gpt/, /^o1/, /^text-embedding/],
|
||||
icon: OpenAIIcon,
|
||||
capabilities: {
|
||||
toolUsageControl: true,
|
||||
|
||||
@@ -12,8 +12,8 @@ export interface ToolCall {
|
||||
startTime: string // ISO timestamp
|
||||
endTime: string // ISO timestamp
|
||||
status: 'success' | 'error' // Status of the tool call
|
||||
input?: Record<string, any> // Input parameters (optional)
|
||||
output?: Record<string, any> // Output data (optional)
|
||||
input?: Record<string, unknown> // Input parameters (optional)
|
||||
output?: Record<string, unknown> // Output data (optional)
|
||||
error?: string // Error message if status is 'error'
|
||||
}
|
||||
|
||||
@@ -51,6 +51,27 @@ export interface CostMetadata {
|
||||
}
|
||||
}
|
||||
|
||||
export interface TokenInfo {
|
||||
input?: number
|
||||
output?: number
|
||||
total?: number
|
||||
prompt?: number
|
||||
completion?: number
|
||||
}
|
||||
|
||||
export interface ProviderTiming {
|
||||
duration: number
|
||||
startTime: string
|
||||
endTime: string
|
||||
segments: Array<{
|
||||
type: string
|
||||
name?: string
|
||||
startTime: string | number
|
||||
endTime: string | number
|
||||
duration: number
|
||||
}>
|
||||
}
|
||||
|
||||
export interface TraceSpan {
|
||||
id: string
|
||||
name: string
|
||||
@@ -61,11 +82,18 @@ export interface TraceSpan {
|
||||
children?: TraceSpan[]
|
||||
toolCalls?: ToolCall[]
|
||||
status?: 'success' | 'error'
|
||||
tokens?: number
|
||||
tokens?: number | TokenInfo
|
||||
relativeStartMs?: number // Time in ms from the start of the parent span
|
||||
blockId?: string // Added to track the original block ID for relationship mapping
|
||||
input?: Record<string, any> // Added to store input data for this span
|
||||
output?: Record<string, any> // Added to store output data for this span
|
||||
input?: Record<string, unknown> // Added to store input data for this span
|
||||
output?: Record<string, unknown> // Added to store output data for this span
|
||||
model?: string
|
||||
cost?: {
|
||||
input?: number
|
||||
output?: number
|
||||
total?: number
|
||||
}
|
||||
providerTiming?: ProviderTiming
|
||||
}
|
||||
|
||||
export interface WorkflowLog {
|
||||
@@ -93,7 +121,7 @@ export interface WorkflowLog {
|
||||
executionData?: ToolCallMetadata & {
|
||||
traceSpans?: TraceSpan[]
|
||||
totalDuration?: number
|
||||
blockInput?: Record<string, any>
|
||||
blockInput?: Record<string, unknown>
|
||||
enhanced?: boolean
|
||||
|
||||
blockExecutions?: Array<{
|
||||
@@ -107,10 +135,10 @@ export interface WorkflowLog {
|
||||
status: 'success' | 'error' | 'skipped'
|
||||
errorMessage?: string
|
||||
errorStackTrace?: string
|
||||
inputData: any
|
||||
outputData: any
|
||||
inputData: unknown
|
||||
outputData: unknown
|
||||
cost?: CostMetadata
|
||||
metadata: any
|
||||
metadata: Record<string, unknown>
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Sim Telemetry Configuration
|
||||
* Sim OpenTelemetry Configuration
|
||||
*
|
||||
* PRIVACY NOTICE:
|
||||
* - Telemetry is enabled by default to help us improve the product
|
||||
@@ -7,14 +7,15 @@
|
||||
* 1. Settings UI > Privacy tab > Toggle off "Allow anonymous telemetry"
|
||||
* 2. Setting NEXT_TELEMETRY_DISABLED=1 environment variable
|
||||
*
|
||||
* This file allows you to configure telemetry collection for your
|
||||
* This file allows you to configure OpenTelemetry collection for your
|
||||
* Sim instance. If you've forked the repository, you can modify
|
||||
* this file to send telemetry to your own collector.
|
||||
*
|
||||
* We only collect anonymous usage data to improve the product:
|
||||
* - Feature usage statistics
|
||||
* - Error rates
|
||||
* - Performance metrics
|
||||
* - Error rates (always captured)
|
||||
* - Performance metrics (sampled at 10%)
|
||||
* - AI/LLM operation traces (always captured for workflows)
|
||||
*
|
||||
* We NEVER collect:
|
||||
* - Personal information
|
||||
@@ -26,14 +27,15 @@ import { env } from './lib/env'
|
||||
|
||||
const config = {
|
||||
/**
|
||||
* Endpoint URL where telemetry data is sent
|
||||
* OTLP Endpoint URL where telemetry data is sent
|
||||
* Change this if you want to send telemetry to your own collector
|
||||
* Supports any OTLP-compatible backend (Jaeger, Grafana Tempo, etc.)
|
||||
*/
|
||||
endpoint: env.TELEMETRY_ENDPOINT || 'https://telemetry.simstudio.ai/v1/traces',
|
||||
|
||||
/**
|
||||
* Service name used to identify this instance
|
||||
* You can change this
|
||||
* You can change this for your fork
|
||||
*/
|
||||
serviceName: 'sim-studio',
|
||||
|
||||
@@ -43,36 +45,70 @@ const config = {
|
||||
serviceVersion: '0.1.0',
|
||||
|
||||
/**
|
||||
* Batch settings for sending telemetry
|
||||
* - maxQueueSize: Max number of spans to buffer
|
||||
* - maxExportBatchSize: Max number of spans to send in a single batch
|
||||
* - scheduledDelayMillis: Delay between batches (ms)
|
||||
* - exportTimeoutMillis: Timeout for exporting data (ms)
|
||||
* Batch settings for OpenTelemetry BatchSpanProcessor
|
||||
* Optimized for production use with minimal overhead
|
||||
*
|
||||
* - maxQueueSize: Max number of spans to buffer (increased from 100 to 2048)
|
||||
* - maxExportBatchSize: Max number of spans per batch (increased from 10 to 512)
|
||||
* - scheduledDelayMillis: Delay between batches (5 seconds)
|
||||
* - exportTimeoutMillis: Timeout for exporting data (30 seconds)
|
||||
*/
|
||||
batchSettings: {
|
||||
maxQueueSize: 100,
|
||||
maxExportBatchSize: 10,
|
||||
maxQueueSize: 2048,
|
||||
maxExportBatchSize: 512,
|
||||
scheduledDelayMillis: 5000,
|
||||
exportTimeoutMillis: 30000,
|
||||
},
|
||||
|
||||
/**
|
||||
* Sampling configuration
|
||||
* - Errors: Always sampled (100%)
|
||||
* - AI/LLM operations: Always sampled (100%)
|
||||
* - Other operations: Sampled at 10%
|
||||
*/
|
||||
sampling: {
|
||||
defaultRate: 0.1, // 10% sampling for regular operations
|
||||
alwaysSampleErrors: true,
|
||||
alwaysSampleAI: true,
|
||||
},
|
||||
|
||||
/**
|
||||
* Categories of events that can be collected
|
||||
* This is used for validation when events are sent
|
||||
*/
|
||||
allowedCategories: ['page_view', 'feature_usage', 'performance', 'error', 'workflow', 'consent'],
|
||||
allowedCategories: [
|
||||
'page_view',
|
||||
'feature_usage',
|
||||
'performance',
|
||||
'error',
|
||||
'workflow',
|
||||
'consent',
|
||||
'batch', // Added for batched events
|
||||
],
|
||||
|
||||
/**
|
||||
* Client-side instrumentation settings
|
||||
* Set enabled: false to disable client-side telemetry entirely
|
||||
*
|
||||
* Client-side telemetry now uses:
|
||||
* - Event batching (send every 10s or 50 events)
|
||||
* - Only critical Web Vitals (LCP, FID, CLS)
|
||||
* - Unhandled errors only
|
||||
*/
|
||||
clientSide: {
|
||||
enabled: true,
|
||||
batchIntervalMs: 10000, // 10 seconds
|
||||
maxBatchSize: 50,
|
||||
},
|
||||
|
||||
/**
|
||||
* Server-side instrumentation settings
|
||||
* Set enabled: false to disable server-side telemetry entirely
|
||||
*
|
||||
* Server-side telemetry uses:
|
||||
* - OpenTelemetry SDK with BatchSpanProcessor
|
||||
* - Intelligent sampling (errors and AI ops always captured)
|
||||
* - Semantic conventions for AI/LLM operations
|
||||
*/
|
||||
serverSide: {
|
||||
enabled: true,
|
||||
|
||||
@@ -24,7 +24,7 @@ export const knowledgeSearchTool: ToolConfig<any, KnowledgeSearchResponse> = {
|
||||
description: 'Number of most similar results to return (1-100)',
|
||||
},
|
||||
tagFilters: {
|
||||
type: 'any',
|
||||
type: 'array',
|
||||
required: false,
|
||||
description: 'Array of tag filters with tagName and tagValue properties',
|
||||
},
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import type { ParameterVisibility, ToolConfig } from '@/tools/types'
|
||||
import { getTool } from '@/tools/utils'
|
||||
|
||||
const logger = createLogger('ToolsParams')
|
||||
|
||||
export interface Option {
|
||||
label: string
|
||||
value: string
|
||||
@@ -133,13 +136,13 @@ export function getToolParametersConfig(
|
||||
try {
|
||||
const toolConfig = getTool(toolId)
|
||||
if (!toolConfig) {
|
||||
console.warn(`Tool not found: ${toolId}`)
|
||||
logger.warn(`Tool not found: ${toolId}`)
|
||||
return null
|
||||
}
|
||||
|
||||
// Validate that toolConfig has required properties
|
||||
if (!toolConfig.params || typeof toolConfig.params !== 'object') {
|
||||
console.warn(`Tool ${toolId} has invalid params configuration`)
|
||||
logger.warn(`Tool ${toolId} has invalid params configuration`)
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -265,7 +268,7 @@ export function getToolParametersConfig(
|
||||
optionalParameters,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting tool parameters config:', error)
|
||||
logger.error('Error getting tool parameters config:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -306,8 +309,13 @@ export function createLLMToolSchema(
|
||||
}
|
||||
|
||||
// Add parameter to LLM schema
|
||||
let schemaType = param.type
|
||||
if (param.type === 'json' || param.type === 'any') {
|
||||
schemaType = 'object'
|
||||
}
|
||||
|
||||
schema.properties[paramId] = {
|
||||
type: param.type === 'json' ? 'object' : param.type,
|
||||
type: schemaType,
|
||||
description: param.description || '',
|
||||
}
|
||||
|
||||
|
||||
@@ -21,10 +21,10 @@ export const insertTool: ToolConfig<SupabaseInsertParams, SupabaseInsertResponse
|
||||
description: 'The name of the Supabase table to insert data into',
|
||||
},
|
||||
data: {
|
||||
type: 'any',
|
||||
type: 'array',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The data to insert',
|
||||
description: 'The data to insert (array of objects or a single object)',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
|
||||
@@ -21,10 +21,10 @@ export const upsertTool: ToolConfig<SupabaseUpsertParams, SupabaseUpsertResponse
|
||||
description: 'The name of the Supabase table to upsert data into',
|
||||
},
|
||||
data: {
|
||||
type: 'any',
|
||||
type: 'array',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The data to upsert (insert or update)',
|
||||
description: 'The data to upsert (insert or update) - array of objects or a single object',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
|
||||
Reference in New Issue
Block a user