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
5 changes: 1 addition & 4 deletions apps/docs/components/MetricsStackCards.data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,7 @@ export const metricsStackOptions: MetricsStackOption[] = [
iconKind: 'grafana',
iconColor: '#F05A28',
iconBg: 'rgba(240,90,40,0.1)',
badges: [
{ label: 'Supabase guide', variant: 'default' },
{ label: 'Community', variant: 'community' },
],
badges: [{ label: 'Supabase guide', variant: 'default' }],
},
{
title: 'Grafana + self-hosted Prometheus',
Expand Down
44 changes: 31 additions & 13 deletions apps/docs/content/guides/telemetry/metrics/grafana-cloud.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -6,37 +6,55 @@ description: 'Use Grafana Cloud’s managed Prometheus to visualize Supabase met

Grafana Cloud gives you a fully managed Prometheus endpoint plus hosted Grafana dashboards, which makes it the fastest way to explore the Supabase Metrics API without operating your own infrastructure.

<Admonition type="note">
## Installation

Grafana maintains a [Supabase integration guide](https://grafana.com/docs/grafana-cloud/monitor-infrastructure/integrations/integration-reference/integration-supabase/) for Grafana Cloud. It runs on the same Metrics API documented here, but is community-maintained by Grafana, so feature coverage might differ from what Supabase officially supports.
The Grafana Cloud integration is available in the Supabase Dashboard. [Add the integration](/dashboard/project/_/integrations/grafana-cloud/overview?utm_source=metrics-api&utm_medium=docs&utm_campaign=supabase_grafana_cloud_one_click_integration) and select a project to get a fully-configured instance in one click: authentication, metric scraping, and a pre-built dashboard tracking 200+ metrics, set up automatically.

By integrating Supabase with Grafana Cloud, users gain monitoring capabilities for Supabase performance and operations. The included dashboard offers a comprehensive overview of Supabase performance, supplemented with Postgres metrics.

## Manual setup

The [Grafana Cloud integration](/dashboard/project/_/integrations/grafana-cloud/overview?utm_source=metrics-api&utm_medium=docs&utm_campaign=supabase_grafana_cloud_one_click_integration) available in the Supabase Dashboard is the recommended path. You can still configure and run the setup manually by following the steps below.

<Admonition type="caution">

Use this guide only if you need full manual control (custom scrape topology, self-hosted
Prometheus or non-standard auth).

</Admonition>

## Prerequisites
### Prerequisites

- A Supabase project with access to the Metrics API (Secret API key `sb_secret_...`).
- A Grafana Cloud account with Prometheus metrics enabled (Free or Pro tier).
- A Grafana API token with the `metrics:write` and `metrics:read` scopes if you plan to push data manually.

<$Partial path="metrics_access.mdx" />

## 1. Create a Grafana Cloud stack
### 1. Create a Grafana Cloud stack

1. Sign in to [Grafana Cloud](https://grafana.com/auth/sign-in).
2. Create (or select) a stack that has **Prometheus Metrics** enabled (Free and Pro tiers both work for this guide).
2. Create or select a stack that has **Prometheus Metrics** enabled.

### 2. Install the Supabase integration for Grafana Cloud

1. In your Grafana Cloud stack, click **Connections** in the left-hand menu.
2. Select the **Supabase** integration and follow the steps outlined on the **Configuration** page:
3. Give your scrape job a descriptive name like `production-eu-central-1`.
4. Set your Project ID and enter the service account API key.
5. Test the connection and save the scrape job.
6. Once scrape jobs are configured, click Install to add the prebuilt dashboards to your Grafana Cloud instance.

## 2. Configure the Supabase integration
### 3. Configure the Supabase integration

1. Navigate to **Connections → Add new connection → Supabase** inside Grafana Cloud.
2. Provide:
- Your Supabase project ref (e.g. `abcd1234`).
- The Metrics API endpoint: `https://<project-ref>.supabase.co/customer/v1/privileged/metrics`.
- The Metrics API endpoint (e.g. `https://<project-ref>.supabase.co/customer/v1/privileged/metrics`).
- HTTP Basic Auth credentials (`sb_secret_...`).
3. Choose the scrape interval (1 minute recommended) and test the connection. Grafana Cloud will deploy an agent in the background that scrapes the Metrics API and forwards the data to Prometheus.
3. Choose the scrape interval. 1 minute is recommended, and test the connection. Grafana Cloud will deploy an agent in the background that scrapes the Metrics API and forwards the data to Prometheus.

If you prefer to reuse an existing Grafana Agent deployment, configure an [integration pipeline](https://grafana.com/docs/grafana-cloud/monitor-infrastructure/integrations/integration-reference/integration-supabase/) with the same URL and credentials.

## 3. Import the Supabase dashboard
### 4. Import the Supabase dashboard

1. Open your Grafana Cloud dashboard list and click **New → Import**.
2. Paste the raw contents of [`supabase-grafana/dashboard.json`](https://raw.githubusercontent.com/supabase/supabase-grafana/refs/heads/main/grafana/dashboard.json).
Expand All @@ -51,11 +69,11 @@ This dashboard includes 200+ charts grouped by CPU, IO, connections, replication
height="1509"
/>

## 4. Configure alerts (optional)
### 5. Configure alerts (optional)

The [`docs/example-alerts.md`](https://github.com/supabase/supabase-grafana/blob/main/docs/example-alerts.md) file contains suggested alert rules (disk saturation, long-running queries, replication lag, etc.). Import the alert rules into Grafana Cloud’s Alerting UI or translate them into Grafana Cloud’s managed alert rule format.

## 5. Troubleshooting
### 6. Troubleshooting

- Metrics missing? Ensure the Grafana Cloud agent can reach `https://<project-ref>.supabase.co` and that the selected Secret API key is still valid.
- 401 errors? Create/rotate a Secret API key in [Project Settings → API Keys](/dashboard/project/_/settings/api-keys) and update the Grafana Cloud credentials.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,19 @@ description: 'Deploy Prometheus and Grafana yourself to monitor Supabase metrics

Self-hosting [Prometheus](https://prometheus.io/docs/prometheus/latest/installation/) and Grafana gives you full control over retention, alert routing, and dashboards. The Supabase Metrics API slots into any standard Prometheus scrape job, so you can run everything locally, on a VM, or inside Kubernetes.

<$Partial path="metrics_access.mdx" />
<Admonition type="caution">

<Admonition type="note">
Use this guide only if you need full manual control (custom scrape topology, self-hosted Prometheus or non-standard auth).

Grafana also documents a [Supabase integration reference](https://grafana.com/docs/grafana-cloud/monitor-infrastructure/integrations/integration-reference/integration-supabase/). While it targets Grafana Cloud, the scrape and agent settings apply equally to self-hosted clusters and offer a community-maintained companion to this guide.
Otherwise, use the [Grafana Cloud integration](/docs/guides/telemetry/metrics/grafana-cloud#installation) available in the Supabase Dashboard.

</Admonition>

## Architecture

1. **Prometheus** scrapes `https://<project-ref>.supabase.co/customer/v1/privileged/metrics` every minute using HTTP Basic Auth.
2. **Grafana** reads from Prometheus and renders dashboards/alerts.
3. (Optional) **Alertmanager** or your preferred system sends notifications when Prometheus rules fire.
3. **Prometheus Alertmanager** or your preferred system sends notifications when Prometheus rules fire (optional) .

## 1. Deploy Prometheus

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export const TOTPFactors = () => {
</p>
<div className="flex items-center gap-4">
<p className="text-sm text-foreground-light">
Added on {dayjs(factor.updated_at).format(DATETIME_FORMAT)}
Added on {dayjs(factor.created_at).format(DATETIME_FORMAT)}
</p>
<Button
size="tiny"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import { formatMigrationVersionLabel, parseMigrationVersion } from '@/lib/migrat
import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
import { useShortcut } from '@/state/shortcuts/useShortcut'

const Migrations = () => {
export const Migrations = () => {
const [search, setSearch] = useState('')
const [selectedMigration, setSelectedMigration] = useState<DatabaseMigration>()
const searchInputRef = useRef<HTMLInputElement>(null)
Expand Down Expand Up @@ -116,7 +116,7 @@ const Migrations = () => {
placeholder="Search for a migration"
value={search}
className="w-full lg:w-52"
onChange={(e: any) => setSearch(e.target.value)}
onChange={(e) => setSearch(e.target.value)}
icon={<Search />}
/>
<Card>
Expand Down Expand Up @@ -221,10 +221,14 @@ const Migrations = () => {
<div className="h-full">
<div className="relative h-full">
<CodeEditor
// The CodeEditor does not react to content only changes,
// specifically when two projects have migrations with the same version but different content.
// Setting the key ensure it always update when the migration changes
key={`${project?.ref}-${selectedMigration?.version}`}
isReadOnly
id={selectedMigration?.version ?? ''}
language="pgsql"
defaultValue={
value={
selectedMigration?.statements?.join(';\n') +
(selectedMigration?.statements?.length ? ';' : '')
}
Expand All @@ -235,5 +239,3 @@ const Migrations = () => {
</>
)
}

export default Migrations
12 changes: 6 additions & 6 deletions apps/studio/components/interfaces/Realtime/RealtimeSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,9 +94,9 @@ export const RealtimeSettings = () => {
.max(maxConn?.maxConnections ?? 100)
.optional(),
max_concurrent_users: z.coerce.number().min(1).max(50000).optional(),
max_events_per_second: z.coerce.number().min(1).max(10000).optional(),
max_presence_events_per_second: z.coerce.number().min(1).max(10000).optional(),
max_payload_size_in_kb: z.coerce.number().min(1).max(3000).optional(),
max_events_per_second: z.coerce.number().min(1).max(50000).optional(),
max_presence_events_per_second: z.coerce.number().min(1).max(5000).optional(),
max_payload_size_in_kb: z.coerce.number().min(1).max(10000).optional(),
// [Joshen] These fields are temporarily hidden from the UI
// max_bytes_per_second: z.coerce.number().min(1).max(10000000).optional(),
// max_channels_per_client: z.coerce.number().min(1).max(10000).optional(),
Expand All @@ -111,9 +111,9 @@ export const RealtimeSettings = () => {
.min(1)
.max(maxConn?.maxConnections ?? 100),
max_concurrent_users: z.coerce.number().min(1).max(50000),
max_events_per_second: z.coerce.number().min(1).max(10000),
max_presence_events_per_second: z.coerce.number().min(1).max(10000),
max_payload_size_in_kb: z.coerce.number().min(1).max(3000),
max_events_per_second: z.coerce.number().min(1).max(50000),
max_presence_events_per_second: z.coerce.number().min(1).max(5000),
max_payload_size_in_kb: z.coerce.number().min(1).max(10000),
// [Joshen] These fields are temporarily hidden from the UI
// max_bytes_per_second: z.coerce.number().min(1).max(10000000),
// max_channels_per_client: z.coerce.number().min(1).max(10000),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -282,14 +282,18 @@ describe('UnifiedLogs.queries (OTEL flat)', () => {
})

describe('user filter', () => {
it('restricts to auth_logs/postgres_logs and skips the default postgres+edge restriction', () => {
it('restricts to auth_logs/edge_logs and skips the default postgres+edge restriction', () => {
const sql = getUnifiedLogsQuery(withUser('user-123'))
const where = sql.split(/\bWHERE\b/)[1] ?? ''
expect(where).toContain(`log_attributes['auth_event.actor_id'] = 'user-123'`)
expect(where).toContain(`source = 'postgres_logs'`)
// The unfiltered default (postgres_logs OR edge_logs) would incorrectly exclude
expect(where).toContain(
`(source = 'auth_logs' AND log_attributes['auth_event.actor_id'] = 'user-123')`
)
expect(where).toContain(
`(source = 'edge_logs' AND log_attributes['request.sb.jwt.authorization.payload.subject'] = 'user-123')`
)
// The unfiltered default (edge_logs OR postgres_logs) would incorrectly exclude
// auth_logs, the primary attributable source, so it must not appear here.
expect(where).not.toContain(`(source = 'postgres_logs') OR (source = 'edge_logs')`)
expect(where).not.toContain(`(source = 'edge_logs') OR (source = 'postgres_logs')`)
})

it('does not restrict sources when the user filter is inactive', () => {
Expand Down Expand Up @@ -321,8 +325,8 @@ describe('UnifiedLogs.queries (OTEL flat)', () => {
expect(isUserFilterUnreachable(withUser('user-123', 'log_type:eq:auth'))).toBe(false)
})

it('is false when the explicit log_type filter includes an attributable source (postgres)', () => {
expect(isUserFilterUnreachable(withUser('user-123', 'log_type:eq:postgres'))).toBe(false)
it('is false when the explicit log_type filter includes an attributable source (edge)', () => {
expect(isUserFilterUnreachable(withUser('user-123', 'log_type:eq:edge'))).toBe(false)
})

it('is false when at least one of several selected log types is attributable', () => {
Expand All @@ -332,12 +336,12 @@ describe('UnifiedLogs.queries (OTEL flat)', () => {
})

it('is true when the explicit log_type filter restricts to a single non-attributable source', () => {
expect(isUserFilterUnreachable(withUser('user-123', 'log_type:eq:edge'))).toBe(true)
expect(isUserFilterUnreachable(withUser('user-123', 'log_type:eq:storage'))).toBe(true)
})

it('is true when every selected log type is non-attributable', () => {
expect(
isUserFilterUnreachable(withUser('user-123', 'log_type:eq:edge', 'log_type:eq:storage'))
isUserFilterUnreachable(withUser('user-123', 'log_type:eq:realtime', 'log_type:eq:storage'))
).toBe(true)
})

Expand All @@ -347,7 +351,7 @@ describe('UnifiedLogs.queries (OTEL flat)', () => {

it('(neq) is true only when both attributable sources are excluded', () => {
expect(
isUserFilterUnreachable(withUser('user-123', 'log_type:neq:auth', 'log_type:neq:postgres'))
isUserFilterUnreachable(withUser('user-123', 'log_type:neq:auth', 'log_type:neq:edge'))
).toBe(true)
})
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,13 @@ const LEVEL_EXPR: SafeLogSqlFragment = safeSql`CASE
ELSE 'success'
END`

// The `auth_user` column is derived from the source-specific fields that can be attributed to a user.
const AUTH_USER_EXPR: SafeLogSqlFragment = safeSql`nullIf(CASE
WHEN source = 'auth_logs' THEN log_attributes['auth_event.actor_id']
WHEN source = 'edge_logs' THEN log_attributes['request.sb.jwt.authorization.payload.subject']
ELSE null
END, '')`

const logTypeWhereCondition = (logTypes: string[]): SafeLogSqlFragment => {
const effective = logTypes.filter((t) => t in LOG_TYPE_CONDITION)
const types = effective.length ? effective : [...DEFAULT_LOG_TYPES]
Expand Down Expand Up @@ -248,7 +255,7 @@ const truncationFunction = (level: 'MINUTE' | 'HOUR' | 'DAY'): SafeLogSqlFragmen
* inlined so the result can be referenced (or filtered) at the same query
* level — the OTEL endpoint rejects subqueries.
*/
const rowProjection = (): SafeLogSqlFragment => safeSql`
const ROW_PROJECTION: SafeLogSqlFragment = safeSql`
id,
null AS source_id,
timestamp,
Expand All @@ -258,6 +265,7 @@ const rowProjection = (): SafeLogSqlFragment => safeSql`
${ATTR.path} AS pathname,
event_message,
${ATTR.method} AS method,
${AUTH_USER_EXPR} AS auth_user,
null AS log_count,
null AS logs
`
Expand Down Expand Up @@ -335,17 +343,14 @@ const userAttributionCondition = (search: QuerySearchParamsType): SafeLogSqlFrag
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 event_message ILIKE ${contains}
))
OR (source = 'postgres_logs' AND event_message ILIKE ${contains})
(source = 'auth_logs' AND log_attributes['auth_event.actor_id'] = ${exact})
OR
(source = 'edge_logs' AND log_attributes['request.sb.jwt.authorization.payload.subject'] = ${exact})
)`
}

const USER_ATTRIBUTABLE_SOURCES = new Set([LOG_TYPE_TO_SOURCE.auth, LOG_TYPE_TO_SOURCE.postgres])
const USER_ATTRIBUTABLE_SOURCES = new Set([LOG_TYPE_TO_SOURCE.auth, LOG_TYPE_TO_SOURCE.edge])

/**
* True when the user filter is active but an explicit log_type filter restricts the
Expand Down Expand Up @@ -411,7 +416,7 @@ const applySearchParamsFilter = (search: QuerySearchParamsType): SafeLogSqlFragm
export const getUnifiedLogsQuery = (search: QuerySearchParamsType): SafeLogSqlFragment => {
const conditions = buildBaseWhere(search)
return safeSql`-- unified logs: row list
SELECT ${rowProjection()}
SELECT ${ROW_PROJECTION}
FROM logs
${whereClause(conditions)}
`
Expand Down
2 changes: 1 addition & 1 deletion apps/studio/pages/project/[ref]/database/migrations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
} from 'ui-patterns/PageHeader'
import { PageSection, PageSectionContent } from 'ui-patterns/PageSection'

import Migrations from '@/components/interfaces/Database/Migrations/Migrations'
import { Migrations } from '@/components/interfaces/Database/Migrations/Migrations'
import DatabaseLayout from '@/components/layouts/DatabaseLayout/DatabaseLayout'
import { DefaultLayout } from '@/components/layouts/DefaultLayout'
import { DocsButton } from '@/components/ui/DocsButton'
Expand Down
Loading
Loading