improvement(logs): added infinite scroll, markdown rendering, and individual block input to logs (#364)

* add performant infinite scroll

* added markdown rendering in the logs

* added individual block input to logs

* fixed markdown render

* consolidate redactApiKeys to utils

* acknowledged PR comments
This commit is contained in:
Waleed Latif
2025-05-15 14:14:12 -07:00
committed by GitHub
parent 1152a264bc
commit f07cffd264
10 changed files with 461 additions and 135 deletions

View File

@@ -0,0 +1,80 @@
import ReactMarkdown from 'react-markdown'
export default function LogMarkdownRenderer({ content }: { content: string }) {
// Process text to clean up unnecessary whitespace and formatting issues
const processedContent = content
.replace(/\n{2,}/g, '\n\n') // Replace multiple newlines with exactly double newlines
.replace(/^(#{1,6})\s+(.+?)\n{2,}/gm, '$1 $2\n') // Reduce space after headings to single newline
.replace(/^(#{1,6}.+)\n\n(-|\*)/gm, '$1\n$2') // Remove double newline between heading and list
.trim()
const customComponents = {
// Default component to ensure monospace font with minimal spacing
p: ({ children }: React.HTMLAttributes<HTMLParagraphElement>) => (
<p className="whitespace-pre-wrap font-mono text-sm leading-tight my-0.5">{children}</p>
),
// Inline code - no background to maintain clean appearance
code: ({
inline,
className,
children,
...props
}: React.HTMLAttributes<HTMLElement> & { className?: string; inline?: boolean }) => {
return (
<code className="font-mono text-sm" {...props}>
{children}
</code>
)
},
// Links - maintain monospace while adding subtle link styling
a: ({ href, children, ...props }: React.AnchorHTMLAttributes<HTMLAnchorElement>) => (
<a
href={href}
className="font-mono text-sm text-blue-600 dark:text-blue-400 hover:underline"
target="_blank"
rel="noopener noreferrer"
{...props}
>
{children}
</a>
),
// Tighter lists with minimal spacing
ul: ({ children }: React.HTMLAttributes<HTMLUListElement>) => (
<ul className="list-disc pl-5 font-mono text-sm -mt-1.5 mb-1 leading-none">{children}</ul>
),
ol: ({ children }: React.HTMLAttributes<HTMLOListElement>) => (
<ol className="list-decimal pl-5 font-mono text-sm -mt-1.5 mb-1 leading-none">{children}</ol>
),
li: ({ children }: React.HTMLAttributes<HTMLLIElement>) => (
<li className="font-mono text-sm mb-0 leading-tight">{children}</li>
),
// Keep blockquotes minimal
blockquote: ({ children }: React.HTMLAttributes<HTMLQuoteElement>) => (
<blockquote className="font-mono text-sm my-0">{children}</blockquote>
),
// Make headings compact with minimal spacing after
h1: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
<h1 className="font-mono text-sm font-medium mt-2 mb-0">{children}</h1>
),
h2: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
<h2 className="font-mono text-sm font-medium mt-2 mb-0">{children}</h2>
),
h3: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
<h3 className="font-mono text-sm font-medium mt-1.5 mb-0">{children}</h3>
),
h4: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
<h4 className="font-mono text-sm font-medium mt-1.5 mb-0">{children}</h4>
),
}
return (
<div className="text-sm whitespace-pre-wrap w-full overflow-visible font-mono leading-tight [&>ul]:mt-0 [&>h2+ul]:-mt-2.5 [&>h3+ul]:-mt-2.5">
<ReactMarkdown components={customComponents}>{processedContent}</ReactMarkdown>
</div>
)
}

View File

@@ -1,16 +1,18 @@
'use client'
import { useEffect, useMemo, useRef, useState } from 'react'
import { ChevronDown, ChevronUp, Code, X } from 'lucide-react'
import { ChevronDown, ChevronUp, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { CopyButton } from '@/components/ui/copy-button'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { redactApiKeys } from '@/lib/utils'
import { WorkflowLog } from '@/app/w/logs/stores/types'
import { formatDate } from '@/app/w/logs/utils/format-date'
import { formatCost } from '@/providers/utils'
import { ToolCallsDisplay } from '../tool-calls/tool-calls-display'
import { TraceSpansDisplay } from '../trace-spans/trace-spans-display'
import LogMarkdownRenderer from './components/markdown-renderer'
interface LogSidebarProps {
log: WorkflowLog | null
@@ -49,7 +51,7 @@ const tryPrettifyJson = (content: string): { isJson: boolean; formatted: string
/**
* Formats JSON content for display, handling multiple JSON objects separated by '--'
*/
const formatJsonContent = (content: string): React.ReactNode => {
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)
@@ -57,30 +59,119 @@ const formatJsonContent = (content: string): React.ReactNode => {
if (match) {
const systemComment = match[1]
const actualContent = content.substring(match[0].length).trim()
const { formatted } = tryPrettifyJson(actualContent)
const { isJson, formatted } = tryPrettifyJson(actualContent)
return (
<div className="w-full">
<div className="text-sm font-medium mb-2 text-muted-foreground">{systemComment}</div>
<div className="bg-secondary/30 p-3 rounded-md relative group">
<CopyButton text={formatted} className="h-7 w-7 z-10" />
<pre className="text-sm whitespace-pre-wrap break-all w-full overflow-y-auto max-h-[500px] overflow-x-hidden">
{formatted}
</pre>
</div>
</div>
<BlockContentDisplay
systemComment={systemComment}
formatted={formatted}
isJson={isJson}
blockInput={blockInput}
/>
)
}
// If no system comment pattern found, show the whole content
const { formatted } = tryPrettifyJson(content)
const { isJson, formatted } = tryPrettifyJson(content)
return (
<div className="bg-secondary/30 p-3 rounded-md relative group w-full">
<CopyButton text={formatted} className="h-7 w-7 z-10" />
<pre className="text-sm whitespace-pre-wrap break-all w-full overflow-y-auto max-h-[500px] overflow-x-hidden">
{formatted}
</pre>
{isJson ? (
<pre className="text-sm whitespace-pre-wrap break-all w-full overflow-y-auto max-h-[500px] overflow-x-hidden">
{formatted}
</pre>
) : (
<LogMarkdownRenderer content={formatted} />
)}
</div>
)
}
const BlockContentDisplay = ({
systemComment,
formatted,
isJson,
blockInput,
}: {
systemComment: string
formatted: string
isJson: boolean
blockInput?: Record<string, any>
}) => {
const [activeTab, setActiveTab] = useState<'output' | 'input'>(blockInput ? 'output' : 'output')
const redactedBlockInput = useMemo(() => {
return blockInput ? redactApiKeys(blockInput) : undefined
}, [blockInput])
const redactedOutput = useMemo(() => {
if (!isJson) return formatted
try {
const parsedOutput = JSON.parse(formatted)
const redactedJson = redactApiKeys(parsedOutput)
return JSON.stringify(redactedJson, null, 2)
} catch (e) {
return formatted
}
}, [formatted, isJson])
return (
<div className="w-full">
<div className="text-sm font-medium text-muted-foreground mb-2">{systemComment}</div>
{/* Tabs for switching between output and input */}
{redactedBlockInput && (
<div className="flex space-x-1 mb-2">
<button
onClick={() => setActiveTab('output')}
className={`px-3 py-1 text-xs rounded-md transition-colors ${
activeTab === 'output'
? 'bg-secondary text-foreground'
: 'hover:bg-secondary/50 text-muted-foreground'
}`}
>
Output
</button>
<button
onClick={() => setActiveTab('input')}
className={`px-3 py-1 text-xs rounded-md transition-colors ${
activeTab === 'input'
? 'bg-secondary text-foreground'
: 'hover:bg-secondary/50 text-muted-foreground'
}`}
>
Input
</button>
</div>
)}
{/* Content based on active tab */}
<div className="bg-secondary/30 p-3 rounded-md relative group">
{activeTab === 'output' ? (
<>
<CopyButton text={redactedOutput} className="h-7 w-7 z-10" />
{isJson ? (
<pre className="text-sm whitespace-pre-wrap break-all w-full overflow-visible">
{redactedOutput}
</pre>
) : (
<LogMarkdownRenderer content={redactedOutput} />
)}
</>
) : (
<>
<CopyButton
text={JSON.stringify(redactedBlockInput, null, 2)}
className="h-7 w-7 z-10"
/>
<pre className="text-sm whitespace-pre-wrap break-all w-full overflow-visible">
{JSON.stringify(redactedBlockInput, null, 2)}
</pre>
</>
)}
</div>
</div>
)
}
@@ -115,10 +206,29 @@ export function Sidebar({
const formattedContent = useMemo(() => {
if (!log) return null
return formatJsonContent(log.message)
let blockInput: Record<string, any> | undefined = undefined
if (log.metadata?.blockInput) {
blockInput = log.metadata.blockInput
} else if (log.metadata?.traceSpans) {
const blockIdMatch = log.message.match(/Block .+?(\d+)/i)
const blockId = blockIdMatch ? blockIdMatch[1] : null
if (blockId) {
const matchingSpan = log.metadata.traceSpans.find(
(span) => span.blockId === blockId || span.name.includes(`Block ${blockId}`)
)
if (matchingSpan && matchingSpan.input) {
blockInput = matchingSpan.input
}
}
}
return formatJsonContent(log.message, blockInput)
}, [log])
// Reset scroll position when log changes
useEffect(() => {
if (scrollAreaRef.current) {
scrollAreaRef.current.scrollTop = 0
@@ -297,8 +407,11 @@ export function Sidebar({
</div>
{/* Content */}
<ScrollArea className="h-[calc(100vh-64px-49px)] w-full" ref={scrollAreaRef}>
<div className="p-4 space-y-4 w-full overflow-hidden pr-6">
<ScrollArea
className="h-[calc(100vh-64px-49px)] w-full overflow-y-auto"
ref={scrollAreaRef}
>
<div className="p-4 space-y-4 w-full pr-6">
{/* Timestamp */}
<div>
<h3 className="text-xs font-medium text-muted-foreground mb-1">Timestamp</h3>
@@ -374,7 +487,7 @@ export function Sidebar({
</div>
)}
{/* Message Content - MOVED ABOVE the Trace Spans and Cost */}
{/* Message Content */}
<div className="pb-2 w-full">
<h3 className="text-xs font-medium text-muted-foreground mb-1">Message</h3>
<div className="w-full">{formattedContent}</div>

View File

@@ -1,6 +1,6 @@
'use client'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { AlertCircle, Info, Loader2 } from 'lucide-react'
import { createLogger } from '@/lib/logs/console-logger'
import { useSidebarStore } from '@/stores/sidebar/store'
@@ -12,8 +12,8 @@ import { LogsResponse, WorkflowLog } from './stores/types'
import { formatDate } from './utils/format-date'
const logger = createLogger('Logs')
const LOGS_PER_PAGE = 50
// Helper function to get level badge styling
const getLevelBadgeStyles = (level: string) => {
switch (level.toLowerCase()) {
case 'error':
@@ -25,7 +25,6 @@ const getLevelBadgeStyles = (level: string) => {
}
}
// Helper function to get trigger badge styling
const getTriggerBadgeStyles = (trigger: string) => {
switch (trigger.toLowerCase()) {
case 'manual':
@@ -43,30 +42,45 @@ const getTriggerBadgeStyles = (trigger: string) => {
}
}
// Add a new CSS class for the selected row animation
const selectedRowAnimation = `
@keyframes borderPulse {
0% { border-left-color: hsl(var(--primary) / 0.3); }
50% { border-left-color: hsl(var(--primary) / 0.7); }
100% { border-left-color: hsl(var(--primary) / 0.5); }
0% { border-left-color: hsl(var(--primary) / 0.3) }
50% { border-left-color: hsl(var(--primary) / 0.7) }
100% { border-left-color: hsl(var(--primary) / 0.5) }
}
.selected-row {
animation: borderPulse 1s ease-in-out;
border-left-color: hsl(var(--primary) / 0.5);
animation: borderPulse 1s ease-in-out
border-left-color: hsl(var(--primary) / 0.5)
}
`
export default function Logs() {
const { filteredLogs, logs, loading, error, setLogs, setLoading, setError } = useFilterStore()
const {
filteredLogs,
logs,
loading,
error,
setLogs,
setLoading,
setError,
page,
setPage,
hasMore,
setHasMore,
isFetchingMore,
setIsFetchingMore,
} = useFilterStore()
const [selectedLog, setSelectedLog] = useState<WorkflowLog | null>(null)
const [selectedLogIndex, setSelectedLogIndex] = useState<number>(-1)
const [isSidebarOpen, setIsSidebarOpen] = useState(false)
const selectedRowRef = useRef<HTMLTableRowElement | null>(null)
const loaderRef = useRef<HTMLDivElement>(null)
const scrollContainerRef = useRef<HTMLDivElement>(null)
const { mode, isExpanded } = useSidebarStore()
const isSidebarCollapsed =
mode === 'expanded' ? !isExpanded : mode === 'collapsed' || mode === 'hover'
// Group logs by executionId to identify the last log in each group
const executionGroups = useMemo(() => {
const groups: Record<string, WorkflowLog[]> = {}
@@ -80,7 +94,6 @@ export default function Logs() {
}
})
// Sort logs within each group by createdAt
Object.keys(groups).forEach((executionId) => {
groups[executionId].sort(
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
@@ -90,16 +103,13 @@ export default function Logs() {
return groups
}, [filteredLogs])
// Handle log click
const handleLogClick = (log: WorkflowLog) => {
setSelectedLog(log)
// Find the index of the clicked log in the filtered logs array
const index = filteredLogs.findIndex((l) => l.id === log.id)
setSelectedLogIndex(index)
setIsSidebarOpen(true)
}
// Navigate to the next log
const handleNavigateNext = () => {
if (selectedLogIndex < filteredLogs.length - 1) {
const nextIndex = selectedLogIndex + 1
@@ -108,7 +118,6 @@ export default function Logs() {
}
}
// Navigate to the previous log
const handleNavigatePrev = () => {
if (selectedLogIndex > 0) {
const prevIndex = selectedLogIndex - 1
@@ -117,12 +126,10 @@ export default function Logs() {
}
}
// Close sidebar
const handleCloseSidebar = () => {
setIsSidebarOpen(false)
}
// Scroll selected log into view when it changes
useEffect(() => {
if (selectedRowRef.current) {
selectedRowRef.current.scrollIntoView({
@@ -132,13 +139,18 @@ export default function Logs() {
}
}, [selectedLogIndex])
// Fetch logs on component mount
useEffect(() => {
const fetchLogs = async () => {
const fetchLogs = useCallback(
async (pageNum: number, append: boolean = false) => {
try {
setLoading(true)
// Include workflow data in the response
const response = await fetch('/api/logs?includeWorkflow=true')
if (pageNum === 1) {
setLoading(true)
} else {
setIsFetchingMore(true)
}
const response = await fetch(
`/api/logs?includeWorkflow=true&limit=${LOGS_PER_PAGE}&offset=${(pageNum - 1) * LOGS_PER_PAGE}`
)
if (!response.ok) {
throw new Error(`Error fetching logs: ${response.statusText}`)
@@ -146,26 +158,94 @@ export default function Logs() {
const data: LogsResponse = await response.json()
setLogs(data.data)
setHasMore(data.data.length === LOGS_PER_PAGE && data.page < data.totalPages)
setLogs(data.data, append)
setError(null)
} catch (err) {
logger.error('Failed to fetch logs:', { err })
setError(err instanceof Error ? err.message : 'An unknown error occurred')
} finally {
setLoading(false)
if (pageNum === 1) {
setLoading(false)
} else {
setIsFetchingMore(false)
}
}
},
[setLogs, setLoading, setError, setHasMore, setIsFetchingMore]
)
const loadMoreLogs = useCallback(() => {
if (!isFetchingMore && hasMore) {
const nextPage = page + 1
setPage(nextPage)
setIsFetchingMore(true)
setTimeout(() => {
fetchLogs(nextPage, true)
}, 50)
}
}, [fetchLogs, isFetchingMore, hasMore, page, setPage, setIsFetchingMore])
useEffect(() => {
if (loading || !hasMore) return
const scrollContainer = scrollContainerRef.current
if (!scrollContainer) return
const handleScroll = () => {
if (!scrollContainer) return
const { scrollTop, scrollHeight, clientHeight } = scrollContainer
const scrollPercentage = (scrollTop / (scrollHeight - clientHeight)) * 100
if (scrollPercentage > 60 && !isFetchingMore && hasMore) {
loadMoreLogs()
}
}
fetchLogs()
}, [setLogs, setLoading, setError])
scrollContainer.addEventListener('scroll', handleScroll)
return () => {
scrollContainer.removeEventListener('scroll', handleScroll)
}
}, [loading, hasMore, isFetchingMore, loadMoreLogs])
useEffect(() => {
const currentLoaderRef = loaderRef.current
const scrollContainer = scrollContainerRef.current
if (!currentLoaderRef || !scrollContainer || loading || !hasMore) return
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && !isFetchingMore) {
loadMoreLogs()
}
},
{
root: scrollContainer,
threshold: 0.1,
rootMargin: '200px 0px 0px 0px',
}
)
observer.observe(currentLoaderRef)
return () => {
observer.unobserve(currentLoaderRef)
}
}, [loading, hasMore, isFetchingMore, loadMoreLogs])
useEffect(() => {
fetchLogs(1)
}, [fetchLogs])
// Add keyboard navigation for the logs table
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Only handle keyboard navigation if we have logs and a log is selected
if (filteredLogs.length === 0) return
// If no log is selected yet, select the first one on arrow key press
if (selectedLogIndex === -1 && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
e.preventDefault()
setSelectedLogIndex(0)
@@ -173,13 +253,11 @@ export default function Logs() {
return
}
// Up arrow key for previous log
if (e.key === 'ArrowUp' && !e.metaKey && !e.ctrlKey && selectedLogIndex > 0) {
e.preventDefault()
handleNavigatePrev()
}
// Down arrow key for next log
if (
e.key === 'ArrowDown' &&
!e.metaKey &&
@@ -190,7 +268,6 @@ export default function Logs() {
handleNavigateNext()
}
// Enter key to open/close sidebar
if (e.key === 'Enter' && selectedLog) {
e.preventDefault()
setIsSidebarOpen(!isSidebarOpen)
@@ -267,8 +344,8 @@ export default function Logs() {
</div>
{/* Table body - scrollable */}
<div className="flex-1 overflow-auto">
{loading ? (
<div className="flex-1 overflow-auto" ref={scrollContainerRef}>
{loading && page === 1 ? (
<div className="flex items-center justify-center h-full">
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" />
@@ -323,9 +400,7 @@ export default function Logs() {
{/* Time column */}
<td className="px-4 py-3">
<div className="flex flex-col justify-center">
<div
className={`text-xs font-medium flex items-center ${isSelected ? 'text-foreground' : ''}`}
>
<div className="text-xs font-medium flex items-center">
<span>{formattedDate.formatted}</span>
<span className="mx-1.5 text-muted-foreground hidden xl:inline">
@@ -389,25 +464,64 @@ export default function Logs() {
{/* Message column */}
<td className="px-4 py-3">
<div
className={`text-sm truncate ${isSelected ? 'text-foreground' : ''}`}
title={log.message}
>
<div className="text-sm truncate" title={log.message}>
{log.message}
</div>
</td>
{/* Duration column */}
<td className="px-4 py-3">
<div
className={`text-xs ${isSelected ? 'text-foreground' : 'text-muted-foreground'}`}
>
<div className="text-xs text-muted-foreground">
{log.duration || '—'}
</div>
</td>
</tr>
)
})}
{/* Infinite scroll loader */}
{hasMore && (
<tr>
<td colSpan={7}>
<div
ref={loaderRef}
className="py-2 flex items-center justify-center"
style={{ height: '50px' }}
>
{isFetchingMore && (
<div className="flex items-center gap-2 text-muted-foreground opacity-70">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-xs">Loading more logs...</span>
</div>
)}
</div>
</td>
</tr>
)}
{/* Footer status indicator - useful for development */}
<tr className="border-t">
<td colSpan={7}>
<div className="py-2 px-4 text-xs text-muted-foreground flex justify-between items-center">
<span>Showing {filteredLogs.length} logs</span>
<div className="flex items-center gap-4">
{isFetchingMore ? (
<div className="flex items-center gap-2"></div>
) : hasMore ? (
<button
type="button"
onClick={loadMoreLogs}
className="text-xs text-primary hover:underline"
>
Load more logs
</button>
) : (
<span>End of logs</span>
)}
</div>
</div>
</td>
</tr>
</tbody>
</table>
)}

View File

@@ -10,23 +10,36 @@ export const useFilterStore = create<FilterState>((set, get) => ({
searchQuery: '',
loading: true,
error: null,
page: 1,
hasMore: true,
isFetchingMore: false,
setLogs: (logs) => {
set({ logs, filteredLogs: logs, loading: false })
setLogs: (logs, append = false) => {
if (append) {
const currentLogs = [...get().logs]
const newLogs = [...currentLogs, ...logs]
set({ logs: newLogs })
get().applyFilters()
} else {
set({ logs, filteredLogs: logs, loading: false })
}
},
setTimeRange: (timeRange) => {
set({ timeRange })
get().resetPagination()
get().applyFilters()
},
setLevel: (level) => {
set({ level })
get().resetPagination()
get().applyFilters()
},
setWorkflowIds: (workflowIds) => {
set({ workflowIds })
get().resetPagination()
get().applyFilters()
},
@@ -41,11 +54,13 @@ export const useFilterStore = create<FilterState>((set, get) => ({
}
set({ workflowIds: currentWorkflowIds })
get().resetPagination()
get().applyFilters()
},
setSearchQuery: (searchQuery) => {
set({ searchQuery })
get().resetPagination()
get().applyFilters()
},
@@ -53,6 +68,14 @@ export const useFilterStore = create<FilterState>((set, get) => ({
setError: (error) => set({ error }),
setPage: (page) => set({ page }),
setHasMore: (hasMore) => set({ hasMore }),
setIsFetchingMore: (isFetchingMore) => set({ isFetchingMore }),
resetPagination: () => set({ page: 1, hasMore: true }),
applyFilters: () => {
const { logs, timeRange, level, workflowIds, searchQuery } = get()

View File

@@ -52,6 +52,7 @@ export interface TraceSpan {
tokens?: number
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
}
export interface WorkflowLog {
@@ -68,6 +69,7 @@ export interface WorkflowLog {
traceSpans?: TraceSpan[]
totalDuration?: number
cost?: CostMetadata
blockInput?: Record<string, any>
}
}
@@ -85,18 +87,27 @@ export type LogLevel = 'error' | 'info' | 'all'
export interface FilterState {
// Original logs from API
logs: WorkflowLog[]
// Filtered logs to display
filteredLogs: WorkflowLog[]
// Filter states
timeRange: TimeRange
level: LogLevel
workflowIds: string[]
searchQuery: string
// Loading state
loading: boolean
error: string | null
// Pagination state
page: number
hasMore: boolean
isFetchingMore: boolean
// Actions
setLogs: (logs: WorkflowLog[]) => void
setLogs: (logs: WorkflowLog[], append?: boolean) => void
setTimeRange: (timeRange: TimeRange) => void
setLevel: (level: LogLevel) => void
setWorkflowIds: (workflowIds: string[]) => void
@@ -104,6 +115,11 @@ export interface FilterState {
setSearchQuery: (query: string) => void
setLoading: (loading: boolean) => void
setError: (error: string | null) => void
setPage: (page: number) => void
setHasMore: (hasMore: boolean) => void
setIsFetchingMore: (isFetchingMore: boolean) => void
resetPagination: () => void
// Apply filters
applyFilters: () => void
}

View File

@@ -1007,6 +1007,9 @@ export class Executor {
// Resolve inputs (which will look up references to other blocks including starter)
const inputs = this.resolver.resolveInputs(block, context)
// Store input data in the block log
blockLog.input = inputs
// Track block execution start
trackWorkflowTelemetry('block_execution_start', {
workflowId: context.workflowId,

View File

@@ -50,6 +50,7 @@ export interface BlockLog {
durationMs: number // Duration of execution in milliseconds
success: boolean // Whether execution completed successfully
output?: any // Output data from successful execution
input?: any // Input data for the block execution
error?: string // Error message if execution failed
}

View File

@@ -2,6 +2,7 @@ import { eq, sql } from 'drizzle-orm'
import { v4 as uuidv4 } from 'uuid'
import { getCostMultiplier } from '@/lib/environment'
import { createLogger } from '@/lib/logs/console-logger'
import { redactApiKeys } from '@/lib/utils'
import { db } from '@/db'
import { userStats, workflow, workflowLogs } from '@/db/schema'
import { ExecutionResult as ExecutorResult } from '@/executor/types'
@@ -581,7 +582,10 @@ export async function persistExecutionLogs(
duration: log.success ? `${log.durationMs}ms` : 'NA',
trigger: triggerType,
createdAt: new Date(log.endedAt || log.startedAt),
metadata,
metadata: {
...metadata,
...(log.input ? { blockInput: log.input } : {}),
},
})
if (metadata) {
@@ -977,33 +981,3 @@ function isValidDate(dateString: string): boolean {
return false
}
}
// Add this utility function for redacting API keys in tool call inputs
function redactApiKeys(obj: any): any {
if (!obj || typeof obj !== 'object') {
return obj
}
if (Array.isArray(obj)) {
return obj.map(redactApiKeys)
}
const result: Record<string, any> = {}
for (const [key, value] of Object.entries(obj)) {
// Check if the key is 'apiKey' (case insensitive) or related keys
if (
key.toLowerCase() === 'apikey' ||
key.toLowerCase() === 'api_key' ||
key.toLowerCase() === 'access_token'
) {
result[key] = '***REDACTED***'
} else if (typeof value === 'object' && value !== null) {
result[key] = redactApiKeys(value)
} else {
result[key] = value
}
}
return result
}

View File

@@ -308,3 +308,38 @@ export function getRotatingApiKey(provider: string): string {
return keys[keyIndex]
}
/**
* Recursively redacts API keys in an object
* @param obj The object to redact API keys from
* @returns A new object with API keys redacted
*/
export const redactApiKeys = (obj: any): any => {
if (!obj || typeof obj !== 'object') {
return obj
}
if (Array.isArray(obj)) {
return obj.map(redactApiKeys)
}
const result: Record<string, any> = {}
for (const [key, value] of Object.entries(obj)) {
if (
key.toLowerCase() === 'apikey' ||
key.toLowerCase() === 'api_key' ||
key.toLowerCase() === 'access_token' ||
/\bsecret\b/i.test(key.toLowerCase()) ||
/\bpassword\b/i.test(key.toLowerCase())
) {
result[key] = '***REDACTED***'
} else if (typeof value === 'object' && value !== null) {
result[key] = redactApiKeys(value)
} else {
result[key] = value
}
}
return result
}

View File

@@ -1,45 +1,12 @@
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
import { redactApiKeys } from '@/lib/utils'
import { useChatStore } from '../chat/store'
import { ConsoleEntry, ConsoleStore } from './types'
// MAX across all workflows
const MAX_ENTRIES = 50
/**
* Recursively redacts API keys in an object
* @param obj The object to redact API keys from
* @returns A new object with API keys redacted
*/
const redactApiKeys = (obj: any): any => {
if (!obj || typeof obj !== 'object') {
return obj
}
if (Array.isArray(obj)) {
return obj.map(redactApiKeys)
}
const result: Record<string, any> = {}
for (const [key, value] of Object.entries(obj)) {
// Check if the key is 'apiKey' (case insensitive)
if (
key.toLowerCase() === 'apikey' ||
key.toLowerCase() === 'api_key' ||
key.toLowerCase() === 'access_token'
) {
result[key] = '***REDACTED***'
} else if (typeof value === 'object' && value !== null) {
result[key] = redactApiKeys(value)
} else {
result[key] = value
}
}
return result
}
/**
* Gets a nested property value from an object using a path string
* @param obj The object to get the value from