Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useParams } from 'common'
import { useRouter } from 'next/router'
import { useEffect, useMemo } from 'react'

import { useProjectOAuthIntegrationData } from './Landing.utils'
import { useAvailableIntegrations } from './useAvailableIntegrations'
import { useInstalledIntegrations } from './useInstalledIntegrations'
import {
Expand Down Expand Up @@ -44,6 +45,14 @@ export const useIntegrationDetail = () => {
[installedIntegrations, id]
)

// There does exist a mgmt-api endpoint to get a single status by integration-id, but the bulk results are likely already cached so go ahead and use them.
const { data: projectData, isLoading: isIntegrationStatusLoading } =
useProjectOAuthIntegrationData(project?.ref, { enabled: integration?.type === 'oauth' })
const integrationStatus = useMemo(
() => projectData.partnerIntegrations.find((i) => i.listing_slug === id),
[projectData, id]
)

const isInstalled = !!integration && !!installation

const areRequiredExtensionsInstalled = useMemo(
Expand Down Expand Up @@ -140,12 +149,14 @@ export const useIntegrationDetail = () => {
integrationsWrappers,
integration,
installation,
integrationStatus,
isInstalled,
areRequiredExtensionsInstalled,
installActionType,
wrappersTabHref,
isAvailableLoading,
isInstalledLoading,
isIntegrationStatusLoading,
Component,
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ArrowUpRight, BookOpen } from 'lucide-react'
import { ArrowUpRight, BookOpen, Gauge, Settings } from 'lucide-react'
import { useRouter } from 'next/router'
import { Button, cn } from 'ui'
import { GenericSkeletonLoader, ShimmeringLoader } from 'ui-patterns'
Expand Down Expand Up @@ -29,18 +29,20 @@ export const MarketplaceDetail = () => {
pageTitle,
pageSubTitle,
integration,
integrationStatus,
isInstalled,
installActionType,
wrappersTabHref,
isAvailableLoading,
isInstalledLoading,
isIntegrationStatusLoading,
Component,
} = useIntegrationDetail()

if (!isReady) return null
if (isWrapperBlocked) return <UnknownInterface urlBack={`/project/${ref}/integrations`} />

if (isAvailableLoading || isInstalledLoading) {
if (isAvailableLoading || isInstalledLoading || isIntegrationStatusLoading) {
return (
<>
<MarketplaceDetailBreadrumbs isLoading />
Expand Down Expand Up @@ -106,6 +108,36 @@ export const MarketplaceDetail = () => {
isInstalled={isInstalled}
actions={
<>
{isInstalled && integrationStatus?.partner_links?.dashboard && (
<Button
type="text"
size="tiny"
icon={<Gauge size={13} />}
iconRight={<ArrowUpRight size={13} />}
asChild
>
<a
href={integrationStatus.partner_links.dashboard}
target="_blank"
rel="noreferrer"
>
Dashboard
</a>
</Button>
)}
{isInstalled && integrationStatus?.partner_links?.manage && (
<Button
type="text"
size="tiny"
icon={<Settings size={13} />}
iconRight={<ArrowUpRight size={13} />}
asChild
>
<a href={integrationStatus.partner_links.manage} target="_blank" rel="noreferrer">
Manage
</a>
</Button>
)}
{integration.docsUrl && (
<Button
type="text"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Column } from 'react-data-grid'
import { TimestampInfo } from 'ui-patterns/TimestampInfo'

import type { LogData } from '../Logs.types'
import { getAuthLogSeverity } from '../Logs.utils'
import { RowLayout, SeverityFormatter, TextFormatter } from '../LogsFormatters'
import { defaultRenderCell } from './DefaultPreviewColumnRenderer'
import { parseAuthLogEventMessage } from '@/components/interfaces/UnifiedLogs/UnifiedLogs.utils'
Expand All @@ -19,7 +20,9 @@ const columns: Column<LogData>[] = [
return (
<RowLayout>
<TimestampInfo utcTimestamp={props.row.timestamp!} />
{props.row.level && <SeverityFormatter value={props.row.level as string} />}
{props.row.level && (
<SeverityFormatter value={getAuthLogSeverity(props.row.level, props.row.status)} />
)}
<TextFormatter
className="w-full"
value={`${props.row.path ? props.row.path + ' | ' : ''}${
Expand Down
16 changes: 13 additions & 3 deletions apps/studio/components/interfaces/Settings/Logs/Logs.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,16 @@ const _SQL_FILTER_COMMON: Record<string, SqlFilterEntry> = {
safeSql`regexp_contains(event_message, ${analyticsLiteral(value)})`,
}

// Auth logs always emit `level: info` even for failed requests, so HTTP status
// is the reliable severity signal. Shared by the severity filter, the chart,
// and the table badge so all three agree: 5xx (or level error/fatal) = error,
// 4xx (or level warning) = warning, everything else = info. IFNULL keeps each
// condition strictly boolean (never NULL) so the info condition can safely
// negate the other two when a row has no status or no level.
export const AUTH_LOG_ERROR_CONDITION: SafeLogSqlFragment = safeSql`IFNULL(metadata.level, '') IN ('error', 'fatal') OR IFNULL(SAFE_CAST(metadata.status AS INT64), 0) >= 500`
export const AUTH_LOG_WARNING_CONDITION: SafeLogSqlFragment = safeSql`IFNULL(metadata.level, '') = 'warning' OR IFNULL(SAFE_CAST(metadata.status AS INT64), 0) BETWEEN 400 AND 499`
export const AUTH_LOG_INFO_CONDITION: SafeLogSqlFragment = safeSql`NOT (${AUTH_LOG_ERROR_CONDITION}) AND NOT (${AUTH_LOG_WARNING_CONDITION})`

export const SQL_FILTER_TEMPLATES: Record<string, Record<string, SqlFilterEntry>> = {
postgres_logs: {
..._SQL_FILTER_COMMON,
Expand Down Expand Up @@ -338,9 +348,9 @@ export const SQL_FILTER_TEMPLATES: Record<string, Record<string, SqlFilterEntry>
},
auth_logs: {
..._SQL_FILTER_COMMON,
'severity.error': safeSql`metadata.level = 'error' or metadata.level = 'fatal'`,
'severity.warning': safeSql`metadata.level = 'warning'`,
'severity.info': safeSql`metadata.level = 'info'`,
'severity.error': AUTH_LOG_ERROR_CONDITION,
'severity.warning': AUTH_LOG_WARNING_CONDITION,
'severity.info': AUTH_LOG_INFO_CONDITION,
'status_code.server_error': safeSql`cast(metadata.status as int64) between 500 and 599`,
'status_code.client_error': safeSql`cast(metadata.status as int64) between 400 and 499`,
'status_code.redirection': safeSql`cast(metadata.status as int64) between 300 and 399`,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { describe, expect, test } from 'vitest'

import type { LogData } from './Logs.types'
import { LogsTableName } from './Logs.constants'
import type { Filters, LogData } from './Logs.types'
import {
buildLogsPrompt,
extractEdgeFunctionName,
formatLogsAsCsv,
formatLogsAsJson,
formatLogsAsMarkdown,
genDefaultQuery,
getAuthLogSeverity,
parseMultigresEventMessage,
} from './Logs.utils'

Expand Down Expand Up @@ -219,4 +222,77 @@ describe('Logs.utils', () => {
expect(parseMultigresEventMessage(42)).toBeNull()
})
})

describe('getAuthLogSeverity', () => {
test('reports server errors (5xx) as error', () => {
expect(getAuthLogSeverity('info', 500)).toBe('error')
expect(getAuthLogSeverity('info', 503)).toBe('error')
})

test('reports client errors (4xx) as warning', () => {
expect(getAuthLogSeverity('info', 400)).toBe('warning')
expect(getAuthLogSeverity('info', 401)).toBe('warning')
expect(getAuthLogSeverity('info', 429)).toBe('warning')
expect(getAuthLogSeverity('info', 499)).toBe('warning')
})

test('handles status passed as a string', () => {
expect(getAuthLogSeverity('info', '500')).toBe('error')
expect(getAuthLogSeverity('info', '404')).toBe('warning')
expect(getAuthLogSeverity('info', '200')).toBe('info')
})

test('preserves the original level for non-error statuses', () => {
expect(getAuthLogSeverity('info', 200)).toBe('info')
expect(getAuthLogSeverity('info', 302)).toBe('info')
expect(getAuthLogSeverity('info', 399)).toBe('info')
})

test('falls back to the level when status is missing or invalid', () => {
expect(getAuthLogSeverity('info')).toBe('info')
expect(getAuthLogSeverity('warning', null)).toBe('warning')
expect(getAuthLogSeverity('info', 'not-a-number')).toBe('info')
})

test('keeps explicit error levels even with a non-error status', () => {
expect(getAuthLogSeverity('error')).toBe('error')
expect(getAuthLogSeverity('fatal')).toBe('fatal')
expect(getAuthLogSeverity('error', 200)).toBe('error')
expect(getAuthLogSeverity('fatal', 404)).toBe('fatal')
})

test('returns an empty string when level is missing', () => {
expect(getAuthLogSeverity()).toBe('')
expect(getAuthLogSeverity(null, 200)).toBe('')
})

test('ignores non-string levels and non-numeric statuses', () => {
expect(getAuthLogSeverity({}, {})).toBe('')
expect(getAuthLogSeverity(42, 500)).toBe('error')
expect(getAuthLogSeverity('info', {})).toBe('info')
expect(getAuthLogSeverity('info', [500])).toBe('info')
})
})

describe('auth_logs severity filter query', () => {
const queryFor = (severity: Filters['severity']) =>
genDefaultQuery(LogsTableName.AUTH, { severity } as Filters, 100)

test('error severity filter matches 5xx status as well as the log level', () => {
const sql = queryFor({ error: true })
expect(sql).toContain("IFNULL(metadata.level, '') IN ('error', 'fatal')")
expect(sql).toContain('IFNULL(SAFE_CAST(metadata.status AS INT64), 0) >= 500')
})

test('warning severity filter matches 4xx status as well as the log level', () => {
const sql = queryFor({ warning: true })
expect(sql).toContain("IFNULL(metadata.level, '') = 'warning'")
expect(sql).toContain('IFNULL(SAFE_CAST(metadata.status AS INT64), 0) BETWEEN 400 AND 499')
})

test('info severity filter excludes error and warning rows', () => {
const sql = queryFor({ info: true })
expect(sql).toContain('NOT (')
})
})
})
38 changes: 35 additions & 3 deletions apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@ import uniqBy from 'lodash/uniqBy'
import { useEffect } from 'react'
import logConstants from 'shared-data/log-constants'

import { LogsTableName, SQL_FILTER_TEMPLATES } from './Logs.constants'
import {
AUTH_LOG_ERROR_CONDITION,
AUTH_LOG_WARNING_CONDITION,
LogsTableName,
SQL_FILTER_TEMPLATES,
} from './Logs.constants'
import type { Filters, LogData, LogsEndpointParams, QueryType } from './Logs.types'
import { convertResultsToCSV } from '@/components/interfaces/SQLEditor/UtilityPanel/Results.utils'
import BackwardIterator from '@/components/ui/CodeEditor/Providers/BackwardIterator'
Expand Down Expand Up @@ -713,7 +718,7 @@ function getErrorCondition(table: LogsTableName): SafeLogSqlFragment {
case 'postgres_logs':
return safeSql`parsed.error_severity IN ('ERROR', 'FATAL', 'PANIC')`
case 'auth_logs':
return safeSql`metadata.level = 'error' OR SAFE_CAST(metadata.status AS INT64) >= 400`
return AUTH_LOG_ERROR_CONDITION
case 'function_edge_logs':
return safeSql`response.status_code >= 500`
case 'function_logs':
Expand All @@ -734,7 +739,7 @@ function getWarningCondition(table: LogsTableName): SafeLogSqlFragment {
case 'postgres_logs':
return safeSql`parsed.error_severity IN ('WARNING')`
case 'auth_logs':
return safeSql`metadata.level = 'warning'`
return AUTH_LOG_WARNING_CONDITION
case 'function_edge_logs':
return safeSql`response.status_code >= 400 AND response.status_code < 500`
case 'function_logs':
Expand All @@ -746,6 +751,33 @@ function getWarningCondition(table: LogsTableName): SafeLogSqlFragment {
}
}

export const HTTP_SERVER_ERROR_STATUS = 500
export const HTTP_CLIENT_ERROR_STATUS = 400

/**
* Derives the severity badge shown in the auth log table from the log level and
* HTTP status, matching the chart: 5xx → error, 4xx → warning, otherwise the
* log level. See the AUTH_LOG_*_CONDITION constants in Logs.constants.ts for the
* matching SQL and the reason auth logs need this.
*/
export function getAuthLogSeverity(level?: unknown, status?: unknown): string {
const normalizedLevel = typeof level === 'string' ? level : ''

// Preserve explicit error-class levels so we never downgrade them and so the
// original label (e.g. "fatal") is kept.
if (normalizedLevel === 'error' || normalizedLevel === 'fatal') return normalizedLevel

const statusCode =
typeof status === 'number' ? status : typeof status === 'string' ? Number(status) : NaN
const hasStatus = Number.isFinite(statusCode)

if (hasStatus && statusCode >= HTTP_SERVER_ERROR_STATUS) return 'error'
if (normalizedLevel === 'warning') return 'warning'
if (hasStatus && statusCode >= HTTP_CLIENT_ERROR_STATUS) return 'warning'

return normalizedLevel
}

export function jwtAPIKey(metadata: any) {
const apikeyHeader = metadata?.[0]?.request?.[0]?.sb?.[0]?.jwt?.[0]?.apikey?.[0]
if (!apikeyHeader) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,16 @@ describe('UnifiedLogs.queries (OTEL flat)', () => {
expect(sql).toMatch(/END\) NOT IN \('401'\)/)
})

it('reads the HTTP status from log_attributes[status] for auth rows', () => {
// Auth-service logs expose their status under `status`, not the gateway's
// `response.status_code`, so without this their 4xx/5xx classify as
// success and the severity filter returns nothing.
const sql = getUnifiedLogsQuery(withFilters('log_type:eq:auth'))
expect(sql).toContain(
`if(source = 'auth_logs', log_attributes['status'], log_attributes['response.status_code'])`
)
})

it('flips LIKE to NOT LIKE when the pathname/host operator is `<>`', () => {
const sql = getUnifiedLogsQuery(withFilters('pathname:neq:/health', 'host:neq:cdn.foo'))
expect(sql).toContain(`log_attributes['request.path'] NOT LIKE '%/health%'`)
Expand Down Expand Up @@ -171,6 +181,13 @@ describe('UnifiedLogs.queries (OTEL flat)', () => {
} as any)
expect(sql).toContain('toStartOfDay(timestamp)')
})

it('buckets auth rows by their log_attributes[status] so 4xx/5xx are not counted as success', () => {
const sql = getLogsChartQuery(baseSearch)
expect(sql).toContain(
`if(source = 'auth_logs', log_attributes['status'], log_attributes['response.status_code'])`
)
})
})

describe('getFacetCountQuery', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,14 @@ const ATTR = {
path: safeSql`log_attributes['request.path']`,
} as const

// The HTTP status code lives under different OTEL attribute keys per service:
// gateway rows (edge / postgrest / storage / edge function) expose it as
// `response.status_code`, while auth-service rows expose it as `status`. This
// normalizes the two so both the displayed status and the derived severity are
// correct for auth logs (which would otherwise have an empty status and fall
// back to their `severity_text` of INFO, classifying every 4xx/5xx as success).
const HTTP_STATUS_EXPR: SafeLogSqlFragment = safeSql`if(source = 'auth_logs', log_attributes['status'], ${ATTR.status})`

/**
* Predicate that matches rows belonging to a given log_type. Mirrors the
* shape of the original BigQuery unified-logs CTEs: edge gateway traffic
Expand Down Expand Up @@ -72,25 +80,26 @@ const LOG_TYPE_EXPR: SafeLogSqlFragment = safeSql`CASE
ELSE source
END`

// Status code is sourced from the HTTP response for gateway-style rows and
// from the Postgres `parsed.sql_state_code` (e.g. `42P01`) for postgres rows.
// Status code is sourced from the HTTP response for gateway-style rows, the
// auth-service `status` attribute for auth rows, and the Postgres
// `parsed.sql_state_code` (e.g. `42P01`) for postgres rows.
const STATUS_EXPR: SafeLogSqlFragment = safeSql`CASE
WHEN source = 'postgres_logs' THEN toString(log_attributes['parsed.sql_state_code'])
ELSE toString(${ATTR.status})
ELSE toString((${HTTP_STATUS_EXPR}))
END`

// SQL expression for derived `level`. Used inline (not as alias reference)
// because the OTEL endpoint can't resolve aliases inside countIf when the
// alias is not in GROUP BY.
//
// HTTP status is checked first so gateway rows (which always carry an
// HTTP status is checked first so gateway and auth rows (which carry a
// `severity_text` of `INFO` regardless of response code) bucket as
// success/warning/error by status. Postgres-style severity is the
// fallback for rows without a status code.
const LEVEL_EXPR: SafeLogSqlFragment = safeSql`CASE
WHEN ${ATTR.status} != '' AND toInt32OrZero(${ATTR.status}) >= 500 THEN 'error'
WHEN ${ATTR.status} != '' AND toInt32OrZero(${ATTR.status}) BETWEEN 400 AND 499 THEN 'warning'
WHEN ${ATTR.status} != '' AND toInt32OrZero(${ATTR.status}) BETWEEN 200 AND 299 THEN 'success'
WHEN (${HTTP_STATUS_EXPR}) != '' AND toInt32OrZero((${HTTP_STATUS_EXPR})) >= 500 THEN 'error'
WHEN (${HTTP_STATUS_EXPR}) != '' AND toInt32OrZero((${HTTP_STATUS_EXPR})) BETWEEN 400 AND 499 THEN 'warning'
WHEN (${HTTP_STATUS_EXPR}) != '' AND toInt32OrZero((${HTTP_STATUS_EXPR})) BETWEEN 200 AND 299 THEN 'success'
WHEN severity_text IN ('ERROR','FATAL','CRITICAL','ALERT','EMERGENCY') THEN 'error'
WHEN severity_text IN ('WARN','WARNING') THEN 'warning'
WHEN severity_text IN ('TRACE','DEBUG','INFO','LOG','NOTICE') THEN 'success'
Expand Down
Loading
Loading