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:
kemalandClaude Opus 4.8 committed 2026-07-09 10:53:57 +01:00
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"