Files
supabase/apps/studio/components/interfaces/UnifiedLogs/QueryOptions.ts
T
Joshen LimandJonathan Summers-Muir f223b455d6 Unified Logs (Part 1) (#36298)
* init new unified page

* moar logs

* init

* add infinite and live logs example

* Update useLogsPreview.tsx

* add more sources

* wrapped auth logs with edge logs

* add role and user id

* move unified logs

* init

* move demo pages. create a new directory to work in

* extracted beta unified logs into own components

* add example base page and components

* add new files to use actual logging query

* more organization

* change import

* adds new logs page. adds new query

* add data table to UI pacakges

* revert

* table styles

* text size

* add timestamp, table, icons for log types, status code styling

* add host

* add log count to edge functions

* starts to add dynamic filtering

* spiking trace UI

* Update status-code.ts

* add new linik

* now using POST

* fix chart data for default 1 hour view

* update API to accept POST requests

* new filters

* Update level.ts

* fixed up chart to work on level filter. split up the logic into new files

* prep for log type

* prepped query for WHERE

* fix: issue with white space in url param column parsing

* level param now being removed correctly.

* fix issue with chart showing wrong buckets for different time ranges

* remove old query

* refactor the queries into function for each source

* total count fixed

* lots of layout

* start fixing log counts

* comment out min and max for a while

* added trace logging prototype in

* random trace logs added for demo

* added logs and ui to view logs if any

* add Auth user

* fix the live logs issue

* some left over code

* Midway

* First pass refactor + clean up + reorganize files

* Fix TS issues

* Remove unused files

* Clean up

* Final clean up

* more clean up

* More clean up

* Remove unused packages

* Fix

* Lint

* Add feature flag for unified logs

* Refactor

* Remove trace UI

* Snake case log types

* more clean up

* More clean up

* Fix ts

* more clean up

* fixes

* add flag check and redirect if flag is false

* Update middleware.ts

* Nit lint

* Fix

* Last refactors

---------

Co-authored-by: Jonathan Summers-Muir <MildTomato@users.noreply.github.com>
2025-06-20 12:46:17 +08:00

618 lines
22 KiB
TypeScript

import { useQuery } from '@tanstack/react-query'
import dayjs from 'dayjs'
import { ARRAY_DELIMITER, SORT_DELIMITER } from 'components/ui/DataTable/DataTable.constants'
import { get } from 'data/fetchers'
import { getLogsChartQuery, getLogsCountQuery, getUnifiedLogsQuery } from './UnifiedLogs.queries'
import { BaseChartSchema, ColumnSchema, type FacetMetadataSchema } from './UnifiedLogs.schema'
import type { PageParam, SearchParamsType, UnifiedLogsMeta } from './UnifiedLogs.types'
// Debug mode flag - set to true to enable detailed logs
const DEBUG_MODE = false
export type ExtendedColumnSchema = ColumnSchema & {
timestamp: string // Original database timestamp
date: Date // Date object for display
}
type InfiniteQueryMeta<TMeta = Record<string, unknown>> = {
totalRowCount: number
filterRowCount: number
chartData: BaseChartSchema[]
facets: Record<string, FacetMetadataSchema>
metadata?: TMeta
}
type InfiniteQueryResponse<TData, TMeta = unknown> = {
data: TData
meta: InfiniteQueryMeta<TMeta>
prevCursor: number | null
nextCursor: number | null
}
export function createApiQueryString(params: Record<string, any>): string {
const queryParams = new URLSearchParams()
for (const [key, value] of Object.entries(params)) {
if (value === null || value === undefined) continue
if (key === 'date' && Array.isArray(value) && value.length === 2) {
queryParams.set('dateStart', value[0].getTime().toString())
queryParams.set('dateEnd', value[1].getTime().toString())
} else if (
[
'latency',
'timing.dns',
'timing.connection',
'timing.tls',
'timing.ttfb',
'timing.transfer',
'status',
].includes(key) &&
Array.isArray(value) &&
value.length > 0
) {
if (value.length >= 2) {
queryParams.set(`${key}Start`, value[0].toString())
queryParams.set(`${key}End`, value[value.length - 1].toString())
}
} else if (Array.isArray(value)) {
if (value.length > 0) {
queryParams.set(key, value.join(ARRAY_DELIMITER))
}
} else if (key === 'sort' && typeof value === 'object' && value !== null) {
queryParams.set(key, `${value.id}${SORT_DELIMITER}${value.desc ? 'desc' : 'asc'}`)
} else if (value instanceof Date) {
queryParams.set(key, value.getTime().toString())
} else {
queryParams.set(key, String(value))
}
}
const queryString = queryParams.toString()
return queryString ? `?${queryString}` : ''
}
// Add a new function to fetch chart data for the entire time period
export const useChartData = (search: SearchParamsType, projectRef: string) => {
// Create a stable query key object by removing nulls/undefined, uuid, and live
const queryKeyParams = Object.entries(search).reduce(
(acc, [key, value]) => {
if (!['uuid', 'live'].includes(key) && value !== null && value !== undefined) {
acc[key] = value
}
return acc
},
{} as Record<string, any>
)
return useQuery({
queryKey: [
'unified-logs-chart',
projectRef,
// Use JSON.stringify for a stable key representation
JSON.stringify(queryKeyParams),
],
queryFn: async () => {
try {
// Use a default date range (last hour) if no date range is selected
let dateStart: string
let dateEnd: string
let startTime: Date
let endTime: Date
if (search.date && search.date.length === 2) {
startTime = new Date(search.date[0])
endTime = new Date(search.date[1])
dateStart = startTime.toISOString()
dateEnd = endTime.toISOString()
} else {
// Default to last hour
endTime = new Date()
startTime = new Date(endTime.getTime() - 60 * 60 * 1000)
dateStart = startTime.toISOString()
dateEnd = endTime.toISOString()
}
// Get SQL query from utility function (with dynamic bucketing)
const sql = getLogsChartQuery(search)
// Use the get function from data/fetchers for chart data
const { data, error } = await get(`/platform/projects/{ref}/analytics/endpoints/logs.all`, {
params: {
path: { ref: projectRef },
query: {
iso_timestamp_start: dateStart,
iso_timestamp_end: dateEnd,
project: projectRef,
sql,
},
},
})
if (error) {
if (DEBUG_MODE) console.error('API returned error for chart data:', error)
throw error
}
// Process API results directly without additional bucketing
const chartData: Array<{
timestamp: number
success: number
warning: number
error: number
}> = []
// Create a map to store data points by their timestamp
const dataByTimestamp = new Map<
number,
{
timestamp: number
success: number
warning: number
error: number
}
>()
// Track the total count from the query results
// this uses the total_per_bucket field that was added to the chart query
let totalCount = 0
// Only process API results if we have them
if (data?.result) {
data.result.forEach((row: any) => {
// The API returns timestamps in microseconds (needs to be converted to milliseconds for JS Date)
const microseconds = Number(row.time_bucket)
const milliseconds = Math.floor(microseconds / 1000)
// Add to total count - this comes directly from the query
totalCount += Number(row.total_per_bucket || 0)
// Create chart data point
const dataPoint = {
timestamp: milliseconds, // Convert to milliseconds for the chart
success: Number(row.success) || 0,
warning: Number(row.warning) || 0,
error: Number(row.error) || 0,
}
// Filter levels if needed
const levelFilter = search.level
if (levelFilter && levelFilter.length > 0) {
// Reset levels not in the filter
if (!levelFilter.includes('success')) dataPoint.success = 0
if (!levelFilter.includes('warning')) dataPoint.warning = 0
if (!levelFilter.includes('error')) dataPoint.error = 0
}
dataByTimestamp.set(milliseconds, dataPoint)
})
}
// Determine bucket size based on the truncation level in the SQL query
// We need to fill in missing data points
const startTimeMs = startTime.getTime()
const endTimeMs = endTime.getTime()
// Calculate appropriate bucket size from the time range
const timeRangeHours = (endTimeMs - startTimeMs) / (1000 * 60 * 60)
let bucketSizeMs: number
if (timeRangeHours > 72) {
// Day-level bucketing (for ranges > 3 days)
bucketSizeMs = 24 * 60 * 60 * 1000
} else if (timeRangeHours > 12) {
// Hour-level bucketing (for ranges > 12 hours)
bucketSizeMs = 60 * 60 * 1000
} else {
// Minute-level bucketing (for shorter ranges)
bucketSizeMs = 60 * 1000
}
// Fill in any missing buckets
for (let t = startTimeMs; t <= endTimeMs; t += bucketSizeMs) {
// Round to the nearest bucket boundary
const bucketTime = Math.floor(t / bucketSizeMs) * bucketSizeMs
if (!dataByTimestamp.has(bucketTime)) {
// Create empty data point for this bucket
dataByTimestamp.set(bucketTime, {
timestamp: bucketTime,
success: 0,
warning: 0,
error: 0,
})
}
}
// Convert map to array
for (const dataPoint of dataByTimestamp.values()) {
chartData.push(dataPoint)
}
// Sort by timestamp
chartData.sort((a, b) => a.timestamp - b.timestamp)
// Add debugging info for totalCount
if (DEBUG_MODE) console.log(`Total count from chart query: ${totalCount}`)
return {
chartData,
totalCount,
}
} catch (error) {
if (DEBUG_MODE) console.error('Error fetching chart data:', error)
throw error
}
},
// Keep chart data fresh for 5 minutes
staleTime: 1000 * 60 * 5,
})
}
// The existing dataOptions function remains with buildChartData fallback
export const dataOptions = (search: SearchParamsType, projectRef: string) => {
// Create a stable query key object by removing nulls/undefined, uuid, and live
const queryKeyParams = Object.entries(search).reduce(
(acc, [key, value]) => {
if (!['uuid', 'live'].includes(key) && value !== null && value !== undefined) {
acc[key] = value
}
return acc
},
{} as Record<string, any>
)
// Calculate the appropriate initial cursor based on the selected date range
// const getInitialCursor = () => {
// if (search.date && search.date.length === 2) {
// // Use the end of the selected date range
// return new Date(search.date[1]).getTime()
// } else {
// // Default to current time if no date range is selected
// return new Date().getTime()
// }
// }
// Simply return the options object
return {
queryKey: [
'unified-logs',
projectRef,
// Use JSON.stringify for a stable key representation
JSON.stringify(queryKeyParams),
],
queryFn: async ({ pageParam }: { pageParam?: PageParam }) => {
try {
const cursorValue = pageParam?.cursor // Already in microseconds
const direction = pageParam?.direction
const isPagination = pageParam !== undefined
// Extract date range from search or use default (last hour)
let isoTimestampStart: string
let isoTimestampEnd: string
if (search.date && search.date.length === 2) {
isoTimestampStart = new Date(search.date[0]).toISOString()
isoTimestampEnd = new Date(search.date[1]).toISOString()
} else {
// Default to last hour
const now = new Date()
isoTimestampEnd = now.toISOString()
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000)
isoTimestampStart = oneHourAgo.toISOString()
}
// Get the unified SQL query for logs data from utility function
let logsSql = getUnifiedLogsQuery(search)
// Add ordering and limit
logsSql += `\nORDER BY timestamp DESC, id DESC`
logsSql += `\nLIMIT 50`
// Get SQL query for counts from utility function
const countsSql = getLogsCountQuery(search)
// First, fetch the counts data
const { data: countsData, error: countsError } = await get(
`/platform/projects/{ref}/analytics/endpoints/logs.all`,
{
params: {
path: { ref: projectRef },
query: {
iso_timestamp_start: isoTimestampStart,
iso_timestamp_end: isoTimestampEnd,
project: projectRef,
sql: countsSql,
},
},
}
)
if (countsError) {
if (DEBUG_MODE) console.error('API returned error for counts data:', countsError)
throw countsError
}
// Process count results into facets structure
const facets: Record<string, FacetMetadataSchema> = {}
const countsByDimension: Record<string, Map<string, number>> = {}
let totalCount = 0
// Group by dimension
if (countsData?.result) {
countsData.result.forEach((row: any) => {
const dimension = row.dimension
const value = row.value
const count = Number(row.count || 0)
// Set total count if this is the total dimension
if (dimension === 'total' && value === 'all') {
totalCount = count
}
// Initialize dimension map if not exists
if (!countsByDimension[dimension]) {
countsByDimension[dimension] = new Map()
}
// Add count to the dimension map
countsByDimension[dimension].set(value, count)
})
}
// Convert dimension maps to facets structure
Object.entries(countsByDimension).forEach(([dimension, countsMap]) => {
// Skip the 'total' dimension as it's not a facet
if (dimension === 'total') return
const dimensionTotal = Array.from(countsMap.values()).reduce(
(sum, count) => sum + count,
0
)
facets[dimension] = {
min: undefined,
max: undefined,
total: dimensionTotal,
rows: Array.from(countsMap.entries()).map(([value, count]) => ({
value,
total: count,
})),
}
})
// Now, fetch the logs data with pagination
// ONLY convert to ISO when we're about to send to the API
let timestampStart: string
let timestampEnd: string
if (isPagination && direction === 'prev') {
// Live mode: fetch logs newer than the cursor
timestampStart = cursorValue
? new Date(Number(cursorValue) / 1000).toISOString() // Convert microseconds to ISO for API
: isoTimestampStart
timestampEnd = new Date().toISOString() // Current time as ISO for API
} else if (isPagination && direction === 'next') {
// Regular pagination: fetch logs older than the cursor
timestampStart = isoTimestampStart
timestampEnd = cursorValue
? new Date(Number(cursorValue) / 1000).toISOString() // Convert microseconds to ISO for API
: isoTimestampEnd
} else {
// Initial load: use the original date range
timestampStart = isoTimestampStart
timestampEnd = isoTimestampEnd
}
if (DEBUG_MODE) {
console.log(
`🔍 Query function called: isPagination=${isPagination}, cursorValue=${cursorValue}, direction=${direction}, iso_timestamp_start=${timestampStart}, iso_timestamp_end=${timestampEnd}`
)
console.log('🔍 Raw pageParam received:', pageParam)
}
const { data: logsData, error: logsError } = await get(
`/platform/projects/{ref}/analytics/endpoints/logs.all`,
{
params: {
path: { ref: projectRef },
query: {
iso_timestamp_start: timestampStart,
iso_timestamp_end: timestampEnd,
project: projectRef,
sql: logsSql,
},
},
}
)
if (logsError) {
if (DEBUG_MODE) console.error('API returned error for logs data:', logsError)
throw logsError
}
// Process the logs results
const resultData = logsData?.result || []
if (DEBUG_MODE) console.log(`Received ${resultData.length} records from API`)
// Define specific level types
type LogLevel = 'success' | 'warning' | 'error'
// Transform results to expected schema
const result = resultData.map((row: any) => {
// Create a unique ID using the timestamp
const uniqueId = `${row.id || 'id'}-${row.timestamp}-${new Date().getTime()}`
// Create a date object for display purposes
// The timestamp is in microseconds, need to convert to milliseconds for JS Date
const date = new Date(Number(row.timestamp) / 1000)
// Use the level directly from SQL rather than determining it in TypeScript
const level = row.level as LogLevel
return {
id: uniqueId,
uuid: uniqueId,
date, // Date object for display purposes
timestamp: row.timestamp, // Original timestamp from the database
level,
status: row.status || 200,
method: row.method,
host: row.host,
pathname: (row.url || '').replace(/^https?:\/\/[^\/]+/, '') || row.path || '',
event_message: row.event_message || row.body || '',
headers:
typeof row.headers === 'string' ? JSON.parse(row.headers || '{}') : row.headers || {},
regions: row.region ? [row.region] : [],
log_type: row.log_type || '',
latency: row.latency || 0,
log_count: row.log_count || null,
logs: row.logs || [],
auth_user: row.auth_user || null,
}
})
// Just use the row timestamps directly for cursors
const lastRow = result.length > 0 ? result[result.length - 1] : null
const firstRow = result.length > 0 ? result[0] : null
const nextCursor = lastRow ? lastRow.timestamp : null
// FIXED: Always provide prevCursor like DataTableDemo does
// This ensures live mode never breaks the infinite query chain
// DataTableDemo uses milliseconds, but our timestamps are in microseconds
const prevCursor = result.length > 0 ? firstRow!.timestamp : new Date().getTime() * 1000
// Determine if there might be more data
const pageLimit = 50
// HACK: Backend uses "timestamp > cursor" which can exclude records with identical timestamps
// THIS CAN SOMETIMES CAUSE 49 RECORDS INSTEAD OF 50 TO BE RETURNED
// TODO: Revisit this - ideally the backend should use composite cursors (timestamp+id) for proper pagination
// For now, we consider 49 records as a "full page" to ensure pagination works correctly
const hasMore = result.length >= pageLimit - 1 // Consider 49 or 50 records as a full page
if (DEBUG_MODE) {
console.log(
`Pagination info: result.length=${result.length}, hasMore=${hasMore}, nextCursor=${nextCursor}, prevCursor=${prevCursor}`
)
}
// Create response with pagination info
const response = {
data: result,
meta: {
// Use the total count from our counts query
totalRowCount: totalCount,
filterRowCount: result.length,
chartData: buildChartData(result),
facets, // Use the facets from the counts query
metadata: {
currentPercentiles: {},
logTypeCounts: calculateLogTypeCounts(result),
},
},
nextCursor: hasMore ? nextCursor : null,
prevCursor,
}
return response
} catch (error) {
if (DEBUG_MODE) console.error('Error fetching unified logs:', error)
throw error
}
},
// Initial load with proper cursor for live mode pagination
// Use microseconds format to match database timestamps
initialPageParam: { cursor: new Date().getTime() * 1000, direction: 'next' } as PageParam,
getPreviousPageParam: (
firstPage: InfiniteQueryResponse<ExtendedColumnSchema[], UnifiedLogsMeta>
) => {
if (DEBUG_MODE) {
console.log('🔍 getPreviousPageParam called with:', {
hasFirstPage: !!firstPage,
dataLength: firstPage?.data?.length || 0,
prevCursor: firstPage?.prevCursor,
nextCursor: firstPage?.nextCursor,
firstPageKeys: firstPage ? Object.keys(firstPage) : 'no firstPage',
})
}
// Use the same logic as the working DataTableDemo
if (!firstPage.prevCursor) {
if (DEBUG_MODE) console.log('🔍 getPreviousPageParam returning: null (no prevCursor)')
return null
}
const result = { cursor: firstPage.prevCursor, direction: 'prev' } as PageParam
if (DEBUG_MODE) console.log('🔍 getPreviousPageParam returning:', result)
return result
},
getNextPageParam: (
lastPage: InfiniteQueryResponse<ExtendedColumnSchema[], UnifiedLogsMeta>,
allPages: InfiniteQueryResponse<ExtendedColumnSchema[], UnifiedLogsMeta>[]
) => {
// Only return a cursor if we actually have more data to fetch
if (!lastPage.nextCursor || lastPage.data.length === 0) return null
// Only trigger fetch when specifically requested, not during column resizing
return { cursor: lastPage.nextCursor, direction: 'next' } as PageParam
},
// Configure React Query to be more stable
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: false,
refetchInterval: 0,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 10, // 10 minutes (formerly cacheTime)
}
}
// Helper functions for chart data and log type counts
function buildChartData(logs: any[]) {
// Group logs by minute and count by level
const chartData = []
const minuteGroups = new Map()
// Sort by timestamp ascending
const sortedLogs = [...logs].sort((a, b) => a.rawTimestamp - b.rawTimestamp)
for (const log of sortedLogs) {
const minute = dayjs(log.date).startOf('minute').valueOf()
if (!minuteGroups.has(minute)) {
minuteGroups.set(minute, {
// Use number timestamp instead of Date object
timestamp: minute,
success: 0,
error: 0,
warning: 0,
})
}
const group = minuteGroups.get(minute)
// Ensure we're only using valid log levels that match the schema
const level = ['success', 'error', 'warning'].includes(log.level) ? log.level : 'success'
group[level] = (group[level] || 0) + 1
}
// Convert map to array
for (const data of minuteGroups.values()) {
chartData.push(data)
}
return chartData.sort((a, b) => a.timestamp - b.timestamp)
}
function calculateLogTypeCounts(logs: any[]) {
const counts: Record<string, number> = {}
logs.forEach((log) => {
const logType = log.log_type
counts[logType] = (counts[logType] || 0) + 1
})
return counts
}