diff --git a/apps/sim/app/w/logs/components/sidebar/components/markdown-renderer.tsx b/apps/sim/app/w/logs/components/sidebar/components/markdown-renderer.tsx new file mode 100644 index 000000000..7a59e3c13 --- /dev/null +++ b/apps/sim/app/w/logs/components/sidebar/components/markdown-renderer.tsx @@ -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) => ( +

{children}

+ ), + + // Inline code - no background to maintain clean appearance + code: ({ + inline, + className, + children, + ...props + }: React.HTMLAttributes & { className?: string; inline?: boolean }) => { + return ( + + {children} + + ) + }, + + // Links - maintain monospace while adding subtle link styling + a: ({ href, children, ...props }: React.AnchorHTMLAttributes) => ( + + {children} + + ), + + // Tighter lists with minimal spacing + ul: ({ children }: React.HTMLAttributes) => ( +
    {children}
+ ), + ol: ({ children }: React.HTMLAttributes) => ( +
    {children}
+ ), + li: ({ children }: React.HTMLAttributes) => ( +
  • {children}
  • + ), + + // Keep blockquotes minimal + blockquote: ({ children }: React.HTMLAttributes) => ( +
    {children}
    + ), + + // Make headings compact with minimal spacing after + h1: ({ children }: React.HTMLAttributes) => ( +

    {children}

    + ), + h2: ({ children }: React.HTMLAttributes) => ( +

    {children}

    + ), + h3: ({ children }: React.HTMLAttributes) => ( +

    {children}

    + ), + h4: ({ children }: React.HTMLAttributes) => ( +

    {children}

    + ), + } + + return ( +
    + {processedContent} +
    + ) +} diff --git a/apps/sim/app/w/logs/components/sidebar/sidebar.tsx b/apps/sim/app/w/logs/components/sidebar/sidebar.tsx index fffb527a7..36b4fcaa4 100644 --- a/apps/sim/app/w/logs/components/sidebar/sidebar.tsx +++ b/apps/sim/app/w/logs/components/sidebar/sidebar.tsx @@ -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): 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 ( -
    -
    {systemComment}
    -
    - -
    -            {formatted}
    -          
    -
    -
    + ) } // If no system comment pattern found, show the whole content - const { formatted } = tryPrettifyJson(content) + const { isJson, formatted } = tryPrettifyJson(content) return (
    -
    -        {formatted}
    -      
    + {isJson ? ( +
    +          {formatted}
    +        
    + ) : ( + + )} +
    + ) +} + +const BlockContentDisplay = ({ + systemComment, + formatted, + isJson, + blockInput, +}: { + systemComment: string + formatted: string + isJson: boolean + blockInput?: Record +}) => { + 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 ( +
    +
    {systemComment}
    + + {/* Tabs for switching between output and input */} + {redactedBlockInput && ( +
    + + +
    + )} + + {/* Content based on active tab */} +
    + {activeTab === 'output' ? ( + <> + + {isJson ? ( +
    +                {redactedOutput}
    +              
    + ) : ( + + )} + + ) : ( + <> + +
    +              {JSON.stringify(redactedBlockInput, null, 2)}
    +            
    + + )} +
    ) } @@ -115,10 +206,29 @@ export function Sidebar({ const formattedContent = useMemo(() => { if (!log) return null - return formatJsonContent(log.message) + + let blockInput: Record | 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({ {/* Content */} - -
    + +
    {/* Timestamp */}

    Timestamp

    @@ -374,7 +487,7 @@ export function Sidebar({
    )} - {/* Message Content - MOVED ABOVE the Trace Spans and Cost */} + {/* Message Content */}

    Message

    {formattedContent}
    diff --git a/apps/sim/app/w/logs/logs.tsx b/apps/sim/app/w/logs/logs.tsx index 121ef895e..cc8dff9e2 100644 --- a/apps/sim/app/w/logs/logs.tsx +++ b/apps/sim/app/w/logs/logs.tsx @@ -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(null) const [selectedLogIndex, setSelectedLogIndex] = useState(-1) const [isSidebarOpen, setIsSidebarOpen] = useState(false) const selectedRowRef = useRef(null) + const loaderRef = useRef(null) + const scrollContainerRef = useRef(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 = {} @@ -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() {
    {/* Table body - scrollable */} -
    - {loading ? ( +
    + {loading && page === 1 ? (
    @@ -323,9 +400,7 @@ export default function Logs() { {/* Time column */}
    -
    +
    {formattedDate.formatted} • @@ -389,25 +464,64 @@ export default function Logs() { {/* Message column */} -
    +
    {log.message}
    {/* Duration column */} -
    +
    {log.duration || '—'}
    ) })} + + {/* Infinite scroll loader */} + {hasMore && ( + + +
    + {isFetchingMore && ( +
    + + Loading more logs... +
    + )} +
    + + + )} + + {/* Footer status indicator - useful for development */} + + +
    + Showing {filteredLogs.length} logs +
    + {isFetchingMore ? ( +
    + ) : hasMore ? ( + + ) : ( + End of logs + )} +
    +
    + + )} diff --git a/apps/sim/app/w/logs/stores/store.ts b/apps/sim/app/w/logs/stores/store.ts index 834e896a7..e0dec45b4 100644 --- a/apps/sim/app/w/logs/stores/store.ts +++ b/apps/sim/app/w/logs/stores/store.ts @@ -10,23 +10,36 @@ export const useFilterStore = create((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((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((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() diff --git a/apps/sim/app/w/logs/stores/types.ts b/apps/sim/app/w/logs/stores/types.ts index 10b72a2f8..0560afb1a 100644 --- a/apps/sim/app/w/logs/stores/types.ts +++ b/apps/sim/app/w/logs/stores/types.ts @@ -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 // 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 } } @@ -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 } diff --git a/apps/sim/executor/index.ts b/apps/sim/executor/index.ts index aa4e4d693..45810d308 100644 --- a/apps/sim/executor/index.ts +++ b/apps/sim/executor/index.ts @@ -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, diff --git a/apps/sim/executor/types.ts b/apps/sim/executor/types.ts index fc16a7956..06834b9a7 100644 --- a/apps/sim/executor/types.ts +++ b/apps/sim/executor/types.ts @@ -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 } diff --git a/apps/sim/lib/logs/execution-logger.ts b/apps/sim/lib/logs/execution-logger.ts index 5cbf688f6..803ea1ef6 100644 --- a/apps/sim/lib/logs/execution-logger.ts +++ b/apps/sim/lib/logs/execution-logger.ts @@ -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 = {} - - 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 -} diff --git a/apps/sim/lib/utils.ts b/apps/sim/lib/utils.ts index 10e60ed01..027e1a347 100644 --- a/apps/sim/lib/utils.ts +++ b/apps/sim/lib/utils.ts @@ -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 = {} + + 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 +} diff --git a/apps/sim/stores/panel/console/store.ts b/apps/sim/stores/panel/console/store.ts index 53ec5aad6..edaa50038 100644 --- a/apps/sim/stores/panel/console/store.ts +++ b/apps/sim/stores/panel/console/store.ts @@ -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 = {} - - 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