From d30b5036ed985ea7ae610544b4b06c7b319bb129 Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Wed, 22 Apr 2026 15:21:20 -0700 Subject: [PATCH 1/3] Add sandbox log field expansion --- src/core/modules/sandboxes/models.ts | 111 ++++- .../dashboard/common/log-viewer-ui.tsx | 44 +- .../dashboard/sandbox/logs/logs-cells.tsx | 12 + src/features/dashboard/sandbox/logs/logs.tsx | 401 +++++++++++++++++- 4 files changed, 551 insertions(+), 17 deletions(-) diff --git a/src/core/modules/sandboxes/models.ts b/src/core/modules/sandboxes/models.ts index 36044f9b3..847680584 100644 --- a/src/core/modules/sandboxes/models.ts +++ b/src/core/modules/sandboxes/models.ts @@ -43,7 +43,9 @@ export type SandboxDetailsModel = export interface SandboxLogModel { timestampUnix: number level: SandboxLogLevel + logger?: string message: string + fields?: Record } export interface SandboxLogsModel { @@ -141,13 +143,118 @@ export function deriveSandboxLifecycleFromEvents( // mappings +const LOG_LEVEL_ALIASES: Record = { + trace: 'debug', + debug: 'debug', + info: 'info', + warning: 'warn', + warn: 'warn', + error: 'error', + fatal: 'error', + panic: 'error', +} + +const PROMOTED_DATA_FIELDS = new Set([ + 'level', + 'severity', + 'logger', + 'name', + 'message', + 'msg', +]) + +function getStringField(value: unknown) { + return typeof value === 'string' && value.trim() !== '' + ? value.trim() + : undefined +} + +function normalizeLogLevel(value?: string) { + if (!value) return undefined + + return LOG_LEVEL_ALIASES[value.toLowerCase()] +} + +interface ParsedDataField { + fields?: Record + level?: SandboxLogLevel + logger?: string + message?: string +} + +function visibleDataFields(data: Record) { + const visibleEntries = Object.entries(data).filter( + ([key]) => !PROMOTED_DATA_FIELDS.has(key) + ) + + return visibleEntries.length > 0 + ? Object.fromEntries(visibleEntries) + : undefined +} + +function parseJsonLines(value: string) { + const lines = value + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + + if (lines.length <= 1) { + return undefined + } + + const parsedLines: unknown[] = [] + for (const line of lines) { + try { + parsedLines.push(JSON.parse(line)) + } catch { + return undefined + } + } + + return parsedLines +} + +function parseDataField(value?: string): ParsedDataField | undefined { + const trimmed = value?.trim() + if (!trimmed) return undefined + + const jsonLines = parseJsonLines(trimmed) + if (jsonLines) { + return { fields: { entries: jsonLines } } + } + + try { + const parsed = JSON.parse(trimmed) + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return { fields: { data: parsed } } + } + + const data = parsed as Record + + return { + fields: visibleDataFields(data), + level: normalizeLogLevel( + getStringField(data.level) ?? getStringField(data.severity) + ), + logger: getStringField(data.logger) ?? getStringField(data.name), + message: getStringField(data.message) ?? getStringField(data.msg), + } + } catch { + return { fields: { data: trimmed } } + } +} + export function mapInfraSandboxLogToModel( log: InfraComponents['schemas']['SandboxLogEntry'] ): SandboxLogModel { + const data = parseDataField(log.fields.data) + return { timestampUnix: new Date(log.timestamp).getTime(), - level: log.level, - message: log.message, + level: data?.level ?? log.level, + logger: data?.logger ?? log.fields.logger, + message: data?.message ?? log.message, + fields: data?.fields, } } diff --git a/src/features/dashboard/common/log-viewer-ui.tsx b/src/features/dashboard/common/log-viewer-ui.tsx index c62a62209..bac49286b 100644 --- a/src/features/dashboard/common/log-viewer-ui.tsx +++ b/src/features/dashboard/common/log-viewer-ui.tsx @@ -1,5 +1,5 @@ import type { VirtualItem, Virtualizer } from '@tanstack/react-virtual' -import type { CSSProperties, ReactNode } from 'react' +import type { CSSProperties, HTMLAttributes, ReactNode } from 'react' import { cn } from '@/lib/utils' import { ArrowDownIcon, ListIcon } from '@/ui/primitives/icons' import { Loader } from '@/ui/primitives/loader' @@ -12,14 +12,20 @@ import { } from '@/ui/primitives/table' interface LogsTableHeaderProps { + expanderWidth?: number timestampWidth: number levelWidth: number + loggerWidth?: number + dataWidth?: number timestampSortDirection?: 'asc' | 'desc' } export function LogsTableHeader({ + expanderWidth, timestampWidth, levelWidth, + loggerWidth, + dataWidth, timestampSortDirection = 'desc', }: LogsTableHeaderProps) { return ( @@ -28,6 +34,15 @@ export function LogsTableHeader({ style={{ display: 'grid', position: 'sticky', top: 0, zIndex: 1 }} > + {expanderWidth !== undefined ? ( + + + + ) : null} Level + {loggerWidth !== undefined ? ( + + Logger + + ) : null} Message + {dataWidth !== undefined ? ( + + Data + + ) : null} ) @@ -109,10 +140,12 @@ export function getLogVirtualRowStyle( } } -interface LogVirtualRowProps { +interface LogVirtualRowProps + extends Omit, 'children' | 'style'> { virtualRow: VirtualItem virtualizer: Virtualizer height: number + style?: CSSProperties className?: string children: ReactNode } @@ -121,15 +154,18 @@ export function LogVirtualRow({ virtualRow, virtualizer, height, + style, className, children, + ...props }: LogVirtualRowProps) { return ( virtualizer.measureElement(node)} className={className} - style={getLogVirtualRowStyle(virtualRow, height)} + style={{ ...getLogVirtualRowStyle(virtualRow, height), ...style }} + {...props} > {children} diff --git a/src/features/dashboard/sandbox/logs/logs-cells.tsx b/src/features/dashboard/sandbox/logs/logs-cells.tsx index 15c138a55..db9bccfe9 100644 --- a/src/features/dashboard/sandbox/logs/logs-cells.tsx +++ b/src/features/dashboard/sandbox/logs/logs-cells.tsx @@ -102,3 +102,15 @@ export const Message = ({ message, search, shouldHighlight }: MessageProps) => { ) } + +interface LoggerProps { + logger?: SandboxLogModel['logger'] +} + +export const Logger = ({ logger }: LoggerProps) => { + if (!logger) { + return - + } + + return {logger} +} diff --git a/src/features/dashboard/sandbox/logs/logs.tsx b/src/features/dashboard/sandbox/logs/logs.tsx index d9e276a8e..5c9f44002 100644 --- a/src/features/dashboard/sandbox/logs/logs.tsx +++ b/src/features/dashboard/sandbox/logs/logs.tsx @@ -6,6 +6,8 @@ import { type Virtualizer, } from '@tanstack/react-virtual' import { + type KeyboardEvent, + type MouseEvent, useCallback, useEffect, useLayoutEffect, @@ -27,22 +29,36 @@ import { LogVirtualRow, } from '@/features/dashboard/common/log-viewer-ui' import { cn } from '@/lib/utils' +import { ChevronRightIcon } from '@/ui/primitives/icons' import { DebouncedInput } from '@/ui/primitives/input' import { Loader } from '@/ui/primitives/loader' import { Table, TableBody, TableCell } from '@/ui/primitives/table' import { useSandboxContext } from '../context' -import { LogLevel, Message, Timestamp } from './logs-cells' +import { Logger, LogLevel, Message, Timestamp } from './logs-cells' import type { LogLevelFilter as SandboxLogLevelFilter } from './logs-filter-params' import useLogFilters from './use-log-filters' import { useSandboxLogs } from './use-sandbox-logs' // column widths are calculated as max width of the content + padding -const COLUMN_WIDTHS_PX = { timestamp: 148 + 16, level: 48 + 16 } as const +const COLUMN_WIDTHS_PX = { + expander: 28, + timestamp: 148 + 16, + level: 48 + 16, + logger: 180, +} as const const ROW_HEIGHT_PX = 26 +const LOG_DETAILS_MIN_HEIGHT_PX = 96 +const LOG_DETAILS_PADDING_Y_PX = 24 +const LOG_DETAILS_LINE_HEIGHT_PX = 20 +const LOG_DETAILS_FIELD_GAP_PX = 6 +const LOG_DETAILS_ENTRY_HEADER_HEIGHT_PX = 28 +const LOG_DETAILS_ENTRY_GAP_PX = 12 const LIVE_STATUS_ROW_HEIGHT_PX = ROW_HEIGHT_PX + 16 const VIRTUAL_OVERSCAN = 16 const SCROLL_LOAD_THRESHOLD_PX = 200 const LOG_RETENTION_DAYS = LOG_RETENTION_MS / 24 / 60 / 60 / 1000 +const STRUCTURED_LOG_ENTRIES_FIELD = 'entries' +const LOG_DETAILS_APPROX_CHARS_PER_LINE = 110 interface LogsProps { teamSlug: string @@ -77,8 +93,10 @@ export default function SandboxLogs({ teamSlug, sandboxId }: LogsProps) {
@@ -199,8 +217,10 @@ function LogsContent({ >
@@ -336,6 +356,9 @@ function VirtualizedLogsBody({ search, }: VirtualizedLogsBodyProps) { const maxWidthRef = useRef(0) + const [expandedLogIds, setExpandedLogIds] = useState>( + () => new Set() + ) useScrollLoadMore({ scrollContainerElement, @@ -355,15 +378,48 @@ function VirtualizedLogsBody({ const liveStatusRowIndex = logsStartIndex + logs.length const virtualRowsCount = logs.length + (showLoadMoreStatusRow ? 1 : 0) + 1 + const toggleLogExpanded = useCallback((logId: string) => { + setExpandedLogIds((current) => { + const next = new Set(current) + if (next.has(logId)) { + next.delete(logId) + } else { + next.add(logId) + } + + return next + }) + }, []) + const virtualizer = useVirtualizer({ count: virtualRowsCount, - estimateSize: (index) => - index === liveStatusRowIndex ? LIVE_STATUS_ROW_HEIGHT_PX : ROW_HEIGHT_PX, + estimateSize: (index) => { + if (index === liveStatusRowIndex) { + return LIVE_STATUS_ROW_HEIGHT_PX + } + + if (showLoadMoreStatusRow && index === 0) { + return ROW_HEIGHT_PX + } + + const logIndex = index - logsStartIndex + const log = logs[logIndex] + if (!log) { + return ROW_HEIGHT_PX + } + + const logId = getLogRowId(log, logIndex) + return getLogRowHeight(log, expandedLogIds.has(logId)) + }, getScrollElement: () => scrollContainerElement, overscan: VIRTUAL_OVERSCAN, paddingStart: 8, }) + useEffect(() => { + virtualizer.measure() + }, [expandedLogIds, virtualizer]) + const scrollToLatestLog = useCallback(() => { if (logs.length === 0) return virtualizer.scrollToIndex(liveStatusRowIndex, { align: 'end' }) @@ -437,14 +493,19 @@ function VirtualizedLogsBody({ if (!log) { return null } + const logId = getLogRowId(log, logIndex) + const isExpanded = expandedLogIds.has(logId) return ( @@ -611,37 +672,250 @@ function useAutoScrollToBottom({ } interface LogRowProps { + logId: string log: SandboxLogModel search: string shouldHighlight: boolean isZebraRow: boolean + isExpanded: boolean + onToggleExpanded: (logId: string) => void virtualRow: VirtualItem virtualizer: Virtualizer } +function getLogRowId(log: SandboxLogModel, logIndex: number) { + return [ + log.timestampUnix, + logIndex, + log.level, + log.logger ?? '', + log.message, + ].join(':') +} + +function getLogFieldEntries(log: SandboxLogModel) { + if (!log.fields) { + return [] + } + + const structuredEntries = getStructuredLogEntries(log) + + return Object.entries(log.fields).filter( + ([key, value]) => + value !== undefined && + !(key === STRUCTURED_LOG_ENTRIES_FIELD && structuredEntries.length > 0) + ) +} + +function getStructuredLogEntries(log: SandboxLogModel) { + const entries = log.fields?.[STRUCTURED_LOG_ENTRIES_FIELD] + + return Array.isArray(entries) ? entries : [] +} + +function hasLogFields(log: SandboxLogModel) { + return ( + getLogFieldEntries(log).length > 0 || + getStructuredLogEntries(log).length > 0 + ) +} + +function getLogDetailsHeight(log: SandboxLogModel) { + const fieldEntries = getLogFieldEntries(log) + const structuredEntries = getStructuredLogEntries(log) + if (fieldEntries.length === 0 && structuredEntries.length === 0) { + return 0 + } + + const structuredEntriesHeight = structuredEntries.reduce( + (totalHeight, entry) => totalHeight + getStructuredEntryHeight(entry), + 0 + ) + + const structuredEntriesGapHeight = + structuredEntries.length > 1 + ? (structuredEntries.length - 1) * LOG_DETAILS_ENTRY_GAP_PX + : 0 + + const separateFieldListGapHeight = + structuredEntries.length > 0 && fieldEntries.length > 0 + ? LOG_DETAILS_ENTRY_GAP_PX + : 0 + + return Math.max( + LOG_DETAILS_MIN_HEIGHT_PX, + LOG_DETAILS_PADDING_Y_PX + + structuredEntriesHeight + + structuredEntriesGapHeight + + separateFieldListGapHeight + + getLogFieldListHeight(fieldEntries) + ) +} + +function getLogRowHeight(log: SandboxLogModel, isExpanded: boolean) { + return ROW_HEIGHT_PX + (isExpanded ? getLogDetailsHeight(log) : 0) +} + +function isInteractiveTarget(target: EventTarget | null) { + return ( + target instanceof HTMLElement && + Boolean(target.closest('button,a,input,select,textarea,[role="button"]')) + ) +} + +function formatLogFieldValue(value: unknown) { + if (typeof value === 'string') { + return value + } + + return JSON.stringify(value, null, 2) +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) +} + +function getFormattedValueLineCount(value: unknown) { + return formatLogFieldValue(value) + .split('\n') + .reduce( + (lineCount, line) => + lineCount + + Math.max(1, Math.ceil(line.length / LOG_DETAILS_APPROX_CHARS_PER_LINE)), + 0 + ) +} + +function getLogFieldHeight([, value]: [string, unknown]) { + return getFormattedValueLineCount(value) * LOG_DETAILS_LINE_HEIGHT_PX +} + +function getLogFieldListHeight(entries: [string, unknown][]) { + if (entries.length === 0) { + return 0 + } + + return ( + entries.reduce( + (totalHeight, entry) => totalHeight + getLogFieldHeight(entry), + 0 + ) + + (entries.length - 1) * LOG_DETAILS_FIELD_GAP_PX + ) +} + +function getStructuredEntryHeight(entry: unknown) { + const bodyHeight = isRecord(entry) + ? getLogFieldListHeight(Object.entries(entry)) + : getFormattedValueLineCount(entry) * LOG_DETAILS_LINE_HEIGHT_PX + + return LOG_DETAILS_ENTRY_HEADER_HEIGHT_PX + bodyHeight +} + function LogRow({ + logId, log, search, shouldHighlight, isZebraRow, + isExpanded, + onToggleExpanded, virtualRow, virtualizer, }: LogRowProps) { + const canExpand = hasLogFields(log) + const rowHeight = getLogRowHeight(log, isExpanded) + + const toggleExpanded = useCallback(() => { + if (canExpand) { + onToggleExpanded(logId) + } + }, [canExpand, logId, onToggleExpanded]) + + const onRowClick = useCallback( + (event: MouseEvent) => { + if (isInteractiveTarget(event.target)) { + return + } + + toggleExpanded() + }, + [toggleExpanded] + ) + + const onRowKeyDown = useCallback( + (event: KeyboardEvent) => { + if (!canExpand || isInteractiveTarget(event.target)) { + return + } + + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + toggleExpanded() + } + }, + [canExpand, toggleExpanded] + ) + return ( + + {canExpand ? ( + + ) : null} + @@ -651,14 +925,27 @@ function LogRow({ style={{ display: 'flex', alignItems: 'center', - width: COLUMN_WIDTHS_PX.level, }} > + + + + {isExpanded ? : null} ) } +interface LogFieldsDetailsProps { + log: SandboxLogModel +} + +function LogFieldsDetails({ log }: LogFieldsDetailsProps) { + const entries = getLogFieldEntries(log) + const structuredEntries = getStructuredLogEntries(log) + if (entries.length === 0 && structuredEntries.length === 0) { + return null + } + + return ( + event.stopPropagation()} + > +
+
+ {structuredEntries.length > 0 ? ( +
+ {structuredEntries.map((entry, index) => ( + + ))} +
+ ) : null} + {entries.length > 0 ? ( + + ) : null} +
+
+
+ ) +} + +interface StructuredLogEntryProps { + index: number + entry: unknown +} + +function StructuredLogEntry({ index, entry }: StructuredLogEntryProps) { + return ( +
+
+ Entry {index + 1} +
+ {isRecord(entry) ? ( + + ) : ( +
+          {formatLogFieldValue(entry)}
+        
+ )} +
+ ) +} + +interface LogFieldListProps { + className?: string + entries: [string, unknown][] +} + +function LogFieldList({ className, entries }: LogFieldListProps) { + return ( +
+ {entries.map(([key, value]) => ( +
+
+ {key} +
+
+
+              {formatLogFieldValue(value)}
+            
+
+
+ ))} +
+ ) +} + interface StatusRowProps { virtualRow: VirtualItem virtualizer: Virtualizer From b35dd82c72806e45c5aea57f41c57f180294b9f6 Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Wed, 22 Apr 2026 15:24:46 -0700 Subject: [PATCH 2/3] Add sandbox log copy actions --- .../dashboard/common/log-viewer-ui.tsx | 11 + src/features/dashboard/sandbox/logs/logs.tsx | 236 +++++++++++++++++- 2 files changed, 237 insertions(+), 10 deletions(-) diff --git a/src/features/dashboard/common/log-viewer-ui.tsx b/src/features/dashboard/common/log-viewer-ui.tsx index bac49286b..1e117af6d 100644 --- a/src/features/dashboard/common/log-viewer-ui.tsx +++ b/src/features/dashboard/common/log-viewer-ui.tsx @@ -16,6 +16,7 @@ interface LogsTableHeaderProps { timestampWidth: number levelWidth: number loggerWidth?: number + actionsWidth?: number dataWidth?: number timestampSortDirection?: 'asc' | 'desc' } @@ -25,6 +26,7 @@ export function LogsTableHeader({ timestampWidth, levelWidth, loggerWidth, + actionsWidth, dataWidth, timestampSortDirection = 'desc', }: LogsTableHeaderProps) { @@ -75,6 +77,15 @@ export function LogsTableHeader({ > Message + {actionsWidth !== undefined ? ( + + + + ) : null} {dataWidth !== undefined ? ( { if (logs.length === 0) return virtualizer.scrollToIndex(liveStatusRowIndex, { align: 'end' }) @@ -684,13 +714,43 @@ interface LogRowProps { } function getLogRowId(log: SandboxLogModel, logIndex: number) { - return [ - log.timestampUnix, - logIndex, - log.level, - log.logger ?? '', - log.message, - ].join(':') + return getLogFingerprint(log) || `${log.timestampUnix}:${logIndex}` +} + +function getLogFingerprint(log: SandboxLogModel) { + return `${log.timestampUnix}:${hashString( + stableStringify({ + fields: log.fields, + level: log.level, + logger: log.logger, + message: log.message, + }) + )}` +} + +function stableStringify(value: unknown): string { + if (Array.isArray(value)) { + return `[${value.map(stableStringify).join(',')}]` + } + + if (isRecord(value)) { + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`) + .join(',')}}` + } + + return JSON.stringify(value) +} + +function hashString(value: string) { + let hash = 5381 + + for (let index = 0; index < value.length; index += 1) { + hash = (hash * 33) ^ value.charCodeAt(index) + } + + return (hash >>> 0).toString(36) } function getLogFieldEntries(log: SandboxLogModel) { @@ -745,6 +805,8 @@ function getLogDetailsHeight(log: SandboxLogModel) { return Math.max( LOG_DETAILS_MIN_HEIGHT_PX, LOG_DETAILS_PADDING_Y_PX + + LOG_DETAILS_ACTIONS_HEIGHT_PX + + LOG_DETAILS_ACTIONS_GAP_PX + structuredEntriesHeight + structuredEntriesGapHeight + separateFieldListGapHeight + @@ -812,6 +874,58 @@ function getStructuredEntryHeight(entry: unknown) { return LOG_DETAILS_ENTRY_HEADER_HEIGHT_PX + bodyHeight } +interface UseScrollToDeepLinkedLogParams { + logs: SandboxLogModel[] + logsStartIndex: number + scrollContainerElement: HTMLDivElement + setExpandedLogIds: Dispatch>> + virtualizer: Virtualizer +} + +function useScrollToDeepLinkedLog({ + logs, + logsStartIndex, + scrollContainerElement, + setExpandedLogIds, + virtualizer, +}: UseScrollToDeepLinkedLogParams) { + const didScrollRef = useRef(false) + + useEffect(() => { + if (didScrollRef.current || logs.length === 0) { + return + } + + const targetLogId = new URLSearchParams(window.location.search).get( + LOG_DEEP_LINK_PARAM + ) + if (!targetLogId) { + return + } + + const logIndex = logs.findIndex( + (log, index) => getLogRowId(log, index) === targetLogId + ) + if (logIndex === -1) { + return + } + + didScrollRef.current = true + setExpandedLogIds((current) => new Set(current).add(targetLogId)) + + requestAnimationFrame(() => { + virtualizer.scrollToIndex(logsStartIndex + logIndex, { align: 'center' }) + scrollContainerElement.focus({ preventScroll: true }) + }) + }, [ + logs, + logsStartIndex, + scrollContainerElement, + setExpandedLogIds, + virtualizer, + ]) +} + function LogRow({ logId, log, @@ -870,6 +984,7 @@ function LogRow({ : `${ROW_HEIGHT_PX}px`, }} className={cn( + 'group/log-row', canExpand && 'cursor-pointer hover:bg-bg-hover focus:bg-bg-hover', isExpanded ? 'border-l-2 bg-bg' @@ -953,16 +1068,114 @@ function LogRow({ shouldHighlight={shouldHighlight} /> - {isExpanded ? : null} + {isExpanded ? : null} ) } +interface LogDetailsActionsProps { + log: SandboxLogModel + logId: string +} + +function LogDetailsActions({ log, logId }: LogDetailsActionsProps) { + const link = getLogDeepLink(logId) + const json = getLogJson(log) + + return ( + + + + + + + + + Options + } + /> + } + /> + + + + ) +} + +interface LogActionMenuItemProps { + value: string + label: string + copiedLabel: string + icon: ReactNode +} + +function LogActionMenuItem({ + value, + label, + copiedLabel, + icon, +}: LogActionMenuItemProps) { + const [wasCopied, copy] = useClipboard() + + const handleSelect = useCallback( + (event: Event) => { + event.preventDefault() + copy(value) + }, + [copy, value] + ) + + return ( + + {wasCopied ? : icon} + {wasCopied ? copiedLabel : label} + + ) +} + +function getLogDeepLink(logId: string) { + if (typeof window === 'undefined') { + return '' + } + + const url = new URL(window.location.href) + url.searchParams.set(LOG_DEEP_LINK_PARAM, logId) + url.hash = '' + return url.toString() +} + +function getLogJson(log: SandboxLogModel) { + const jsonValue: Record = { + timestamp: new Date(log.timestampUnix).toISOString(), + level: log.level, + message: log.message, + } + + if (log.logger) { + jsonValue.logger = log.logger + } + + if (log.fields) { + jsonValue.fields = log.fields + } + + return JSON.stringify(jsonValue, null, 2) +} + interface LogFieldsDetailsProps { log: SandboxLogModel + logId: string } -function LogFieldsDetails({ log }: LogFieldsDetailsProps) { +function LogFieldsDetails({ log, logId }: LogFieldsDetailsProps) { const entries = getLogFieldEntries(log) const structuredEntries = getStructuredLogEntries(log) if (entries.length === 0 && structuredEntries.length === 0) { @@ -976,6 +1189,9 @@ function LogFieldsDetails({ log }: LogFieldsDetailsProps) { onClick={(event) => event.stopPropagation()} >
+
+ +
{structuredEntries.length > 0 ? (
From 92e789b7802e41490ac452dde12bab0e20abad17 Mon Sep 17 00:00:00 2001 From: Matt Brockman Date: Wed, 22 Apr 2026 17:39:37 -0700 Subject: [PATCH 3/3] Show user loggers separately from capture metadata --- src/core/modules/sandboxes/models.ts | 122 +++++++++++++++---- src/core/server/api/routers/sandbox.ts | 12 +- src/features/dashboard/sandbox/logs/logs.tsx | 35 +++++- 3 files changed, 131 insertions(+), 38 deletions(-) diff --git a/src/core/modules/sandboxes/models.ts b/src/core/modules/sandboxes/models.ts index 847680584..d485fcefe 100644 --- a/src/core/modules/sandboxes/models.ts +++ b/src/core/modules/sandboxes/models.ts @@ -45,6 +45,12 @@ export interface SandboxLogModel { level: SandboxLogLevel logger?: string message: string + origin?: 'user' | 'platform' + capturedBy?: { + logger?: string + message?: string + event_type?: string + } fields?: Record } @@ -192,7 +198,20 @@ function visibleDataFields(data: Record) { : undefined } -function parseJsonLines(value: string) { +function parseJsonObject(value: string) { + try { + const parsed = JSON.parse(value) + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return undefined + } + + return parsed as Record + } catch { + return undefined + } +} + +function parseJsonLineObjects(value: string) { const lines = value .split('\n') .map((line) => line.trim()) @@ -202,60 +221,111 @@ function parseJsonLines(value: string) { return undefined } - const parsedLines: unknown[] = [] + const parsedLines: Record[] = [] for (const line of lines) { - try { - parsedLines.push(JSON.parse(line)) - } catch { + const parsed = parseJsonObject(line) + if (!parsed) { return undefined } + + parsedLines.push(parsed) } return parsedLines } -function parseDataField(value?: string): ParsedDataField | undefined { +function parseDataObject(data: Record): ParsedDataField { + return { + fields: visibleDataFields(data), + level: normalizeLogLevel( + getStringField(data.level) ?? getStringField(data.severity) + ), + logger: getStringField(data.logger) ?? getStringField(data.name), + message: getStringField(data.message) ?? getStringField(data.msg), + } +} + +function parseDataFields(value?: string): ParsedDataField[] | undefined { const trimmed = value?.trim() if (!trimmed) return undefined - const jsonLines = parseJsonLines(trimmed) + const jsonLines = parseJsonLineObjects(trimmed) if (jsonLines) { - return { fields: { entries: jsonLines } } + return jsonLines.map(parseDataObject) } try { const parsed = JSON.parse(trimmed) if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - return { fields: { data: parsed } } + return [{ fields: { data: parsed } }] } - const data = parsed as Record - - return { - fields: visibleDataFields(data), - level: normalizeLogLevel( - getStringField(data.level) ?? getStringField(data.severity) - ), - logger: getStringField(data.logger) ?? getStringField(data.name), - message: getStringField(data.message) ?? getStringField(data.msg), - } + return [parseDataObject(parsed as Record)] } catch { - return { fields: { data: trimmed } } + return [{ fields: { data: trimmed } }] + } +} + +function isCapturedUserLog(log: InfraComponents['schemas']['SandboxLogEntry']) { + return Boolean( + getStringField(log.fields.captured_by_logger) || + getStringField(log.fields.captured_by_message) || + getStringField(log.fields.captured_by_event_type) || + getStringField(log.fields.event_type) === 'stdout' || + getStringField(log.fields.event_type) === 'stderr' || + (getStringField(log.fields.logger) === 'process' && + getStringField(log.message) === 'Streaming process event') + ) +} + +function getCapturedBy(log: InfraComponents['schemas']['SandboxLogEntry']) { + if (!isCapturedUserLog(log)) { + return undefined + } + + const capturedBy = { + logger: + getStringField(log.fields.captured_by_logger) ?? + getStringField(log.fields.logger), + message: + getStringField(log.fields.captured_by_message) ?? + getStringField(log.message), + event_type: + getStringField(log.fields.captured_by_event_type) ?? + getStringField(log.fields.event_type), } + + return Object.values(capturedBy).some(Boolean) ? capturedBy : undefined } -export function mapInfraSandboxLogToModel( +export function mapInfraSandboxLogToModels( log: InfraComponents['schemas']['SandboxLogEntry'] -): SandboxLogModel { - const data = parseDataField(log.fields.data) +): SandboxLogModel[] { + const parsedDataFields = parseDataFields(log.fields.data) + if (!parsedDataFields) { + return [ + { + timestampUnix: new Date(log.timestamp).getTime(), + level: log.level, + logger: log.fields.logger, + message: log.message, + fields: undefined, + }, + ] + } - return { + const capturedBy = getCapturedBy(log) + const origin = capturedBy ? 'user' : undefined + + return parsedDataFields.map((data) => ({ timestampUnix: new Date(log.timestamp).getTime(), level: data?.level ?? log.level, - logger: data?.logger ?? log.fields.logger, + logger: data?.logger ?? (origin ? undefined : log.fields.logger), message: data?.message ?? log.message, + origin, + capturedBy, fields: data?.fields, - } + })) } export function mapInfraSandboxDetailsToModel( diff --git a/src/core/server/api/routers/sandbox.ts b/src/core/server/api/routers/sandbox.ts index 948367c59..be10a7777 100644 --- a/src/core/server/api/routers/sandbox.ts +++ b/src/core/server/api/routers/sandbox.ts @@ -4,7 +4,7 @@ import { deriveSandboxLifecycleFromEvents, mapApiSandboxRecordToModel, mapInfraSandboxDetailsToModel, - mapInfraSandboxLogToModel, + mapInfraSandboxLogToModels, type SandboxDetailsModel, type SandboxLogModel, type SandboxLogsModel, @@ -102,11 +102,11 @@ export const sandboxRouter = createTRPCRouter({ } const sandboxLogs = sandboxLogsResult.data - const logs: SandboxLogModel[] = sandboxLogs.logs - .map(mapInfraSandboxLogToModel) + const logs: SandboxLogModel[] = [...sandboxLogs.logs] .reverse() + .flatMap(mapInfraSandboxLogToModels) - const hasMore = logs.length === limit + const hasMore = sandboxLogs.logs.length === limit const cursorLog = logs[0] const nextCursor = hasMore ? (cursorLog?.timestampUnix ?? null) : null @@ -145,8 +145,8 @@ export const sandboxRouter = createTRPCRouter({ } const sandboxLogs = sandboxLogsResult.data - const logs: SandboxLogModel[] = sandboxLogs.logs.map( - mapInfraSandboxLogToModel + const logs: SandboxLogModel[] = sandboxLogs.logs.flatMap( + mapInfraSandboxLogToModels ) const newestLog = logs[logs.length - 1] diff --git a/src/features/dashboard/sandbox/logs/logs.tsx b/src/features/dashboard/sandbox/logs/logs.tsx index ea618c250..11faf67d6 100644 --- a/src/features/dashboard/sandbox/logs/logs.tsx +++ b/src/features/dashboard/sandbox/logs/logs.tsx @@ -721,9 +721,11 @@ function getLogFingerprint(log: SandboxLogModel) { return `${log.timestampUnix}:${hashString( stableStringify({ fields: log.fields, + capturedBy: log.capturedBy, level: log.level, logger: log.logger, message: log.message, + origin: log.origin, }) )}` } @@ -754,17 +756,30 @@ function hashString(value: string) { } function getLogFieldEntries(log: SandboxLogModel) { + const entries: [string, unknown][] = [] + + if (log.origin) { + entries.push(['origin', log.origin]) + } + + if (log.capturedBy) { + entries.push(['captured_by', log.capturedBy]) + } + if (!log.fields) { - return [] + return entries } const structuredEntries = getStructuredLogEntries(log) - return Object.entries(log.fields).filter( - ([key, value]) => - value !== undefined && - !(key === STRUCTURED_LOG_ENTRIES_FIELD && structuredEntries.length > 0) - ) + return [ + ...entries, + ...Object.entries(log.fields).filter( + ([key, value]) => + value !== undefined && + !(key === STRUCTURED_LOG_ENTRIES_FIELD && structuredEntries.length > 0) + ), + ] } function getStructuredLogEntries(log: SandboxLogModel) { @@ -1159,10 +1174,18 @@ function getLogJson(log: SandboxLogModel) { message: log.message, } + if (log.origin) { + jsonValue.origin = log.origin + } + if (log.logger) { jsonValue.logger = log.logger } + if (log.capturedBy) { + jsonValue.captured_by = log.capturedBy + } + if (log.fields) { jsonValue.fields = log.fields }