+
+
+
+ setMessage(event.target.value)}
+ />
+
+
+
+
+
}
@@ -75,7 +124,7 @@ export const WorkerLogsTab = ({ workerName, stream }: WorkerLogsTabProps) => {
onSelectedLogChange={(log) => setSelectedLog(log)}
EmptyState={
-
No {label} in the last 24 hours
+
No {label} in the selected time range
Follow them from the Supabase CLI while you wait for traffic.
diff --git a/apps/studio/components/interfaces/Workers/WorkersList.test.tsx b/apps/studio/components/interfaces/Workers/WorkersList.test.tsx
index e78acd3b3026e..09bd808ad2b92 100644
--- a/apps/studio/components/interfaces/Workers/WorkersList.test.tsx
+++ b/apps/studio/components/interfaces/Workers/WorkersList.test.tsx
@@ -18,8 +18,16 @@ const worker = (name: string, overrides: Partial
= {}): Worker => ({
...overrides,
})
-const renderList = (workers: Worker[]) =>
- customRender()
+const renderList = (workers: Worker[], onRefresh = vi.fn()) =>
+ customRender(
+
+ )
const rowNames = () =>
screen
@@ -48,12 +56,19 @@ describe('WorkersList', () => {
await userEvent.type(screen.getByPlaceholderText('Search by name'), 'resize')
expect(rowNames()).toEqual(['resize-images'])
- expect(screen.getByText('1 worker')).toBeVisible()
-
await userEvent.type(screen.getByPlaceholderText('Search by name'), '-nope')
expect(screen.getByText('No workers match your filters')).toBeVisible()
})
+ it('refreshes the workers list on request', async () => {
+ const onRefresh = vi.fn()
+ renderList([worker('embed')], onRefresh)
+
+ await userEvent.click(screen.getByRole('button', { name: 'Refresh' }))
+
+ expect(onRefresh).toHaveBeenCalledOnce()
+ })
+
it('pages through the workers ten at a time', async () => {
const workers = Array.from({ length: 12 }, (_, index) => worker(`worker-${index}`))
renderList(workers)
diff --git a/apps/studio/components/interfaces/Workers/WorkersList.tsx b/apps/studio/components/interfaces/Workers/WorkersList.tsx
index 7503d8c1533e2..ca2aa4b9d46b7 100644
--- a/apps/studio/components/interfaces/Workers/WorkersList.tsx
+++ b/apps/studio/components/interfaces/Workers/WorkersList.tsx
@@ -1,4 +1,4 @@
-import { ChevronLeft, ChevronRight, Terminal } from 'lucide-react'
+import { ChevronLeft, ChevronRight, RefreshCw, Terminal } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useState } from 'react'
@@ -32,6 +32,8 @@ interface WorkersListProps {
projectRef: string
workers: Worker[]
onDeploy: () => void
+ onRefresh: () => void
+ isRefreshing: boolean
}
const STATE_FILTERS: { value: WorkerBuildState | 'all'; label: string }[] = [
@@ -55,7 +57,13 @@ const parseStateFilter = (value: string): WorkerBuildState | 'all' =>
const parseAccessFilter = (value: string): WorkerAccess | 'all' =>
ACCESS_FILTERS.find((option) => option.value === value)?.value ?? 'all'
-export const WorkersList = ({ projectRef, workers, onDeploy }: WorkersListProps) => {
+export const WorkersList = ({
+ projectRef,
+ workers,
+ onDeploy,
+ onRefresh,
+ isRefreshing,
+}: WorkersListProps) => {
const router = useRouter()
const [search, setSearch] = useState('')
const [stateFilter, setStateFilter] = useState('all')
@@ -124,9 +132,9 @@ export const WorkersList = ({ projectRef, workers, onDeploy }: WorkersListProps)
-
- {filtered.length} worker{filtered.length === 1 ? '' : 's'}
-
+
} loading={isRefreshing} onClick={onRefresh}>
+ Refresh
+
} onClick={onDeploy}>
Deploy a worker
diff --git a/apps/studio/components/interfaces/Workers/workerSnippets.test.ts b/apps/studio/components/interfaces/Workers/workerSnippets.test.ts
index 77f434a143731..bf8c212e7e50b 100644
--- a/apps/studio/components/interfaces/Workers/workerSnippets.test.ts
+++ b/apps/studio/components/interfaces/Workers/workerSnippets.test.ts
@@ -1,6 +1,5 @@
import { describe, expect, it } from 'vitest'
-import { WORKERS_REGION } from './Workers.constants'
import { buildWorkerCliCommands, buildWorkerSnippets, EXAMPLE_WORKER } from './workerSnippets'
const input = (overrides: Partial
[0]> = {}) => ({
@@ -10,73 +9,19 @@ const input = (overrides: Partial[0]> = {
})
describe('buildWorkerSnippets', () => {
- it('points the invoke examples at the worker URL', () => {
- const { curl, javascript, python } = buildWorkerSnippets(input({ name: 'embed' }))
- const url = 'https://abcdefgh.supabase.co/workers/v1/embed'
+ it('passes private exposure to the CLI and config', () => {
+ const { cli, configToml } = buildWorkerSnippets(input({ access: 'private' }))
- expect(curl).toContain(`'${url}'`)
- expect(javascript).toContain(`'${url}'`)
- expect(python).toContain(`"${url}"`)
- })
-
- it('leaves a placeholder invoke URL until the project settings resolve', () => {
- const { curl } = buildWorkerSnippets(input({ endpoint: undefined }))
- expect(curl).toContain('[YOUR WORKER URL]')
- })
-
- it('does not require authorization for the CLI invoke example', () => {
- expect(buildWorkerSnippets(input()).curl).not.toContain('Authorization')
- })
-
- it('asks for the anon key to invoke a public worker and the service role key for a private one', () => {
- expect(buildWorkerSnippets(input({ access: 'public' })).javascript).toContain('[YOUR ANON KEY]')
- expect(buildWorkerSnippets(input({ access: 'private' })).javascript).toContain(
- '[YOUR SERVICE ROLE KEY]'
- )
- })
-
- it('falls back to a placeholder name when the worker has none', () => {
- expect(buildWorkerSnippets(input({ name: ' ' })).cli).toContain('my-worker')
- })
-
- it('trims the name before interpolating it', () => {
- expect(buildWorkerSnippets(input({ name: ' embed ' })).cli).toContain('new embed --runtime')
- })
-
- it('defaults the runtime when the API omits it', () => {
- expect(buildWorkerSnippets(input({ runtime: undefined })).cli).toContain('--runtime node')
- })
-
- it('writes the worker spec into the config.toml block', () => {
- const { configToml } = buildWorkerSnippets(
- input({
- name: 'embed',
- runtime: 'python',
- size: '4gb-2vcpu',
- access: 'private',
- instances: 3,
- })
- )
-
- expect(configToml).toContain('[workers.embed]')
- expect(configToml).toContain('runtime = "python"')
- expect(configToml).toContain('size = "4gb-2vcpu" # 4 GB · 2 vCPU')
- expect(configToml).toContain('access = "private"')
- expect(configToml).toContain('instances = 3')
- expect(configToml).toContain(WORKERS_REGION)
+ expect(cli).toContain('--exposure private')
+ expect(configToml).toContain('exposure = "private"')
})
})
describe('buildWorkerCliCommands', () => {
- it('names the worker in every command', () => {
+ it('targets the requested worker in every management command', () => {
const commands = buildWorkerCliCommands('embed')
- expect(commands).not.toHaveLength(0)
- for (const { command } of commands) {
- expect(command).toContain('embed')
- }
- })
- it('falls back to a placeholder name when the worker has none', () => {
- expect(buildWorkerCliCommands(' ')[0].command).toContain('my-worker')
+ expect(commands).toHaveLength(4)
+ expect(commands.every(({ command }) => command.includes('embed'))).toBe(true)
})
})
diff --git a/apps/studio/components/interfaces/Workers/workerSnippets.ts b/apps/studio/components/interfaces/Workers/workerSnippets.ts
index 7cc337d332c39..aaea57235f50a 100644
--- a/apps/studio/components/interfaces/Workers/workerSnippets.ts
+++ b/apps/studio/components/interfaces/Workers/workerSnippets.ts
@@ -41,10 +41,7 @@ export function buildWorkerSnippets(input: WorkerSnippetInput): WorkerSnippets {
const cli = [
`supabase ${CLI_NAME} new ${name} --runtime ${runtime}`,
- // size comes from config.toml — push has no flag for it. Same for access: the CLI
- // doesn't have a route to a private worker yet, so this always deploys as public.
- `supabase ${CLI_NAME} push ${name} --instances ${input.instances}`,
- ...(input.access === 'private' ? [`# note: the CLI can only deploy public workers today`] : []),
+ `supabase ${CLI_NAME} push ${name} --instances ${input.instances} --exposure ${input.access}`,
].join('\n')
const curl = [
@@ -57,7 +54,7 @@ export function buildWorkerSnippets(input: WorkerSnippetInput): WorkerSnippets {
`[${CLI_NAME}.${name}]`,
`runtime = "${runtime}"`,
`size = "${input.size}" # ${formatSize(input.size)}`,
- `access = "${input.access}"`,
+ `exposure = "${input.access}"`,
`instances = ${input.instances}`,
`# region is locked to ${WORKERS_REGION} at alpha`,
].join('\n')
@@ -78,10 +75,7 @@ export function buildWorkerSnippets(input: WorkerSnippetInput): WorkerSnippets {
configBlock,
'```',
``,
- `3. Run \`supabase ${CLI_NAME} push ${name}\` to deploy it.`,
- ...(input.access === 'private'
- ? [``, `Note: the CLI can only deploy public workers today.`]
- : []),
+ `3. Run \`supabase ${CLI_NAME} push ${name} --exposure ${input.access}\` to deploy it.`,
].join('\n')
const keyPlaceholder = input.access === 'public' ? '[YOUR ANON KEY]' : '[YOUR SERVICE ROLE KEY]'
diff --git a/apps/studio/components/layouts/ExplorerLayout/ExplorerLayout.tsx b/apps/studio/components/layouts/ExplorerLayout/ExplorerLayout.tsx
index 2011c7b5ff99a..5414ed312a692 100644
--- a/apps/studio/components/layouts/ExplorerLayout/ExplorerLayout.tsx
+++ b/apps/studio/components/layouts/ExplorerLayout/ExplorerLayout.tsx
@@ -27,6 +27,7 @@ import {
useCreateQuery,
} from '@/components/interfaces/Explorer/hooks'
import { useIsTemporarySqlEditorVisit } from '@/hooks/misc/useIsTemporarySqlEditorVisit'
+import { useTrack } from '@/lib/telemetry/track'
import {
editorEntityTypes,
EXPLORER_HOME_TAB,
@@ -102,6 +103,7 @@ export const ExplorerLayout = ({ browserTitle, children, title }: ExplorerLayout
const BackToSqlEditorButton = () => {
const { ref } = useParams()
+ const track = useTrack()
const { setIsTemporary } = useIsTemporarySqlEditorVisit(ref)
if (!ref) return null
@@ -114,7 +116,10 @@ const BackToSqlEditorButton = () => {
setIsTemporary(true)}
+ onClick={() => {
+ setIsTemporary(true)
+ track('explorer_temp_access_sql_editor_clicked')
+ }}
/>
)
diff --git a/apps/studio/components/layouts/editors/EditorBaseLayout.tsx b/apps/studio/components/layouts/editors/EditorBaseLayout.tsx
index 78e3bfc29960c..7f61a8a5b9fe6 100644
--- a/apps/studio/components/layouts/editors/EditorBaseLayout.tsx
+++ b/apps/studio/components/layouts/editors/EditorBaseLayout.tsx
@@ -9,6 +9,7 @@ import { CollapseButton } from '../Tabs/CollapseButton'
import { EditorTabs } from '../Tabs/Tabs'
import { useEditorType } from './EditorsLayout.hooks'
import { useIsTemporarySqlEditorVisit } from '@/hooks/misc/useIsTemporarySqlEditorVisit'
+import { useTrack } from '@/lib/telemetry/track'
import { useTabsStateSnapshot } from '@/state/tabs'
export interface ExplorerLayoutProps extends ComponentProps {
@@ -86,6 +87,7 @@ export const EditorBaseLayout = ({
const BackToExplorerButton = () => {
const { ref } = useParams()
const router = useRouter()
+ const track = useTrack()
const { isTemporary, setIsTemporary } = useIsTemporarySqlEditorVisit(ref)
if (!ref || !isTemporary) return null
@@ -95,6 +97,7 @@ const BackToExplorerButton = () => {
tooltip="Back to Explorer"
onClick={() => {
setIsTemporary(false)
+ track('sql_editor_back_explorer_clicked')
router.push(`/project/${ref}/explorer`)
}}
/>
diff --git a/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.utils.test.ts b/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.utils.test.ts
index fe13a029f6fdd..4562af634fc49 100644
--- a/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.utils.test.ts
+++ b/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.utils.test.ts
@@ -48,6 +48,38 @@ const agentDatabaseCell = (database_identifier?: string): AgentCell => ({
database_identifier,
})
+const chartDatabaseCell = (id: string, ySeries: string[]): CellWire => ({
+ _tag: 'database_cell',
+ _id: id,
+ sql: 'select 1',
+ row_limit: 100,
+ view: 'chart',
+ chart: {
+ type: 'bar',
+ x_column: 'day',
+ y_series: ySeries,
+ cumulative: false,
+ scale: 'linear',
+ show_labels: false,
+ },
+})
+
+const agentChartDatabaseCell = (ySeries: string[], database_identifier?: string): AgentCell => ({
+ _tag: 'database_cell',
+ sql: 'select 1',
+ row_limit: 100,
+ database_identifier,
+ view: 'chart',
+ chart: {
+ type: 'bar',
+ x_column: 'day',
+ y_series: ySeries,
+ cumulative: false,
+ scale: 'linear',
+ show_labels: false,
+ },
+})
+
const successfulDatabaseContext = (
databases: Array<{ identifier: string; region: string }> = []
): NotebookDatabaseContext => ({
@@ -217,10 +249,11 @@ describe('getCellMetadata', () => {
})
})
- it('labels an implicit primary database', () => {
+ it('labels an implicit primary database and table view', () => {
expect(getCellMetadata(wireDatabaseCell('cell-1'), successfulDatabaseContext())).toEqual({
status: 'ready',
- text: 'Database: Primary',
+ source: 'Database: Primary',
+ view: 'Table',
})
})
@@ -232,7 +265,8 @@ describe('getCellMetadata', () => {
)
).toEqual({
status: 'ready',
- text: 'Database: Replica',
+ source: 'Database: Replica',
+ view: 'Table',
})
})
@@ -251,13 +285,24 @@ describe('getCellMetadata', () => {
wireDatabaseCell('cell-1', 'Signups', 'missing-database'),
successfulDatabaseContext()
)
- ).toEqual({ status: 'ready', text: 'Database: Unknown' })
+ ).toEqual({ status: 'ready', source: 'Database: Unknown', view: 'Table' })
})
it('labels a log cell with its formatted time range', () => {
expect(getCellMetadata(wireLogCell('cell-1'), successfulDatabaseContext())).toEqual({
status: 'ready',
- text: 'Time range: Last 7 days',
+ source: 'Time range: Last 7 days',
+ view: 'Table',
+ })
+ })
+
+ it('reports the chart summary as the view field for a chart-view database cell', () => {
+ expect(
+ getCellMetadata(chartDatabaseCell('cell-1', ['signups']), successfulDatabaseContext())
+ ).toEqual({
+ status: 'ready',
+ source: 'Database: Primary',
+ view: 'Chart (bar, x: day, y: signups)',
})
})
})
@@ -309,7 +354,7 @@ describe('getEntryMetadata', () => {
).toEqual({ status: 'loading' })
})
- it('returns a single line when replacement metadata is unchanged', () => {
+ it('hides metadata entirely when a replacement changes neither the database nor the view', () => {
expect(
getEntryMetadata(
{
@@ -320,7 +365,58 @@ describe('getEntryMetadata', () => {
},
successfulDatabaseContext()
)
- ).toEqual({ status: 'ready', text: 'Database: Primary' })
+ ).toEqual({ status: 'hidden' })
+ })
+
+ it('surfaces only the view change from table to chart, omitting the unchanged database', () => {
+ expect(
+ getEntryMetadata(
+ {
+ _tag: 'replaced',
+ before: wireDatabaseCell('cell-1'),
+ after: agentChartDatabaseCell(['signups']),
+ operationIndex: 0,
+ },
+ successfulDatabaseContext()
+ )
+ ).toEqual({
+ status: 'ready',
+ text: 'Table → Chart (bar, x: day, y: signups)',
+ })
+ })
+
+ it('surfaces only a chart parameter change, omitting the unchanged database', () => {
+ expect(
+ getEntryMetadata(
+ {
+ _tag: 'replaced',
+ before: chartDatabaseCell('cell-1', ['signups']),
+ after: agentChartDatabaseCell(['active_users']),
+ operationIndex: 0,
+ },
+ successfulDatabaseContext()
+ )
+ ).toEqual({
+ status: 'ready',
+ text: 'Chart (bar, x: day, y: signups) → Chart (bar, x: day, y: active_users)',
+ })
+ })
+
+ it('surfaces only a database change, omitting the unchanged view', () => {
+ expect(
+ getEntryMetadata(
+ {
+ _tag: 'replaced',
+ before: chartDatabaseCell('cell-1', ['signups']),
+ after: agentChartDatabaseCell(['signups'], 'replica-3'),
+ operationIndex: 0,
+ },
+ successfulDatabaseContext([{ identifier: 'replica-3', region: 'us-east-1' }])
+ )
+ ).toEqual({
+ status: 'ready',
+ text: 'Database: Primary → Database: Replica',
+ })
})
})
diff --git a/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.utils.ts b/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.utils.ts
index a8717269f6a5d..bedbe9ae1e89c 100644
--- a/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.utils.ts
+++ b/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.utils.ts
@@ -5,7 +5,7 @@ import type {
NotebookCellDiffEntry,
OperationResultCell,
} from '@/data/content/notebooks/notebook-operations'
-import type { TimeRange } from '@/data/content/notebooks/notebook-schema'
+import type { ChartConfig, TimeRange } from '@/data/content/notebooks/notebook-schema'
import type { Database } from '@/data/read-replicas/replicas-query'
type DatabaseDetails = Pick
@@ -23,6 +23,11 @@ export type NotebookDatabaseTarget =
| { status: 'unknown' }
| { status: 'error' }
+export type NotebookCellFields =
+ | { status: 'hidden' }
+ | { status: 'loading' }
+ | { status: 'ready'; source?: string; view?: string }
+
export type NotebookCellMetadata =
| { status: 'hidden' }
| { status: 'loading' }
@@ -139,49 +144,83 @@ function formatDatabaseTarget(target: Exclude
+): string {
+ if ((cell.view ?? 'table') !== 'chart') return 'Table'
+ return `Chart (${cell.chart !== undefined ? formatChartConfig(cell.chart) : 'unconfigured'})`
+}
+
export function getCellMetadata(
cell: OperationResultCell,
databaseContext: NotebookDatabaseContext
-): NotebookCellMetadata {
+): NotebookCellFields {
switch (cell._tag) {
case 'markdown_cell':
return { status: 'hidden' }
case 'database_cell': {
const target = resolveNotebookDatabaseTarget(cell.database_identifier, databaseContext)
- return target.status === 'loading'
- ? { status: 'loading' }
- : { status: 'ready', text: formatDatabaseTarget(target) }
+ if (target.status === 'loading') return { status: 'loading' }
+
+ return { status: 'ready', source: formatDatabaseTarget(target), view: formatCellView(cell) }
}
case 'log_cell':
- return { status: 'ready', text: `Time range: ${formatTimeRange(cell.time_range)}` }
+ return {
+ status: 'ready',
+ source: `Time range: ${formatTimeRange(cell.time_range)}`,
+ view: formatCellView(cell),
+ }
}
}
-/** Header metadata for a diff row, including a before → after pair on replacements. */
+/** Joins a cell's fields into display text, dropping the view when it's just the default table. */
+function formatCellFieldsText(fields: { source?: string; view?: string }): string {
+ return [fields.source, fields.view === 'Table' ? undefined : fields.view]
+ .filter((part): part is string => part !== undefined)
+ .join(' · ')
+}
+
+/** A field that's identical before and after carries no information about what changed, so it's dropped. */
+function diffField(before: string | undefined, after: string | undefined): string | undefined {
+ if (before === after) return undefined
+ return `${before ?? 'Not configured'} → ${after ?? 'Not configured'}`
+}
+
+/** Header metadata for a diff row. On a replacement, only the fields that actually changed are shown. */
export function getEntryMetadata(
entry: NotebookCellDiffEntry,
databaseContext: NotebookDatabaseContext
): NotebookCellMetadata {
if (entry._tag !== 'replaced') {
- return getCellMetadata(entry.cell, databaseContext)
- }
+ const fields = getCellMetadata(entry.cell, databaseContext)
+ if (fields.status !== 'ready') return fields
- const beforeMetadata = getCellMetadata(entry.before, databaseContext)
- const afterMetadata = getCellMetadata(entry.after, databaseContext)
- if (beforeMetadata.status === 'loading' || afterMetadata.status === 'loading') {
- return { status: 'loading' }
+ const text = formatCellFieldsText(fields)
+ return text === '' ? { status: 'hidden' } : { status: 'ready', text }
}
- const beforeText = beforeMetadata.status === 'ready' ? beforeMetadata.text : null
- const afterText = afterMetadata.status === 'ready' ? afterMetadata.text : null
- if (beforeText === null && afterText === null) return { status: 'hidden' }
- if (beforeText === afterText) return { status: 'ready', text: afterText ?? 'No metadata' }
+ const before = getCellMetadata(entry.before, databaseContext)
+ const after = getCellMetadata(entry.after, databaseContext)
+ if (before.status === 'loading' || after.status === 'loading') return { status: 'loading' }
- return {
- status: 'ready',
- text: `${beforeText ?? 'No metadata'} → ${afterText ?? 'No metadata'}`,
- }
+ const beforeSource = before.status === 'ready' ? before.source : undefined
+ const afterSource = after.status === 'ready' ? after.source : undefined
+ const beforeView = before.status === 'ready' ? before.view : undefined
+ const afterView = after.status === 'ready' ? after.view : undefined
+
+ const parts = [diffField(beforeSource, afterSource), diffField(beforeView, afterView)].filter(
+ (part): part is string => part !== undefined
+ )
+
+ return parts.length > 0 ? { status: 'ready', text: parts.join(' · ') } : { status: 'hidden' }
}
export type NotebookDiffSummary =
diff --git a/apps/studio/data/reports/database-charts.test.ts b/apps/studio/data/reports/database-charts.test.ts
index 1fdf6e2c06b5b..8d41b0365877d 100644
--- a/apps/studio/data/reports/database-charts.test.ts
+++ b/apps/studio/data/reports/database-charts.test.ts
@@ -77,6 +77,29 @@ describe('getReportAttributesV2 dedicated pooler chart', () => {
})
})
+const getSupavisorChart = (project: Project) =>
+ getReportAttributesV2(ENTITLED_FEATURES, project).find(
+ (chart) => chart.id === 'supavisor-connections-active'
+ )
+
+describe('getReportAttributesV2 shared pooler chart', () => {
+ it('shows the chart for standard projects', () => {
+ expect(getSupavisorChart(buildProject())?.hide).toBe(false)
+ })
+
+ it('hides the chart for high availability projects', () => {
+ expect(getSupavisorChart(buildProject({ high_availability: true }))?.hide).toBe(true)
+ })
+
+ it('hides the chart when the database entitlement is missing', () => {
+ expect(
+ getReportAttributesV2([], buildProject()).find(
+ (chart) => chart.id === 'supavisor-connections-active'
+ )?.hide
+ ).toBe(true)
+ })
+})
+
describe('getReportAttributesV2 disk-io-burst-balance chart', () => {
it('shows the chart for burstable non high availability projects', () => {
expect(getBurstBalanceChart(buildProject())?.hide).toBe(false)
diff --git a/apps/studio/data/reports/database-charts.ts b/apps/studio/data/reports/database-charts.ts
index 0615d1f7052cd..65e300266e016 100644
--- a/apps/studio/data/reports/database-charts.ts
+++ b/apps/studio/data/reports/database-charts.ts
@@ -541,7 +541,8 @@ export const getReportAttributesV2: (
valuePrecision: 0,
entitlement: 'database',
requiredPlan: 'Pro',
- hide: !entitledFeatures.includes('database'),
+ // High Availability projects don't run Supavisor, so there's no data to show.
+ hide: !entitledFeatures.includes('database') || isHighAvailability,
showTooltip: true,
showLegend: false,
showMaxValue: false,
diff --git a/apps/studio/data/workers/keys.ts b/apps/studio/data/workers/keys.ts
index 9eedc104d21fd..cdbce56445254 100644
--- a/apps/studio/data/workers/keys.ts
+++ b/apps/studio/data/workers/keys.ts
@@ -2,6 +2,14 @@ export const workersKeys = {
list: (projectRef: string | undefined) => ['projects', projectRef, 'workers'] as const,
detail: (projectRef: string | undefined, name: string | undefined) =>
['projects', projectRef, 'worker', name, 'detail'] as const,
- logs: (projectRef: string | undefined, name: string | undefined, stream: string) =>
- ['projects', projectRef, 'worker', name, 'logs', stream] as const,
+ logs: (
+ projectRef: string | undefined,
+ name: string | undefined,
+ stream: string,
+ filters: {
+ iso_timestamp_start?: string
+ iso_timestamp_end?: string
+ message?: string
+ }
+ ) => ['projects', projectRef, 'worker', name, 'logs', stream, filters] as const,
}
diff --git a/apps/studio/data/workers/worker-logs-query.test.ts b/apps/studio/data/workers/worker-logs-query.test.ts
index 2c909f8459d26..baf6d79a80ddb 100644
--- a/apps/studio/data/workers/worker-logs-query.test.ts
+++ b/apps/studio/data/workers/worker-logs-query.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
+import { workersKeys } from './keys'
import { parseWorkerLogRows, workerLogsSql } from './worker-logs-query'
describe('workerLogsSql', () => {
@@ -9,6 +10,12 @@ describe('workerLogsSql', () => {
)
})
+ it('filters by event message before applying the limit', () => {
+ expect(workerLogsSql('embed', 'requests', { message: 'timeout' })).toBe(
+ "select id, timestamp, severity_text as severity, event_message as message from logs where log_attributes['worker'] = 'embed' and log_attributes['source'] = 'worker_ingress_logs' and event_message ilike '%timeout%' order by timestamp desc limit 100"
+ )
+ })
+
it('names the right stream for each tab', () => {
expect(workerLogsSql('embed', 'requests')).toContain("'worker_ingress_logs'")
expect(workerLogsSql('embed', 'builds')).toContain("'worker_api_logs'")
@@ -19,6 +26,36 @@ describe('workerLogsSql', () => {
"log_attributes['worker'] = 'embed'' or ''1''=''1'"
)
})
+
+ it('escapes filter values rather than interpolating them raw', () => {
+ expect(workerLogsSql('embed', 'requests', { message: "can't connect" })).toContain(
+ "event_message ilike '%can''t connect%'"
+ )
+ })
+})
+
+describe('workersKeys.logs', () => {
+ it('includes the selected time range and filters', () => {
+ expect(
+ workersKeys.logs('project-ref', 'embed', 'requests', {
+ iso_timestamp_start: '2026-09-01T12:00:00.000Z',
+ iso_timestamp_end: '2026-09-02T12:00:00.000Z',
+ message: 'timeout',
+ })
+ ).toEqual([
+ 'projects',
+ 'project-ref',
+ 'worker',
+ 'embed',
+ 'logs',
+ 'requests',
+ {
+ iso_timestamp_start: '2026-09-01T12:00:00.000Z',
+ iso_timestamp_end: '2026-09-02T12:00:00.000Z',
+ message: 'timeout',
+ },
+ ])
+ })
})
describe('parseWorkerLogRows', () => {
diff --git a/apps/studio/data/workers/worker-logs-query.ts b/apps/studio/data/workers/worker-logs-query.ts
index eb68cd8968ca9..7fb4cc10ca82c 100644
--- a/apps/studio/data/workers/worker-logs-query.ts
+++ b/apps/studio/data/workers/worker-logs-query.ts
@@ -1,5 +1,4 @@
import { queryOptions } from '@tanstack/react-query'
-import dayjs from 'dayjs'
import { z } from 'zod'
import { workersKeys } from './keys'
@@ -29,7 +28,6 @@ export const WORKER_LOG_STREAM_LABEL: Record = {
const WORKER_NAME_KEY = 'worker'
const STREAM_KEY = 'source'
-const LOOKBACK_HOURS = 24
const LOG_LIMIT = 100
const workerLogRowSchema = z.object({
@@ -43,10 +41,22 @@ export type WorkerLogsVariables = {
projectRef?: string
name?: string
stream: WorkerLogStream
+ iso_timestamp_start: string
+ iso_timestamp_end: string
+ message?: string
}
-export const workerLogsSql = (name: string, stream: WorkerLogStream) =>
- safeSql`select id, timestamp, severity_text as severity, event_message as message from logs where log_attributes[${analyticsLiteral(WORKER_NAME_KEY)}] = ${analyticsLiteral(name)} and log_attributes[${analyticsLiteral(STREAM_KEY)}] = ${analyticsLiteral(WORKER_LOG_SOURCES[stream])} order by timestamp desc limit ${analyticsLiteral(LOG_LIMIT)}`
+export const workerLogsSql = (
+ name: string,
+ stream: WorkerLogStream,
+ { message }: Pick = {}
+) => {
+ const messageFilter = message
+ ? safeSql` and event_message ilike ${analyticsLiteral(`%${message}%`)}`
+ : safeSql``
+
+ return safeSql`select id, timestamp, severity_text as severity, event_message as message from logs where log_attributes[${analyticsLiteral(WORKER_NAME_KEY)}] = ${analyticsLiteral(name)} and log_attributes[${analyticsLiteral(STREAM_KEY)}] = ${analyticsLiteral(WORKER_LOG_SOURCES[stream])}${messageFilter} order by timestamp desc limit ${analyticsLiteral(LOG_LIMIT)}`
+}
export const parseWorkerLogRows = (result: unknown): LogData[] =>
z
@@ -60,30 +70,46 @@ export const parseWorkerLogRows = (result: unknown): LogData[] =>
}))
async function getWorkerLogs(
- { projectRef, name, stream }: WorkerLogsVariables,
+ {
+ projectRef,
+ name,
+ stream,
+ iso_timestamp_start,
+ iso_timestamp_end,
+ message,
+ }: WorkerLogsVariables,
signal?: AbortSignal
): Promise {
if (!projectRef) throw new Error('projectRef is required')
if (!name) throw new Error('name is required')
- const end = dayjs()
- const start = end.subtract(LOOKBACK_HOURS, 'hour')
-
const data = await executeAnalyticsSql({
projectRef,
endpoint: logsAllEndpointUrl(true),
- sql: workerLogsSql(name, stream),
- iso_timestamp_start: start.toISOString(),
- iso_timestamp_end: end.toISOString(),
+ sql: workerLogsSql(name, stream, { message }),
+ iso_timestamp_start,
+ iso_timestamp_end,
signal,
})
return parseWorkerLogRows(data?.result)
}
-export const workerLogsQueryOptions = ({ projectRef, name, stream }: WorkerLogsVariables) =>
- queryOptions({
- queryKey: workersKeys.logs(projectRef, name, stream),
- queryFn: ({ signal }) => getWorkerLogs({ projectRef, name, stream }, signal),
+export const workerLogsQueryOptions = (variables: WorkerLogsVariables) => {
+ const { projectRef, name, stream, iso_timestamp_start, iso_timestamp_end } = variables
+ const message = variables.message?.trim() || undefined
+
+ return queryOptions({
+ queryKey: workersKeys.logs(projectRef, name, stream, {
+ iso_timestamp_start,
+ iso_timestamp_end,
+ message,
+ }),
+ queryFn: ({ signal }) =>
+ getWorkerLogs(
+ { projectRef, name, stream, iso_timestamp_start, iso_timestamp_end, message },
+ signal
+ ),
enabled: IS_PLATFORM && typeof projectRef !== 'undefined' && typeof name !== 'undefined',
})
+}
diff --git a/apps/studio/data/workers/workers-query.ts b/apps/studio/data/workers/workers-query.ts
index 5aaed1734df58..a74453ffa6335 100644
--- a/apps/studio/data/workers/workers-query.ts
+++ b/apps/studio/data/workers/workers-query.ts
@@ -28,4 +28,5 @@ export const workersQueryOptions = ({ projectRef }: WorkersVariables) =>
queryKey: workersKeys.list(projectRef),
queryFn: ({ signal }) => getWorkers({ projectRef }, signal),
enabled: IS_PLATFORM && typeof projectRef !== 'undefined',
+ refetchOnWindowFocus: 'always',
})
diff --git a/apps/studio/lib/telemetry/funnel-errors.test.ts b/apps/studio/lib/telemetry/funnel-errors.test.ts
index 6abe0049b6d73..161a1c79df08e 100644
--- a/apps/studio/lib/telemetry/funnel-errors.test.ts
+++ b/apps/studio/lib/telemetry/funnel-errors.test.ts
@@ -58,6 +58,84 @@ describe('classifyApiError', () => {
errorCode: 400,
})
})
+
+ describe('signin', () => {
+ it('reads a GoTrue AuthError status as the code', () => {
+ expect(
+ classifyApiError('signin', { status: 400, message: 'Invalid login credentials' })
+ ).toEqual({
+ errorCategory: 'api',
+ errorReason: 'invalid_credentials',
+ errorCode: 400,
+ })
+ })
+
+ it('classifies an unconfirmed email', () => {
+ expect(classifyApiError('signin', { status: 400, message: 'Email not confirmed' })).toEqual({
+ errorCategory: 'api',
+ errorReason: 'email_not_confirmed',
+ errorCode: 400,
+ })
+ })
+
+ it('classifies 429 via status as rate_limited', () => {
+ expect(classifyApiError('signin', { status: 429, message: 'Rate limit exceeded' })).toEqual({
+ errorCategory: 'api',
+ errorReason: 'rate_limited',
+ errorCode: 429,
+ })
+ })
+
+ it('classifies a captcha failure', () => {
+ expect(
+ classifyApiError('signin', { status: 400, message: 'captcha verification process failed' })
+ ).toEqual({ errorCategory: 'api', errorReason: 'captcha_failed', errorCode: 400 })
+ })
+
+ it('matches the SSO pattern before the 404 status map', () => {
+ expect(
+ classifyApiError('signin', {
+ status: 404,
+ message: 'No SSO provider assigned for this domain',
+ })
+ ).toEqual({ errorCategory: 'api', errorReason: 'sso_provider_not_found', errorCode: 404 })
+ })
+
+ it('classifies a redirect allow-list rejection', () => {
+ expect(classifyApiError('signin', { status: 400, message: 'Invalid redirect URL' })).toEqual({
+ errorCategory: 'api',
+ errorReason: 'redirect_not_allowed',
+ errorCode: 400,
+ })
+ })
+
+ it('classifies a disabled provider', () => {
+ expect(
+ classifyApiError('signin', {
+ status: 400,
+ message: 'Unsupported provider: provider is not enabled',
+ })
+ ).toEqual({ errorCategory: 'api', errorReason: 'provider_not_enabled', errorCode: 400 })
+ })
+
+ it('classifies a GoTrue transport failure (status 0) as network_error, never api/other', () => {
+ expect(
+ classifyApiError('signin', {
+ name: 'AuthRetryableFetchError',
+ status: 0,
+ message: 'Failed to fetch',
+ })
+ ).toEqual({ errorCategory: 'network', errorReason: 'network_error' })
+ })
+
+ it('classifies a retryable 5xx via status as server_error', () => {
+ expect(classifyApiError('signin', { status: 503, message: 'Service unavailable' })).toEqual({
+ errorCategory: 'api',
+ errorReason: 'server_error',
+ errorCode: 503,
+ })
+ })
+ })
})
describe('classifyValidationError', () => {
@@ -79,6 +157,15 @@ describe('classifyValidationError', () => {
).toEqual({ errorCategory: 'validation', errorReason: 'email_invalid' })
})
+ it('maps a signin password error to password_invalid', () => {
+ expect(
+ classifyValidationError('signin', { password: { type: 'too_small' } } as FieldErrors)
+ ).toEqual({
+ errorCategory: 'validation',
+ errorReason: 'password_invalid',
+ })
+ })
+
it('maps an org name error to org_name_missing', () => {
expect(
classifyValidationError('org_creation', { name: { type: 'too_small' } } as FieldErrors)
diff --git a/apps/studio/lib/telemetry/funnel-errors.ts b/apps/studio/lib/telemetry/funnel-errors.ts
index 67c92592fed41..d4873ae20398f 100644
--- a/apps/studio/lib/telemetry/funnel-errors.ts
+++ b/apps/studio/lib/telemetry/funnel-errors.ts
@@ -1,6 +1,6 @@
import type { FieldErrors } from 'react-hook-form'
-export type FunnelOrigin = 'signup' | 'project_creation' | 'org_creation'
+export type FunnelOrigin = 'signup' | 'signin' | 'project_creation' | 'org_creation'
export type ErrorCategory = 'validation' | 'api' | 'network' | 'payment' | 'unknown'
export interface FunnelErrorClassification {
@@ -19,6 +19,16 @@ const API_REASON_PATTERNS = {
[/password/i, 'password_rejected'],
[/valid email|invalid email|email address/i, 'email_invalid'],
],
+ signin: [
+ [/invalid login credentials/i, 'invalid_credentials'],
+ [/email not confirmed/i, 'email_not_confirmed'],
+ [/rate limit|too many requests|after \d+ second/i, 'rate_limited'],
+ [/captcha/i, 'captcha_failed'],
+ [/sso provider/i, 'sso_provider_not_found'],
+ [/redirect|requested path is invalid/i, 'redirect_not_allowed'],
+ [/provider is not enabled|unsupported provider/i, 'provider_not_enabled'],
+ [/valid email|invalid email|email address/i, 'email_invalid'],
+ ],
project_creation: [
[/already exists/i, 'project_name_taken'],
[/free plan|free tier/i, 'free_tier_limit'],
@@ -39,6 +49,10 @@ const VALIDATION_FIELD_REASONS = {
email: 'email_invalid',
password: 'password_invalid',
},
+ signin: {
+ email: 'email_invalid',
+ password: 'password_invalid',
+ },
project_creation: {
organization: 'organization_missing',
projectName: 'project_name_invalid',
@@ -67,6 +81,7 @@ const STRIPE_DECLINE_REASONS = {
} as const satisfies Record
const GENERIC_REASONS = [
+ 'captcha_challenge_failed',
'rate_limited',
'server_error',
'connection_timeout',
@@ -95,8 +110,16 @@ const STATUS_REASONS: Readonly>> = {
}
export function classifyApiError(origin: FunnelOrigin, error: unknown): FunnelErrorClassification {
- const err = error as { code?: unknown; errorType?: unknown; message?: unknown }
- const code = typeof err?.code === 'number' ? err.code : undefined
+ const err = error as { code?: unknown; status?: unknown; errorType?: unknown; message?: unknown }
+ // GoTrue AuthErrors carry a numeric `status` and a string `code` slug; auth-js uses
+ // status 0 for transport failures (AuthRetryableFetchError), which must classify as
+ // network_error, so the fallback only accepts positive statuses.
+ const code =
+ typeof err?.code === 'number'
+ ? err.code
+ : typeof err?.status === 'number' && err.status > 0
+ ? err.status
+ : undefined
const message = typeof err?.message === 'string' ? err.message : ''
if (err?.errorType === 'connection-timeout') {
diff --git a/apps/studio/lib/toast-errors.test.tsx b/apps/studio/lib/toast-errors.test.tsx
index 07e0971d3adaf..60f0141b9d1e9 100644
--- a/apps/studio/lib/toast-errors.test.tsx
+++ b/apps/studio/lib/toast-errors.test.tsx
@@ -76,6 +76,33 @@ describe('ToastErrorTracker', () => {
expect(mockTrack).toHaveBeenCalledTimes(1)
})
+ it('tracks a loading toast updated to an error exactly once (sign-in reuses the loading toast id)', async () => {
+ render()
+ let toastId: string | number
+ act(() => {
+ toastId = toast.loading('Signing in...')
+ })
+ act(() => {
+ toast.error('Invalid login credentials', { id: toastId })
+ registerFunnelErrorToast(toastId, {
+ origin: 'signin',
+ errorCategory: 'api',
+ errorReason: 'invalid_credentials',
+ errorCode: 400,
+ })
+ })
+ await waitFor(() =>
+ expect(mockTrack).toHaveBeenCalledWith('dashboard_error_created', {
+ source: 'toast',
+ origin: 'signin',
+ errorCategory: 'api',
+ errorReason: 'invalid_credentials',
+ errorCode: 400,
+ })
+ )
+ expect(mockTrack).toHaveBeenCalledTimes(1)
+ })
+
it('ignores non-error toasts', async () => {
render()
act(() => {
diff --git a/apps/studio/pages/project/[ref]/workers/index.tsx b/apps/studio/pages/project/[ref]/workers/index.tsx
index 73debf88890ce..327dfd8e7320f 100644
--- a/apps/studio/pages/project/[ref]/workers/index.tsx
+++ b/apps/studio/pages/project/[ref]/workers/index.tsx
@@ -1,6 +1,8 @@
import { useQuery } from '@tanstack/react-query'
import { useParams } from 'common'
+import { RefreshCw } from 'lucide-react'
import { useState } from 'react'
+import { Button } from 'ui'
import { Admonition } from 'ui-patterns/Admonition'
import { PageContainer } from 'ui-patterns/PageContainer'
import {
@@ -38,6 +40,8 @@ const WorkersPage: NextPageWithLayout = () => {
isPending,
isError,
isSuccess,
+ isFetching,
+ refetch,
} = useQuery(workersQueryOptions({ projectRef: ref }))
const isNotEnrolled = isError && isWorkersUnavailable(error)
@@ -74,7 +78,22 @@ const WorkersPage: NextPageWithLayout = () => {
/>
)}
{isMissingPermission && }
- {isUnexpectedError && }
+ {isUnexpectedError && (
+ }
+ loading={isFetching}
+ onClick={() => refetch()}
+ >
+ Refresh
+
+ }
+ />
+ )}
{isSuccess && workers.length === 0 && (
setIsDeployInstructionsOpen(true)} />
)}
@@ -83,6 +102,8 @@ const WorkersPage: NextPageWithLayout = () => {
projectRef={ref}
workers={workers}
onDeploy={() => setIsDeployInstructionsOpen(true)}
+ onRefresh={() => refetch()}
+ isRefreshing={isFetching}
/>
)}
diff --git a/apps/studio/state/storage-explorer.tsx b/apps/studio/state/storage-explorer.tsx
index d4fd8a846910e..a2c7b08930896 100644
--- a/apps/studio/state/storage-explorer.tsx
+++ b/apps/studio/state/storage-explorer.tsx
@@ -1899,6 +1899,16 @@ export const StorageExplorerStateContextProvider = ({ children }: PropsWithChild
bucket,
])
+ // [Monica] The effect above only refreshes `selectedBucket` when the project changes, so
+ // editing the current bucket (e.g. toggling public/private) doesn't update it there. This
+ // keeps `selectedBucket` synced to the bucket query on every change, so Get URL always
+ // uses the current public/private state instead of a stale one from initial load.
+ useEffect(() => {
+ if (bucket && state.projectRef === project?.ref) {
+ state.selectedBucket = bucket
+ }
+ }, [bucket, project?.ref, state.projectRef])
+
return (
{children}
diff --git a/apps/studio/tests/pages/project/[ref]/workers/index.test.tsx b/apps/studio/tests/pages/project/[ref]/workers/index.test.tsx
index a30ff8ba80bd2..72111ae36b9ea 100644
--- a/apps/studio/tests/pages/project/[ref]/workers/index.test.tsx
+++ b/apps/studio/tests/pages/project/[ref]/workers/index.test.tsx
@@ -1,5 +1,5 @@
import { QueryClient } from '@tanstack/react-query'
-import { screen } from '@testing-library/react'
+import { fireEvent, screen } from '@testing-library/react'
import type { components } from 'api-types'
import { HttpResponse } from 'msw'
import { beforeEach, describe, expect, it } from 'vitest'
@@ -90,6 +90,17 @@ describe('/project/[ref]/workers', () => {
expect(screen.queryByRole('table')).not.toBeInTheDocument()
})
+ it('refreshes the workers list on request', async () => {
+ mockWorkersList([workerDatum('existing')])
+
+ await renderWorkersPage()
+
+ mockWorkersList([workerDatum('embed')])
+ fireEvent.click(screen.getByRole('button', { name: 'Refresh' }))
+
+ expect(await screen.findByRole('link', { name: 'embed' })).toBeVisible()
+ })
+
it('explains that a project outside the alpha is not enrolled', async () => {
mockWorkersListFailure(404)
@@ -109,4 +120,25 @@ describe('/project/[ref]/workers', () => {
).toBeVisible()
expect(screen.queryByRole('table')).not.toBeInTheDocument()
})
+
+ it('allows retrying an unexpected error', async () => {
+ let requestCount = 0
+ addAPIMock({
+ method: 'get',
+ path: '/v2/projects/:ref/workers',
+ response: (): HttpResponse | HttpResponse => {
+ if (requestCount++ === 0) {
+ return HttpResponse.json({ message: 'Unavailable' }, { status: 500 })
+ }
+
+ return HttpResponse.json({ data: [workerDatum('embed')] })
+ },
+ })
+
+ await renderWorkersPage()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Refresh' }))
+
+ expect(await screen.findByRole('link', { name: 'embed' })).toBeVisible()
+ })
})
diff --git a/apps/www/.env.local.example b/apps/www/.env.local.example
index 517bb89bd7b04..0c5e2947a5304 100644
--- a/apps/www/.env.local.example
+++ b/apps/www/.env.local.example
@@ -10,6 +10,7 @@ NEXT_PUBLIC_DOCS_URL=http://localhost:3005
NEXT_PUBLIC_ENVIRONMENT=local
NEXT_PUBLIC_HCAPTCHA_SITE_KEY=10000000-ffff-ffff-ffff-000000000001
NEXT_PUBLIC_IS_PLATFORM=true
+NEXT_PUBLIC_KB_URL=http://localhost:3008/kb
NEXT_PUBLIC_MARKETPLACE_API_URL=https://fgxbxpvumhvzrhqngsyu.supabase.co
NEXT_PUBLIC_MARKETPLACE_PUBLISHABLE_KEY=sb_publishable_VuF5ZvGqj6ODhZgN1J_vMw_YbiEs1R6
NEXT_PUBLIC_MISC_USE_ANON_KEY=sb_publishable_t45SVhgymMJOuamUXzJzPQ_sY-tSoUr
diff --git a/apps/www/lib/rewrites.js b/apps/www/lib/rewrites.js
index c11c0129b135f..6c7aba2f384c9 100644
--- a/apps/www/lib/rewrites.js
+++ b/apps/www/lib/rewrites.js
@@ -39,6 +39,14 @@ const rewrites = [
source: '/design-system/:path*',
destination: `${process.env.NEXT_PUBLIC_DESIGN_SYSTEM_URL}/:path*`,
},
+ {
+ source: '/kb',
+ destination: `${process.env.NEXT_PUBLIC_KB_URL}`,
+ },
+ {
+ source: '/kb/:path*',
+ destination: `${process.env.NEXT_PUBLIC_KB_URL}/:path*`,
+ },
{
source: '/evals',
destination: 'https://supabase-evals.vercel.app',
diff --git a/apps/www/turbo.jsonc b/apps/www/turbo.jsonc
index ea5a9af80f3b1..09078f1751445 100644
--- a/apps/www/turbo.jsonc
+++ b/apps/www/turbo.jsonc
@@ -21,6 +21,7 @@
"NEXT_PUBLIC_DOCS_URL",
"NEXT_PUBLIC_REFERENCE_DOCS_URL",
"NEXT_PUBLIC_LIBRARY_URL",
+ "NEXT_PUBLIC_KB_URL",
// Temporary fallback during the production environment-variable migration.
"NEXT_PUBLIC_UI_LIBRARY_URL",
"NEXT_PUBLIC_SUPABASE_URL",
diff --git a/packages/common/telemetry-constants.ts b/packages/common/telemetry-constants.ts
index 2b7ff3134ac4b..dbe916ee052e7 100644
--- a/packages/common/telemetry-constants.ts
+++ b/packages/common/telemetry-constants.ts
@@ -50,8 +50,7 @@ export interface SignUpEvent {
*
* Some unintuitive behavior:
* - If signing up with GitHub the SignInEvent gets triggered first before the SignUpEvent.
- * - Captured server-side; the distinct_id often resolves to the anonymous cookie because
- * the event races identify, so don't use it as a funnel join key across the auth boundary.
+ * - distinct_id often resolves to the anonymous cookie (races identify); not a person-level join key.
*
* @group Events
* @source studio
@@ -68,6 +67,26 @@ export interface SignInEvent {
}
}
+/**
+ * Triggered when a user initiates a sign-in (form submit including client-side validation
+ * failures, OAuth or custom-provider click, partner token exchange), before auth resolves.
+ * Pre-auth, so distinct_id is the anonymous cookie: not a person-level join key.
+ *
+ * @group Events
+ * @source studio
+ * @page /sign-in, /sign-in-sso, /sign-in-partner
+ */
+export interface SignInSubmittedEvent {
+ action: 'sign_in_submitted'
+ properties: {
+ category: 'account'
+ /**
+ * Matches the sign_in event's method vocabulary, e.g. email (password path), github, sso
+ */
+ method: string
+ }
+}
+
/**
* User copied the database connection string.
*
@@ -1568,6 +1587,32 @@ export interface ExplorerBannerCtaButtonClickedEvent {
groups: TelemetryGroups
}
+/**
+ * User clicked the button in the Explorer sidebar title bar to temporarily switch to the SQL
+ * Editor for snippet access.
+ *
+ * @group Events
+ * @source studio
+ * @page /project/{ref}/explorer
+ */
+export interface ExplorerTempAccessSqlEditorClickedEvent {
+ action: 'explorer_temp_access_sql_editor_clicked'
+ groups: TelemetryGroups
+}
+
+/**
+ * User clicked the "Back to Explorer" button in the SQL Editor title bar, shown only when the
+ * visit originated from the Explorer's temporary switch button.
+ *
+ * @group Events
+ * @source studio
+ * @page /project/{ref}/sql
+ */
+export interface SqlEditorBackExplorerClickedEvent {
+ action: 'sql_editor_back_explorer_clicked'
+ groups: TelemetryGroups
+}
+
/**
* User clicked a metric card PID in the Overview panel of the Database Connections observability page, selecting it in the activity table below.
*
@@ -3176,7 +3221,7 @@ export interface DashboardErrorCreatedEvent {
/**
* Funnel the error occurred in (set only for instrumented funnel errors)
*/
- origin?: 'signup' | 'project_creation' | 'org_creation'
+ origin?: 'signup' | 'signin' | 'project_creation' | 'org_creation'
/**
* Coarse classification of the funnel error
*/
@@ -3772,6 +3817,7 @@ export interface HeaderLocalVersionPopoverOpenedEvent {
export type TelemetryEvent =
| SignUpEvent
| SignInEvent
+ | SignInSubmittedEvent
| ConnectionStringCopiedEvent
| McpInstallButtonClickedEvent
| ApiDocsOpenedEvent
@@ -3871,6 +3917,8 @@ export type TelemetryEvent =
| ExplorerBannerExposedEvent
| ExplorerBannerDismissButtonClickedEvent
| ExplorerBannerCtaButtonClickedEvent
+ | ExplorerTempAccessSqlEditorClickedEvent
+ | SqlEditorBackExplorerClickedEvent
| SessionTerminateButtonClickedEvent
| SessionTerminateSubmittedEvent
| QueryCancelButtonClickedEvent
diff --git a/packages/common/telemetry.tsx b/packages/common/telemetry.tsx
index 4e8e13aa9f897..f3cce2f62862f 100644
--- a/packages/common/telemetry.tsx
+++ b/packages/common/telemetry.tsx
@@ -428,8 +428,16 @@ export function sendTelemetryEvent(API_URL: string, event: TelemetryEvent, pathn
}
}
+ // keepalive lets the request survive the same-tick OAuth redirect after
+ // sign_in_submitted, but keepalive requests share a ~64KB in-flight quota
+ // page-wide, so it stays scoped to that event. Callers like useTrack
+ // fire-and-forget, so rejections are handled here rather than surfacing
+ // as unhandled promise rejections.
return post(`${ensurePlatformSuffix(API_URL)}/telemetry/event`, body, {
headers: { Version: '2' },
+ keepalive: event.action === 'sign_in_submitted',
+ }).catch((error) => {
+ console.error('Problem sending telemetry event:', error)
})
}