unify worker logs in single tab

This commit is contained in:
Francesco Sansalvadore committed 2026-09-08 16:08:14 +02:00
1 parent 4a5db160be
commit c9e618bfdb
14 files changed
+368 -366

No files matched your search

@@ -1,22 +0,0 @@
import { Column } from 'react-data-grid'
import { TimestampInfo } from 'ui-patterns/TimestampInfo'
import type { LogData } from '../Logs.types'
import { RowLayout, SeverityFormatter, TextFormatter } from '../LogsFormatters'
export const WorkersLogsColumnRender: Column<LogData>[] = [
{
name: 'workers-logs-first-column',
key: 'workers-logs-first-column',
renderHeaderCell: () => null,
renderCell: (props) => (
<RowLayout>
<TimestampInfo utcTimestamp={props.row.timestamp!} />
{props.row.severity_text ? (
<SeverityFormatter value={props.row.severity_text as string} />
) : null}
<TextFormatter className="w-full" value={props.row.event_message} />
</RowLayout>
),
},
]
@@ -0,0 +1,20 @@
import { ChartConfig } from 'ui'
import { TooltipLabel } from './components/TooltipLabel'
// Extracted from UnifiedLogs.tsx so it can be reused by other timeline charts
// (e.g. the embedded Worker logs tab) without pulling in the full page module.
export const CHART_CONFIG = {
success: {
label: <TooltipLabel level="success" />,
color: 'var(--chart-success)',
},
warning: {
label: <TooltipLabel level="warning" />,
color: 'var(--chart-warning)',
},
error: {
label: <TooltipLabel level="error" />,
color: 'hsl(var(--destructive-default))',
},
} satisfies ChartConfig
@@ -91,6 +91,9 @@ export const SEARCH_PARAMS_PARSER = {
edge_auth: parseAsBoolean.withDefault(true),
edge_storage: parseAsBoolean.withDefault(true),
edge_postgrest: parseAsBoolean.withDefault(true),
worker_requests: parseAsBoolean.withDefault(true),
worker_output: parseAsBoolean.withDefault(true),
worker_builds: parseAsBoolean.withDefault(true),
// Support searching for user against user ID
// See userAttributionCondition in UnifiedLogs.queries.ts.
@@ -36,7 +36,13 @@ export const filterFields = [
]
: value === 'postgres'
? [{ label: 'Connection logs', value: 'show_connection_logs' }]
: [],
: value === 'workers'
? [
{ label: 'Invocations', value: 'worker_requests' },
{ label: 'Logs', value: 'worker_output' },
{ label: 'Activity', value: 'worker_builds' },
]
: [],
})),
component: (props: Option) => {
return (
@@ -181,6 +181,28 @@ describe('UnifiedLogs.queries (OTEL flat)', () => {
expect(sql).not.toContain("log_attributes['request.path'] NOT LIKE '%/rest/%'")
})
it.each([
['worker_requests', 'worker_ingress_logs'],
['worker_output', 'worker_guest_logs'],
['worker_builds', 'worker_api_logs'],
] as const)('excludes the %s stream from the Workers log type when %s=false', (key, source) => {
const sql = getUnifiedLogsQuery({ ...baseSearch, [key]: false } as any)
expect(sql).toContain(`log_attributes['source'] != '${source}'`)
})
it('does not filter worker streams by default (all worker_* toggles true)', () => {
const sql = getUnifiedLogsQuery(baseSearch)
expect(sql).not.toContain("log_attributes['source'] != 'worker_ingress_logs'")
expect(sql).not.toContain("log_attributes['source'] != 'worker_guest_logs'")
expect(sql).not.toContain("log_attributes['source'] != 'worker_api_logs'")
})
it('scopes to a single worker via the generic log_attributes fallback', () => {
const sql = getUnifiedLogsQuery(withFilters('log_type:eq:workers', 'worker:eq:fran-worker'))
const where = sql.split(/\bWHERE\b/)[1] ?? ''
expect(where).toContain(`log_attributes['worker'] IN ('fran-worker')`)
})
it('leaves dedicated auth_logs/storage_logs/postgrest_logs rows untouched by the edge_* toggles', () => {
// These toggles only hide traffic nested inside the `edge_logs` (API
// Gateway) source — the dedicated sources are separate log types now
@@ -335,6 +335,18 @@ const EDGE_SERVICE_PATH_FILTER: Record<'edge_auth' | 'edge_storage' | 'edge_post
edge_postgrest: '%/rest/%',
}
// Maps each worker stream's view-option toggle to the OTEL `log_attributes['source']`
// value it hides. Mirrors EDGE_SERVICE_PATH_FILTER above, but for the three streams
// (Invocations/Logs/Activity) nested under the `workers` log type.
const WORKER_STREAM_VIEW_OPTION: Record<
'worker_requests' | 'worker_output' | 'worker_builds',
string
> = {
worker_requests: WORKER_LOG_SOURCES.requests,
worker_output: WORKER_LOG_SOURCES.output,
worker_builds: WORKER_LOG_SOURCES.builds,
}
/**
* Returns view-option WHERE conditions — toggles from the filter sidebar that
* hide a subset of rows without being a `filter` URL param (Postgres
@@ -424,6 +436,14 @@ const applySearchParamsFilter = (search: QuerySearchParamsType): SafeLogSqlFragm
}
}
// Visible by default — only an explicit `false` hides that stream within
// the Workers log type (Invocations/Logs/Activity).
for (const key of ['worker_requests', 'worker_output', 'worker_builds'] as const) {
if (search[key] === false) {
conditions.push(safeSql`log_attributes['source'] != ${lit(WORKER_STREAM_VIEW_OPTION[key])}`)
}
}
if (conditions.length === 0) return null
return safeSql`(${joinSqlFragments(conditions, ' AND ')})`
}
@@ -32,9 +32,9 @@ import { generateDynamicColumns, UNIFIED_LOGS_COLUMNS } from './components/Colum
import { DownloadLogsButton } from './components/DownloadLogsButton'
import { LogsFilterBar } from './components/LogsFilterBar'
import { LogsListPanel } from './components/LogsListPanel'
import { TooltipLabel } from './components/TooltipLabel'
import { RowSelectionHeader } from './RowSelectionHeader'
import { ServiceFlowPanel } from './ServiceFlowPanel'
import { CHART_CONFIG } from './UnifiedLogs.chart-config'
import { SEARCH_PARAMS_PARSER } from './UnifiedLogs.constants'
import { filterFields as defaultFilterFields } from './UnifiedLogs.fields'
import {
@@ -74,21 +74,6 @@ import { useTrack } from '@/lib/telemetry/track'
import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
import { useShortcut } from '@/state/shortcuts/useShortcut'
export const CHART_CONFIG = {
success: {
label: <TooltipLabel level="success" />,
color: 'var(--chart-success)',
},
warning: {
label: <TooltipLabel level="warning" />,
color: 'var(--chart-warning)',
},
error: {
label: <TooltipLabel level="error" />,
color: 'hsl(var(--destructive-default))',
},
} satisfies ChartConfig
export const UnifiedLogs = () => {
useResetFocus()
@@ -52,6 +52,22 @@ describe('buildUnifiedLogsUrl', () => {
)
expect(params.has('date')).toBe(false)
})
it('appends extraFilters alongside the log_type filter', () => {
const { params } = parse(
buildUnifiedLogsUrl({
projectRef: 'abc',
logType: 'workers',
extraFilters: ['worker:eq:fran-worker'],
})
)
expect(params.getAll('filter')).toEqual(['log_type:eq:workers', 'worker:eq:fran-worker'])
})
it('sets the id param to pre-select a row', () => {
const { params } = parse(buildUnifiedLogsUrl({ projectRef: 'abc', id: 'row-123' }))
expect(params.get('id')).toBe('row-123')
})
})
describe('parseMultigresEventMessage', () => {
@@ -34,6 +34,8 @@ export const buildUnifiedLogsUrl = ({
user,
start,
end,
extraFilters,
id,
}: {
projectRef: string
logType?: UnifiedLogType
@@ -41,13 +43,19 @@ export const buildUnifiedLogsUrl = ({
user?: string
start?: string | Date
end?: string | Date
/** Additional raw `column:opAbbrev:value` filter strings, appended alongside `logType`. */
extraFilters?: string[]
/** Pre-selects this row so its detail panel opens as soon as the page loads. */
id?: string
}) => {
const params = new URLSearchParams()
if (logType) params.append('filter', `log_type:eq:${logType}`)
extraFilters?.forEach((filter) => params.append('filter', filter))
if (user) params.set('user', user)
if (start && end) {
params.set('date', `${new Date(start).valueOf()}-${new Date(end).valueOf()}`)
}
if (id) params.set('id', id)
return `/project/${projectRef}/logs?${params.toString()}`
}
@@ -31,24 +31,15 @@ import { WorkerStatePill } from '../WorkerStatePill'
import { WorkerLogsTab } from './WorkerLogsTab'
import { WorkerOverviewTab } from './WorkerOverviewTab'
import { AlertError } from '@/components/ui/AlertError'
import type { WorkerLogStream } from '@/data/workers/worker-logs-query'
import { workerQueryOptions } from '@/data/workers/worker-query'
import { PRODUCT_NAME } from '@/lib/constants/workers'
type WorkerTab = 'overview' | 'invocations' | 'logs' | 'activity'
const WORKER_TABS: WorkerTab[] = ['overview', 'invocations', 'logs', 'activity']
type WorkerTab = 'overview' | 'logs'
const WORKER_TABS: WorkerTab[] = ['overview', 'logs']
const TAB_LABEL: Record<WorkerTab, string> = {
overview: 'Overview',
invocations: 'Invocations',
logs: 'Logs',
activity: 'Activity',
}
const TAB_STREAM: Partial<Record<WorkerTab, WorkerLogStream>> = {
invocations: 'requests',
logs: 'output',
activity: 'builds',
}
export const WorkerDetail = () => {
@@ -85,8 +76,6 @@ export const WorkerDetail = () => {
)
}
const stream = TAB_STREAM[tab]
return (
<div className="w-full min-h-full flex flex-col items-stretch">
<PageBreadcrumbs>
@@ -138,9 +127,9 @@ export const WorkerDetail = () => {
</PageNav>
{tab === 'overview' && <WorkerOverviewTab worker={worker} />}
{stream !== undefined && (
{tab === 'logs' && (
<div className="flex flex-1 flex-col min-h-0">
<WorkerLogsTab key={stream} workerName={worker.name} stream={stream} />
<WorkerLogsTab workerName={worker.name} />
</div>
)}
</div>
@@ -1,130 +1,293 @@
import { useQuery } from '@tanstack/react-query'
import {
ColumnFiltersState,
getCoreRowModel,
getFacetedRowModel,
getFilteredRowModel,
getFacetedMinMaxValues as getTTableFacetedMinMaxValues,
getFacetedUniqueValues as getTTableFacetedUniqueValues,
Row,
Table,
useReactTable,
VisibilityState,
} from '@tanstack/react-table'
import { useParams } from 'common'
import { RefreshCw, Search } from 'lucide-react'
import { useState } from 'react'
import { Button, InputGroup, InputGroupAddon, InputGroupInput } from 'ui'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import { ExternalLink, Loader2 } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useQueryStates } from 'nuqs'
import { useMemo, useState } from 'react'
import { Button, cn } from 'ui'
import { WorkerCommandLine } from '../WorkerCommandLine'
import { WorkersLogsColumnRender } from '@/components/interfaces/Settings/Logs/LogColumnRenderers/WorkersLogsColumnRender'
import { EXPLORER_DATEPICKER_HELPERS } from '@/components/interfaces/Settings/Logs/Logs.constants'
import {
LogsDatePicker,
type DatePickerValue,
} from '@/components/interfaces/Settings/Logs/Logs.DatePickers'
import type { LogData } from '@/components/interfaces/Settings/Logs/Logs.types'
import { LogTable } from '@/components/interfaces/Settings/Logs/LogTable'
import { AlertError } from '@/components/ui/AlertError'
generateDynamicColumns,
UNIFIED_LOGS_COLUMNS,
} from '@/components/interfaces/UnifiedLogs/components/Columns'
import { LogsFilterBar } from '@/components/interfaces/UnifiedLogs/components/LogsFilterBar'
import { CHART_CONFIG } from '@/components/interfaces/UnifiedLogs/UnifiedLogs.chart-config'
import {
WORKER_LOG_STREAM_LABEL,
workerLogsQueryOptions,
type WorkerLogStream,
} from '@/data/workers/worker-logs-query'
import { useDebouncedValue } from '@/hooks/misc/useDebouncedValue'
LOG_TYPES,
SEARCH_PARAMS_PARSER,
} from '@/components/interfaces/UnifiedLogs/UnifiedLogs.constants'
import { filterFields as defaultFilterFields } from '@/components/interfaces/UnifiedLogs/UnifiedLogs.fields'
import {
buildDefaultColumnFilters,
buildFilterSearchUpdate,
} from '@/components/interfaces/UnifiedLogs/UnifiedLogs.filters'
import { useFilterSearchSync } from '@/components/interfaces/UnifiedLogs/UnifiedLogs.hooks'
import { ColumnSchema } from '@/components/interfaces/UnifiedLogs/UnifiedLogs.schema'
import { QuerySearchParamsType } from '@/components/interfaces/UnifiedLogs/UnifiedLogs.types'
import {
buildUnifiedLogsUrl,
gateLogTypeOptions,
getFacetedUniqueValues,
getLevelRowClassName,
type UnifiedLogType,
} from '@/components/interfaces/UnifiedLogs/UnifiedLogs.utils'
import { arrSome, inDateRange } from '@/components/ui/DataTable/DataTable.utils'
import { DataTableFilterControlsDrawer } from '@/components/ui/DataTable/DataTableFilters/DataTableFilterControlsDrawer'
import { DataTableInfinite } from '@/components/ui/DataTable/DataTableInfinite'
import { DataTableProvider } from '@/components/ui/DataTable/providers/DataTableProvider'
import { RefreshButton } from '@/components/ui/DataTable/RefreshButton'
import { TimelineChart } from '@/components/ui/DataTable/TimelineChart'
import { useUnifiedLogsChartQuery } from '@/data/logs/unified-logs-chart-query'
import { useUnifiedLogsCountQuery } from '@/data/logs/unified-logs-count-query'
import { useUnifiedLogsInfiniteQuery } from '@/data/logs/unified-logs-infinite-query'
import { CLI_NAME } from '@/lib/constants/workers'
interface WorkerLogsTabProps {
workerName: string
stream: WorkerLogStream
}
const defaultDateRange = (): DatePickerValue => {
const helper = EXPLORER_DATEPICKER_HELPERS.find((helper) => helper.text === 'Last 24 hours')!
// This tab is always scoped to a single worker, so every log type other than
// `workers` is irrelevant — restrict the shared Log Type filter down to just that
// one option (with its nested Invocations/Logs/Activity stream toggles).
const WORKERS_ONLY_VISIBILITY = LOG_TYPES.reduce<Partial<Record<UnifiedLogType, boolean>>>(
(acc, type) => ({ ...acc, [type]: type === 'workers' }),
{}
)
return {
from: helper.calcFrom(),
to: helper.calcTo(),
isHelper: true,
text: helper.text,
}
}
// Worker rows always have `level`/`method`/`status`/`pathname` forced to null
// (see the worker source condition in UnifiedLogs.queries.ts), so those filters
// would only ever be dead ends here — only keep what's actually usable.
const WORKER_FILTER_FIELD_VALUES = new Set(['date', 'log_type', 'event_message'])
export const WorkerLogsTab = ({ workerName, stream }: WorkerLogsTabProps) => {
const WORKER_FILTER_FIELDS = gateLogTypeOptions(
defaultFilterFields.filter((field) => WORKER_FILTER_FIELD_VALUES.has(field.value)),
WORKERS_ONLY_VISIBILITY
)
const seedColumnFilters = (search: QuerySearchParamsType): ColumnFiltersState => [
...buildDefaultColumnFilters(search).filter((f) => f.id !== 'log_type'),
{ id: 'log_type', value: ['workers'] },
]
export const WorkerLogsTab = ({ workerName }: WorkerLogsTabProps) => {
const router = useRouter()
const { ref: projectRef } = useParams()
const [selectedLog, setSelectedLog] = useState<LogData | null>(null)
const [dateRange, setDateRange] = useState<DatePickerValue>(defaultDateRange)
const [message, setMessage] = useState('')
const debouncedMessage = useDebouncedValue(message, 300)
const [search, setSearch] = useQueryStates(SEARCH_PARAMS_PARSER)
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>(() =>
seedColumnFilters(search)
)
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({ select: false })
const [columnOrder, setColumnOrder] = useState<string[]>([])
// Always scope the query to this worker's rows — regardless of what's reflected in
// the (read-only, single-option) Log Type filter above — so an accidental change
// to that filter's state can never widen the query beyond this worker.
const searchParameters = useMemo(() => {
const parameters = Object.entries(search).reduce(
(acc, [key, value]) => {
if (!['id', 'live'].includes(key) && value !== null && value !== undefined) {
acc[key] = value
}
return acc
},
{} as Record<string, unknown>
) as QuerySearchParamsType
return {
...parameters,
filter: [...(parameters.filter ?? []), 'log_type:eq:workers', `worker:eq:${workerName}`],
}
}, [search, workerName])
const {
data: logs,
data: unifiedLogsData,
error,
isPending,
isError,
isLoading,
isFetching,
refetch,
} = useQuery(
workerLogsQueryOptions({
projectRef,
name: workerName,
stream,
iso_timestamp_start: dateRange.from,
iso_timestamp_end: dateRange.to,
message: debouncedMessage,
})
)
hasNextPage,
refetch: refetchLogs,
fetchNextPage,
} = useUnifiedLogsInfiniteQuery({ projectRef, search: searchParameters })
const label = WORKER_LOG_STREAM_LABEL[stream].toLowerCase()
const {
data: counts,
isPending: isLoadingCounts,
isFetching: isFetchingCounts,
refetch: refetchCounts,
} = useUnifiedLogsCountQuery({ projectRef, search: searchParameters })
const {
data: unifiedLogsChart = [],
isFetching: isFetchingCharts,
refetch: refetchCharts,
} = useUnifiedLogsChartQuery({ projectRef, search: searchParameters })
const refetchAllData = () => {
refetchLogs()
refetchCounts()
refetchCharts()
}
const isRefetchingData = isFetching || isFetchingCounts || isFetchingCharts
const rawFlatData = useMemo(() => {
return unifiedLogsData?.pages?.flatMap((page) => page.data ?? []) ?? []
}, [unifiedLogsData?.pages])
// [Joshen] Refer to unified-logs-infinite-query on why the need to dedupe
const flatData = useMemo(() => {
return rawFlatData.filter(
(value, idx) => idx === rawFlatData.findIndex((x) => x.id === value.id)
)
}, [rawFlatData])
const totalDBRowCount = counts?.totalRowCount
const filterDBRowCount = flatData.length
const totalFetched = flatData.length
const facets = counts?.facets
const getRowClassName = (row: Row<ColumnSchema>) =>
cn(getLevelRowClassName(row.original.level), 'h-[30px]')
const { columns: dynamicColumns, columnVisibility: dynamicColumnVisibility } = useMemo(() => {
return generateDynamicColumns({ data: flatData })
}, [flatData])
const table: Table<ColumnSchema> = useReactTable({
data: flatData,
columns: dynamicColumns,
state: {
columnFilters,
columnVisibility: { ...dynamicColumnVisibility, ...columnVisibility },
columnOrder,
},
columnResizeMode: 'onChange',
filterFns: { inDateRange, arrSome },
meta: { getRowClassName },
getRowId: (row) => row.id,
onColumnVisibilityChange: setColumnVisibility,
onColumnFiltersChange: setColumnFilters,
onColumnOrderChange: setColumnOrder,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getTTableFacetedUniqueValues(),
getFacetedMinMaxValues: getTTableFacetedMinMaxValues(),
})
const applyFilterSearch = () => {
setSearch(buildFilterSearchUpdate(columnFilters, WORKER_FILTER_FIELDS))
}
useFilterSearchSync({ applyFilterSearch, columnFilters, enabled: true })
// Rows open in the global Logs Explorer instead of an inline panel — this tab
// stays a compact, worker-scoped stream rather than duplicating the full page.
const handleOpenRow = (rowId: string | undefined) => {
if (!rowId || !projectRef) return
const row = table.getCoreRowModel().flatRows.find((r) => r.id === rowId)
if (!row) return
const halfWindowMs = 15 * 60 * 1000
router.push(
buildUnifiedLogsUrl({
projectRef,
logType: 'workers',
extraFilters: [`worker:eq:${workerName}`],
start: new Date(row.original.date.getTime() - halfWindowMs),
end: new Date(row.original.date.getTime() + halfWindowMs),
id: row.original.id,
})
)
}
if (!projectRef) return null
const openInLogsExplorerUrl = buildUnifiedLogsUrl({
projectRef,
logType: 'workers',
extraFilters: [`worker:eq:${workerName}`],
...(search.date?.length === 2 ? { start: search.date[0], end: search.date[1] } : {}),
})
return (
<div className="flex flex-1 flex-col min-h-0">
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-default px-4 py-2">
<div className="flex flex-wrap items-center gap-2">
<LogsDatePicker
hideWarnings
value={dateRange}
onSubmit={setDateRange}
helpers={EXPLORER_DATEPICKER_HELPERS}
align="start"
<DataTableProvider
table={table}
error={error}
columns={UNIFIED_LOGS_COLUMNS}
filterFields={WORKER_FILTER_FIELDS}
columnFilters={columnFilters}
openRowId={undefined}
setOpenRowId={handleOpenRow}
columnOrder={columnOrder}
columnVisibility={columnVisibility}
searchParameters={searchParameters}
isFetching={isFetching}
isError={isError}
isLoading={isLoading}
isLoadingCounts={isLoadingCounts}
getFacetedUniqueValues={getFacetedUniqueValues(facets)}
>
<div className="flex flex-1 flex-col min-h-0">
<div className="flex flex-wrap items-center gap-2 border-b border-default px-4 py-2">
<DataTableFilterControlsDrawer />
<div className="order-first w-full min-w-0 sm:order-none sm:w-auto sm:flex-1">
<LogsFilterBar />
</div>
<div className="ml-auto flex items-center gap-x-2">
<RefreshButton isLoading={isRefetchingData} onRefresh={refetchAllData} />
<Button asChild variant="outline" icon={<ExternalLink size={14} />}>
<Link href={openInLogsExplorerUrl}>Open in Logs Explorer</Link>
</Button>
</div>
</div>
{isLoading ? (
<div className="h-[60px] flex items-center justify-center">
<Loader2 size={14} className="animate-spin text-foreground-lighter" />
</div>
) : (
<TimelineChart
data={unifiedLogsChart}
className={cn(
'-mb-1.5 mt-1.5',
isFetchingCharts && 'opacity-60 transition-opacity duration-150'
)}
columnId="timestamp"
filterColumnId="date"
chartConfig={CHART_CONFIG}
/>
<InputGroup className="w-60">
<InputGroupInput
size="tiny"
placeholder="Filter by event message"
value={message}
onChange={(event) => setMessage(event.target.value)}
/>
<InputGroupAddon>
<Search />
</InputGroupAddon>
</InputGroup>
</div>
<Button
variant="default"
icon={<RefreshCw />}
loading={isFetching}
onClick={() => refetch()}
>
Refresh
</Button>
</div>
)}
{isError && (
<div className="p-4">
<AlertError error={error} subject="Failed to retrieve worker logs" />
</div>
)}
{!isError && isPending && (
<div className="p-4">
<GenericSkeletonLoader />
</div>
)}
{!isError && !isPending && (
<div className="relative flex flex-1 flex-col grow overflow-auto">
<LogTable
projectRef={projectRef ?? ''}
columnRenderers={WorkersLogsColumnRender}
data={logs ?? []}
isLoading={isPending}
showHeader={false}
showHistogramToggle={false}
selectedLog={selectedLog ?? undefined}
onSelectedLogChange={(log) => setSelectedLog(log)}
EmptyState={
<div className="flex-1 min-h-0 border-t">
<DataTableInfinite
columns={UNIFIED_LOGS_COLUMNS}
defaultColumnVisibility={{ select: false }}
totalRows={totalDBRowCount}
filterRows={filterDBRowCount}
totalRowsFetched={totalFetched}
fetchNextPage={fetchNextPage}
hasNextPage={hasNextPage}
setColumnOrder={setColumnOrder}
setColumnVisibility={setColumnVisibility}
searchParamsParser={SEARCH_PARAMS_PARSER}
emptyStateMessage={
<div className="mx-auto max-w-md space-y-3 py-16 text-center">
<p className="text-sm text-foreground">No {label} in the selected time range</p>
<p className="text-sm text-foreground">No logs in the selected time range</p>
<p className="text-sm text-foreground-lighter">
Follow them from the Supabase CLI while you wait for traffic.
</p>
@@ -135,7 +298,7 @@ export const WorkerLogsTab = ({ workerName, stream }: WorkerLogsTabProps) => {
}
/>
</div>
)}
</div>
</div>
</DataTableProvider>
)
}
-10
View File
@@ -2,14 +2,4 @@ export const workersKeys = {
list: (projectRef: string | undefined) => ['projects', projectRef, 'workers'] as const,
detail: (projectRef: string | undefined, name: string | undefined) =>
['projects', projectRef, 'worker', name, 'detail'] as const,
logs: (
projectRef: string | undefined,
name: string | undefined,
stream: string,
filters: {
iso_timestamp_start?: string
iso_timestamp_end?: string
message?: string
}
) => ['projects', projectRef, 'worker', name, 'logs', stream, filters] as const,
}
@@ -1,88 +0,0 @@
import { describe, expect, it } from 'vitest'
import { workersKeys } from './keys'
import { parseWorkerLogRows, workerLogsSql } from './worker-logs-query'
describe('workerLogsSql', () => {
it('reads one worker stream, newest first', () => {
expect(workerLogsSql('embed', 'output')).toBe(
"select id, timestamp, severity_text as severity, event_message as message from logs where log_attributes['worker'] = 'embed' and log_attributes['source'] = 'worker_guest_logs' order by timestamp desc limit 100"
)
})
it('filters by event message before applying the limit', () => {
expect(workerLogsSql('embed', 'requests', { message: 'timeout' })).toBe(
"select id, timestamp, severity_text as severity, event_message as message from logs where log_attributes['worker'] = 'embed' and log_attributes['source'] = 'worker_ingress_logs' and event_message ilike '%timeout%' order by timestamp desc limit 100"
)
})
it('names the right stream for each tab', () => {
expect(workerLogsSql('embed', 'requests')).toContain("'worker_ingress_logs'")
expect(workerLogsSql('embed', 'builds')).toContain("'worker_api_logs'")
})
it('escapes a worker name rather than interpolating it raw', () => {
expect(workerLogsSql("embed' or '1'='1", 'output')).toContain(
"log_attributes['worker'] = 'embed'' or ''1''=''1'"
)
})
it('escapes filter values rather than interpolating them raw', () => {
expect(workerLogsSql('embed', 'requests', { message: "can't connect" })).toContain(
"event_message ilike '%can''t connect%'"
)
})
})
describe('workersKeys.logs', () => {
it('includes the selected time range and filters', () => {
expect(
workersKeys.logs('project-ref', 'embed', 'requests', {
iso_timestamp_start: '2026-09-01T12:00:00.000Z',
iso_timestamp_end: '2026-09-02T12:00:00.000Z',
message: 'timeout',
})
).toEqual([
'projects',
'project-ref',
'worker',
'embed',
'logs',
'requests',
{
iso_timestamp_start: '2026-09-01T12:00:00.000Z',
iso_timestamp_end: '2026-09-02T12:00:00.000Z',
message: 'timeout',
},
])
})
})
describe('parseWorkerLogRows', () => {
it('shapes rows for the logs table with a numeric timestamp', () => {
const [row] = parseWorkerLogRows([
{ id: 'a', timestamp: '2026-08-24T10:00:00.000000', severity: 'ERROR', message: 'boom' },
])
expect(row.id).toBe('a')
expect(row.event_message).toBe('boom')
expect(row.severity_text).toBe('ERROR')
expect(typeof row.timestamp).toBe('number')
expect(Number.isFinite(row.timestamp)).toBe(true)
})
it('falls back to empty strings for null severity and message', () => {
const [row] = parseWorkerLogRows([
{ id: 'a', timestamp: '2026-08-24T10:00:00.000000', severity: null, message: null },
])
expect(row.severity_text).toBe('')
expect(row.event_message).toBe('')
})
it('returns an empty array for a missing result', () => {
expect(parseWorkerLogRows(undefined)).toEqual([])
})
it('rejects a malformed row rather than passing bad data to the table', () => {
expect(() => parseWorkerLogRows([{ timestamp: '2026-08-24T10:00:00.000000' }])).toThrow()
})
})
@@ -1,110 +0,0 @@
import { queryOptions } from '@tanstack/react-query'
import { z } from 'zod'
import { workersKeys } from './keys'
import type { LogData } from '@/components/interfaces/Settings/Logs/Logs.types'
import { otelTimestampToMicros } from '@/components/interfaces/Settings/Logs/Logs.utils.otel'
import { executeAnalyticsSql } from '@/data/logs/execute-analytics-sql'
import { logsAllEndpointUrl } from '@/data/logs/logs-endpoint'
import { analyticsLiteral, safeSql } from '@/data/logs/safe-analytics-sql'
import { IS_PLATFORM } from '@/lib/constants'
import { WORKER_LOG_SOURCES } from '@/lib/constants/workers'
export type WorkerLogStream = keyof typeof WORKER_LOG_SOURCES
export const WORKER_LOG_STREAM_LABEL: Record<WorkerLogStream, string> = {
requests: 'Invocations',
output: 'Logs',
builds: 'Activity',
}
// Both are read from `log_attributes` rather than the endpoint's own `source` column:
// that column is derived from a mapping which does not currently classify worker rows.
const WORKER_NAME_KEY = 'worker'
const STREAM_KEY = 'source'
const LOG_LIMIT = 100
const workerLogRowSchema = z.object({
id: z.string(),
timestamp: z.union([z.string(), z.number()]),
severity: z.string().nullish(),
message: z.string().nullish(),
})
export type WorkerLogsVariables = {
projectRef?: string
name?: string
stream: WorkerLogStream
iso_timestamp_start: string
iso_timestamp_end: string
message?: string
}
export const workerLogsSql = (
name: string,
stream: WorkerLogStream,
{ message }: Pick<WorkerLogsVariables, 'message'> = {}
) => {
const messageFilter = message
? safeSql` and event_message ilike ${analyticsLiteral(`%${message}%`)}`
: safeSql``
return safeSql`select id, timestamp, severity_text as severity, event_message as message from logs where log_attributes[${analyticsLiteral(WORKER_NAME_KEY)}] = ${analyticsLiteral(name)} and log_attributes[${analyticsLiteral(STREAM_KEY)}] = ${analyticsLiteral(WORKER_LOG_SOURCES[stream])}${messageFilter} order by timestamp desc limit ${analyticsLiteral(LOG_LIMIT)}`
}
export const parseWorkerLogRows = (result: unknown): LogData[] =>
z
.array(workerLogRowSchema)
.parse(result ?? [])
.map((row) => ({
id: row.id,
timestamp: otelTimestampToMicros(row.timestamp),
event_message: row.message ?? '',
severity_text: row.severity ?? '',
}))
async function getWorkerLogs(
{
projectRef,
name,
stream,
iso_timestamp_start,
iso_timestamp_end,
message,
}: WorkerLogsVariables,
signal?: AbortSignal
): Promise<LogData[]> {
if (!projectRef) throw new Error('projectRef is required')
if (!name) throw new Error('name is required')
const data = await executeAnalyticsSql({
projectRef,
endpoint: logsAllEndpointUrl(true),
sql: workerLogsSql(name, stream, { message }),
iso_timestamp_start,
iso_timestamp_end,
signal,
})
return parseWorkerLogRows(data?.result)
}
export const workerLogsQueryOptions = (variables: WorkerLogsVariables) => {
const { projectRef, name, stream, iso_timestamp_start, iso_timestamp_end } = variables
const message = variables.message?.trim() || undefined
return queryOptions({
queryKey: workersKeys.logs(projectRef, name, stream, {
iso_timestamp_start,
iso_timestamp_end,
message,
}),
queryFn: ({ signal }) =>
getWorkerLogs(
{ projectRef, name, stream, iso_timestamp_start, iso_timestamp_end, message },
signal
),
enabled: IS_PLATFORM && typeof projectRef !== 'undefined' && typeof name !== 'undefined',
})
}