From 7037a0bd4326cfa8b3aed2b7ff8d2b997dbcf46b Mon Sep 17 00:00:00 2001 From: kemal Date: Thu, 9 Jul 2026 10:53:57 +0100 Subject: [PATCH] feat(studio): add cross-cutting "filter by user" to Unified Logs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../UnifiedLogs/UnifiedLogs.constants.tsx | 7 ++ .../UnifiedLogs/UnifiedLogs.queries.ts | 39 +++++++- .../interfaces/UnifiedLogs/UnifiedLogs.tsx | 10 ++ .../components/UserLogFilterControl.tsx | 95 +++++++++++++++++++ .../components/UserLogFilterNotice.tsx | 57 +++++++++++ .../UnifiedLogs/hooks/useResolvedLogUser.ts | 49 ++++++++++ .../components/ui/DataTable/FilterSideBar.tsx | 2 + 7 files changed, 258 insertions(+), 1 deletion(-) create mode 100644 apps/studio/components/interfaces/UnifiedLogs/components/UserLogFilterControl.tsx create mode 100644 apps/studio/components/interfaces/UnifiedLogs/components/UserLogFilterNotice.tsx create mode 100644 apps/studio/components/interfaces/UnifiedLogs/hooks/useResolvedLogUser.ts diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.constants.tsx b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.constants.tsx index 36dd514026a..1149ed7a559 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.constants.tsx +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.constants.tsx @@ -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 = { diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.ts b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.ts index 2968e77238b..b63bc9d87d4 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.ts +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.ts @@ -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 ( diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx index c927d36fe83..2e24a741618 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx @@ -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 = () => { )} + {search.user ? ( + setSearch({ user: null })} + /> + ) : null} + { + 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) => { + if (event.key === 'Enter') { + event.preventDefault() + apply() + } + } + + return ( +
+ Filter by user + + setValue(e.target.value)} + onKeyDown={onKeyDown} + onBlur={apply} + disabled={isResolving} + /> + + + + {user ? ( + +
+ ) +} diff --git a/apps/studio/components/interfaces/UnifiedLogs/components/UserLogFilterNotice.tsx b/apps/studio/components/interfaces/UnifiedLogs/components/UserLogFilterNotice.tsx new file mode 100644 index 00000000000..bd93e463180 --- /dev/null +++ b/apps/studio/components/interfaces/UnifiedLogs/components/UserLogFilterNotice.tsx @@ -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 = ( + + ) + + if (isEmpty) { + return ( + + ) + } + + return ( + + ) +} diff --git a/apps/studio/components/interfaces/UnifiedLogs/hooks/useResolvedLogUser.ts b/apps/studio/components/interfaces/UnifiedLogs/hooks/useResolvedLogUser.ts new file mode 100644 index 00000000000..669230cca61 --- /dev/null +++ b/apps/studio/components/interfaces/UnifiedLogs/hooks/useResolvedLogUser.ts @@ -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({ + 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 } + }, + }) +} diff --git a/apps/studio/components/ui/DataTable/FilterSideBar.tsx b/apps/studio/components/ui/DataTable/FilterSideBar.tsx index 71fb110655c..a876ee398d3 100644 --- a/apps/studio/components/ui/DataTable/FilterSideBar.tsx +++ b/apps/studio/components/ui/DataTable/FilterSideBar.tsx @@ -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({
+