mirror of
https://github.com/supabase/supabase.git
synced 2026-10-08 19:05:06 +03:00
feat(studio): add cross-cutting "filter by user" to Unified Logs
Adds a dedicated ?user= filter that scopes logs to a single user across the
sources that can be positively attributed to them:
- auth_logs via auth_event.actor_id / traits.user_email (+ raw event text,
so failed signups with no auth.users row still match on their email)
- postgres_logs when the id appears verbatim in the error text
edge/storage/realtime carry no per-user field and are auto-excluded while the
filter is active — never guessed via IP or timestamp proximity.
- ?user= is a dedicated search-param key (not the eq/neq filter array) since it
gates sources rather than filtering one column; consumed in
applySearchParamsFilter, composes with existing filters, inert when unset.
- Sidebar input (email or id; email resolves to id on submit) + persistent
notice stating coverage is partial, with an empty-state hint that auth event
logs may not be enabled. Reuses the identity-resolution queries.
- Inherits the otelUnifiedLogs endpoint/flag conditioning of the existing hooks.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
2d5d2f261e
commit
7037a0bd43
7 files changed
+258
-1
No files matched your search
@@ -76,6 +76,13 @@ export const SEARCH_PARAMS_PARSER = {
|
||||
edge_auth: parseAsBoolean.withDefault(true),
|
||||
edge_storage: parseAsBoolean.withDefault(true),
|
||||
edge_postgrest: parseAsBoolean.withDefault(true),
|
||||
|
||||
// Cross-cutting "attributable to one user" filter. A dedicated key (not part of
|
||||
// the eq/neq `filter` array) because it restricts which *sources* are eligible and
|
||||
// applies per-source match logic, rather than being a value filter on one column.
|
||||
// Holds a single identifier (user id, or email for accounts with no auth.users row
|
||||
// yet, e.g. failed signups). See applySearchParamsFilter in UnifiedLogs.queries.ts.
|
||||
user: parseAsString,
|
||||
}
|
||||
|
||||
const POSTGRES_STATUS_CODE_LABELS = {
|
||||
|
||||
@@ -278,7 +278,10 @@ const buildBaseWhere = (
|
||||
if (logTypeFilter) {
|
||||
const condition = translateFilter('log_type', logTypeFilter.values, logTypeFilter.operator)
|
||||
if (condition) parts.push(condition)
|
||||
} else {
|
||||
} else if (!userFilterValue(search)) {
|
||||
// Skip the default (postgres|edge) source restriction while the user filter is
|
||||
// active — the user clause governs which sources are eligible (auth + postgres),
|
||||
// and the default would otherwise exclude auth_logs, the primary attributable source.
|
||||
parts.push(logTypeWhereCondition([...DEFAULT_LOG_TYPES]))
|
||||
}
|
||||
}
|
||||
@@ -318,9 +321,43 @@ const EDGE_SERVICE_PATH_FILTER: Record<'edge_auth' | 'edge_storage' | 'edge_post
|
||||
* so the row list, chart and sidebar facet counts stay in sync (otherwise the
|
||||
* badges over-count by the rows the list hides).
|
||||
*/
|
||||
/** Trimmed value of the cross-cutting user filter, or '' when inactive. */
|
||||
const userFilterValue = (search: QuerySearchParamsType): string =>
|
||||
typeof search.user === 'string' ? search.user.trim() : ''
|
||||
|
||||
/**
|
||||
* Cross-cutting "attributable to one user" condition. Only the two sources that can
|
||||
* be positively tied to a user are eligible, each with its own match:
|
||||
* - auth_logs: structured identity (`auth_event.actor_id` / `traits.user_email`),
|
||||
* plus a raw event_message match so a failed signup (no auth.users row, so the
|
||||
* identifier is an email) still surfaces.
|
||||
* - postgres_logs: the identifier appears verbatim in the error text (e.g. a 23502
|
||||
* failing row echoing the id column).
|
||||
* edge_logs / storage_logs / realtime_logs carry no per-user field and can't satisfy
|
||||
* either branch, so they're auto-excluded while the filter is active — never guessed
|
||||
* at via IP or timestamp proximity.
|
||||
*/
|
||||
const userAttributionCondition = (search: QuerySearchParamsType): SafeLogSqlFragment | null => {
|
||||
const value = userFilterValue(search)
|
||||
if (!value) return null
|
||||
const exact = lit(value)
|
||||
const contains = lit('%' + value + '%')
|
||||
return safeSql`(
|
||||
(source = 'auth_logs' AND (
|
||||
log_attributes['auth_event.actor_id'] = ${exact}
|
||||
OR log_attributes['auth_event.traits.user_email'] = ${exact}
|
||||
OR event_message ILIKE ${contains}
|
||||
))
|
||||
OR (source = 'postgres_logs' AND event_message ILIKE ${contains})
|
||||
)`
|
||||
}
|
||||
|
||||
const applySearchParamsFilter = (search: QuerySearchParamsType): SafeLogSqlFragment | null => {
|
||||
const conditions: SafeLogSqlFragment[] = []
|
||||
|
||||
const userCondition = userAttributionCondition(search)
|
||||
if (userCondition) conditions.push(userCondition)
|
||||
|
||||
// Visible by default — only an explicit `false` hides connection logs.
|
||||
if (search.show_connection_logs === false) {
|
||||
conditions.push(safeSql`(source != 'postgres_logs' OR (
|
||||
|
||||
@@ -33,6 +33,7 @@ import { DownloadLogsButton } from './components/DownloadLogsButton'
|
||||
import { LogsFilterBar } from './components/LogsFilterBar'
|
||||
import { LogsListPanel } from './components/LogsListPanel'
|
||||
import { TooltipLabel } from './components/TooltipLabel'
|
||||
import { UserLogFilterNotice } from './components/UserLogFilterNotice'
|
||||
import { RowSelectionHeader } from './RowSelectionHeader'
|
||||
import { ServiceFlowPanel } from './ServiceFlowPanel'
|
||||
import { SEARCH_PARAMS_PARSER } from './UnifiedLogs.constants'
|
||||
@@ -440,6 +441,15 @@ export const UnifiedLogs = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{search.user ? (
|
||||
<UserLogFilterNotice
|
||||
className="mx-2 mt-2"
|
||||
identifier={search.user}
|
||||
isEmpty={!isLoading && !isFetching && filterDBRowCount === 0}
|
||||
onClear={() => setSearch({ user: null })}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<RowSelectionHeader />
|
||||
|
||||
<ResizablePanelGroup
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useParams } from 'common'
|
||||
import { User, X } from 'lucide-react'
|
||||
import { parseAsString, useQueryState } from 'nuqs'
|
||||
import { useEffect, useState, type KeyboardEvent } from 'react'
|
||||
import { Button, InputGroup, InputGroupAddon, InputGroupInput } from 'ui'
|
||||
|
||||
import { searchAuthUserByEmail } from '@/components/interfaces/UserJourneys/UserJourneys.queries'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
import { UUID_REGEX } from '@/lib/constants'
|
||||
|
||||
/**
|
||||
* Dedicated "filter by user" control for Unified Logs. Writes the `?user=` key
|
||||
* (not the generic `filter=` array) since the filter is cross-cutting — see
|
||||
* SEARCH_PARAMS_PARSER / applySearchParamsFilter. An email is resolved to a user id
|
||||
* on submit (so it also matches postgres error text, which carries the id not the
|
||||
* email); an identifier with no auth.users row is kept as-is so failed signups still
|
||||
* match on their auth email.
|
||||
*/
|
||||
export const UserLogFilterControl = () => {
|
||||
const { ref: projectRef } = useParams()
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
|
||||
const [user, setUser] = useQueryState('user', parseAsString)
|
||||
const [value, setValue] = useState(user ?? '')
|
||||
const [isResolving, setIsResolving] = useState(false)
|
||||
|
||||
// Keep the input in sync when the filter is set/cleared elsewhere (deep link, clear button).
|
||||
useEffect(() => {
|
||||
setValue(user ?? '')
|
||||
}, [user])
|
||||
|
||||
const apply = async () => {
|
||||
const raw = value.trim()
|
||||
if (!raw) {
|
||||
setUser(null)
|
||||
return
|
||||
}
|
||||
if (UUID_REGEX.test(raw) || !raw.includes('@')) {
|
||||
setUser(raw)
|
||||
return
|
||||
}
|
||||
// Email → resolve to id where an account exists; fall back to the raw email otherwise.
|
||||
setIsResolving(true)
|
||||
try {
|
||||
const resolved = await searchAuthUserByEmail(
|
||||
projectRef!,
|
||||
project?.connectionString ?? null,
|
||||
raw
|
||||
).catch(() => undefined)
|
||||
setUser(resolved?.id ?? raw)
|
||||
} finally {
|
||||
setIsResolving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
apply()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-2 pt-2 pb-1 flex flex-col gap-1.5">
|
||||
<span className="text-xs text-foreground-light">Filter by user</span>
|
||||
<InputGroup>
|
||||
<InputGroupInput
|
||||
size="tiny"
|
||||
placeholder="Email or user id"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
onBlur={apply}
|
||||
disabled={isResolving}
|
||||
/>
|
||||
<InputGroupAddon>
|
||||
<User size={14} />
|
||||
</InputGroupAddon>
|
||||
{user ? (
|
||||
<InputGroupAddon align="inline-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="text"
|
||||
size="tiny"
|
||||
className="px-1"
|
||||
aria-label="Clear user filter"
|
||||
icon={<X size={14} />}
|
||||
onClick={() => setUser(null)}
|
||||
/>
|
||||
</InputGroupAddon>
|
||||
) : null}
|
||||
</InputGroup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Button } from 'ui'
|
||||
import { Admonition } from 'ui-patterns/admonition'
|
||||
|
||||
import { useResolvedLogUser } from '../hooks/useResolvedLogUser'
|
||||
|
||||
interface UserLogFilterNoticeProps {
|
||||
/** The active `?user=` identifier. */
|
||||
identifier: string
|
||||
/** True once loading has settled and no rows are attributable to this user. */
|
||||
isEmpty: boolean
|
||||
onClear: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Persistent notice shown while the user filter is active. Honesty requirement: it
|
||||
* always states that coverage is partial (some sources can't be attributed and are
|
||||
* hidden), and on an empty result it surfaces the likely cause — auth event logs not
|
||||
* being enabled — as a system condition rather than reading as "no such user".
|
||||
*/
|
||||
export const UserLogFilterNotice = ({
|
||||
identifier,
|
||||
isEmpty,
|
||||
onClear,
|
||||
className,
|
||||
}: UserLogFilterNoticeProps) => {
|
||||
const { data: resolved } = useResolvedLogUser(identifier)
|
||||
const label = resolved?.email ?? identifier
|
||||
|
||||
const clearAction = (
|
||||
<Button type="button" variant="default" size="tiny" onClick={onClear}>
|
||||
Clear
|
||||
</Button>
|
||||
)
|
||||
|
||||
if (isEmpty) {
|
||||
return (
|
||||
<Admonition
|
||||
type="warning"
|
||||
className={className}
|
||||
title={`No logs attributable to ${label} in the selected time range`}
|
||||
description="Auth event logs may not be enabled for this project. Some log sources also can't be filtered by user and are hidden."
|
||||
actions={clearAction}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Admonition
|
||||
type="default"
|
||||
className={className}
|
||||
title={`Showing logs attributable to ${label}`}
|
||||
description="Some sources can't be filtered by user and are hidden."
|
||||
actions={clearAction}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useParams } from 'common'
|
||||
|
||||
import {
|
||||
searchAuthUserByEmail,
|
||||
searchAuthUserById,
|
||||
} from '@/components/interfaces/UserJourneys/UserJourneys.queries'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
import { UUID_REGEX } from '@/lib/constants'
|
||||
|
||||
export interface ResolvedLogUser {
|
||||
/** The raw identifier from the URL (id or email). */
|
||||
identifier: string
|
||||
/** Resolved email, if an auth.users row was found (or the identifier itself when it's an email). */
|
||||
email: string | null
|
||||
/** Resolved auth.users id, if one exists. Null for e.g. a failed signup with no row. */
|
||||
userId: string | null
|
||||
/** Whether an auth.users row matched. False is normal for failed signups. */
|
||||
exists: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a Unified Logs `?user=` identifier (email or id) into display info for the
|
||||
* active-filter notice. Reuses the same identity-resolution queries the parked user-journey
|
||||
* timeline depends on. Display-only — the log SQL matches the raw `?user=` value directly.
|
||||
*/
|
||||
export function useResolvedLogUser(identifier: string | null | undefined) {
|
||||
const { ref: projectRef } = useParams()
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const connectionString = project?.connectionString ?? null
|
||||
const id = identifier?.trim() ?? ''
|
||||
|
||||
return useQuery<ResolvedLogUser | null>({
|
||||
queryKey: ['unified-logs-resolve-user', projectRef, connectionString, id],
|
||||
enabled: Boolean(projectRef) && id.length > 0,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
queryFn: async () => {
|
||||
const isId = UUID_REGEX.test(id)
|
||||
const user = isId
|
||||
? await searchAuthUserById(projectRef!, connectionString, id)
|
||||
: await searchAuthUserByEmail(projectRef!, connectionString, id)
|
||||
|
||||
if (!user) {
|
||||
return { identifier: id, email: isId ? null : id, userId: isId ? id : null, exists: false }
|
||||
}
|
||||
return { identifier: id, email: user.email, userId: user.id, exists: true }
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { DataTableFilterControls } from './DataTableFilters/DataTableFilterContr
|
||||
import { DataTableResetButton } from './DataTableResetButton'
|
||||
import { useDataTable } from './providers/DataTableProvider'
|
||||
import { LOG_DRAIN_TYPES } from '@/components/interfaces/LogDrains/LogDrains.constants'
|
||||
import { UserLogFilterControl } from '@/components/interfaces/UnifiedLogs/components/UserLogFilterControl'
|
||||
import { UnifiedLogsBanner } from '@/components/interfaces/UnifiedLogs/UnifiedLogsBanner'
|
||||
|
||||
interface FilterSideBarProps {
|
||||
@@ -65,6 +66,7 @@ export function FilterSideBar({
|
||||
<UnifiedLogsBanner />
|
||||
|
||||
<div className="flex-1 p-2 sm:overflow-y-scroll">
|
||||
<UserLogFilterControl />
|
||||
<DataTableFilterControls dateRangeDisabled={dateRangeDisabled} />
|
||||
<FeaturePreviewSidebarPanel
|
||||
className="mx-2 my-4"
|
||||
|
||||
Reference in new issue
Block a user