feat: try real data

This commit is contained in:
kemal committed 2026-07-13 16:57:25 +01:00
1 parent 39de6061e2
commit cc8c4dcab2
3 files changed
+519 -87

No files matched your search

@@ -1,89 +1,49 @@
/**
* UserJourney — PROTOTYPE
*
* This is a demo-quality prototype of the "User Journey" tab. It renders a
* chronological timeline reconstructed from Auth and PostgREST logs using
* HARDCODED MOCK DATA — there are no real log queries wired up yet.
* Renders a chronological timeline of everything one user did, reconstructed
* from Auth and PostgREST logs: signed up, authenticated, API reads/writes,
* and where a request was denied.
*
* This wires up REAL log queries (auth_logs + edge_logs filtered by the user)
* via useLogsQuery. Where the logs can't tell the full story — most notably the
* exact RLS policy that denied a write — it degrades gracefully (see the
* data-availability note in UserJourney.utils.ts). A "Sample data" toggle shows
* a hardcoded reference scenario so the tab stays demoable on projects with no
* matching logs.
*
* See PRFAQ: https://www.notion.so/supabase/User-Journey-PRFAQ (placeholder link)
*/
import { useParams } from 'common'
import { AlertTriangle, Database, ExternalLink, LogIn, RefreshCw, UserPlus } from 'lucide-react'
import { useFlag, useParams } from 'common'
import {
AlertTriangle,
Database,
ExternalLink,
FlaskConical,
LogIn,
RefreshCw,
UserPlus,
} from 'lucide-react'
import Link from 'next/link'
import { useState } from 'react'
import { useMemo, useState } from 'react'
import { Button, cn, Separator } from 'ui'
import { Admonition } from 'ui-patterns/admonition'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import { TimestampInfo } from 'ui-patterns/TimestampInfo'
import { UserHeader } from './UserHeader'
import {
buildAuthLogsSql,
buildEdgeLogsSql,
buildJourney,
isValidUserId,
SAMPLE_JOURNEY_EVENTS,
type JourneyEvent,
} from './UserJourney.utils'
import { PANEL_PADDING } from './Users.constants'
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
import { User } from '@/data/auth/users-infinite-query'
type JourneyEventStatus = 'success' | 'neutral' | 'error'
type JourneyEventSource = 'auth' | 'postgrest'
interface JourneyEvent {
id: string
timestamp: string // ISO, ms precision
source: JourneyEventSource
status: JourneyEventStatus
title: string
description: string
request?: { method: string; path: string; statusCode: number }
error?: { message: string; policy?: string; table?: string }
}
const MOCK_JOURNEY_EVENTS: JourneyEvent[] = [
{
id: '1',
timestamp: '2026-07-13T09:41:02.118Z',
source: 'auth',
status: 'success',
title: 'Signed up',
description: 'Auth · new user created via email signup',
},
{
id: '2',
timestamp: '2026-07-13T09:41:02.421Z',
source: 'auth',
status: 'success',
title: 'Authenticated',
description: 'Auth · session issued, JWT minted',
},
{
id: '3',
timestamp: '2026-07-13T09:41:03.905Z',
source: 'postgrest',
status: 'neutral',
title: 'Read profile',
description: 'PostgREST · fetched the current user profile',
request: { method: 'GET', path: '/rest/v1/profiles', statusCode: 200 },
},
{
id: '4',
timestamp: '2026-07-13T09:41:05.332Z',
source: 'postgrest',
status: 'neutral',
title: 'Created order',
description: 'PostgREST · inserted a new order row',
request: { method: 'POST', path: '/rest/v1/orders', statusCode: 201 },
},
{
id: '5',
timestamp: '2026-07-13T09:41:06.744Z',
source: 'postgrest',
status: 'error',
title: 'Write blocked',
description: 'PostgREST · insert rejected before reaching the table',
request: { method: 'POST', path: '/rest/v1/payments', statusCode: 403 },
error: {
message: 'Denied by RLS policy',
policy: 'payments_insert_owner',
table: 'payments',
},
},
]
import { useLogsQuery } from '@/hooks/analytics/useLogsQuery'
const getEventIcon = (event: JourneyEvent) => {
if (event.status === 'error') return AlertTriangle
@@ -103,11 +63,58 @@ interface UserJourneyProps {
export const UserJourney = ({ user }: UserJourneyProps) => {
const { ref } = useParams()
const [errorsOnly, setErrorsOnly] = useState(false)
const useOtel = useFlag('otelLegacyLogs')
const events = errorsOnly
? MOCK_JOURNEY_EVENTS.filter((event) => event.status === 'error')
: MOCK_JOURNEY_EVENTS
const [errorsOnly, setErrorsOnly] = useState(false)
const [showSample, setShowSample] = useState(false)
const userId = user.id
const canQuery = isValidUserId(userId) && !showSample
const authSql = useMemo(
() => (isValidUserId(userId) ? buildAuthLogsSql(userId, useOtel) : ''),
[userId, useOtel]
)
const edgeSql = useMemo(
() => (isValidUserId(userId) ? buildEdgeLogsSql(userId, useOtel) : ''),
[userId, useOtel]
)
const {
logData: authLogs,
isLoading: isLoadingAuth,
error: authError,
runQuery: runAuthQuery,
} = useLogsQuery({
projectRef: ref,
initialParams: { sql: authSql },
enabled: canQuery,
options: { useOtel },
})
const {
logData: edgeLogs,
isLoading: isLoadingEdge,
error: edgeError,
runQuery: runEdgeQuery,
} = useLogsQuery({
projectRef: ref,
initialParams: { sql: edgeSql },
enabled: canQuery,
options: { useOtel },
})
const liveEvents = useMemo(() => buildJourney(authLogs, edgeLogs), [authLogs, edgeLogs])
const isLoading = canQuery && (isLoadingAuth || isLoadingEdge)
const error = authError || edgeError
const allEvents = showSample ? SAMPLE_JOURNEY_EVENTS : liveEvents
const events = errorsOnly ? allEvents.filter((event) => event.status === 'error') : allEvents
const refresh = () => {
runAuthQuery()
runEdgeQuery()
}
return (
<div>
@@ -144,6 +151,15 @@ export const UserJourney = ({ user }: UserJourneyProps) => {
</Button>
</div>
<div className="flex items-center gap-x-2">
<ButtonTooltip
variant={showSample ? 'secondary' : 'default'}
className="px-2"
icon={<FlaskConical />}
onClick={() => setShowSample((value) => !value)}
tooltip={{
content: { text: showSample ? 'Showing sample data' : 'Show sample data' },
}}
/>
<ButtonTooltip
asChild
variant="default"
@@ -158,13 +174,23 @@ export const UserJourney = ({ user }: UserJourneyProps) => {
variant="default"
className="px-2"
icon={<RefreshCw />}
onClick={() => setErrorsOnly(false)}
loading={isLoading}
disabled={isLoading || showSample}
onClick={refresh}
tooltip={{ content: { text: 'Refresh' } }}
/>
</div>
</div>
{events.length === 0 ? (
{isLoading && events.length === 0 ? (
<GenericSkeletonLoader />
) : error && events.length === 0 && !showSample ? (
<Admonition
type="warning"
title="Unable to load this user's journey"
description={typeof error === 'string' ? error : 'Failed to query auth and API logs'}
/>
) : events.length === 0 ? (
<Admonition
type="note"
title="No journey available for this user"
@@ -219,16 +245,30 @@ export const UserJourney = ({ user }: UserJourneyProps) => {
<Separator className="bg-destructive-400" />
<div className="px-3 py-2.5">
<p className="text-xs text-foreground-light">{event.error.message}</p>
<p className="text-xs mt-1">
<span className="text-foreground-light">policy </span>
<span className="font-mono text-foreground">
{event.error.policy}
</span>
<span className="text-foreground-light"> on table </span>
<span className="font-mono text-foreground">
{event.error.table}
</span>
</p>
{(event.error.policy || event.error.table) && (
<p className="text-xs mt-1">
{event.error.policy ? (
<>
<span className="text-foreground-light">policy </span>
<span className="font-mono text-foreground">
{event.error.policy}
</span>
</>
) : (
<span className="text-foreground-light">
policy name unavailable from logs
</span>
)}
{event.error.table && (
<>
<span className="text-foreground-light"> on table </span>
<span className="font-mono text-foreground">
{event.error.table}
</span>
</>
)}
</p>
)}
</div>
</>
)}
@@ -0,0 +1,148 @@
import { describe, expect, it } from 'vitest'
import {
buildAuthLogsSql,
buildEdgeLogsSql,
buildJourney,
classifyAuthLog,
classifyEdgeLog,
isValidUserId,
} from './UserJourney.utils'
import type { LogData } from '@/components/interfaces/Settings/Logs/Logs.types'
const USER_ID = '00000000-0000-4000-8000-000000000000'
const authRow = (overrides: Partial<LogData> = {}): LogData =>
({ id: 'a1', timestamp: 1_700_000_000_000_000, event_message: '', ...overrides }) as LogData
const edgeRow = (overrides: Partial<LogData> = {}): LogData =>
({ id: 'e1', timestamp: 1_700_000_000_000_000, event_message: '', ...overrides }) as LogData
describe('isValidUserId', () => {
it('accepts a uuid', () => {
expect(isValidUserId(USER_ID)).toBe(true)
})
it('rejects empty, null, and non-uuid values', () => {
expect(isValidUserId(undefined)).toBe(false)
expect(isValidUserId(null)).toBe(false)
expect(isValidUserId('')).toBe(false)
expect(isValidUserId('not-a-uuid')).toBe(false)
})
})
describe('buildAuthLogsSql', () => {
it('targets auth_logs and filters by user in BigQuery dialect', () => {
const sql = buildAuthLogsSql(USER_ID, false)
expect(sql).toContain('from auth_logs')
expect(sql).toContain(`regexp_contains(event_message, '${USER_ID}')`)
})
it('targets the ClickHouse logs table with a source filter in OTEL dialect', () => {
const sql = buildAuthLogsSql(USER_ID, true)
expect(sql).toContain("where source = 'auth_logs'")
expect(sql).toContain(`ilike '%${USER_ID}%'`)
})
})
describe('buildEdgeLogsSql', () => {
it('filters edge logs on auth_user in BigQuery dialect', () => {
const sql = buildEdgeLogsSql(USER_ID, false)
expect(sql).toContain('from edge_logs')
expect(sql).toContain(`sb.auth_user = '${USER_ID}'`)
})
it('filters edge logs on the auth_user attribute in OTEL dialect', () => {
const sql = buildEdgeLogsSql(USER_ID, true)
expect(sql).toContain("where source = 'edge_logs'")
expect(sql).toContain(`log_attributes['request.sb.auth_user'] = '${USER_ID}'`)
})
})
describe('classifyAuthLog', () => {
it('labels a signup event as a success', () => {
const event = classifyAuthLog(authRow({ path: '/signup', msg: 'user signed up' }))
expect(event).toMatchObject({ source: 'auth', status: 'success', title: 'Signed up' })
})
it('labels a token/login event as Authenticated', () => {
const event = classifyAuthLog(authRow({ path: '/token', msg: 'login' }))
expect(event.title).toBe('Authenticated')
})
it('marks error/fatal levels as errors and attaches an error object', () => {
const event = classifyAuthLog(authRow({ level: 'error', msg: 'invalid password' }))
expect(event.status).toBe('error')
expect(event.error?.message).toBe('invalid password')
})
})
describe('classifyEdgeLog', () => {
it('labels a successful GET as a neutral read', () => {
const event = classifyEdgeLog(
edgeRow({ method: 'GET', path: '/rest/v1/profiles', status_code: '200' })
)
expect(event).toMatchObject({
source: 'postgrest',
status: 'neutral',
title: 'Read profiles',
})
expect(event.request).toEqual({ method: 'GET', path: '/rest/v1/profiles', statusCode: 200 })
})
it('labels a successful POST as a neutral create', () => {
const event = classifyEdgeLog(
edgeRow({ method: 'POST', path: '/rest/v1/orders', status_code: '201' })
)
expect(event.title).toBe('Created orders')
expect(event.status).toBe('neutral')
})
it('treats a 403 write as a blocked error and infers the table but not the policy', () => {
const event = classifyEdgeLog(
edgeRow({ method: 'POST', path: '/rest/v1/payments', status_code: '403' })
)
expect(event.status).toBe('error')
expect(event.title).toBe('Write blocked')
expect(event.error?.table).toBe('payments')
expect(event.error?.policy).toBeUndefined()
expect(event.error?.policyUnknown).toBe(true)
})
it('handles a query string in the path when inferring the resource', () => {
const event = classifyEdgeLog(
edgeRow({ method: 'GET', path: '/rest/v1/orders?select=*&id=eq.1', status_code: '200' })
)
expect(event.title).toBe('Read orders')
})
})
describe('buildJourney', () => {
it('merges auth and edge events into a single oldest-to-newest timeline', () => {
const authLogs = [
authRow({ id: 'signup', timestamp: 100, path: '/signup', msg: 'signed up' }),
authRow({ id: 'login', timestamp: 200, path: '/token', msg: 'login' }),
]
const edgeLogs = [
edgeRow({
id: 'read',
timestamp: 300,
method: 'GET',
path: '/rest/v1/p',
status_code: '200',
}),
edgeRow({
id: 'blocked',
timestamp: 400,
method: 'POST',
path: '/rest/v1/payments',
status_code: '403',
}),
]
const journey = buildJourney(authLogs, edgeLogs)
expect(journey.map((event) => event.id)).toEqual(['signup', 'login', 'read', 'blocked'])
expect(journey[journey.length - 1].status).toBe('error')
})
})
@@ -0,0 +1,244 @@
/**
* UserJourney.utils — pure helpers for the User Journey tab.
*
* These build the logs SQL, classify raw auth/edge log rows into journey
* events, and merge them chronologically. Kept free of React so they can be
* unit-tested in isolation (see studio-testing conventions).
*
* DATA-AVAILABILITY NOTE: the logs give us the auth timeline (auth_logs) and
* per-user API activity with method/path/status (edge_logs, filtered on the
* caller's JWT `auth_user`). What they do NOT give us is the exact RLS policy
* that denied a write — Postgres' RLS error names the table, never the policy,
* and there's no request id joining an edge_logs row to a postgres_logs row.
* So a 403 on a `/rest/v1/*` write is surfaced as "likely blocked by RLS" with
* the table inferred from the path, and the policy name is left unknown.
*/
import type { LogData } from '@/components/interfaces/Settings/Logs/Logs.types'
export type JourneyEventStatus = 'success' | 'neutral' | 'error'
export type JourneyEventSource = 'auth' | 'postgrest'
export interface JourneyEvent {
id: string
/** ISO string (sample data) or unix microseconds (live logs) — both render via TimestampInfo. */
timestamp: string | number
source: JourneyEventSource
status: JourneyEventStatus
title: string
description: string
request?: { method: string; path: string; statusCode: number }
error?: { message: string; policy?: string; table?: string; policyUnknown?: boolean }
}
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
export const isValidUserId = (userId?: string | null): userId is string =>
!!userId && UUID_RE.test(userId)
/** Last meaningful path segment, e.g. `/rest/v1/orders?select=*` → `orders`. */
const resourceFromPath = (path: string) => {
const clean = path.split('?')[0].replace(/\/+$/, '')
const segments = clean.split('/').filter(Boolean)
return segments[segments.length - 1] || clean || 'resource'
}
/**
* SQL to pull this user's auth events. Matched on the user id appearing in the
* raw event message — the same heuristic the Logs tab uses (`search_query`).
*/
export const buildAuthLogsSql = (userId: string, useOtel: boolean, limit = 50) => {
if (useOtel) {
return `select
id,
timestamp,
event_message,
log_attributes['level'] as level,
log_attributes['status'] as status,
log_attributes['path'] as path,
log_attributes['msg'] as msg
from logs
where source = 'auth_logs'
and event_message ilike '%${userId}%'
order by timestamp desc
limit ${limit}`
}
return `select
id,
timestamp,
event_message,
metadata.level as level,
metadata.status as status,
metadata.path as path,
metadata.msg as msg
from auth_logs
cross join unnest(metadata) as metadata
where regexp_contains(event_message, '${userId}')
order by timestamp desc
limit ${limit}`
}
/**
* SQL to pull this user's API activity from the edge gateway, filtered on the
* JWT `auth_user` (the acting user's id) carried on each request.
*
* The OTEL/ClickHouse `log_attributes` key for auth_user is the BigQuery path
* with the `metadata` root dropped; if it ever returns nothing on a ClickHouse
* project, confirm the key via the Field Reference drawer / `mapKeys`.
*/
export const buildEdgeLogsSql = (userId: string, useOtel: boolean, limit = 50) => {
if (useOtel) {
return `select
id,
timestamp,
log_attributes['request.method'] as method,
log_attributes['request.path'] as path,
log_attributes['response.status_code'] as status_code
from logs
where source = 'edge_logs'
and log_attributes['request.sb.auth_user'] = '${userId}'
order by timestamp desc
limit ${limit}`
}
return `select
id,
timestamp,
request.method as method,
request.path as path,
response.status_code as status_code
from edge_logs
cross join unnest(metadata) as m
cross join unnest(m.request) as request
cross join unnest(m.response) as response
cross join unnest(request.sb) as sb
where sb.auth_user = '${userId}'
order by timestamp desc
limit ${limit}`
}
const toSortableTimestamp = (timestamp: string | number) =>
typeof timestamp === 'number' ? timestamp : Date.parse(timestamp)
export const classifyAuthLog = (log: LogData): JourneyEvent => {
const msg = String(log.msg ?? log.event_message ?? '')
const path = String(log.path ?? '')
const level = String(log.level ?? '').toLowerCase()
const haystack = `${msg} ${path}`.toLowerCase()
const isError = level === 'error' || level === 'fatal'
let title = 'Authentication event'
if (haystack.includes('signup') || haystack.includes('sign up')) title = 'Signed up'
else if (haystack.includes('token') || haystack.includes('login')) title = 'Authenticated'
else if (haystack.includes('logout')) title = 'Signed out'
else if (haystack.includes('recover')) title = 'Password recovery'
return {
id: String(log.id),
timestamp: log.timestamp,
source: 'auth',
status: isError ? 'error' : 'success',
title,
description: `Auth · ${msg || path || 'event'}`,
...(isError && { error: { message: msg || 'Authentication error' } }),
}
}
export const classifyEdgeLog = (log: LogData): JourneyEvent => {
const method = String(log.method ?? '').toUpperCase()
const path = String(log.path ?? '')
const statusCode = Number(log.status_code ?? 0)
const resource = resourceFromPath(path)
const isError = statusCode >= 400
const isWrite = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)
let title: string
if (isError) title = isWrite ? 'Write blocked' : 'Request failed'
else if (method === 'GET') title = `Read ${resource}`
else if (method === 'POST') title = `Created ${resource}`
else if (method === 'PATCH' || method === 'PUT') title = `Updated ${resource}`
else if (method === 'DELETE') title = `Deleted ${resource}`
else title = `${method || 'Request'} ${resource}`.trim()
const event: JourneyEvent = {
id: String(log.id),
timestamp: log.timestamp,
source: 'postgrest',
status: isError ? 'error' : 'neutral',
title,
description: `PostgREST · ${method || 'request'}`,
...(path && { request: { method, path, statusCode } }),
}
if (isError) {
const likelyRls = statusCode === 403 && path.includes('/rest/')
event.error = {
message: likelyRls
? 'Request denied — likely blocked by a row-level security policy'
: `Request failed with status ${statusCode}`,
...(likelyRls && { table: resource, policyUnknown: true }),
}
}
return event
}
/** Merge auth + edge rows into a single chronological (oldest → newest) timeline. */
export const buildJourney = (authLogs: LogData[], edgeLogs: LogData[]): JourneyEvent[] => {
return [...authLogs.map(classifyAuthLog), ...edgeLogs.map(classifyEdgeLog)].sort(
(a, b) => toSortableTimestamp(a.timestamp) - toSortableTimestamp(b.timestamp)
)
}
/**
* Hardcoded reference scenario (mirrors a real support case). Used by the
* "Sample data" toggle so the tab is demoable on projects with no matching logs.
*/
export const SAMPLE_JOURNEY_EVENTS: JourneyEvent[] = [
{
id: 'sample-1',
timestamp: '2026-07-13T09:41:02.118Z',
source: 'auth',
status: 'success',
title: 'Signed up',
description: 'Auth · new user created via email signup',
},
{
id: 'sample-2',
timestamp: '2026-07-13T09:41:02.421Z',
source: 'auth',
status: 'success',
title: 'Authenticated',
description: 'Auth · session issued, JWT minted',
},
{
id: 'sample-3',
timestamp: '2026-07-13T09:41:03.905Z',
source: 'postgrest',
status: 'neutral',
title: 'Read profile',
description: 'PostgREST · fetched the current user profile',
request: { method: 'GET', path: '/rest/v1/profiles', statusCode: 200 },
},
{
id: 'sample-4',
timestamp: '2026-07-13T09:41:05.332Z',
source: 'postgrest',
status: 'neutral',
title: 'Created order',
description: 'PostgREST · inserted a new order row',
request: { method: 'POST', path: '/rest/v1/orders', statusCode: 201 },
},
{
id: 'sample-5',
timestamp: '2026-07-13T09:41:06.744Z',
source: 'postgrest',
status: 'error',
title: 'Write blocked',
description: 'PostgREST · insert rejected before reaching the table',
request: { method: 'POST', path: '/rest/v1/payments', statusCode: 403 },
error: {
message: 'Denied by RLS policy',
policy: 'payments_insert_owner',
table: 'payments',
},
},
]