mirror of
https://github.com/supabase/supabase.git
synced 2026-10-06 01:45:10 +03:00
## Context In unified logs, filtering on pathname can benefit using the `ilike` comparator so this PR adds support for that <img width="423" height="247" alt="image" src="https://github.com/user-attachments/assets/31e5f09b-7506-4588-90e3-ee705f805d43" /> FilterBar is also updated to omit facet values if the selected comparator is `ilike`, otherwise it doesn't really make sense to show a dropdown of values for users to select as the value for `ilike` filtering. <img width="240" height="62" alt="image" src="https://github.com/user-attachments/assets/b5fc97e7-d826-4184-8b70-bfb4875d1822" /> The facet values should still show if the selected comparator is an equals comparator <img width="391" height="154" alt="image" src="https://github.com/user-attachments/assets/7ccded04-3db1-4406-af40-4377ffe3bd8d" /> Related to https://github.com/supabase/supabase/pull/50394 - am also updating ilike comparator logic for unified logs to implicitly wrap the provided string with `%`, but only if the string doesn't already contain a `%` or `_` for UX convenience. (Unified logs already had this behaviour, except the latter part RE omitting default `%` if string already has) - this affects pathname and event_message searching <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit ## Summary by CodeRabbit * **New Features** * Added case-insensitive pathname filtering for logs with ILIKE and NOT ILIKE. * Added pathname support for comparison and pattern-matching operators. * Preserved user-provided `%` and `_` wildcard patterns in searches. * **Bug Fixes** * Corrected BigQuery pathname filtering for consistent case-insensitive matching. * Prevented misleading exact-value suggestions for pattern-based searches. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
279 lines
9.1 KiB
TypeScript
279 lines
9.1 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
|
|
import {
|
|
buildUnifiedLogsUrl,
|
|
gateLogTypeFilters,
|
|
gateLogTypeOptions,
|
|
getComputeLogsAvailability,
|
|
getEventMessageDisplay,
|
|
getRawLogData,
|
|
parseMultigresEventMessage,
|
|
wrapIlikePattern,
|
|
} from './UnifiedLogs.utils'
|
|
|
|
describe('buildUnifiedLogsUrl', () => {
|
|
const parse = (url: string) => {
|
|
const [path, query] = url.split('?')
|
|
return { path, params: new URLSearchParams(query) }
|
|
}
|
|
|
|
it('targets the project logs route with a log_type filter', () => {
|
|
const { path, params } = parse(buildUnifiedLogsUrl({ projectRef: 'abc', logType: 'postgres' }))
|
|
expect(path).toBe('/project/abc/logs')
|
|
expect(params.get('filter')).toBe('log_type:eq:postgres')
|
|
expect(params.has('date')).toBe(false)
|
|
})
|
|
|
|
it('preserves multi-word log types once decoded', () => {
|
|
const { params } = parse(buildUnifiedLogsUrl({ projectRef: 'abc', logType: 'edge function' }))
|
|
expect(params.get('filter')).toBe('log_type:eq:edge function')
|
|
})
|
|
|
|
it('adds the date range as an epoch-ms pair when start and end are provided', () => {
|
|
const start = new Date('2026-05-08T00:00:00.000Z')
|
|
const end = new Date('2026-05-08T01:00:00.000Z')
|
|
const { params } = parse(
|
|
buildUnifiedLogsUrl({ projectRef: 'abc', logType: 'auth', start, end })
|
|
)
|
|
expect(params.get('date')).toBe(`${start.valueOf()}-${end.valueOf()}`)
|
|
})
|
|
|
|
it('accepts ISO strings for the date range', () => {
|
|
const start = '2026-05-08T00:00:00.000Z'
|
|
const end = '2026-05-08T01:00:00.000Z'
|
|
const { params } = parse(
|
|
buildUnifiedLogsUrl({ projectRef: 'abc', logType: 'auth', start, end })
|
|
)
|
|
expect(params.get('date')).toBe(`${new Date(start).valueOf()}-${new Date(end).valueOf()}`)
|
|
})
|
|
|
|
it('omits the date range when only one bound is provided', () => {
|
|
const { params } = parse(
|
|
buildUnifiedLogsUrl({ projectRef: 'abc', logType: 'storage', start: new Date() })
|
|
)
|
|
expect(params.has('date')).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('parseMultigresEventMessage', () => {
|
|
it('extracts the msg field from a stringified JSON payload', () => {
|
|
const value = JSON.stringify({
|
|
time: '2026-07-03T09:42:12.344925698Z',
|
|
level: 'INFO',
|
|
msg: 'user pool capacity updated',
|
|
user: 'supabase_admin',
|
|
})
|
|
expect(parseMultigresEventMessage(value)).toBe('user pool capacity updated')
|
|
})
|
|
|
|
it('returns the raw string when it is not JSON', () => {
|
|
expect(parseMultigresEventMessage('plain text message')).toBe('plain text message')
|
|
})
|
|
|
|
it('returns the raw string when msg is missing or empty', () => {
|
|
expect(parseMultigresEventMessage(JSON.stringify({ level: 'INFO' }))).toBe('{"level":"INFO"}')
|
|
expect(parseMultigresEventMessage(JSON.stringify({ msg: ' ' }))).toBe('{"msg":" "}')
|
|
})
|
|
|
|
it('passes empty values through unchanged', () => {
|
|
expect(parseMultigresEventMessage(undefined)).toBeUndefined()
|
|
expect(parseMultigresEventMessage('')).toBe('')
|
|
})
|
|
})
|
|
|
|
describe('getEventMessageDisplay', () => {
|
|
it('parses multigres rows into their msg field and capitalizes them', () => {
|
|
const value = JSON.stringify({ level: 'INFO', msg: 'Configuring synchronous replication' })
|
|
expect(getEventMessageDisplay('multigres', value)).toEqual({
|
|
message: 'Configuring synchronous replication',
|
|
capitalize: true,
|
|
})
|
|
})
|
|
|
|
it('leaves non-parsed log types untouched and uncapitalized', () => {
|
|
expect(getEventMessageDisplay('postgres', 'relation does not exist')).toEqual({
|
|
message: 'relation does not exist',
|
|
capitalize: false,
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('getRawLogData', () => {
|
|
it('returns only the real Compute payload fields', () => {
|
|
const row = {
|
|
event_message: 'Error: Dynamic require of "path" is not supported',
|
|
id: '51a29911-9293-4616-8984-743cc548b629',
|
|
metadata: {
|
|
cw_event_id: '39883203917805946105278943454814281535421893832620638214',
|
|
launch_id: '1788424715503435269',
|
|
log_group: '/aws/lambda-microvms/workers/cxkpapyhaaywrtudnqpl/api',
|
|
log_stream: 'launch-1788424715503435269',
|
|
source: 'worker_guest_logs',
|
|
worker: 'api',
|
|
},
|
|
project: 'cxkpapyhaaywrtudnqpl',
|
|
timestamp: 1788424716876000,
|
|
log_type: 'compute' as const,
|
|
status: null,
|
|
level: null,
|
|
method: null,
|
|
pathname: null,
|
|
auth_user: null,
|
|
date: new Date(1788424716876),
|
|
}
|
|
|
|
expect(getRawLogData(row)).toEqual({
|
|
id: '51a29911-9293-4616-8984-743cc548b629',
|
|
timestamp: 1788424716876000,
|
|
event_message: 'Error: Dynamic require of "path" is not supported',
|
|
metadata: row.metadata,
|
|
})
|
|
})
|
|
|
|
it('returns non-Compute rows unchanged', () => {
|
|
const row = {
|
|
id: 'edge-log',
|
|
timestamp: 1788424716876000,
|
|
log_type: 'edge' as const,
|
|
status: 200,
|
|
method: 'GET' as const,
|
|
pathname: '/rest/v1',
|
|
level: 'success' as const,
|
|
date: new Date(1788424716876),
|
|
}
|
|
|
|
expect(getRawLogData(row)).toBe(row)
|
|
})
|
|
})
|
|
|
|
describe('gateLogTypeOptions', () => {
|
|
const fields = [
|
|
{ value: 'date' },
|
|
{
|
|
value: 'log_type',
|
|
options: [
|
|
{ label: 'Postgres', value: 'postgres' },
|
|
{ label: 'Multigres', value: 'multigres' },
|
|
{ label: 'Compute', value: 'compute' },
|
|
],
|
|
},
|
|
]
|
|
|
|
it('drops log_type options whose flags are disabled', () => {
|
|
const gated = gateLogTypeOptions(fields, { multigres: false, compute: false })
|
|
const logType = gated.find((field) => field.value === 'log_type')
|
|
expect(logType?.options?.map((option) => option.value)).toEqual(['postgres'])
|
|
})
|
|
|
|
it('keeps independently enabled log types', () => {
|
|
const gated = gateLogTypeOptions(fields, { multigres: false, compute: true })
|
|
const logType = gated.find((field) => field.value === 'log_type')
|
|
expect(logType?.options?.map((option) => option.value)).toEqual(['postgres', 'compute'])
|
|
})
|
|
|
|
it('returns the original fields when every gated log type is enabled', () => {
|
|
const gated = gateLogTypeOptions(fields, { multigres: true, compute: true })
|
|
expect(gated).toBe(fields)
|
|
})
|
|
|
|
it('leaves non log_type fields untouched', () => {
|
|
const gated = gateLogTypeOptions(fields, { compute: false })
|
|
expect(gated.find((field) => field.value === 'date')).toEqual({ value: 'date' })
|
|
})
|
|
})
|
|
|
|
describe('gateLogTypeFilters', () => {
|
|
it('removes disabled log types from equality and inequality filters', () => {
|
|
expect(
|
|
gateLogTypeFilters(
|
|
['log_type:eq:compute', 'log_type:neq:multigres', 'log_type:eq:postgres', 'method:eq:GET'],
|
|
{ compute: false, multigres: false }
|
|
)
|
|
).toEqual(['log_type:eq:postgres', 'method:eq:GET'])
|
|
})
|
|
|
|
it('keeps enabled log types and unrelated filters unchanged', () => {
|
|
const filters = ['log_type:eq:compute', 'method:eq:GET']
|
|
expect(gateLogTypeFilters(filters, { compute: true })).toBe(filters)
|
|
})
|
|
|
|
it('preserves absent filter values', () => {
|
|
expect(gateLogTypeFilters(undefined, { compute: false })).toBeUndefined()
|
|
expect(gateLogTypeFilters(null, { compute: false })).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('getComputeLogsAvailability', () => {
|
|
const computeFilter = ['log_type:eq:compute']
|
|
|
|
it('preserves an unresolved platform filter without allowing it into queries or sync', () => {
|
|
const availability = getComputeLogsAvailability({
|
|
isPlatform: true,
|
|
flagsLoaded: false,
|
|
computeEnabled: false,
|
|
})
|
|
|
|
expect(gateLogTypeFilters(computeFilter, { compute: availability.preserveComputeFilter })).toBe(
|
|
computeFilter
|
|
)
|
|
expect(gateLogTypeFilters(computeFilter, { compute: availability.canQueryCompute })).toEqual([])
|
|
expect(availability.readyToSyncFilters).toBe(false)
|
|
})
|
|
|
|
it('allows Compute filters and queries when the platform flag is enabled', () => {
|
|
const availability = getComputeLogsAvailability({
|
|
isPlatform: true,
|
|
flagsLoaded: true,
|
|
computeEnabled: true,
|
|
})
|
|
|
|
expect(availability).toEqual({
|
|
canQueryCompute: true,
|
|
preserveComputeFilter: true,
|
|
readyToSyncFilters: true,
|
|
})
|
|
})
|
|
|
|
it('removes Compute filters and queries when the platform flag is disabled', () => {
|
|
const availability = getComputeLogsAvailability({
|
|
isPlatform: true,
|
|
flagsLoaded: true,
|
|
computeEnabled: false,
|
|
})
|
|
|
|
expect(availability).toEqual({
|
|
canQueryCompute: false,
|
|
preserveComputeFilter: false,
|
|
readyToSyncFilters: true,
|
|
})
|
|
})
|
|
|
|
it('syncs generic filters immediately while keeping Compute unavailable on self-hosted', () => {
|
|
const availability = getComputeLogsAvailability({
|
|
isPlatform: false,
|
|
computeEnabled: false,
|
|
})
|
|
|
|
expect(availability).toEqual({
|
|
canQueryCompute: false,
|
|
preserveComputeFilter: false,
|
|
readyToSyncFilters: true,
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('wrapIlikePattern', () => {
|
|
it('wraps a bare value in %...% for a contains match', () => {
|
|
expect(wrapIlikePattern('foo')).toBe('%foo%')
|
|
})
|
|
|
|
it('leaves the value untouched when it already contains a % wildcard', () => {
|
|
expect(wrapIlikePattern('foo%')).toBe('foo%')
|
|
expect(wrapIlikePattern('%foo')).toBe('%foo')
|
|
})
|
|
|
|
it('leaves the value untouched when it already contains a _ wildcard', () => {
|
|
expect(wrapIlikePattern('fo_bar')).toBe('fo_bar')
|
|
})
|
|
})
|