diff --git a/apps/docs/app/guides/troubleshooting/[slug]/page.tsx b/apps/docs/app/guides/troubleshooting/[slug]/page.tsx index 9f15cac7d53fc..40d22deedb31a 100644 --- a/apps/docs/app/guides/troubleshooting/[slug]/page.tsx +++ b/apps/docs/app/guides/troubleshooting/[slug]/page.tsx @@ -1,9 +1,9 @@ -import { notFound } from 'next/navigation' - import TroubleshootingPage from '~/features/docs/Troubleshooting.page' import { getAllTroubleshootingEntries, getArticleSlug } from '~/features/docs/Troubleshooting.utils' import { PROD_URL } from '~/lib/constants' import { getCustomContent } from '~/lib/custom-content/getCustomContent' +import { mdAlternate } from '~/lib/md-alternates' +import { notFound } from 'next/navigation' export const dynamicParams = false @@ -38,6 +38,7 @@ export const generateMetadata = async (props: { params: Promise<{ slug: string } title: `${metadataTitle || 'Supabase'} | Troubleshooting${entry ? ` | ${entry.data.title}` : ''}`, alternates: { canonical: `${PROD_URL}/guides/troubleshooting/${slug}`, + types: mdAlternate(`troubleshooting/${slug}`), }, } } diff --git a/apps/docs/app/guides/troubleshooting/page.tsx b/apps/docs/app/guides/troubleshooting/page.tsx index 19e92851bb5dd..66d34249eb9d5 100644 --- a/apps/docs/app/guides/troubleshooting/page.tsx +++ b/apps/docs/app/guides/troubleshooting/page.tsx @@ -14,6 +14,7 @@ import { TROUBLESHOOTING_CONTAINER_ID } from '~/features/docs/Troubleshooting.ut import { SidebarSkeleton } from '~/layouts/MainSkeleton' import { PROD_URL } from '~/lib/constants' import { getCustomContent } from '~/lib/custom-content/getCustomContent' +import { mdAlternate } from '~/lib/md-alternates' import { type Metadata } from 'next' const { metadataTitle } = getCustomContent(['metadata:title']) @@ -62,5 +63,6 @@ export const metadata: Metadata = { title: `${metadataTitle || 'Supabase'} | Troubleshooting`, alternates: { canonical: `${PROD_URL}/guides/troubleshooting`, + types: mdAlternate('troubleshooting'), }, } diff --git a/apps/docs/app/page.tsx b/apps/docs/app/page.tsx index 452dad1311080..27e6a28281c9d 100644 --- a/apps/docs/app/page.tsx +++ b/apps/docs/app/page.tsx @@ -28,10 +28,7 @@ const generateMetadata = async (_, parent: ResolvingMetadata): Promise ...(parentAlternates && { languages: parentAlternates.languages || undefined, media: parentAlternates.media || undefined, - types: { - ...(parentAlternates.types ?? {}), - 'text/markdown': 'https://supabase.com/llms-full.txt', - }, + types: parentAlternates.types || undefined, }), }, } diff --git a/apps/docs/components/StepHikeCompact/index.tsx b/apps/docs/components/StepHikeCompact/index.tsx index 7d86f47884adb..33e15d219cbfe 100644 --- a/apps/docs/components/StepHikeCompact/index.tsx +++ b/apps/docs/components/StepHikeCompact/index.tsx @@ -108,10 +108,18 @@ const Details: FC> = ({ children, title, fullWidth = } const Code: FC> = ({ children }) => { + // Not `not-prose`: steps interleave labels and admonitions with their code samples, and + // stripping prose leaves that text unstyled and flush against the samples. return (
{children}
diff --git a/apps/docs/content/guides/api/rest/postgrest-error-codes.mdx b/apps/docs/content/guides/api/rest/postgrest-error-codes.mdx index 4e0d3274518dd..603d458fdd2c3 100644 --- a/apps/docs/content/guides/api/rest/postgrest-error-codes.mdx +++ b/apps/docs/content/guides/api/rest/postgrest-error-codes.mdx @@ -145,28 +145,26 @@ Data API error unspecified ## Viewing errors in the logs -One can filter for API errors in the [log explorer](/dashboard/project/_/logs/explorer). Below are useful queries for filtering and analyzing API errors: +One can filter for API errors in the [SQL Editor](/dashboard/project/_/sql/new?skip=true&source=logs) with the query source set to **Logs**. Below are useful queries for filtering and analyzing API errors: ### Find all API errors that occurred at the database level ```sql select - cast(postgres_logs.timestamp as datetime) as timestamp, + timestamp, event_message, - parsed.error_severity, - parsed.user_name, - parsed.query, - parsed.detail, - parsed.hint, - parsed.sql_state_code, - parsed.backend_type -from - postgres_logs - cross join unnest(metadata) as metadata - cross join unnest(metadata.parsed) as parsed + log_attributes['parsed.error_severity'] as error_severity, + log_attributes['parsed.user_name'] as user_name, + log_attributes['parsed.query'] as query, + log_attributes['parsed.detail'] as detail, + log_attributes['parsed.hint'] as hint, + log_attributes['parsed.sql_state_code'] as sql_state_code, + log_attributes['parsed.backend_type'] as backend_type +from logs where - regexp_contains(parsed.error_severity, 'ERROR|FATAL|PANIC') - and parsed.user_name = 'authenticator' -- the authenticator role represents the database API + source = 'postgres_logs' + and log_attributes['parsed.error_severity'] in ('ERROR', 'FATAL', 'PANIC') + and log_attributes['parsed.user_name'] = 'authenticator' -- the authenticator role represents the database API order by timestamp desc limit 100; ``` @@ -175,102 +173,88 @@ limit 100; ```sql select - cast(postgres_logs.timestamp as datetime) as timestamp, + timestamp, event_message, - parsed.error_severity, - parsed.user_name, - parsed.query, - parsed.detail, - parsed.hint, - parsed.sql_state_code, - parsed.backend_type -from - postgres_logs - cross join unnest(metadata) as metadata - cross join unnest(metadata.parsed) as parsed -where parsed.sql_state_code like '42501' and parsed.user_name = 'authenticator' -- the authenticator role represents the database API + log_attributes['parsed.error_severity'] as error_severity, + log_attributes['parsed.user_name'] as user_name, + log_attributes['parsed.query'] as query, + log_attributes['parsed.detail'] as detail, + log_attributes['parsed.hint'] as hint, + log_attributes['parsed.sql_state_code'] as sql_state_code, + log_attributes['parsed.backend_type'] as backend_type +from logs +where + source = 'postgres_logs' + and log_attributes['parsed.sql_state_code'] = '42501' + and log_attributes['parsed.user_name'] = 'authenticator' -- the authenticator role represents the database API order by timestamp desc limit 100; ``` -PostgREST error codes are only captured in the logs for projects running V14+. You can check your PostgREST version and upgrade your project in the [General Settings](/dashboard/project/_/settings/general) +The codes in the table above are returned in the response body, not recorded in the logs. Use the queries below to find the failing requests, then read the `code` from the response your client received. -### Find specific API error +### Find API errors at the gateway + +`sb_error_code` is the error code the API gateway recorded for a request, such as `UNAUTHORIZED_MISSING_API_KEY`. It is empty when the request reached PostgREST and failed there. ```sql select - cast(timestamp as datetime) as timestamp, - status_code, - event_message, - coalesce(proxy_status, 'not_recorded') as error_codes, - path -from - edge_logs - cross join unnest(metadata) as metadata - cross join unnest(response) as response - cross join unnest(request) as request + timestamp, + log_attributes['response.status_code'] as status_code, + log_attributes['response.headers.sb_error_code'] as gateway_error_code, + log_attributes['request.path'] as path, + event_message +from logs where - status_code >= 300 - and regexp_contains(path, '^/rest/v1/') - and regexp_contains(proxy_status, '(?i)THE_RELEVANT_STATUS_CODE'); + source = 'edge_logs' + and toInt32OrZero(log_attributes['response.status_code']) >= 300 + and match(log_attributes['request.path'], '^/rest/v1/') +order by timestamp desc +limit 100; ``` ### Count errors per path by hour: ```sql select - format_timestamp( - "%c", - timestamp_trunc(cast(edge_logs.timestamp as timestamp), hour), - "UTC" - ) as hour, - count(proxy_status) as error_count, - path, - coalesce(proxy_status, 'not_recorded') as error_codes -from - edge_logs - cross join unnest(metadata) as metadata - cross join unnest(response) as response - cross join unnest(response.headers) as headers - cross join unnest(request) as request -where status_code >= 300 and regexp_contains(path, '^/rest/v1/') -group by hour, proxy_status, path; + toStartOfHour(timestamp) as hour, + count() as error_count, + log_attributes['request.path'] as path +from logs +where + source = 'edge_logs' + and toInt32OrZero(log_attributes['response.status_code']) >= 300 + and match(log_attributes['request.path'], '^/rest/v1/') +group by hour, path +order by hour desc +limit 100; ``` ### Find data API request from specific authenticated user ```sql select - cast(timestamp as datetime) as timestamp, + timestamp, event_message, - cf_connecting_ip as requesters_ip, - url as request_url, - request.method as request_method, - sb.auth_user as user_id, - apikey_payload.role as apikey_role, - authorization_payload.role as authorization_token_role, - user_agent, - city, - country, - continent, - postalCode -from - edge_logs - cross join unnest(metadata) as metadata - cross join unnest(request) as request - cross join unnest(sb) as sb - cross join unnest(jwt) as jwt - cross join unnest(jwt.apikey) as jwt_apikey - cross join unnest(jwt_apikey.payload) as apikey_payload - cross join unnest(authorization) as authorization_key - cross join unnest(authorization_key.payload) as authorization_payload - cross join unnest(headers) as headers - cross join unnest(cf) as cf - cross join unnest(response) as response -where regexp_contains(path, '^/rest/v1/') and sb.auth_user = 'SOME_USER_ID' -- <---ADD USER_ID from auth.users table -order by timestamp desc; + log_attributes['request.headers.cf_connecting_ip'] as requesters_ip, + log_attributes['request.url'] as request_url, + log_attributes['request.method'] as request_method, + log_attributes['request.sb.jwt.authorization.payload.subject'] as user_id, + log_attributes['request.sb.jwt.apikey.payload.role'] as apikey_role, + log_attributes['request.sb.jwt.authorization.payload.role'] as authorization_token_role, + log_attributes['request.headers.user_agent'] as user_agent, + log_attributes['request.cf.city'] as city, + log_attributes['request.cf.country'] as country, + log_attributes['request.cf.postalCode'] as postalCode +from logs +where + source = 'edge_logs' + and match(log_attributes['request.path'], '^/rest/v1/') + and log_attributes['request.sb.jwt.authorization.payload.subject'] = 'SOME_USER_ID' -- <---ADD USER_ID from auth.users table +order by timestamp desc +limit 100; ``` diff --git a/apps/docs/content/guides/database/extensions/pgaudit.mdx b/apps/docs/content/guides/database/extensions/pgaudit.mdx index 3095e9c5c8a42..5df317f86d733 100644 --- a/apps/docs/content/guides/database/extensions/pgaudit.mdx +++ b/apps/docs/content/guides/database/extensions/pgaudit.mdx @@ -254,17 +254,14 @@ Generates the following log in the [Dashboard's Postgres Logs](/dashboard/projec ## Finding and filtering audit logs -Logs generated by PGAudit can be found in [Postgres Logs](/dashboard/project/_/logs/postgres-logs?s=AUDIT). To find a specific log, you can use the log explorer. Below is a basic example to extract logs referencing `CREATE TABLE` events +Logs generated by PGAudit can be found in [Postgres Logs](/dashboard/project/_/logs/postgres-logs?s=AUDIT). To find a specific log, you can use the [SQL Editor](/dashboard/project/_/sql/new?skip=true&source=logs) with the query source set to **Logs**. Below is a basic example to extract logs referencing `CREATE TABLE` events ```sql select - cast(t.timestamp as datetime) as timestamp, + timestamp, event_message -from - postgres_logs as t - cross join unnest(metadata) as m - cross join unnest(m.parsed) as p -where event_message like 'AUDIT%CREATE TABLE%' +from logs +where source = 'postgres_logs' and event_message like 'AUDIT%CREATE TABLE%' order by timestamp desc limit 100; ``` diff --git a/apps/docs/content/guides/database/postgres/timeouts.mdx b/apps/docs/content/guides/database/postgres/timeouts.mdx index cdaadd602642b..3415ac23e936c 100644 --- a/apps/docs/content/guides/database/postgres/timeouts.mdx +++ b/apps/docs/content/guides/database/postgres/timeouts.mdx @@ -126,36 +126,34 @@ language sql; The Supabase Dashboard contains tools to help you identify timed-out and long-running queries. -### Using the Logs Explorer +### Using the SQL Editor -Go to the [Logs Explorer](/dashboard/project/_/logs/explorer), and run the following query to identify timed-out events (`statement timeout`) and queries that successfully run for longer than 10 seconds (`duration`). +Go to the [SQL Editor](/dashboard/project/_/sql/new?skip=true&source=logs), set the query source to **Logs**, and run the following query to identify timed-out events (`statement timeout`) and queries that successfully run for longer than 10 seconds (`duration`). ```sql select - cast(postgres_logs.timestamp as datetime) as timestamp, + timestamp, event_message, - parsed.error_severity, - parsed.user_name, - parsed.query, - parsed.detail, - parsed.hint, - parsed.sql_state_code, - parsed.backend_type -from - postgres_logs - cross join unnest(metadata) as metadata - cross join unnest(metadata.parsed) as parsed + log_attributes['parsed.error_severity'] as error_severity, + log_attributes['parsed.user_name'] as user_name, + log_attributes['parsed.query'] as query, + log_attributes['parsed.detail'] as detail, + log_attributes['parsed.hint'] as hint, + log_attributes['parsed.sql_state_code'] as sql_state_code, + log_attributes['parsed.backend_type'] as backend_type +from logs where - regexp_contains(event_message, 'duration|statement timeout') + source = 'postgres_logs' + and match(event_message, 'duration|statement timeout') -- (OPTIONAL) MODIFY OR REMOVE - and parsed.user_name = 'authenticator' -- <--------CHANGE + and log_attributes['parsed.user_name'] = 'authenticator' -- <--------CHANGE order by timestamp desc limit 100; ``` ### Using the Query Performance page -Go to the [Query Performance page](/dashboard/project/_/advisors/query-performance?preset=slowest_execution) and filter by relevant role and query speeds. This only identifies slow-running but successful queries. Unlike the Log Explorer, it does not show you timed-out queries. +Go to the [Query Performance page](/dashboard/project/_/advisors/query-performance?preset=slowest_execution) and filter by relevant role and query speeds. This only identifies slow-running but successful queries. Unlike the logs, it does not show you timed-out queries. ### Understanding roles in logs @@ -178,5 +176,5 @@ Filter by the `parsed.user_name` field to only retrieve logs made by specific us ... query where -- find events from the relevant role - parsed.user_name = '' + log_attributes['parsed.user_name'] = '' ``` diff --git a/apps/docs/content/guides/database/prisma.mdx b/apps/docs/content/guides/database/prisma.mdx index 059f603190c86..8f913682789aa 100644 --- a/apps/docs/content/guides/database/prisma.mdx +++ b/apps/docs/content/guides/database/prisma.mdx @@ -141,7 +141,7 @@ If you plan to solely use Prisma instead of the Supabase Data API (PostgREST), t - + In your .env file, set the DATABASE_URL variable to your connection string ```text .env @@ -192,7 +192,7 @@ If you plan to solely use Prisma instead of the Supabase Data API (PostgREST), t - + ```ts prisma.config.ts import "dotenv/config"; @@ -240,7 +240,7 @@ If you plan to solely use Prisma instead of the Supabase Data API (PostgREST), t - + Create new tables in your prisma.schema file diff --git a/apps/docs/content/guides/database/tables.mdx b/apps/docs/content/guides/database/tables.mdx index b185d17a19146..a2f32f4a4aa80 100644 --- a/apps/docs/content/guides/database/tables.mdx +++ b/apps/docs/content/guides/database/tables.mdx @@ -635,7 +635,7 @@ Views can restrict the amount and type of data presented to a user. Instead of a ### Materialized views -A [materialized view](https://www.postgresql.org/docs/12/rules-materializedviews.html) is a form of view but it also stores the results to disk. In subsequent reads of a materialized view, the time taken to return its results would be much faster than a conventional view. This is because the data is readily available for a materialized view while the conventional view executes the underlying query each time it is called. +A [materialized view](https://www.postgresql.org/docs/current/rules-materializedviews.html) is a form of view but it also stores the results to disk. In subsequent reads of a materialized view, the time taken to return its results would be much faster than a conventional view. This is because the data is readily available for a materialized view while the conventional view executes the underlying query each time it is called. Using our example above, a materialized view can be created like this: @@ -678,7 +678,7 @@ Creating a materialized view is not a solution to inefficient queries. You shoul ## Resources - [Official Docs: Create table](https://www.postgresql.org/docs/current/sql-createtable.html) -- [Official Docs: Create view](https://www.postgresql.org/docs/12/sql-createview.html) +- [Official Docs: Create view](https://www.postgresql.org/docs/current/sql-createview.html) - [Postgres Tutorial: Create tables](https://www.postgresqltutorial.com/postgresql-tutorial/postgresql-create-table/) - [Postgres Tutorial: Add column](https://www.postgresqltutorial.com/postgresql-tutorial/postgresql-add-column/) - [Postgres Tutorial: Views](https://www.postgresqltutorial.com/postgresql-views/) diff --git a/apps/docs/content/guides/integrations/supabase-for-platforms.mdx b/apps/docs/content/guides/integrations/supabase-for-platforms.mdx index d2927d0085641..d94dfad64297d 100644 --- a/apps/docs/content/guides/integrations/supabase-for-platforms.mdx +++ b/apps/docs/content/guides/integrations/supabase-for-platforms.mdx @@ -413,21 +413,26 @@ We've created Platform Kit, a collection of UI components that interact with Man ## Debugging projects -Management API endpoint: [`GET /v1/projects/{ref}/analytics/endpoints/logs.all`](https://api.supabase.com/api/v1#tag/analytics/get/v1/projects/{ref}/analytics/endpoints/logs.all) +Management API endpoint: [`GET /v1/projects/{ref}/analytics/endpoints/logs`](https://api.supabase.com/api/v1#tag/analytics/get/v1/projects/{ref}/analytics/endpoints/logs) When you need to debug a project, you can query the project's logs to see if there are any errors and address them accordingly. +Every log line from every service is a row in a single `logs` table, so filter by the `source` column to pick a service. Structured fields live in the `log_attributes` map, and the SQL is the ClickHouse dialect. + ```sh -curl 'https://api.supabase.com/v1/projects/{ref}/analytics/endpoints/logs.all' \ +curl 'https://api.supabase.com/v1/projects/{ref}/analytics/endpoints/logs' \ --get \ --header 'Authorization: Bearer YOUR_SECRET_TOKEN' \ - --data-urlencode 'sql=SELECT datetime(timestamp), status_code, path, event_message - FROM edge_logs - CROSS JOIN UNNEST(metadata) AS metadata - CROSS JOIN UNNEST(response) AS response - WHERE status_code >= 400 - ORDER BY timestamp DESC - LIMIT 100' \ + --data-urlencode "sql=select + timestamp, + log_attributes['response.status_code'] as status_code, + log_attributes['request.path'] as path, + event_message + from logs + where source = 'edge_logs' + and toInt32OrZero(log_attributes['response.status_code']) >= 400 + order by timestamp desc + limit 100" \ --data-urlencode 'iso_timestamp_start=2025-03-23T00:00:00Z' \ --data-urlencode 'iso_timestamp_end=2025-03-23T01:00:00Z' ``` diff --git a/apps/docs/content/guides/storage/cdn/metrics.mdx b/apps/docs/content/guides/storage/cdn/metrics.mdx index 13818122f1482..139010a6214c9 100644 --- a/apps/docs/content/guides/storage/cdn/metrics.mdx +++ b/apps/docs/content/guides/storage/cdn/metrics.mdx @@ -5,46 +5,39 @@ description: 'Learn how Supabase Storage caches objects with a CDN.' sidebar_label: 'CDN' --- -Cache hits can be determined via the `metadata.response.headers.cf_cache_status` key in our [Logs Explorer](/docs/guides/monitoring-and-debugging/logs#logs-explorer). Any value that corresponds to either `HIT`, `STALE`, `REVALIDATED`, or `UPDATING` is categorized as a cache hit. +Cache hits can be determined via the `log_attributes['response.headers.cf_cache_status']` key in the [logs](/docs/guides/monitoring-and-debugging/logs). Any value that corresponds to either `HIT`, `STALE`, `REVALIDATED`, or `UPDATING` is categorized as a cache hit. The following example query will show the top cache misses from the `edge_logs`: ```sql select - r.path as path, - r.search as search, - count(id) as count -from - edge_logs as f - cross join unnest(f.metadata) as m - cross join unnest(m.request) as r - cross join unnest(m.response) as res - cross join unnest(res.headers) as h -where - starts_with(r.path, '/storage/v1/object') - and r.method = 'GET' - and h.cf_cache_status in ('MISS', 'NONE/UNKNOWN', 'EXPIRED', 'BYPASS', 'DYNAMIC') + log_attributes['request.path'] as path, + log_attributes['request.search'] as search, + count() as count +from logs +where source = 'edge_logs' + and startsWith(log_attributes['request.path'], '/storage/v1/object') + and log_attributes['request.method'] = 'GET' + and log_attributes['response.headers.cf_cache_status'] in ('MISS', 'NONE/UNKNOWN', 'EXPIRED', 'BYPASS', 'DYNAMIC') group by path, search order by count desc limit 50; ``` -Try out [this query](/dashboard/project/_/logs/explorer?q=%0Aselect%0A++r.path+as+path%2C%0A++r.search+as+search%2C%0A++count%28id%29+as+count%0Afrom%0A++edge_logs+as+f%0A++cross+join+unnest%28f.metadata%29+as+m%0A++cross+join+unnest%28m.request%29+as+r%0A++cross+join+unnest%28m.response%29+as+res%0A++cross+join+unnest%28res.headers%29+as+h%0Awhere%0A++starts_with%28r.path%2C+%27%2Fstorage%2Fv1%2Fobject%27%29%0A++and+r.method+%3D+%27GET%27%0A++and+h.cf_cache_status+in+%28%27MISS%27%2C+%27NONE%2FUNKNOWN%27%2C+%27EXPIRED%27%2C+%27BYPASS%27%2C+%27DYNAMIC%27%29%0Agroup+by+path%2C+search%0Aorder+by+count+desc%0Alimit+50%3B) in the Logs Explorer. +Try out [this query](/dashboard/project/_/sql/new?skip=true&source=logs&content=select%0A%20%20log_attributes%5B%27request.path%27%5D%20as%20path%2C%0A%20%20log_attributes%5B%27request.search%27%5D%20as%20search%2C%0A%20%20count%28%29%20as%20count%0Afrom%20logs%0Awhere%20source%20%3D%20%27edge_logs%27%0A%20%20and%20startsWith%28log_attributes%5B%27request.path%27%5D%2C%20%27/storage/v1/object%27%29%0A%20%20and%20log_attributes%5B%27request.method%27%5D%20%3D%20%27GET%27%0A%20%20and%20log_attributes%5B%27response.headers.cf_cache_status%27%5D%20in%20%28%27MISS%27%2C%20%27NONE/UNKNOWN%27%2C%20%27EXPIRED%27%2C%20%27BYPASS%27%2C%20%27DYNAMIC%27%29%0Agroup%20by%20path%2C%20search%0Aorder%20by%20count%20desc%0Alimit%2050%3B) in the SQL Editor. Your cache hit ratio over time can then be determined using the following query: ```sql select - timestamp_trunc(timestamp, hour) as timestamp, - countif(h.cf_cache_status in ('HIT', 'STALE', 'REVALIDATED', 'UPDATING')) / count(f.id) as ratio -from - edge_logs as f - cross join unnest(f.metadata) as m - cross join unnest(m.request) as r - cross join unnest(m.response) as res - cross join unnest(res.headers) as h -where starts_with(r.path, '/storage/v1/object') and r.method = 'GET' + toStartOfHour(timestamp) as timestamp, + countIf(log_attributes['response.headers.cf_cache_status'] in ('HIT', 'STALE', 'REVALIDATED', 'UPDATING')) / count() as ratio +from logs +where source = 'edge_logs' + and startsWith(log_attributes['request.path'], '/storage/v1/object') + and log_attributes['request.method'] = 'GET' group by timestamp -order by timestamp desc; +order by timestamp desc +limit 100; ``` -Try out [this query](/dashboard/project/_/logs/explorer?q=%0Aselect%0A++timestamp_trunc%28timestamp%2C+hour%29+as+timestamp%2C%0A++countif%28h.cf_cache_status+in+%28%27HIT%27%2C+%27STALE%27%2C+%27REVALIDATED%27%2C+%27UPDATING%27%29%29+%2F+count%28f.id%29+as+ratio%0Afrom%0A++edge_logs+as+f%0A++cross+join+unnest%28f.metadata%29+as+m%0A++cross+join+unnest%28m.request%29+as+r%0A++cross+join+unnest%28m.response%29+as+res%0A++cross+join+unnest%28res.headers%29+as+h%0Awhere+starts_with%28r.path%2C+%27%2Fstorage%2Fv1%2Fobject%27%29+and+r.method+%3D+%27GET%27%0Agroup+by+timestamp%0Aorder+by+timestamp+desc%3B) in the Logs Explorer. +Try out [this query](/dashboard/project/_/sql/new?skip=true&source=logs&content=select%0A%20%20toStartOfHour%28timestamp%29%20as%20timestamp%2C%0A%20%20countIf%28log_attributes%5B%27response.headers.cf_cache_status%27%5D%20in%20%28%27HIT%27%2C%20%27STALE%27%2C%20%27REVALIDATED%27%2C%20%27UPDATING%27%29%29%20/%20count%28%29%20as%20ratio%0Afrom%20logs%0Awhere%20source%20%3D%20%27edge_logs%27%0A%20%20and%20startsWith%28log_attributes%5B%27request.path%27%5D%2C%20%27/storage/v1/object%27%29%0A%20%20and%20log_attributes%5B%27request.method%27%5D%20%3D%20%27GET%27%0Agroup%20by%20timestamp%0Aorder%20by%20timestamp%20desc%0Alimit%20100%3B) in the SQL Editor. diff --git a/apps/docs/content/guides/storage/debugging/logs.mdx b/apps/docs/content/guides/storage/debugging/logs.mdx index 27848a61b9a85..6586e3828fb1b 100644 --- a/apps/docs/content/guides/storage/debugging/logs.mdx +++ b/apps/docs/content/guides/storage/debugging/logs.mdx @@ -7,7 +7,7 @@ sidebar_label: 'Debugging' The [Storage Logs](/dashboard/project/_/logs/storage-logs) provide a convenient way to examine all incoming request logs to your Storage service. You can filter by time and keyword searches. -For more advanced filtering needs, use the [Logs Explorer](/dashboard/project/_/logs/explorer) to query the Storage logs dataset directly. The Logs Explorer is separate from the SQL Editor and uses a subset of the BigQuery SQL syntax rather than traditional SQL. +For more advanced filtering needs, use the [SQL Editor](/dashboard/project/_/sql/new?skip=true&source=logs) with the query source set to **Logs** to query the Storage logs directly. A Logs query runs ClickHouse SQL rather than Postgres SQL. Every log line is a row in the `logs` table, tagged by a `source` column, with structured fields in a `log_attributes` map. @@ -15,24 +15,21 @@ For more details on filtering the log tables, see [Advanced Log Filtering](/docs -## Example Storage queries for the Logs Explorer +## Example Storage queries ### Filter by status 5XX error ```sql select id, - storage_logs.timestamp, + timestamp, event_message, - r.statusCode, - e.message as errorMessage, - e.raw as rawError -from - storage_logs - cross join unnest(metadata) as m - cross join unnest(m.res) as r - cross join unnest(m.error) as e -where r.statusCode >= 500 + toInt32OrZero(log_attributes['res.statusCode']) as statusCode, + log_attributes['error.message'] as errorMessage, + log_attributes['error.raw'] as rawError +from logs +where source = 'storage_logs' + and toInt32OrZero(log_attributes['res.statusCode']) >= 500 order by timestamp desc limit 100; ``` @@ -42,17 +39,14 @@ limit 100; ```sql select id, - storage_logs.timestamp, + timestamp, event_message, - r.statusCode, - e.message as errorMessage, - e.raw as rawError -from - storage_logs - cross join unnest(metadata) as m - cross join unnest(m.res) as r - cross join unnest(m.error) as e -where r.statusCode >= 400 and r.statusCode < 500 + toInt32OrZero(log_attributes['res.statusCode']) as statusCode, + log_attributes['error.message'] as errorMessage, + log_attributes['error.raw'] as rawError +from logs +where source = 'storage_logs' + and toInt32OrZero(log_attributes['res.statusCode']) between 400 and 499 order by timestamp desc limit 100; ``` @@ -60,12 +54,10 @@ limit 100; ### Filter by method ```sql -select id, storage_logs.timestamp, event_message, r.method -from - storage_logs - cross join unnest(metadata) as m - cross join unnest(m.req) as r -where r.method in ("POST") +select id, timestamp, event_message, log_attributes['req.method'] as method +from logs +where source = 'storage_logs' + and log_attributes['req.method'] in ('POST') order by timestamp desc limit 100; ``` @@ -73,12 +65,10 @@ limit 100; ### Filter by IP address ```sql -select id, storage_logs.timestamp, event_message, r.remoteAddress -from - storage_logs - cross join unnest(metadata) as m - cross join unnest(m.req) as r -where r.remoteAddress in ("IP_ADDRESS") +select id, timestamp, event_message, log_attributes['req.remoteAddress'] as remoteAddress +from logs +where source = 'storage_logs' + and log_attributes['req.remoteAddress'] in ('IP_ADDRESS') order by timestamp desc limit 100; ``` diff --git a/apps/docs/content/guides/storage/serving/bandwidth.mdx b/apps/docs/content/guides/storage/serving/bandwidth.mdx index 8bdf13e981276..815133e81031e 100644 --- a/apps/docs/content/guides/storage/serving/bandwidth.mdx +++ b/apps/docs/content/guides/storage/serving/bandwidth.mdx @@ -10,26 +10,24 @@ sidebar_label: 'Bandwidth & Storage Egress' Free Plan Organizations in Supabase have a limit of 10 GB of bandwidth (5 GB cached + 5 GB uncached). This limit is calculated by the sum of all the data transferred from the Supabase servers to the client. This includes all the data transferred from the database, storage, and functions. -### Checking Storage egress requests in Logs Explorer +### Checking Storage egress requests in the SQL Editor -We have a template query that you can use to get the number of requests for each object in [Logs Explorer](/dashboard/project/_/logs/explorer/templates). +You can use the following query to get the number of requests for each object. Run it in the [SQL Editor](/dashboard/project/_/sql/new?skip=true&source=logs) with the query source set to **Logs**. ```sql select - request.method as http_verb, - request.path as filepath, - (responseHeaders.cf_cache_status = 'HIT') as cached, - count(*) as num_requests -from - edge_logs - cross join unnest(metadata) as metadata - cross join unnest(metadata.request) as request - cross join unnest(metadata.response) as response - cross join unnest(response.headers) as responseHeaders -where - (path like '%storage/v1/object/%' or path like '%storage/v1/render/%') - and request.method = 'GET' -group by 1, 2, 3 + log_attributes['request.method'] as http_verb, + log_attributes['request.path'] as filepath, + (log_attributes['response.headers.cf_cache_status'] = 'HIT') as cached, + count() as num_requests +from logs +where source = 'edge_logs' + and ( + log_attributes['request.path'] like '%storage/v1/object/%' + or log_attributes['request.path'] like '%storage/v1/render/%' + ) + and log_attributes['request.method'] = 'GET' +group by http_verb, filepath, cached order by num_requests desc limit 100; ``` @@ -41,13 +39,13 @@ Example of the output: { "filepath": "/storage/v1/object/sign/large%20bucket/20230902_200037.gif", "http_verb": "GET", - "cached": true, + "cached": 1, "num_requests": 100 }, { "filepath": "/storage/v1/object/public/demob/Sports/volleyball.png", "http_verb": "GET", - "cached": false, + "cached": 0, "num_requests": 168 } ] diff --git a/apps/docs/content/troubleshooting/database-api-42501-errors.mdx b/apps/docs/content/troubleshooting/database-api-42501-errors.mdx index fe0db64c9a859..4e8a84cd550b7 100644 --- a/apps/docs/content/troubleshooting/database-api-42501-errors.mdx +++ b/apps/docs/content/troubleshooting/database-api-42501-errors.mdx @@ -13,24 +13,22 @@ http_status_code = 403 code = "42501" --- -[Postgres 42501 errors](https://www.postgresql.org/docs/current/errcodes-appendix.html), often reported by clients as 401 or 403 errors, imply the request lacked adequate privileges. They can be viewed in the [log explorer](/dashboard/project/_/logs/explorer?q=select%0A++++cast%28postgres_logs.timestamp+as+datetime%29+as+timestamp%2C%0A++++event_message%2C%0A++++parsed.error_severity%2C%0A++++parsed.user_name%2C%0A++++parsed.query%2C%0A++++parsed.detail%2C%0A++++parsed.hint%2C%0A++++parsed.sql_state_code%2C%0A++++parsed.backend_type%0Afrom%0A++++postgres_logs%0A++++cross+join+unnest%28metadata%29+as+metadata%0A++++cross+join+unnest%28metadata.parsed%29+as+parsed%0Awhere%0A++++parsed.sql_state_code+%3D+%2742501%27%0Aorder+by%0A++++timestamp+desc%0Alimit+100%3B%0A) by running: +[Postgres 42501 errors](https://www.postgresql.org/docs/current/errcodes-appendix.html), often reported by clients as 401 or 403 errors, imply the request lacked adequate privileges. They can be viewed in the [SQL Editor](/dashboard/project/_/sql/new?skip=true&source=logs&content=select%0A%20%20timestamp%2C%0A%20%20event_message%2C%0A%20%20log_attributes%5B%27parsed.error_severity%27%5D%20as%20error_severity%2C%0A%20%20log_attributes%5B%27parsed.user_name%27%5D%20as%20user_name%2C%0A%20%20log_attributes%5B%27parsed.query%27%5D%20as%20query%2C%0A%20%20log_attributes%5B%27parsed.detail%27%5D%20as%20detail%2C%0A%20%20log_attributes%5B%27parsed.hint%27%5D%20as%20hint%0Afrom%20logs%0Awhere%0A%20%20source%20%3D%20%27postgres_logs%27%0A%20%20and%20log_attributes%5B%27parsed.error_severity%27%5D%20in%20%28%27ERROR%27%2C%20%27FATAL%27%2C%20%27PANIC%27%29%0A%20%20and%20log_attributes%5B%27parsed.sql_state_code%27%5D%20%3D%20%2742501%27%0Aorder%20by%20timestamp%20desc%0Alimit%20100%3B) by running: ```sql select - cast(postgres_logs.timestamp as datetime) as timestamp, + timestamp, event_message, - parsed.error_severity, - parsed.user_name, - parsed.query, - parsed.detail, - parsed.hint -from - postgres_logs - cross join unnest(metadata) as metadata - cross join unnest(metadata.parsed) as parsed + log_attributes['parsed.error_severity'] as error_severity, + log_attributes['parsed.user_name'] as user_name, + log_attributes['parsed.query'] as query, + log_attributes['parsed.detail'] as detail, + log_attributes['parsed.hint'] as hint +from logs where - regexp_contains(parsed.error_severity, 'ERROR|FATAL|PANIC') - and parsed.sql_state_code = '42501' + source = 'postgres_logs' + and log_attributes['parsed.error_severity'] in ('ERROR', 'FATAL', 'PANIC') + and log_attributes['parsed.sql_state_code'] = '42501' order by timestamp desc limit 100; ``` diff --git a/apps/docs/content/troubleshooting/discovering-and-interpreting-api-errors-in-the-logs-7xREI9.mdx b/apps/docs/content/troubleshooting/discovering-and-interpreting-api-errors-in-the-logs-7xREI9.mdx index 69b09ddfba4a2..d45b79326c0d6 100644 --- a/apps/docs/content/troubleshooting/discovering-and-interpreting-api-errors-in-the-logs-7xREI9.mdx +++ b/apps/docs/content/troubleshooting/discovering-and-interpreting-api-errors-in-the-logs-7xREI9.mdx @@ -11,39 +11,35 @@ database_id = "188986c9-019d-4f26-baaf-6f58cec8fa7a" ## Navigating the API logs: -The Database API is powered by a [ PostgREST web-server](https://postgrest.org/en/v12/), recording every request to the API Edge Network logs. To precisely navigate them, use the [Log Explorer](/dashboard/project/_/logs/explorer). These logs are managed through [Logflare](/blog/supabase-logs-self-hosted) and can be queried with a subset of BigQuery SQL syntax. +The Database API is powered by a [ PostgREST web-server](https://postgrest.org/en/v12/), recording every request to the API Edge Network logs. To precisely navigate them, use the [SQL Editor](/dashboard/project/_/sql/new?skip=true&source=logs) with the query source set to **Logs**. These logs run on ClickHouse. Every log line is a row in a single `logs` table, tagged by a `source` column. -The log table that contains API requests is `edge_logs`. +API requests are the rows where `source = 'edge_logs'`. Notably, it contains: | field | description | --------|-------------| | event_message | the log's message | | timestamp | time event was recorded | -| request metadata | metadata about the REST request | -| response metadata | metadata about the REST response | +| log_attributes | structured request and response fields, keyed by dotted path | -The request and response columns are arrays in the metadata field and must be unnested. This is done with a `cross join`. +Request and response details live in the `log_attributes` map. Read a field with bracket access, keeping the full dotted key. There are no unnesting joins. -**Unnesting example** +**Field access example** ```sql select - -- the event message does not require unnesting + -- event_message is a column, so it needs no lookup event_message, - -- unnested status_code column from metadata.response field - status_code -from - edge_logs - -- Unpack data stored in the 'metadata' field - cross join unnest(metadata) as metadata - -- After unpacking the 'metadata' field, extract the 'response' field from it - cross join unnest(response) as response; + -- response.status_code is a log_attributes key + log_attributes['response.status_code'] as status_code +from logs +where source = 'edge_logs' +limit 100; ``` The most useful fields for debugging are: -> NOTE: not every field is included below. For a full list, check the API Edge field reference in the [Log Explorer](/dashboard/project/_/logs/explorer) +> NOTE: not every field is included below. For a full list, check the API Edge [field reference](/docs/guides/monitoring-and-debugging/logs#logs-field-reference) ### Request object @@ -68,15 +64,10 @@ The most useful fields for debugging are: ```sql select - city -from - edge_logs --- Unpack 'metadata' field -cross join unnest(metadata) AS metadata --- unpack 'request' from 'metadata' -cross join unnest(request) AS request; --- unpack 'cf' from 'request' -cross join unnest(cf) AS cf; + log_attributes['request.cf.city'] as city +from logs +where source = 'edge_logs' +limit 100; ``` #### IP and browser/environment data: @@ -96,15 +87,10 @@ cross join unnest(cf) AS cf; ```sql select - cf_connecting_ip -from - edge_logs --- Unpack 'metadata' field -cross join unnest(metadata) AS metadata --- unpack 'request' from 'metadata' -cross join unnest(request) AS request; --- unpack 'headers' from 'request' -cross join unnest(headers) AS headers; + log_attributes['request.headers.cf_connecting_ip'] as cf_connecting_ip +from logs +where source = 'edge_logs' +limit 100; ``` #### Query type and formatting data: @@ -114,27 +100,22 @@ cross join unnest(headers) AS headers; - identify problematic queries - identify unusual behavior by authenticated users -| Column | Description | Sample value | -| --------------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | -| request.method | Request Method (PATCH, GET, PUT...) | GET | -| request.url | Request URL, which contains the PostgREST formatted query | https://yuhplfrsdxxxtldakizi.supabase.co/rest/v1/users?select=username&id=eq.63b6190e-214f-4b8a-b72d-3af6e1921411&limit=1 | -| request.sb.auth_users | authenticated user's ID | 63b6190e-214f-4b8a-b72d-3af6e1921411 | +| Column | Description | Sample value | +| -------------------------------------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | +| request.method | Request Method (PATCH, GET, PUT...) | GET | +| request.url | Request URL, which contains the PostgREST formatted query | https://yuhplfrsdxxxtldakizi.supabase.co/rest/v1/users?select=username&id=eq.63b6190e-214f-4b8a-b72d-3af6e1921411&limit=1 | +| request.sb.jwt.authorization.payload.subject | authenticated user's ID | 63b6190e-214f-4b8a-b72d-3af6e1921411 | **Unnesting example:** ```sql select - method, - url, - auth_users -from - edge_logs --- Unpack 'metadata' field -cross join unnest(metadata) AS metadata --- unpack 'request' from 'metadata' -cross join unnest(request) AS request; --- unpack 'sb' from 'request' -cross join unnest(sb) AS sb; + log_attributes['request.method'] as method, + log_attributes['request.url'] as url, + log_attributes['request.sb.jwt.authorization.payload.subject'] as auth_user +from logs +where source = 'edge_logs' +limit 100; ``` ### Response object @@ -153,13 +134,10 @@ cross join unnest(sb) AS sb; ```sql select - status_code -from - edge_logs - -- Unpack 'metadata' field - cross join unnest(metadata) as metadata - -- unpack 'response' from 'metadata' - cross join unnest(response) as response; + log_attributes['response.status_code'] as status_code +from logs +where source = 'edge_logs' +limit 100; ``` ## Finding errors @@ -191,24 +169,20 @@ Example: ```sql select - cast(timestamp as datetime) as timestamp, - status_code, - url, + timestamp, + log_attributes['response.status_code'] as status_code, + log_attributes['request.url'] as url, event_message -from edge_logs -cross join unnest(metadata) as metadata -cross join unnest(response) AS request; -cross join unnest(response) AS response; +from logs where + source = 'edge_logs' -- find all errors - status_code >= 400 - and - -- find queries featuring the a specific and - ( - regexp_contains(url, '') - and - regexp_contains(event_message, '|') - ) + and toInt32OrZero(log_attributes['response.status_code']) >= 400 + -- find queries featuring a specific and + and match(log_attributes['request.url'], '') + and match(event_message, '|') +order by timestamp desc +limit 100; ``` PostgREST has an [error reference table](https://postgrest.org/en/v12/references/errors.html) that you can use to interpret status codes. @@ -219,30 +193,25 @@ However, some errors that are reported through the Database API occur at the Pos ```sql select - cast(postgres_logs.timestamp as datetime) as timestamp, - error_severity, - user_name, - query, - detail, - sql_state_code, + timestamp, + log_attributes['parsed.error_severity'] as error_severity, + log_attributes['parsed.user_name'] as user_name, + log_attributes['parsed.query'] as query, + log_attributes['parsed.detail'] as detail, + log_attributes['parsed.sql_state_code'] as sql_state_code, event_message -from postgres_logs - cross join unnest(metadata) as metadata - cross join unnest(metadata.parsed) as parsed +from logs where + source = 'postgres_logs' -- filter only for error events - regexp_contains(parsed.error_severity, 'ERROR|FATAL|PANIC') - and + and log_attributes['parsed.error_severity'] in ('ERROR', 'FATAL', 'PANIC') -- All DB API requests are registered as the authenticator role - parsed.user_name = 'authenticator' - and + and log_attributes['parsed.user_name'] = 'authenticator' -- find failed queries featuring the function - regexp_contains(parsed.query, '') - and + and match(log_attributes['parsed.query'], '') -- limit the time of the search to be around the time of the failed API request -postgres_logs.timestamp between '2024-04-15 10:50:00' AND '2024-04-15 10:50:27' -order by - timestamp desc + and timestamp between '2024-04-15 10:50:00' and '2024-04-15 10:50:27' +order by timestamp desc limit 100; ``` @@ -258,77 +227,69 @@ In some cases, errors may emerge because of Cloudflare or PostgREST server error ```sql select - cast(timestamp as datetime) as timestamp, - status_code, + timestamp, + log_attributes['response.status_code'] as status_code, event_message, - path -from - edge_logs - cross join unnest(metadata) as metadata - cross join unnest(response) as response - cross join unnest(request) as request + log_attributes['request.path'] as path +from logs where + source = 'edge_logs' -- find all errors - status_code >= 400 - and regexp_contains(path, '^/rest/v1/'); --- only look at DB API + and toInt32OrZero(log_attributes['response.status_code']) >= 400 + -- only look at DB API + and match(log_attributes['request.path'], '^/rest/v1/') +order by timestamp desc +limit 100; ``` **Group errors by path and code:** ```sql select - status_code, - path, - count(path) as reoccurrence_per_path -from - edge_logs - cross join unnest(metadata) as metadata - cross join unnest(response) as response - cross join unnest(request) as request + log_attributes['response.status_code'] as status_code, + log_attributes['request.path'] as path, + count() as reoccurrence_per_path +from logs where + source = 'edge_logs' -- find all errors - status_code >= 400 - and regexp_contains(path, '^/rest/v1/') -- only look at DB API + and toInt32OrZero(log_attributes['response.status_code']) >= 400 + and match(log_attributes['request.path'], '^/rest/v1/') -- only look at DB API group by path, status_code -order by reoccurrence_per_path; +order by reoccurrence_per_path desc +limit 100; ``` **Find requests by region:** ```sql select - path, - region, - count(region) as region_count -from - edge_logs - cross join unnest(metadata) as metadata - cross join unnest(request) as request - cross join unnest(cf) as cf + log_attributes['request.path'] as path, + log_attributes['request.cf.region'] as region, + count() as region_count +from logs where + source = 'edge_logs' -- only look at DB API - regexp_contains(path, '^/rest/v1/') + and match(log_attributes['request.path'], '^/rest/v1/') group by region, path -order by requester_region_count; +order by region_count desc +limit 100; ``` **Find total requests by IP:** ```sql select - cf_connecting_ip as ip, - count(cf_connecting_ip) as ip_count -from - edge_logs - cross join unnest(metadata) as metadata - cross join unnest(request) as request - cross join unnest(headers) as headers - cross join unnest(cf) as cf - cross join unnest(response) as response -where regexp_contains(path, '^/auth/v1/') + log_attributes['request.headers.cf_connecting_ip'] as ip, + count() as ip_count +from logs +where + source = 'edge_logs' + and match(log_attributes['request.path'], '^/auth/v1/') group by ip -order by ip_count; +order by ip_count desc +limit 100; ``` **Search frequented query paths by authenticated user:** @@ -336,16 +297,15 @@ order by ip_count; ```sql select -- only available for front-end clients - auth_users, - path, - count(auth_users) as ip_count -from - edge_logs - cross join unnest(metadata) as metadata - cross join unnest(request) as request - cross join unnest(sb) as sb + log_attributes['request.sb.jwt.authorization.payload.subject'] as auth_user, + log_attributes['request.path'] as path, + count() as request_count +from logs where + source = 'edge_logs' -- only look at DB API - regexp_contains(path, '^/rest/v1/') -group by auth_users, path; + and match(log_attributes['request.path'], '^/rest/v1/') +group by auth_user, path +order by request_count desc +limit 100; ``` diff --git a/apps/docs/content/troubleshooting/edge-function-401-error-response.mdx b/apps/docs/content/troubleshooting/edge-function-401-error-response.mdx index 3a074478749f7..4721aaa63da99 100644 --- a/apps/docs/content/troubleshooting/edge-function-401-error-response.mdx +++ b/apps/docs/content/troubleshooting/edge-function-401-error-response.mdx @@ -39,42 +39,36 @@ Go to: [Your function returned a 401](#your-function-returned-a-401) ### Case 3: Not sure -Run this query in [Log Explorer](/dashboard/project/_/logs/explorer?q=SELECT%0A++++cast%28timestamp+AS+datetime%29++AS+timestamp%2C%0A++++req.pathname+++++++++++++++++AS+function_name%2C%0A%0A++++CASE%0A++++++++WHEN+metadata.execution_id+IS+NOT+NULL%0A++++++++++++THEN+%27your_code_returned_401%27%0A++++++++WHEN+metadata.execution_id+IS+NULL%0A+++++++++AND+%28new_auth.prefix+IS+NOT+NULL+OR+legacy_payload.algorithm+<>+%27HS256%27%29%0A++++++++++++THEN+%27incompatible_keys%27%0A++++++++WHEN+metadata.execution_id+IS+NULL%0A++++++++AND+%0A++++++++++++%28%0A++++++++++++++++%28legacy_auth_data.invalid+IS+NOT+NULL+OR+new_auth.error+IS+NOT+NULL%29%0A++++++++++++++++++++OR%0A++++++++++++++++legacy_payload.algorithm+%3D+%27HS256%27%0A++++++++++++%29%0A++++++++++++THEN+%27invalid_key%27%0A++++++++WHEN+metadata.execution_id+IS+NULL%0A+++++++++AND+legacy_auth_data+++++++IS+NULL%0A+++++++++AND+new_auth.prefix+IS+NULL%0A++++++++++++THEN+%27missing_auth_header%27%0A++++END+AS+cause%0A%0AFROM+function_edge_logs%0A%0A++++--+unnesting+metadata%0A++++CROSS+JOIN+UNNEST%28metadata%29++++++++++AS+metadata%0A++++CROSS+JOIN+UNNEST%28metadata.request%29++AS+req%0A++++CROSS+JOIN+UNNEST%28metadata.response%29+AS+res%0A++++--+unnesting+auth+details%0A++++LEFT+JOIN+UNNEST%28req.sb%29++++++++++++++++++++AS+sb%0A++++LEFT+JOIN+UNNEST%28sb.apikey%29+++++++++++++++++AS+apikey%0A++++LEFT+JOIN+UNNEST%28apikey.authorization%29++++++AS+new_auth%0A++++LEFT+JOIN+UNNEST%28sb.jwt%29++++++++++++++++++++AS+legacy_jwt%0A++++LEFT+JOIN+UNNEST%28legacy_jwt.authorization%29++AS+legacy_auth_data%0A++++LEFT+JOIN+UNNEST%28legacy_auth_data.payload%29++AS+legacy_payload%0A%0AWHERE+res.status_code+%3D+401%0AORDER+BY+timestamp+DESC%0ALIMIT+200) to classify recent 401s: +Run this query in the [SQL Editor](/dashboard/project/_/sql/new?skip=true&source=logs&content=select%0A%20%20timestamp%2C%0A%20%20log_attributes%5B%27request.pathname%27%5D%20as%20function_name%2C%0A%20%20case%0A%20%20%20%20when%20log_attributes%5B%27execution_id%27%5D%20%21%3D%20%27%27%20then%20%27your_code_returned_401%27%0A%20%20%20%20when%20log_attributes%5B%27execution_id%27%5D%20%3D%20%27%27%0A%20%20%20%20and%20%28%0A%20%20%20%20%20%20log_attributes%5B%27request.sb.apikey.apikey.prefix%27%5D%20%21%3D%20%27%27%0A%20%20%20%20%20%20or%20%28%0A%20%20%20%20%20%20%20%20log_attributes%5B%27request.sb.jwt.authorization.payload.algorithm%27%5D%20%21%3D%20%27%27%0A%20%20%20%20%20%20%20%20and%20log_attributes%5B%27request.sb.jwt.authorization.payload.algorithm%27%5D%20%21%3D%20%27HS256%27%0A%20%20%20%20%20%20%29%0A%20%20%20%20%29%20then%20%27incompatible_keys%27%0A%20%20%20%20when%20log_attributes%5B%27execution_id%27%5D%20%3D%20%27%27%0A%20%20%20%20and%20%28%0A%20%20%20%20%20%20log_attributes%5B%27request.sb.jwt.authorization.invalid%27%5D%20%21%3D%20%27%27%0A%20%20%20%20%20%20or%20log_attributes%5B%27request.sb.apikey.apikey.error%27%5D%20%21%3D%20%27%27%0A%20%20%20%20%20%20or%20log_attributes%5B%27request.sb.jwt.authorization.payload.algorithm%27%5D%20%3D%20%27HS256%27%0A%20%20%20%20%29%20then%20%27invalid_key%27%0A%20%20%20%20when%20log_attributes%5B%27execution_id%27%5D%20%3D%20%27%27%0A%20%20%20%20and%20log_attributes%5B%27request.sb.jwt.authorization.payload.algorithm%27%5D%20%3D%20%27%27%0A%20%20%20%20and%20log_attributes%5B%27request.sb.apikey.apikey.prefix%27%5D%20%3D%20%27%27%20then%20%27missing_auth_header%27%0A%20%20end%20as%20cause%0Afrom%20logs%0Awhere%0A%20%20source%20%3D%20%27function_edge_logs%27%0A%20%20and%20toInt32OrZero%28log_attributes%5B%27response.status_code%27%5D%29%20%3D%20401%0Aorder%20by%20timestamp%20desc%0Alimit%2050%3B) to classify recent 401s: ```sql select - cast(timestamp as datetime) as timestamp, - req.pathname as function_name, + timestamp, + log_attributes['request.pathname'] as function_name, case - when metadata.execution_id is not null then 'your_code_returned_401' - when metadata.execution_id is null + when log_attributes['execution_id'] != '' then 'your_code_returned_401' + when log_attributes['execution_id'] = '' and ( - new_auth.prefix is not null - or legacy_payload.algorithm != 'HS256' + log_attributes['request.sb.apikey.apikey.prefix'] != '' + or ( + log_attributes['request.sb.jwt.authorization.payload.algorithm'] != '' + and log_attributes['request.sb.jwt.authorization.payload.algorithm'] != 'HS256' + ) ) then 'incompatible_keys' - when metadata.execution_id is null + when log_attributes['execution_id'] = '' and ( - (legacy_auth_data.invalid is not null or new_auth.error is not null) - or legacy_payload.algorithm = 'HS256' + log_attributes['request.sb.jwt.authorization.invalid'] != '' + or log_attributes['request.sb.apikey.apikey.error'] != '' + or log_attributes['request.sb.jwt.authorization.payload.algorithm'] = 'HS256' ) then 'invalid_key' - when metadata.execution_id is null - and legacy_auth_data is null - and new_auth.prefix is null then 'missing_auth_header' + when log_attributes['execution_id'] = '' + and log_attributes['request.sb.jwt.authorization.payload.algorithm'] = '' + and log_attributes['request.sb.apikey.apikey.prefix'] = '' then 'missing_auth_header' end as cause -from - function_edge_logs - -- unnesting metadata - cross join UNNEST(metadata) as metadata - cross join UNNEST(metadata.request) as req - cross join UNNEST(metadata.response) as res - -- unnesting auth details - left join UNNEST(req.sb) as sb - left join UNNEST(sb.apikey) as apikey - left join UNNEST(apikey.authorization) as new_auth - left join UNNEST(sb.jwt) as legacy_jwt - left join UNNEST(legacy_jwt.authorization) as legacy_auth_data - left join UNNEST(legacy_auth_data.payload) as legacy_payload -where res.status_code = 401 +from logs +where + source = 'function_edge_logs' + and toInt32OrZero(log_attributes['response.status_code']) = 401 order by timestamp desc limit 50; ``` diff --git a/apps/docs/content/troubleshooting/edge-function-404-error-response.mdx b/apps/docs/content/troubleshooting/edge-function-404-error-response.mdx index c3016171c83ea..ae1aae9a6af68 100644 --- a/apps/docs/content/troubleshooting/edge-function-404-error-response.mdx +++ b/apps/docs/content/troubleshooting/edge-function-404-error-response.mdx @@ -74,28 +74,26 @@ The difference between the two errors is: -Always configure an appropriate time frame when using the log explorer +Always configure an appropriate time frame when querying the logs ![image](/docs/img/troubleshooting/edge_function_404_set_timeframe.png) -You cannot inspect the function dashboard to find platform 404 errors, instead, run the below query in the [log explorer](). The results show all requests that reached Supabase but were rejected as unrecognizable. +You cannot inspect the function dashboard to find platform 404 errors, instead, run the below query in the [SQL Editor](/dashboard/project/_/sql/new?skip=true&source=logs&content=select%20distinct%0A%20%20log_attributes%5B%27request.pathname%27%5D%20as%20function_name%2C%0A%20%20log_attributes%5B%27response.status_code%27%5D%20as%20status_code%2C%0A%20%20case%0A%20%20%20%20when%20log_attributes%5B%27execution_id%27%5D%20%3D%20%27%27%20then%20%27FUNCTION_NOT_FOUND%27%0A%20%20%20%20else%20%27FUNCTION%20RECOGNIZED%3A%20custom%20404%20message%20in%20app%20logic%27%0A%20%20end%20as%20type_of_404%0Afrom%20logs%0Awhere%0A%20%20source%20%3D%20%27function_edge_logs%27%0A%20%20and%20toInt32OrZero%28log_attributes%5B%27response.status_code%27%5D%29%20%3D%20404%0Alimit%2010%3B). The results show all requests that reached Supabase but were rejected as unrecognizable. ```sql select distinct - req.pathname as function_name, - res.status_code, + log_attributes['request.pathname'] as function_name, + log_attributes['response.status_code'] as status_code, case - when metadata.execution_id is null then 'FUNCTION_NOT_FOUND' + when log_attributes['execution_id'] = '' then 'FUNCTION_NOT_FOUND' else 'FUNCTION RECOGNIZED: custom 404 message in app logic' end as type_of_404 -from - function_edge_logs - cross join UNNEST(metadata) as metadata - cross join UNNEST(metadata.request) as req - cross join UNNEST(metadata.response) as res -where status_code = 404 +from logs +where + source = 'function_edge_logs' + and toInt32OrZero(log_attributes['response.status_code']) = 404 limit 10; ``` diff --git a/apps/docs/content/troubleshooting/edge-function-500-error-response.mdx b/apps/docs/content/troubleshooting/edge-function-500-error-response.mdx index 2351c1cb1e0cd..365d9847d7562 100644 --- a/apps/docs/content/troubleshooting/edge-function-500-error-response.mdx +++ b/apps/docs/content/troubleshooting/edge-function-500-error-response.mdx @@ -21,35 +21,34 @@ If you received back the below message, then go to the [JavaScript failure](#jav Internal Server Error ``` -If the body contained a custom message, or nothing at all, run the below query in [Log Explorer]() after setting the time range: +If the body contained a custom message, or nothing at all, run the below query in the [SQL Editor](/dashboard/project/_/sql/new?skip=true&source=logs&content=select%0A%20%20console_logs.event_message%2C%0A%20%20console_logs.timestamp%2C%0A%20%20invocation_events.function_name%0Afrom%0A%20%20%28%0A%20%20%20%20select%0A%20%20%20%20%20%20timestamp%2C%0A%20%20%20%20%20%20event_message%2C%0A%20%20%20%20%20%20log_attributes%5B%27execution_id%27%5D%20as%20execution_id%0A%20%20%20%20from%20logs%0A%20%20%20%20where%20source%20%3D%20%27function_logs%27%0A%20%20%20%20%20%20and%20log_attributes%5B%27level%27%5D%20%3D%20%27error%27%0A%20%20%20%20%20%20and%20log_attributes%5B%27event_type%27%5D%20in%20%28%27Log%27%2C%20%27UncaughtException%27%29%0A%20%20%20%20%20%20and%20event_message%20like%20%27%25Error%3A%25file%3A///%25%27%0A%20%20%29%20as%20console_logs%0A%20%20inner%20join%20%28%0A%20%20%20%20select%0A%20%20%20%20%20%20log_attributes%5B%27execution_id%27%5D%20as%20execution_id%2C%0A%20%20%20%20%20%20log_attributes%5B%27request.pathname%27%5D%20as%20function_name%0A%20%20%20%20from%20logs%0A%20%20%20%20where%20source%20%3D%20%27function_edge_logs%27%0A%20%20%20%20%20%20and%20toInt32OrZero%28log_attributes%5B%27response.status_code%27%5D%29%20%3D%20500%0A%20%20%29%20as%20invocation_events%20on%20console_logs.execution_id%20%3D%20invocation_events.execution_id%0Aorder%20by%20invocation_events.function_name%2C%20console_logs.timestamp%0Alimit%2050%3B) after setting the time range: ```sql select console_logs.event_message, - cast(invocation_events.timestamp as datetime) as timestamp, + console_logs.timestamp, invocation_events.function_name from - function_logs as console_logs - left join UNNEST(console_logs.metadata) as metadata on true - left join ( + ( select timestamp, - em.execution_id, - res.status_code, - req.pathname as function_name - from - function_edge_logs - left join UNNEST(metadata) as em on true - left join UNNEST(em.request) as req on true - left join UNNEST(em.response) as res on true - ) as invocation_events - on metadata.execution_id = invocation_events.execution_id -where - invocation_events.status_code = 500 - and metadata.level = 'error' - and metadata.event_type in ('Log', 'UncaughtException') - and console_logs.event_message like '%Error:%file:///%' -order by invocation_events.function_name, invocation_events.timestamp + event_message, + log_attributes['execution_id'] as execution_id + from logs + where source = 'function_logs' + and log_attributes['level'] = 'error' + and log_attributes['event_type'] in ('Log', 'UncaughtException') + and event_message like '%Error:%file:///%' + ) as console_logs + inner join ( + select + log_attributes['execution_id'] as execution_id, + log_attributes['request.pathname'] as function_name + from logs + where source = 'function_edge_logs' + and toInt32OrZero(log_attributes['response.status_code']) = 500 + ) as invocation_events on console_logs.execution_id = invocation_events.execution_id +order by invocation_events.function_name, console_logs.timestamp limit 50; ``` diff --git a/apps/docs/content/troubleshooting/edge-function-503-response.mdx b/apps/docs/content/troubleshooting/edge-function-503-response.mdx index 979414f8f2aba..db330901ecb8d 100644 --- a/apps/docs/content/troubleshooting/edge-function-503-response.mdx +++ b/apps/docs/content/troubleshooting/edge-function-503-response.mdx @@ -28,26 +28,24 @@ If you received back a `BOOT_ERROR` message, like the one below, you can jump to } ``` -Otherwise, run the below query in your [Log Explorer](/dashboard/project/_/logs/explorer?q=SELECT%0A++++req.pathname+AS+function_name%2C%0A++++res.status_code%2C%0A++++CASE%0A++++++++WHEN+metadata.execution_id+IS+NOT+NULL+AND+metadata.function_id+IS+NOT+NULL+THEN+%27app_level%27%0A++++++++WHEN+metadata.execution_id+IS+NULL+++++AND+metadata.function_id+IS+NOT+NULL+THEN+%27boot_error%27%0A++++++++WHEN+metadata.execution_id+IS+NULL+++++AND+metadata.function_id+IS+NULL+++++THEN+%27internal_failure%27%0A++++END+AS+error_type%0AFROM+function_edge_logs%0ACROSS+JOIN+UNNEST%28metadata%29+AS+metadata+%0ACROSS+JOIN+UNNEST%28metadata.request%29+AS+req+%0ACROSS+JOIN+UNNEST%28metadata.response%29+AS+res+%0AWHERE+%0A++++status_code+%3D+503+%0ALIMIT+50%3B). +Otherwise, run the below query in the [SQL Editor](/dashboard/project/_/sql/new?skip=true&source=logs&content=select%0A%20%20log_attributes%5B%27request.pathname%27%5D%20as%20function_name%2C%0A%20%20log_attributes%5B%27response.status_code%27%5D%20as%20status_code%2C%0A%20%20case%0A%20%20%20%20when%20log_attributes%5B%27execution_id%27%5D%20%21%3D%20%27%27%0A%20%20%20%20and%20log_attributes%5B%27function_id%27%5D%20%21%3D%20%27%27%20then%20%27app_level%27%0A%20%20%20%20when%20log_attributes%5B%27execution_id%27%5D%20%3D%20%27%27%0A%20%20%20%20and%20log_attributes%5B%27function_id%27%5D%20%21%3D%20%27%27%20then%20%27boot_error%27%0A%20%20%20%20when%20log_attributes%5B%27execution_id%27%5D%20%3D%20%27%27%0A%20%20%20%20and%20log_attributes%5B%27function_id%27%5D%20%3D%20%27%27%20then%20%27internal_failure%27%0A%20%20end%20as%20error_type%0Afrom%20logs%0Awhere%0A%20%20source%20%3D%20%27function_edge_logs%27%0A%20%20and%20toInt32OrZero%28log_attributes%5B%27response.status_code%27%5D%29%20%3D%20503%0Alimit%2050%3B). ```sql select - req.pathname as function_name, - res.status_code, + log_attributes['request.pathname'] as function_name, + log_attributes['response.status_code'] as status_code, case - when metadata.execution_id is not null - and metadata.function_id is not null then 'app_level' - when metadata.execution_id is null - and metadata.function_id is not null then 'boot_error' - when metadata.execution_id is null - and metadata.function_id is null then 'internal_failure' + when log_attributes['execution_id'] != '' + and log_attributes['function_id'] != '' then 'app_level' + when log_attributes['execution_id'] = '' + and log_attributes['function_id'] != '' then 'boot_error' + when log_attributes['execution_id'] = '' + and log_attributes['function_id'] = '' then 'internal_failure' end as error_type -from - function_edge_logs - cross join UNNEST(metadata) as metadata - cross join UNNEST(metadata.request) as req - cross join UNNEST(metadata.response) as res -where status_code = 503 +from logs +where + source = 'function_edge_logs' + and toInt32OrZero(log_attributes['response.status_code']) = 503 limit 50; ``` @@ -94,32 +92,35 @@ In the [Function Dashboard](/dashboard/project/_/functions), under the affected ![image](/docs/img/troubleshooting/filter_503.png) -Alternatively, instead of using the Function Dashboard, you can programmatically find boot failure error messages in the [Log Explorer]() with the below query: +Alternatively, instead of using the Function Dashboard, you can programmatically find boot failure error messages in the [SQL Editor](/dashboard/project/_/sql/new?skip=true&source=logs&content=select%0A%20%20fl.event_message%2C%0A%20%20fl.timestamp%2C%0A%20%20fel.function_name%2C%0A%20%20fel.status_code%0Afrom%0A%20%20%28%0A%20%20%20%20select%0A%20%20%20%20%20%20timestamp%2C%0A%20%20%20%20%20%20event_message%2C%0A%20%20%20%20%20%20log_attributes%5B%27function_id%27%5D%20as%20function_id%2C%0A%20%20%20%20%20%20log_attributes%5B%27version%27%5D%20as%20version%0A%20%20%20%20from%20logs%0A%20%20%20%20where%20source%20%3D%20%27function_logs%27%0A%20%20%20%20%20%20and%20log_attributes%5B%27event_type%27%5D%20%3D%20%27BootFailure%27%0A%20%20%29%20as%20fl%0A%20%20left%20join%20%28%0A%20%20%20%20select%0A%20%20%20%20%20%20log_attributes%5B%27function_id%27%5D%20as%20function_id%2C%0A%20%20%20%20%20%20log_attributes%5B%27version%27%5D%20as%20version%2C%0A%20%20%20%20%20%20log_attributes%5B%27request.pathname%27%5D%20as%20function_name%2C%0A%20%20%20%20%20%20log_attributes%5B%27response.status_code%27%5D%20as%20status_code%0A%20%20%20%20from%20logs%0A%20%20%20%20where%20source%20%3D%20%27function_edge_logs%27%0A%20%20%29%20as%20fel%20on%20fl.function_id%20%3D%20fel.function_id%20and%20fl.version%20%3D%20fel.version%0Aorder%20by%20fl.timestamp%2C%20fel.function_name%0Alimit%2020%3B) with the below query: ```sql select fl.event_message, - content.timestamp, + fl.timestamp, fel.function_name, fel.status_code from - function_logs as fl - left join UNNEST(fl.metadata) as content on true + ( + select + timestamp, + event_message, + log_attributes['function_id'] as function_id, + log_attributes['version'] as version + from logs + where source = 'function_logs' + and log_attributes['event_type'] = 'BootFailure' + ) as fl left join ( select - em.function_id, - em.version, - req.pathname as function_name, - res.status_code - from - function_edge_logs - left join UNNEST(metadata) as em on true - left join UNNEST(em.request) as req on true - left join UNNEST(em.response) as res on true - ) as fel - on content.function_id = fel.function_id and content.version = fel.version -where content.event_type = 'BootFailure' -order by timestamp, function_name + log_attributes['function_id'] as function_id, + log_attributes['version'] as version, + log_attributes['request.pathname'] as function_name, + log_attributes['response.status_code'] as status_code + from logs + where source = 'function_edge_logs' + ) as fel on fl.function_id = fel.function_id and fl.version = fel.version +order by fl.timestamp, fel.function_name limit 20; ``` @@ -165,7 +166,6 @@ Imports can cause errors if they're not available within the edge function: ```js name=bad_imports // importing non-existent module import supabase from 'does_not_exist' - // or accessing non-existent export import { doesNotExist } from 'jsr:@supabase/functions-js' diff --git a/apps/docs/content/troubleshooting/edge-function-504-error-response.mdx b/apps/docs/content/troubleshooting/edge-function-504-error-response.mdx index 0d940386d119a..e5c4c1feccdda 100644 --- a/apps/docs/content/troubleshooting/edge-function-504-error-response.mdx +++ b/apps/docs/content/troubleshooting/edge-function-504-error-response.mdx @@ -13,41 +13,36 @@ As of now, this limit cannot be increased. If your function always needs more ti ## Step 1: Identifying slow Functions -You can filter for 504 events in the Log Explorer with the below [query](/dashboard/project/_/logs/explorer?q=select%0A++cast%28timestamp+as+datetime%29+as+timestamp%2C%0A++req.pathname%2C%0A++res.status_code%2C%0A++metadata.execution_time_ms%0Afrom%0A++function_edge_logs%0A++cross+join+UNNEST%28metadata%29+as+metadata%0A++cross+join+UNNEST%28metadata.request%29+as+req%0A++cross+join+UNNEST%28metadata.response%29+as+res%0Awhere+res.status_code+%3D+504%0Alimit+20%3B): +You can filter for 504 events in the SQL Editor with the below [query](/dashboard/project/_/sql/new?skip=true&source=logs&content=select%0A%20%20timestamp%2C%0A%20%20log_attributes%5B%27request.pathname%27%5D%20as%20pathname%2C%0A%20%20log_attributes%5B%27response.status_code%27%5D%20as%20status_code%2C%0A%20%20toFloat64OrZero%28log_attributes%5B%27execution_time_ms%27%5D%29%20as%20execution_time_ms%0Afrom%20logs%0Awhere%0A%20%20source%20%3D%20%27function_edge_logs%27%0A%20%20and%20toInt32OrZero%28log_attributes%5B%27response.status_code%27%5D%29%20%3D%20504%0Aorder%20by%20timestamp%20desc%0Alimit%2020%3B): ```sql select - cast(timestamp as datetime) as timestamp, - req.pathname, - res.status_code, - metadata.execution_time_ms -from - function_edge_logs - cross join UNNEST(metadata) as metadata - cross join UNNEST(metadata.request) as req - cross join UNNEST(metadata.response) as res -where res.status_code = 504 + timestamp, + log_attributes['request.pathname'] as pathname, + log_attributes['response.status_code'] as status_code, + toFloat64OrZero(log_attributes['execution_time_ms']) as execution_time_ms +from logs +where + source = 'function_edge_logs' + and toInt32OrZero(log_attributes['response.status_code']) = 504 +order by timestamp desc limit 20; ``` -You can further explore how much time a specific function takes on average by running the below [query](/dashboard/project/_/logs/explorer?q=select%0A++req.pathname%2C%0A++res.status_code%2C%0A++AVG%28metadata.execution_time_ms%29+AS+avg_runtime_ms%2C%0A++MIN%28metadata.execution_time_ms%29+AS+min_runtime_ms%2C%0A++MAX%28metadata.execution_time_ms%29+AS+max_runtime_ms%0Afrom%0A++function_edge_logs%0A++cross+join+UNNEST%28metadata%29+as+metadata%0A++cross+join+UNNEST%28metadata.request%29+as+req%0A++cross+join+UNNEST%28sb%29+as+sb%0A++cross+join+UNNEST%28req.headers%29+as+headers%0A++cross+join+UNNEST%28metadata.response%29+as+res%0Awhere+req.pathname+%3D+%27%2Ffunctions%2Fv1%2FYOUR_FUNCTION_NAME%27+--<---add+your+function+name+or+remove+filter%0Agroup+by+req.pathname%2C+res.status_code%0Alimit+20%3B): +You can further explore how much time a specific function takes on average by running the below [query](/dashboard/project/_/sql/new?skip=true&source=logs&content=select%0A%20%20log_attributes%5B%27request.pathname%27%5D%20as%20pathname%2C%0A%20%20log_attributes%5B%27response.status_code%27%5D%20as%20status_code%2C%0A%20%20avg%28toFloat64OrZero%28log_attributes%5B%27execution_time_ms%27%5D%29%29%20as%20avg_runtime_ms%2C%0A%20%20min%28toFloat64OrZero%28log_attributes%5B%27execution_time_ms%27%5D%29%29%20as%20min_runtime_ms%2C%0A%20%20max%28toFloat64OrZero%28log_attributes%5B%27execution_time_ms%27%5D%29%29%20as%20max_runtime_ms%0Afrom%20logs%0Awhere%0A%20%20source%20%3D%20%27function_edge_logs%27%0A%20%20and%20log_attributes%5B%27request.pathname%27%5D%20%3D%20%27/functions/v1/YOUR_FUNCTION_NAME%27%20--%20%3C---add%20your%20function%20name%20or%20remove%20filter%0Agroup%20by%20pathname%2C%20status_code%0Alimit%2020%3B): ```sql select - req.pathname, - res.status_code, - AVG(metadata.execution_time_ms) as avg_runtime_ms, - MIN(metadata.execution_time_ms) as min_runtime_ms, - MAX(metadata.execution_time_ms) as max_runtime_ms -from - function_edge_logs - cross join UNNEST(metadata) as metadata - cross join UNNEST(metadata.request) as req - cross join UNNEST(sb) as sb - cross join UNNEST(req.headers) as headers - cross join UNNEST(metadata.response) as res -where req.pathname = '/functions/v1/YOUR_FUNCTION_NAME' -- <---add your function name or remove filter -group by req.pathname, res.status_code + log_attributes['request.pathname'] as pathname, + log_attributes['response.status_code'] as status_code, + avg(toFloat64OrZero(log_attributes['execution_time_ms'])) as avg_runtime_ms, + min(toFloat64OrZero(log_attributes['execution_time_ms'])) as min_runtime_ms, + max(toFloat64OrZero(log_attributes['execution_time_ms'])) as max_runtime_ms +from logs +where + source = 'function_edge_logs' + and log_attributes['request.pathname'] = '/functions/v1/YOUR_FUNCTION_NAME' -- <---add your function name or remove filter +group by pathname, status_code limit 20; ``` @@ -122,24 +117,20 @@ If you are making requests to the same resource and the return values change irr ### Restrict request load -If the function is used to evaluate user submissions, you can restrict load size to reduce computational time. You can use the below query in the [log explorer](/dashboard/project/_/logs/explorer?q=select%0A++req.pathname+as+function_name%2C%0A++res.status_code%2C%0A++AVG%28COALESCE%28CAST%28headers.content_length+AS+INT%29%2C+0%29%29+AS+avg_content_size_in_bytes%2C%0A++MIN%28COALESCE%28CAST%28headers.content_length+AS+INT%29%2C+0%29%29+AS+min_content_size_in_bytes%2C%0A++MAX%28COALESCE%28CAST%28headers.content_length+AS+INT%29%2C+0%29%29+AS+max_content_size_in_bytes%0Afrom%0A++function_edge_logs%0A++cross+join+UNNEST%28metadata%29+as+metadata%0A++cross+join+UNNEST%28metadata.request%29+as+req%0A++cross+join+UNNEST%28sb%29+as+sb%0A++cross+join+UNNEST%28req.headers%29+as+headers%0A++cross+join+UNNEST%28metadata.response%29+as+res%0Awhere+%0A++++req.pathname+%3D+%27%2Ffunctions%2Fv1%2Finduce-504%27%0AGROUP+BY+req.pathname%2C+res.status_code+++++%0Alimit+10) to filter by the content size in bytes provided by the initial requester: +If the function is used to evaluate user submissions, you can restrict load size to reduce computational time. You can use the below query in the [SQL Editor](/dashboard/project/_/sql/new?skip=true&source=logs&content=select%0A%20%20log_attributes%5B%27request.pathname%27%5D%20as%20function_name%2C%0A%20%20log_attributes%5B%27response.status_code%27%5D%20as%20status_code%2C%0A%20%20avg%28toInt64OrZero%28log_attributes%5B%27request.headers.content_length%27%5D%29%29%20as%20avg_content_size_in_bytes%2C%0A%20%20min%28toInt64OrZero%28log_attributes%5B%27request.headers.content_length%27%5D%29%29%20as%20min_content_size_in_bytes%2C%0A%20%20max%28toInt64OrZero%28log_attributes%5B%27request.headers.content_length%27%5D%29%29%20as%20max_content_size_in_bytes%0Afrom%20logs%0Awhere%0A%20%20source%20%3D%20%27function_edge_logs%27%0A%20%20and%20log_attributes%5B%27request.pathname%27%5D%20%3D%20%27/functions/v1/induce-504%27%0Agroup%20by%20function_name%2C%20status_code%0Alimit%2010%3B) to filter by the content size in bytes provided by the initial requester: ```sql select - req.pathname as function_name, - res.status_code, - AVG(COALESCE(cast(headers.content_length as int), 0)) as avg_content_size_in_bytes, - MIN(COALESCE(cast(headers.content_length as int), 0)) as min_content_size_in_bytes, - MAX(COALESCE(cast(headers.content_length as int), 0)) as max_content_size_in_bytes -from - function_edge_logs - cross join UNNEST(metadata) as metadata - cross join UNNEST(metadata.request) as req - cross join UNNEST(sb) as sb - cross join UNNEST(req.headers) as headers - cross join UNNEST(metadata.response) as res -where req.pathname = '/functions/v1/induce-504' -group by req.pathname, res.status_code + log_attributes['request.pathname'] as function_name, + log_attributes['response.status_code'] as status_code, + avg(toInt64OrZero(log_attributes['request.headers.content_length'])) as avg_content_size_in_bytes, + min(toInt64OrZero(log_attributes['request.headers.content_length'])) as min_content_size_in_bytes, + max(toInt64OrZero(log_attributes['request.headers.content_length'])) as max_content_size_in_bytes +from logs +where + source = 'function_edge_logs' + and log_attributes['request.pathname'] = '/functions/v1/induce-504' +group by function_name, status_code limit 10; ``` diff --git a/apps/docs/content/troubleshooting/edge-function-546-error-response.mdx b/apps/docs/content/troubleshooting/edge-function-546-error-response.mdx index 942e4c6e31fbb..f5c5a59b13120 100644 --- a/apps/docs/content/troubleshooting/edge-function-546-error-response.mdx +++ b/apps/docs/content/troubleshooting/edge-function-546-error-response.mdx @@ -53,50 +53,53 @@ In the [function dashboard's](/dashboard/project/_/functions/) `Logs` tab, you c ![image](/docs/img/troubleshooting/limit_logs.png) -Alternatively, you can filter for the specific errors from the function using the [log explorer](/dashboard/project/_/logs/explorer?q=SELECT%0A++fl.event_message%2C%0A++content.timestamp%2C%0A++fel.function_name%2C%0A++fel.status_code%0AFROM+function_logs+fl%0ALEFT+JOIN+UNNEST%28fl.metadata%29+AS+content+ON+TRUE%0ALEFT+JOIN+%28%0A++SELECT%0A++++em.execution_id%2C%0A++++req.pathname+AS+function_name%2C%0A++++res.status_code%0A++FROM+function_edge_logs%0A++LEFT+JOIN+UNNEST%28metadata%29+AS+em+ON+TRUE%0A++LEFT+JOIN+UNNEST%28em.request%29+AS+req+ON+TRUE%0A++LEFT+JOIN+UNNEST%28em.response%29+AS+res+ON+TRUE%0A%29+fel+ON+content.execution_id+%3D+fel.execution_id%0AWHERE+%0A++content.level+%3D+%27error%27%0A++++AND%0A++fel.status_code+%3D+546%0AORDER+BY+function_name%2C+timestamp%0ALIMIT+5) +Alternatively, you can filter for the specific errors from the function using the [SQL Editor](/dashboard/project/_/sql/new?skip=true&source=logs&content=select%0A%20%20fl.event_message%2C%0A%20%20fl.timestamp%2C%0A%20%20fel.function_name%2C%0A%20%20fel.status_code%0Afrom%0A%20%20%28%0A%20%20%20%20select%0A%20%20%20%20%20%20timestamp%2C%0A%20%20%20%20%20%20event_message%2C%0A%20%20%20%20%20%20log_attributes%5B%27execution_id%27%5D%20as%20execution_id%0A%20%20%20%20from%20logs%0A%20%20%20%20where%20source%20%3D%20%27function_logs%27%0A%20%20%20%20%20%20and%20log_attributes%5B%27level%27%5D%20%3D%20%27error%27%0A%20%20%29%20as%20fl%0A%20%20inner%20join%20%28%0A%20%20%20%20select%0A%20%20%20%20%20%20log_attributes%5B%27execution_id%27%5D%20as%20execution_id%2C%0A%20%20%20%20%20%20log_attributes%5B%27request.pathname%27%5D%20as%20function_name%2C%0A%20%20%20%20%20%20log_attributes%5B%27response.status_code%27%5D%20as%20status_code%0A%20%20%20%20from%20logs%0A%20%20%20%20where%20source%20%3D%20%27function_edge_logs%27%0A%20%20%20%20%20%20and%20toInt32OrZero%28log_attributes%5B%27response.status_code%27%5D%29%20%3D%20546%0A%20%20%29%20as%20fel%20on%20fl.execution_id%20%3D%20fel.execution_id%0Aorder%20by%20fl.timestamp%2C%20fel.function_name%0Alimit%2020%3B) ```sql select fl.event_message, - content.timestamp, + fl.timestamp, fel.function_name, fel.status_code from - function_logs as fl - left join UNNEST(fl.metadata) as content on true - left join ( + ( select - em.execution_id, - req.pathname as function_name, - res.status_code - from - function_edge_logs - left join UNNEST(metadata) as em on true - left join UNNEST(em.request) as req on true - left join UNNEST(em.response) as res on true - ) as fel - on content.execution_id = fel.execution_id -where content.level = 'error' and fel.status_code = 546 -order by timestamp, function_name + timestamp, + event_message, + log_attributes['execution_id'] as execution_id + from logs + where source = 'function_logs' + and log_attributes['level'] = 'error' + ) as fl + inner join ( + select + log_attributes['execution_id'] as execution_id, + log_attributes['request.pathname'] as function_name, + log_attributes['response.status_code'] as status_code + from logs + where source = 'function_edge_logs' + and toInt32OrZero(log_attributes['response.status_code']) = 546 + ) as fel on fl.execution_id = fel.execution_id +order by fl.timestamp, fel.function_name limit 20; ``` ## Step 2: Check error frequency -Before optimizing, run the below query in the [Log Explorer](/dashboard/project/_/logs/explorer?q=SELECT%0A++COUNT%28id%29+AS+total_responses%2C%0A++COUNTIF%28response.status_code+%3D+546%29+AS+total_546%2C%0A++SAFE_DIVIDE%28COUNTIF%28response.status_code+%3D+546%29%2C+COUNT%28*%29%29+*+100+AS+pct_546%0AFROM+function_edge_logs%0ACROSS+JOIN+UNNEST%28function_edge_logs.metadata%29+AS+metadata%0ACROSS+JOIN+UNNEST%28metadata.response%29+AS+response%0ACROSS+JOIN+UNNEST%28metadata.request%29+AS+request%0AWHERE+pathname+%3D+%27%2Ffunctions%2Fv1%2FYOUR_FUNCTION_NAME%27+) to understand how often 546s are occurring relative to total requests: +Before optimizing, run the below query in the [SQL Editor](/dashboard/project/_/sql/new?skip=true&source=logs&content=select%0A%20%20count%28%29%20as%20total_responses%2C%0A%20%20countIf%28toInt32OrZero%28log_attributes%5B%27response.status_code%27%5D%29%20%3D%20546%29%20as%20total_546%2C%0A%20%20countIf%28toInt32OrZero%28log_attributes%5B%27response.status_code%27%5D%29%20%3D%20546%29%20/%20count%28%29%20%2A%20100%20as%20pct_546%0Afrom%20logs%0Awhere%0A%20%20source%20%3D%20%27function_edge_logs%27%0A%20%20and%20log_attributes%5B%27request.method%27%5D%20%21%3D%20%27OPTIONS%27%0A%20%20--%20%3C--%20add%20your%20function%20name%20to%20inspect%20specific%20endpoints%0A%20%20and%20log_attributes%5B%27request.pathname%27%5D%20%3D%20%27/functions/v1/YOUR_FUNCTION_NAME%27%0Alimit%201%3B) to understand how often 546s are occurring relative to total requests: ```sql select - COUNT(id) as total_responses, - COUNTIF(response.status_code = 546) as total_546, - SAFE_DIVIDE(COUNTIF(response.status_code = 546), COUNT(*)) * 100 as pct_546 -from - function_edge_logs - cross join UNNEST(function_edge_logs.metadata) as metadata - cross join UNNEST(metadata.response) as response - cross join UNNEST(metadata.request) as request -where method != 'OPTIONS' and pathname = '/functions/v1/YOUR_FUNCTION_NAME'; --- <-- add your function name to inspect specific endpoints + count() as total_responses, + countIf(toInt32OrZero(log_attributes['response.status_code']) = 546) as total_546, + countIf(toInt32OrZero(log_attributes['response.status_code']) = 546) / count() * 100 as pct_546 +from logs +where + source = 'function_edge_logs' + and log_attributes['request.method'] != 'OPTIONS' + -- <-- add your function name to inspect specific endpoints + and log_attributes['request.pathname'] = '/functions/v1/YOUR_FUNCTION_NAME' +limit 1; ``` Depending on the results, you may be able to determine if the event is an edge case or affecting a function's overall behavior. @@ -150,19 +153,19 @@ There are a few other queries that may be useful for identifying patterns around ```sql select - COUNT(id) as total_responses, - version, - COUNTIF(response.status_code = 546) as total_546, - SAFE_DIVIDE(COUNTIF(response.status_code = 546), COUNT(*)) * 100 as pct_546 -from - function_edge_logs - cross join UNNEST(function_edge_logs.metadata) as metadata - cross join UNNEST(metadata.response) as response - cross join UNNEST(metadata.request) as request -where method != 'OPTIONS' and pathname = '/functions/v1/FUNCTION_NAME' -- <--OPTIONAL FILTER: add specific function name to target query + count() as total_responses, + log_attributes['version'] as version, + countIf(toInt32OrZero(log_attributes['response.status_code']) = 546) as total_546, + countIf(toInt32OrZero(log_attributes['response.status_code']) = 546) / count() * 100 as pct_546 +from logs +where + source = 'function_edge_logs' + and log_attributes['request.method'] != 'OPTIONS' + and log_attributes['request.pathname'] = '/functions/v1/FUNCTION_NAME' -- <--OPTIONAL FILTER: add specific function name to target query group by version having pct_546 > 5 -- <--Failure percentage threshold. The query only shows versions with a 5% or above 546 error rate -order by pct_546; +order by pct_546 +limit 100; ``` @@ -175,26 +178,20 @@ order by pct_546; You can check to see how frequent 546 errors are per hour with the below query: ```sql - -SELECT -FORMAT_TIMESTAMP("%Y-%m-%d %H:00", TIMESTAMP(timestamp), "UTC") AS hour, -COUNT(id) AS total_responses, - -COUNTIF(response.status_code = 546) AS total_546, - -SAFE_DIVIDE(COUNTIF(response.status_code = 546), COUNT(id)) \* 100 -AS pct_546 - -FROM function_edge_logs -CROSS JOIN UNNEST(function_edge_logs.metadata) AS metadata -CROSS JOIN UNNEST(metadata.response) AS response -CROSS JOIN UNNEST(metadata.request) AS request -WHERE pathname = '/functions/v1/FUNCTION_NAME'--<--OPTIONAL FILTER: add specific function name to target query -group by hour -ORDER by hour DESC -LIMIT 24; - -```` + select + formatDateTime(toStartOfHour(timestamp), '%Y-%m-%d %H:00', 'UTC') as hour, + count() as total_responses, + countIf(toInt32OrZero(log_attributes['response.status_code']) = 546) as total_546, + countIf(toInt32OrZero(log_attributes['response.status_code']) = 546) / count() * 100 as pct_546 + from logs + where + source = 'function_edge_logs' + -- <--OPTIONAL FILTER: add specific function name to target query + and log_attributes['request.pathname'] = '/functions/v1/FUNCTION_NAME' + group by hour + order by hour desc + limit 24; + ``` The output may look like: ![image](/docs/img/troubleshooting/546_errors_by_hour.png) @@ -212,30 +209,30 @@ LIMIT 24; If your isolates are serving more than 2 requests before retiring, it suggests variability in how much processing each request needs. In that case, you may want to cross compare your successful requests with your failed ones. Maybe there's a query parameter or specific content-length header that makes failures more likely. ```sql - -SELECT -COUNT(fel.id) AS requests_served, -metadata.execution_id AS isolate_id -FROM function_logs -LEFT JOIN UNNEST(function_logs.metadata) AS metadata ON TRUE -LEFT JOIN ( -SELECT -em.execution_id, -id, -pathname, -method -FROM function_edge_logs -LEFT JOIN UNNEST(function_edge_logs.metadata) AS em ON TRUE -LEFT JOIN UNNEST(em.request) AS req ON TRUE -) fel ON metadata.execution_id = fel.execution_id -WHERE -metadata.reason IN ('Memory', 'CPUTime') -AND -method <> 'OPTIONS' --ignore OPTION requests -AND -pathname = '/functions/v1/FUNCTION_NAME' --<-- add your function name to inspect specific endpoints -GROUP BY metadata.execution_id -```` + select + count() as requests_served, + fl.execution_id as isolate_id + from + ( + select log_attributes['execution_id'] as execution_id + from logs + where source = 'function_logs' + and log_attributes['reason'] in ('Memory', 'CPUTime') + ) as fl + inner join ( + select + log_attributes['execution_id'] as execution_id, + log_attributes['request.pathname'] as pathname, + log_attributes['request.method'] as method + from logs + where source = 'function_edge_logs' + and log_attributes['request.method'] != 'OPTIONS' --ignore OPTION requests + -- <-- add your function name to inspect specific endpoints + and log_attributes['request.pathname'] = '/functions/v1/FUNCTION_NAME' + ) as fel on fl.execution_id = fel.execution_id + group by isolate_id + limit 100; + ``` diff --git a/apps/docs/content/troubleshooting/how-to-interpret-and-explore-the-postgres-logs-OuCIOj.mdx b/apps/docs/content/troubleshooting/how-to-interpret-and-explore-the-postgres-logs-OuCIOj.mdx index 5ed6eea10b049..f2045b422d32c 100644 --- a/apps/docs/content/troubleshooting/how-to-interpret-and-explore-the-postgres-logs-OuCIOj.mdx +++ b/apps/docs/content/troubleshooting/how-to-interpret-and-explore-the-postgres-logs-OuCIOj.mdx @@ -35,42 +35,39 @@ Logs provide insights into Postgres operations. They help meet compliance requir ### Querying logs -The most practical way to explore and filter logs is through the [Logs Explorer](/dashboard/project/_/logs/explorer). +The most practical way to explore and filter logs is through the [SQL Editor](/dashboard/project/_/sql/new?skip=true&source=logs), with the query source set to **Logs**. -It uses a subset of BigQuery SQL syntax and pre-parses queries for optimization. This imposes three primary limitations: +It runs ClickHouse SQL and pre-parses queries for optimization. This imposes two primary limitations: -- No subqueries or `WITH` statements - No `*` wildcards for column names -- No `ILIKE` statements +- A maximum of 1000 rows per run -Although there are many strategies to filter logs, such as `like` and `in` statements, a helper function called [`regexp_contains`](https://github.com/orgs/supabase/discussions/22640) provides the most flexibility and control. +Although there are many strategies to filter logs, such as `like` and `in` statements, the [`match`](https://clickhouse.com/docs/sql-reference/functions/string-search-functions#match) function provides the most flexibility and control. -The `postgres_logs` table contains Postgres events. +Postgres events are the rows in the `logs` table where `source = 'postgres_logs'`. -#### `postgres_logs` table structure +#### `logs` table structure -The table contains 3 fundamental columns: +Every log source shares one `logs` table. These are the columns you use most: -| column | description | -| --------------- | ----------------------- | -| event_message | the log's message | -| timestamp | time event was recorded | -| parsed metadata | metadata about event | +| column | description | +| -------------- | -------------------------------------------------- | +| event_message | the log's message | +| timestamp | time event was recorded | +| source | the service the log came from | +| log_attributes | structured per-source fields, keyed by dotted path | -The parsed metadata column is an array that contains relevant information about events. To access the information, it must be unnested. This is done with a `cross join`. +Postgres-specific details live in the `log_attributes` map. Read a field with bracket access, keeping the full dotted key. There are no unnesting joins. -**Unnesting example** +**Field access example** ```sql select event_message, - parsed. -from - postgres_logs --- Unpack data stored in the 'metadata' field -cross join unnest(metadata) AS metadata --- After unpacking the 'metadata' field, extract the 'parsed' field from it -cross join unnest(parsed) AS parsed; + log_attributes['parsed.'] as +from logs +where source = 'postgres_logs' +limit 100; ``` #### Parsed metadata fields @@ -213,7 +210,7 @@ Filter by the `parsed.user_name` role to only retrieve logs made by specific rol ... query where -- find events from the relevant role - parsed.user_name = '' + log_attributes['parsed.user_name'] = '' ... ``` @@ -225,32 +222,30 @@ Queries from the Supabase Dashboard are executed under the `postgres` role and i -- find queries executed by the Dashboard ...query where - regexp_contains(parsed.query, '-- source: dashboard') + match(log_attributes['parsed.query'], '-- source: dashboard') ``` ### Full example for finding errors ```sql select - cast(postgres_logs.timestamp as datetime) as timestamp, + timestamp, event_message, - parsed.error_severity, - parsed.user_name, - parsed.query, - parsed.detail, - parsed.hint, - parsed.sql_state_code, - parsed.backend_type -from - postgres_logs - cross join unnest(metadata) as metadata - cross join unnest(metadata.parsed) as parsed + log_attributes['parsed.error_severity'] as error_severity, + log_attributes['parsed.user_name'] as user_name, + log_attributes['parsed.query'] as query, + log_attributes['parsed.detail'] as detail, + log_attributes['parsed.hint'] as hint, + log_attributes['parsed.sql_state_code'] as sql_state_code, + log_attributes['parsed.backend_type'] as backend_type +from logs where - regexp_contains(parsed.error_severity, 'ERROR|FATAL|PANIC') - and parsed.user_name = 'postgres' - and regexp_contains(event_message, 'duration|operator') - and not regexp_contains(parsed.query, '') - and postgres_logs.timestamp between '2024-04-15 10:50:00' and '2024-04-15 10:50:27' + source = 'postgres_logs' + and log_attributes['parsed.error_severity'] in ('ERROR', 'FATAL', 'PANIC') + and log_attributes['parsed.user_name'] = 'postgres' + and match(event_message, 'duration|operator') + and not match(log_attributes['parsed.query'], '') + and timestamp between '2024-04-15 10:50:00' and '2024-04-15 10:50:27' order by timestamp desc limit 100; ``` @@ -273,10 +268,10 @@ You should take care when using the extension to not log all database events, bu ... query where -- all pg_audit recorded events start with 'AUDIT' - regexp_contains(event_message, '^AUDIT') + match(event_message, '^AUDIT') and -- Finding queries executed from the relevant role (e.g., 'API_role') - parsed.user_name = 'API_role' + log_attributes['parsed.user_name'] = 'API_role' ``` ### Filtering by IP @@ -291,17 +286,15 @@ IP tracking is most effective when consistently relying on direct database conne -- filter by IP select event_message, - connection_from as ip, - count(connection_from) as ip_count -from - postgres_logs - cross join unnest(metadata) as metadata - cross join unnest(parsed) as parsed + log_attributes['parsed.connection_from'] as ip, + count() as ip_count +from logs where - regexp_contains(user_name, '') - and regexp_contains(backend_type, 'client backend') -- only search for connections from outside the database (excludes cron jobs) - and regexp_contains(event_message, '^connection authenticated') -- only view successful authentication events -group by connection_from, event_message + source = 'postgres_logs' + and log_attributes['parsed.user_name'] = '' + and log_attributes['parsed.backend_type'] = 'client backend' -- only search for connections from outside the database (excludes cron jobs) + and match(event_message, '^connection authenticated') -- only view successful authentication events +group by ip, event_message order by ip_count desc limit 100; ``` diff --git a/apps/docs/content/troubleshooting/pgcron-debugging-guide-n1KTaz.mdx b/apps/docs/content/troubleshooting/pgcron-debugging-guide-n1KTaz.mdx index 7602ce6a06433..f2d72bb059c36 100644 --- a/apps/docs/content/troubleshooting/pgcron-debugging-guide-n1KTaz.mdx +++ b/apps/docs/content/troubleshooting/pgcron-debugging-guide-n1KTaz.mdx @@ -125,29 +125,27 @@ It is important to make sure you are running the latest release of pg_cron (1.6.
-#### Check the log explorer for more information +#### Check the logs for more information -Although `pg_cron` records errors in the `cron.job_run_details` table, in rare cases, more information can be found in the general Postgres logs. You can check the [Log Explorer](/dashboard/project/_/logs/explorer) for failure events with the following query +Although `pg_cron` records errors in the `cron.job_run_details` table, in rare cases, more information can be found in the general Postgres logs. You can check the [SQL Editor](/dashboard/project/_/sql/new?skip=true&source=logs), with the query source set to **Logs**, for failure events with the following query ```sql select - cast(postgres_logs.timestamp as datetime) as timestamp, + timestamp, event_message, - parsed.error_severity, - parsed.user_name, - parsed.query, - parsed.detail, - parsed.hint, - parsed.sql_state_code, - parsed.backend_type, - parsed.application_name -from - postgres_logs - cross join unnest(metadata) as metadata - cross join unnest(metadata.parsed) as parsed + log_attributes['parsed.error_severity'] as error_severity, + log_attributes['parsed.user_name'] as user_name, + log_attributes['parsed.query'] as query, + log_attributes['parsed.detail'] as detail, + log_attributes['parsed.hint'] as hint, + log_attributes['parsed.sql_state_code'] as sql_state_code, + log_attributes['parsed.backend_type'] as backend_type, + log_attributes['parsed.application_name'] as application_name +from logs where - regexp_contains(parsed.error_severity, 'ERROR|FATAL|PANIC') - and regexp_contains(parsed.application_name, 'pg_cron') + source = 'postgres_logs' + and log_attributes['parsed.error_severity'] in ('ERROR', 'FATAL', 'PANIC') + and match(log_attributes['parsed.application_name'], 'pg_cron') order by timestamp desc limit 100; ``` diff --git a/apps/docs/content/troubleshooting/resolving-500-status-authentication-errors-7bU5U8.mdx b/apps/docs/content/troubleshooting/resolving-500-status-authentication-errors-7bU5U8.mdx index c3dd78fd39e05..37945fdee892e 100644 --- a/apps/docs/content/troubleshooting/resolving-500-status-authentication-errors-7bU5U8.mdx +++ b/apps/docs/content/troubleshooting/resolving-500-status-authentication-errors-7bU5U8.mdx @@ -16,9 +16,9 @@ A 500 error in Auth typically indicates an issue with an external dependency, su ### Prerequisites -#### Open the log explorer +#### Open the SQL Editor -Ensure you have access to the [Dashboard's Log Explorer](/dashboard/project/_/logs/explorer) and set the time range appropriately: +Ensure you have access to the [SQL Editor](/dashboard/project/_/sql/new?skip=true&source=logs), set the query source to **Logs**, and set the time range appropriately: ![image](/docs/img/troubleshooting/152d65ad-f0ed-47cf-8dcb-1e31c6221e71.png) @@ -40,22 +40,20 @@ Use the following SQL query to check for any recent errors the Auth server encou ```sql select - cast(postgres_logs.timestamp as datetime) as timestamp, + timestamp, event_message, - parsed.error_severity, - parsed.user_name, - parsed.query, - parsed.detail, - parsed.hint, - parsed.sql_state_code, - parsed.backend_type -from - postgres_logs - cross join unnest(metadata) as metadata - cross join unnest(metadata.parsed) as parsed + log_attributes['parsed.error_severity'] as error_severity, + log_attributes['parsed.user_name'] as user_name, + log_attributes['parsed.query'] as query, + log_attributes['parsed.detail'] as detail, + log_attributes['parsed.hint'] as hint, + log_attributes['parsed.sql_state_code'] as sql_state_code, + log_attributes['parsed.backend_type'] as backend_type +from logs where - regexp_contains(parsed.error_severity, 'ERROR|FATAL|PANIC') - and regexp_contains(parsed.user_name, 'supabase_auth_admin') + source = 'postgres_logs' + and log_attributes['parsed.error_severity'] in ('ERROR', 'FATAL', 'PANIC') + and log_attributes['parsed.user_name'] = 'supabase_auth_admin' order by timestamp desc limit 100; ``` @@ -147,24 +145,26 @@ If you made any customizations to the auth schema, such as adding RLS, modifying #### Query for Auth errors -Run this SQL query in the Log Explorer to find Auth-related errors: +Run this SQL query in the SQL Editor to find Auth-related errors: ```sql select - cast(metadata.timestamp as datetime) as timestamp, - msg, + timestamp, + log_attributes['msg'] as msg, event_message, - status, - path, - level -from auth_logs -cross join unnest(metadata) as metadata + log_attributes['status'] as status, + log_attributes['path'] as path, + log_attributes['level'] as level +from logs where - -- find all errors - status::INT = 500 - OR - regexp_contains(level, 'error|fatal') + source = 'auth_logs' + -- find all errors + and ( + toInt32OrZero(log_attributes['status']) = 500 + or log_attributes['level'] in ('error', 'fatal') + ) order by timestamp +limit 100; ``` #### Database migration errors diff --git a/apps/docs/content/troubleshooting/running-explain-analyze-on-functions.mdx b/apps/docs/content/troubleshooting/running-explain-analyze-on-functions.mdx index 35ec1fc84e943..54c8b5d652077 100644 --- a/apps/docs/content/troubleshooting/running-explain-analyze-on-functions.mdx +++ b/apps/docs/content/troubleshooting/running-explain-analyze-on-functions.mdx @@ -43,19 +43,19 @@ ALTER ROLE postgres SET auto_explain.log_min_duration = '.5s'; After running your test, you should be able to find the plan in the [Postgres logs](/dashboard/project/_/logs/postgres-logs?s=duration:). The auto_explain module always starts logs with the term "duration:", which can be used as a filter keyword. -You can also filter for the specific function in the [log explorer](/dashboard/project/_/logs/explorer?q=select%0A++cast%28postgres_logs.timestamp+as+datetime%29+as+timestamp%2C%0A++event_message+AS+query_and_plan%2C%0A++parsed.user_name%2C%0A++parsed.context%0Afrom%0A++postgres_logs%0A++cross+join+unnest%28metadata%29+as+metadata%0A++cross+join+unnest%28metadata.parsed%29+as+parsed%0Awhere%0A++regexp_contains%28event_message%2C+%27duration%3A%27%29%0A++AND%0A++regexp_contains%28context%2C+%27example_func%27%29+--%3C----ADD+FUNCTION+NAME+HERE.+IS+CASE+SENSITIVE%0Aorder+by+timestamp+desc%0Alimit+100%3B) with the below query: +You can also filter for the specific function in the [SQL Editor](/dashboard/project/_/sql/new?skip=true&source=logs&content=select%0A%20%20timestamp%2C%0A%20%20event_message%20as%20query_and_plan%2C%0A%20%20log_attributes%5B%27parsed.user_name%27%5D%20as%20user_name%2C%0A%20%20log_attributes%5B%27parsed.context%27%5D%20as%20context%0Afrom%20logs%0Awhere%0A%20%20source%20%3D%20%27postgres_logs%27%0A%20%20and%20match%28event_message%2C%20%27duration%3A%27%29%0A%20%20and%20match%28log_attributes%5B%27parsed.context%27%5D%2C%20%27%28%3Fi%29FUNCTION_NAME%27%29%0Aorder%20by%20timestamp%20desc%0Alimit%20100%3B) with the below query: ```sql select - cast(postgres_logs.timestamp as datetime) as timestamp, + timestamp, event_message as query_and_plan, - parsed.user_name, - parsed.context -from - postgres_logs - cross join unnest(metadata) as metadata - cross join unnest(metadata.parsed) as parsed -where regexp_contains(event_message, 'duration:') and regexp_contains(context, '(?i)FUNCTION_NAME') + log_attributes['parsed.user_name'] as user_name, + log_attributes['parsed.context'] as context +from logs +where + source = 'postgres_logs' + and match(event_message, 'duration:') + and match(log_attributes['parsed.context'], '(?i)FUNCTION_NAME') order by timestamp desc limit 100; ``` diff --git a/apps/docs/content/troubleshooting/tracking-postgres-role-activity-to-specific-dashboard-users-8d3715.mdx b/apps/docs/content/troubleshooting/tracking-postgres-role-activity-to-specific-dashboard-users-8d3715.mdx index 9a5618bfeb89b..a24c3c98154fe 100644 --- a/apps/docs/content/troubleshooting/tracking-postgres-role-activity-to-specific-dashboard-users-8d3715.mdx +++ b/apps/docs/content/troubleshooting/tracking-postgres-role-activity-to-specific-dashboard-users-8d3715.mdx @@ -74,33 +74,31 @@ Now you can directly match `user_id` values from the Postgres logs to the corres ## **Querying logs for specific operations** -Navigate to the [Logs Explorer](/dashboard/project/_/logs/explorer) and query `postgres_logs`. Here's an example query that searches for data-modifying operations and maps user IDs to team members: +Navigate to the [SQL Editor](/dashboard/project/_/sql/new?skip=true&source=logs), set the query source to **Logs**, and query `postgres_logs`. Here's an example query that searches for data-modifying operations and maps user IDs to team members: ```sql -SELECT - DATETIME(postgres_logs.timestamp) AS time, - parsed.session_id, - postgres_logs.identifier, - parsed.user_name AS db_role, - CASE - WHEN REGEXP_CONTAINS(postgres_logs.event_message, 'f8c2e1a9-3b4d-4f7e-8c9a-1d2e3f4a5b6c') - THEN 'john@example.com' - WHEN REGEXP_CONTAINS(postgres_logs.event_message, 'insert another-uuid-here') - THEN 'jane@example.io' - ELSE 'unknown' - END AS detected_user, - parsed.error_severity, - postgres_logs.event_message -FROM postgres_logs -CROSS JOIN UNNEST(metadata) AS metadata -CROSS JOIN UNNEST(parsed) AS parsed -WHERE postgres_logs.timestamp > TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 7 DAY) - AND ( - REGEXP_CONTAINS(postgres_logs.event_message, '(?i)DELETE|TRUNCATE|UPDATE|ALTER|DROP') - OR REGEXP_CONTAINS(parsed.query, '(?i)DELETE|TRUNCATE|UPDATE|ALTER|DROP') +select + timestamp as time, + log_attributes['parsed.session_id'] as session_id, + log_attributes['identifier'] as identifier, + log_attributes['parsed.user_name'] as db_role, + case + when match(event_message, 'f8c2e1a9-3b4d-4f7e-8c9a-1d2e3f4a5b6c') then 'john@example.com' + when match(event_message, 'insert another-uuid-here') then 'jane@example.io' + else 'unknown' + end as detected_user, + log_attributes['parsed.error_severity'] as error_severity, + event_message +from logs +where + source = 'postgres_logs' + and timestamp > now() - interval 7 day + and ( + match(event_message, '(?i)DELETE|TRUNCATE|UPDATE|ALTER|DROP') + or match(log_attributes['parsed.query'], '(?i)DELETE|TRUNCATE|UPDATE|ALTER|DROP') ) -ORDER BY postgres_logs.timestamp DESC -LIMIT 500; +order by timestamp desc +limit 500; ``` This query: diff --git a/apps/docs/features/docs/GuidesMdx.utils.tsx b/apps/docs/features/docs/GuidesMdx.utils.tsx index 70a23ed0961b4..ab1d34c649453 100644 --- a/apps/docs/features/docs/GuidesMdx.utils.tsx +++ b/apps/docs/features/docs/GuidesMdx.utils.tsx @@ -9,6 +9,7 @@ import { generateOpenGraphImageMeta } from '~/features/seo/openGraph' import { BASE_PATH } from '~/lib/constants' import { getCustomContent } from '~/lib/custom-content/getCustomContent' import { GUIDES_DIRECTORY, isValidGuideFrontmatter, type GuideFrontmatter } from '~/lib/docs' +import { mdAlternate } from '~/lib/md-alternates' import { GuideModelLoader } from '~/resources/guide/guideModelLoader' import { fromMarkdown } from 'mdast-util-from-markdown' import { gfmFromMarkdown } from 'mdast-util-gfm' @@ -182,7 +183,7 @@ const genGuideMeta = canonical: meta.canonical || `${BASE_PATH}${pathname}`, types: { ...(parentAlternates?.types ?? {}), - 'text/markdown': `${BASE_PATH}${pathname}.md`, + ...mdAlternate(pathname.replace(/^\/guides\//, '')), }, }, openGraph: { diff --git a/apps/docs/features/docs/TroubleshootingSection.page.tsx b/apps/docs/features/docs/TroubleshootingSection.page.tsx index 9fdeb8b61738d..3cb49e2ca3799 100644 --- a/apps/docs/features/docs/TroubleshootingSection.page.tsx +++ b/apps/docs/features/docs/TroubleshootingSection.page.tsx @@ -1,18 +1,18 @@ -import { type Metadata } from 'next' - -import { TroubleshootingHeader, TroubleshootingEntries } from '~/features/docs/Troubleshooting.ui' +import { TroubleshootingEntries, TroubleshootingHeader } from '~/features/docs/Troubleshooting.ui' import { TroubleshootingFilterEmptyState, TroubleshootingListController, } from '~/features/docs/Troubleshooting.ui.client' import { - type ITroubleshootingMetadata, getTroubleshootingEntriesByTopic, getTroubleshootingErrorsByTopic, getTroubleshootingKeywordsByTopic, + type ITroubleshootingMetadata, } from '~/features/docs/Troubleshooting.utils' import { PROD_URL } from '~/lib/constants' import { getCustomContent } from '~/lib/custom-content/getCustomContent' +import { mdAlternate } from '~/lib/md-alternates' +import { type Metadata } from 'next' const { metadataTitle } = getCustomContent(['metadata:title']) @@ -66,6 +66,7 @@ export function generateSectionTroubleshootingMetadata( title: `${metadataTitle ?? 'Supabase'} | ${sectionName} Troubleshooting`, alternates: { canonical: `${PROD_URL}/guides/${topic}/troubleshooting`, + types: mdAlternate(`${topic}/troubleshooting`), }, } } diff --git a/apps/docs/lib/md-alternates.test.ts b/apps/docs/lib/md-alternates.test.ts new file mode 100644 index 0000000000000..9aa9296f111a3 --- /dev/null +++ b/apps/docs/lib/md-alternates.test.ts @@ -0,0 +1,117 @@ +import { promises as fs } from 'node:fs' +import path from 'node:path' +import { PROD_URL } from '~/lib/constants' +import { describe, expect, it, vi } from 'vitest' + +import { mdAlternate } from './md-alternates' + +vi.mock('~/public/markdown/manifest.json', () => ({ + default: [ + 'getting-started/quickstarts/react', + 'troubleshooting/all-about-supabase-egress-a_Sg_e', + 'troubleshooting', + ], +})) + +describe('mdAlternate', () => { + it('returns the absolute .md sibling for a manifest-listed guide slug', () => { + expect(mdAlternate('getting-started/quickstarts/react')).toEqual({ + 'text/markdown': `${PROD_URL}/guides/getting-started/quickstarts/react.md`, + }) + }) + + it('returns the sibling for a troubleshooting entry', () => { + expect(mdAlternate('troubleshooting/all-about-supabase-egress-a_Sg_e')).toEqual({ + 'text/markdown': `${PROD_URL}/guides/troubleshooting/all-about-supabase-egress-a_Sg_e.md`, + }) + }) + + it('returns the sibling for the troubleshooting index', () => { + expect(mdAlternate('troubleshooting')).toEqual({ + 'text/markdown': `${PROD_URL}/guides/troubleshooting.md`, + }) + }) + + it('returns undefined for slugs without generated markdown', () => { + expect(mdAlternate('database/extensions/wrappers/s3')).toBeUndefined() + expect(mdAlternate('local-development/cli/config')).toBeUndefined() + }) +}) + +const WIRING: [string, string][] = [ + ['features/docs/GuidesMdx.utils.tsx', 'mdAlternate('], + ['app/guides/troubleshooting/[slug]/page.tsx', 'mdAlternate(`troubleshooting/${slug}`)'], + ['app/guides/troubleshooting/page.tsx', "mdAlternate('troubleshooting')"], + ['features/docs/TroubleshootingSection.page.tsx', 'mdAlternate(`${topic}/troubleshooting`)'], +] + +describe('markdown alternate wiring', () => { + it('every tag emitter routes through mdAlternate', async () => { + for (const [file, wiring] of WIRING) { + const source = await fs.readFile(path.join(process.cwd(), file), 'utf-8') + expect(source.includes(wiring), `${file} must contain "${wiring}"`).toBe(true) + } + }) + + it('the generator registers the troubleshooting index in the manifest', async () => { + const source = await fs.readFile( + path.join(process.cwd(), 'internals/generate-guides-markdown.ts'), + 'utf-8' + ) + expect(source.includes("renderManifest(sources, ['troubleshooting'])")).toBe(true) + }) +}) + +const SCAN_ROOTS = ['app', 'components', 'features', 'lib', 'internals'] +const ALLOWED_MD_LITERAL_FILES = new Set([ + 'lib/md-alternates.ts', + 'app/api/guides-md/[...slug]/route.ts', +]) + +function isScannableSource(fileName: string): boolean { + return ( + /\.(ts|tsx)$/.test(fileName) && + !/\.test\.(ts|tsx)$/.test(fileName) && + !fileName.endsWith('.d.ts') + ) +} + +async function collectSourceFiles(dir: string): Promise { + const dirents = await fs.readdir(dir, { withFileTypes: true }) + const files: string[] = [] + for (const dirent of dirents) { + const full = path.join(dir, dirent.name) + if (dirent.isDirectory()) { + if (dirent.name === 'node_modules') continue + files.push(...(await collectSourceFiles(full))) + } else if (isScannableSource(dirent.name)) { + files.push(full) + } + } + return files +} + +describe('no hardcoded text/markdown outside the helper', () => { + it('every text/markdown occurrence lives in an allowed file', async () => { + const rootFiles = (await fs.readdir(process.cwd(), { withFileTypes: true })) + .filter((dirent) => dirent.isFile() && isScannableSource(dirent.name)) + .map((dirent) => path.join(process.cwd(), dirent.name)) + const nestedFiles = ( + await Promise.all( + SCAN_ROOTS.map((root) => collectSourceFiles(path.join(process.cwd(), root))) + ) + ).flat() + + const offenders = ( + await Promise.all( + [...rootFiles, ...nestedFiles].map(async (file) => { + const rel = path.relative(process.cwd(), file) + if (ALLOWED_MD_LITERAL_FILES.has(rel)) return null + const source = await fs.readFile(file, 'utf-8') + return source.includes('text/markdown') ? rel : null + }) + ) + ).filter((rel): rel is string => rel !== null) + expect(offenders, `hardcoded text/markdown in: ${offenders.join(', ')}`).toEqual([]) + }) +}) diff --git a/apps/docs/lib/md-alternates.ts b/apps/docs/lib/md-alternates.ts new file mode 100644 index 0000000000000..6e2294acbc091 --- /dev/null +++ b/apps/docs/lib/md-alternates.ts @@ -0,0 +1,9 @@ +import { PROD_URL } from '~/lib/constants' +import MARKDOWN_SLUGS from '~/public/markdown/manifest.json' + +const SLUGS = new Set(MARKDOWN_SLUGS) + +export function mdAlternate(slug: string): { 'text/markdown': string } | undefined { + if (!SLUGS.has(slug)) return undefined + return { 'text/markdown': `${PROD_URL}/guides/${slug}.md` } +} diff --git a/apps/docs/turbo.jsonc b/apps/docs/turbo.jsonc index 9d663a7ff71bc..3bd522d323562 100644 --- a/apps/docs/turbo.jsonc +++ b/apps/docs/turbo.jsonc @@ -10,10 +10,21 @@ "inputs": ["spec/**"], "outputs": ["features/docs/generated/**"], }, + // Fetches remote (GitHub) content and the JSON artifacts the markdown generator reads. + // Not cached: the inputs are remote, so a cache restore could resurrect stale content. + "build:federated-content": { + "cache": false, + "env": [ + "DOCS_GITHUB_APP_ID", + "DOCS_GITHUB_APP_INSTALLATION_ID", + "DOCS_GITHUB_APP_PRIVATE_KEY", + ], + }, // Generates the guide/reference .md files and manifest.json under public/markdown/. // Declaring outputs lets Turbo restore these artifacts on a cache hit; without it a // cached run would skip the script and leave the generated markdown missing for `next build`. "build:markdown": { + "dependsOn": ["build:federated-content"], "outputs": ["public/markdown/**", "public/markdown/manifest.json"], }, "test": { diff --git a/apps/studio/TANSTACK_MIGRATION.md b/apps/studio/TANSTACK_MIGRATION.md index e9f5d3bb0b608..e112a426448a5 100644 --- a/apps/studio/TANSTACK_MIGRATION.md +++ b/apps/studio/TANSTACK_MIGRATION.md @@ -93,6 +93,7 @@ These are the layout-only TanStack files. Most hold a single product layout comp - [x] `routes/project/$ref/branches.tsx` — BranchLayout only. **Delta vs plan:** the per-page `PageLayout` (with different titles + primary/secondary actions) stays in each leaf. Hoisted `BranchesPageWrapper` and `MergeRequestsPageWrapper` to top-level exports in their respective `pages/...` files so the route files can import + re-use the same wrapping. - [x] `routes/project/$ref/logs.tsx` — LogsLayout (reads `logsLayoutTitle` from leaf staticData). Honours `skipLogsLayout: true` for `logs/index` (page handles its own ProjectLayout-wrapped content for the UnifiedLogs / no-permission cases). Refactored `pages/.../logs/index.tsx` to move the inline `` into `getLayout` so it isn't duplicated when the TanStack project shell already provides DefaultLayout. - [x] `routes/project/$ref/observability.tsx` — ObservabilityLayout (reads `observabilityLayoutTitle` from leaf staticData) +- [x] `routes/project/$ref/workers.tsx` — WorkersLayout (reads `workersLayoutTitle` from leaf `staticData`). Flag-gated: `WorkersLayout` itself redirects to the project home when `useFlag('workers')` is off, so the shell needs no extra guard. - [x] `routes/project/$ref/advisors.tsx` — AdvisorsLayout (reads `advisorsLayoutTitle` from leaf staticData). Honours `skipAdvisorsLayout: true` opt-out for the rules sub-shell, which provides its own AdvisorsLayout-less-DefaultLayout wrap. Scans whole match chain (same pattern as functions.tsx). - [x] `routes/project/$ref/advisors/rules.tsx` — sub-shell that inlines the inner body of `AdvisorRulesLayout` (AdvisorsLayout + PageLayout with title/tabs/feature-preview badge), minus the outer DefaultLayout (already provided by the parent project shell). Sets `skipAdvisorsLayout: true` on its own staticData. **Delta vs plan:** the existing `AdvisorRulesLayout` component wraps in DefaultLayout + AdvisorsLayout internally, so reusing it as-is would double-wrap both. Inlined the inner part; the Next-side component is untouched. - [x] `routes/project/$ref/settings.tsx` — SettingsLayout (reads `settingsLayoutTitle` from leaf staticData). Honours `skipSettingsLayout: true` for `settings/api` (redirect-only page). Adds a sub-shell at `routes/project/$ref/settings/api-keys.tsx` providing `ApiKeysLayout` for both api-keys leaves; `jwt/index` wraps in `JWTKeysLayout` inline since `jwt/legacy` doesn't share it. @@ -227,6 +228,10 @@ These are the layout-only TanStack files. Most hold a single product layout comp - [x] A `routes/project/$ref/realtime/policies.tsx` ← `pages/project/[ref]/realtime/policies.tsx` - [x] A `routes/project/$ref/realtime/settings.tsx` ← `pages/project/[ref]/realtime/settings.tsx` +### Project shell — `/workers/*` + +- [x] A `routes/project/$ref/workers/index.tsx` ← `pages/project/[ref]/workers/index.tsx` + ### Project shell — `/functions/*` - [x] A `routes/project/$ref/functions/index.tsx` ← `pages/project/[ref]/functions/index.tsx` (route wraps in exported `EdgeFunctionsIndexPageWrapper` for the inline PageHeader + actions) diff --git a/apps/studio/components/interfaces/App/AppBannerWrapper.tsx b/apps/studio/components/interfaces/App/AppBannerWrapper.tsx index dbb1d6cef9601..a0e19ea5d92b0 100644 --- a/apps/studio/components/interfaces/App/AppBannerWrapper.tsx +++ b/apps/studio/components/interfaces/App/AppBannerWrapper.tsx @@ -1,21 +1,35 @@ -import { LOCAL_STORAGE_KEYS, useFlag } from 'common' -import { PropsWithChildren, useEffect } from 'react' +import { IS_PLATFORM, LOCAL_STORAGE_KEYS, useFlag } from 'common' +import dayjs from 'dayjs' +import { usePathname } from 'next/navigation' +import { PropsWithChildren, useEffect, useRef, useState } from 'react' import { OrganizationResourceBanner } from '../Organization/HeaderBanner' +import { isLogsOrObservabilityPath } from './AppBannerWrapper.utils' import { ClockSkewBanner } from '@/components/layouts/AppLayout/ClockSkewBanner' import { NoticeBanner } from '@/components/layouts/AppLayout/NoticeBanner' import { StatusPageBanner } from '@/components/layouts/AppLayout/StatusPageBanner' +import { BannerLogsAllDeprecation } from '@/components/ui/BannerStack/Banners/BannerLogsAllDeprecation' import { BannerTOSUpdate } from '@/components/ui/BannerStack/Banners/BannerTOSUpdate' -import { useBannerStack } from '@/components/ui/BannerStack/BannerStackProvider' +import { BANNER_ID, useBannerStack } from '@/components/ui/BannerStack/BannerStackProvider' import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' +import { useTrack } from '@/lib/telemetry/track' const TOSUpdateExpiry = new Date('2026-08-29T00:00:00Z') +// Update this whenever the banner content changes so old client bundles stop +// displaying the notice after the removal date passes. +const LogsAllDeprecationExpiry = dayjs('2026-09-24T00:00:00Z') + +// setTimeout overflows above ~24.8 days; re-arm until the real expiry. +const MAX_TIMEOUT_MS = 2_147_483_647 + export const AppBannerWrapper = ({ children }: PropsWithChildren<{}>) => { const showNoticeBanner = useFlag('showNoticeBanner') const clockSkewBanner = useFlag('clockSkewBanner') const { addBanner, dismissBanner } = useBannerStack() + const pathname = usePathname() + const track = useTrack() const [TOSUpdateAcknowledged, , { isSuccess }] = useLocalStorageQuery( LOCAL_STORAGE_KEYS.TERMS_OF_SERVICE_UPDATE, @@ -37,6 +51,67 @@ export const AppBannerWrapper = ({ children }: PropsWithChildren<{}>) => { } }, [TOSUpdateAcknowledged, isSuccess, addBanner, dismissBanner]) + const [isLogsAllDeprecationDismissed, , { isSuccess: isLogsAllDeprecationLoaded }] = + useLocalStorageQuery(LOCAL_STORAGE_KEYS.LOGS_ALL_DEPRECATION_2026_09_23, false) + + const [isLogsAllDeprecationExpired, setIsLogsAllDeprecationExpired] = useState( + () => !dayjs().isBefore(LogsAllDeprecationExpiry) + ) + + useEffect(() => { + if (isLogsAllDeprecationExpired) return + + let timeoutId: ReturnType | undefined + + const armExpiryTimer = () => { + const msUntilExpiry = LogsAllDeprecationExpiry.diff(dayjs()) + if (msUntilExpiry <= 0) { + setIsLogsAllDeprecationExpired(true) + return + } + timeoutId = setTimeout(armExpiryTimer, Math.min(msUntilExpiry, MAX_TIMEOUT_MS)) + } + + armExpiryTimer() + return () => clearTimeout(timeoutId) + }, [isLogsAllDeprecationExpired]) + + const hasTrackedLogsAllExposure = useRef(false) + useEffect(() => { + if (!isLogsAllDeprecationLoaded || pathname == null) return + + const shouldShow = + IS_PLATFORM && + !isLogsAllDeprecationExpired && + isLogsOrObservabilityPath(pathname) && + !isLogsAllDeprecationDismissed + + if (!shouldShow) { + dismissBanner(BANNER_ID.LOGS_ALL_DEPRECATION) + return + } + + addBanner({ + id: BANNER_ID.LOGS_ALL_DEPRECATION, + isDismissed: false, + content: , + priority: 4, + }) + + if (!hasTrackedLogsAllExposure.current) { + hasTrackedLogsAllExposure.current = true + track('logs_all_deprecation_banner_exposed') + } + }, [ + pathname, + isLogsAllDeprecationLoaded, + isLogsAllDeprecationDismissed, + isLogsAllDeprecationExpired, + addBanner, + dismissBanner, + track, + ]) + return (
diff --git a/apps/studio/components/interfaces/App/AppBannerWrapper.utils.ts b/apps/studio/components/interfaces/App/AppBannerWrapper.utils.ts new file mode 100644 index 0000000000000..2035a8d9baddc --- /dev/null +++ b/apps/studio/components/interfaces/App/AppBannerWrapper.utils.ts @@ -0,0 +1,8 @@ +// Anchored to the section root so per-resource log pages (e.g. a single edge +// function's logs) don't pull the banner outside Logs/Observability. +const LOGS_SECTION_PATH = /^\/project\/[^/]+\/(logs|observability)(\/|$)/ + +export function isLogsOrObservabilityPath(pathname: string | null | undefined): boolean { + if (!pathname) return false + return LOGS_SECTION_PATH.test(pathname) +} diff --git a/apps/studio/components/interfaces/AuditLogs/LogDetailsPanel.tsx b/apps/studio/components/interfaces/AuditLogs/LogDetailsPanel.tsx index 488e5b7386df3..4dbe508ee0131 100644 --- a/apps/studio/components/interfaces/AuditLogs/LogDetailsPanel.tsx +++ b/apps/studio/components/interfaces/AuditLogs/LogDetailsPanel.tsx @@ -94,6 +94,30 @@ export const LogDetailsPanel = ({ selectedLog, onClose }: LogDetailsPanelProps) )} + {selectedLog?.actor.partner && ( + + + + )} + {selectedLog?.actor.partner_installation_id && ( + + + + )} + {selectedLog?.actor.partner_user_email && ( + + + + )} + {selectedLog?.actor.partner_user_id && ( + + + + )} diff --git a/apps/studio/components/interfaces/Auth/ProtectionAuthSettingsForm/ProtectionAuthSettingsForm.tsx b/apps/studio/components/interfaces/Auth/ProtectionAuthSettingsForm/ProtectionAuthSettingsForm.tsx index a626f8a0de8b5..d2f5606bf608a 100644 --- a/apps/studio/components/interfaces/Auth/ProtectionAuthSettingsForm/ProtectionAuthSettingsForm.tsx +++ b/apps/studio/components/interfaces/Auth/ProtectionAuthSettingsForm/ProtectionAuthSettingsForm.tsx @@ -54,19 +54,6 @@ const baseSchema = z.object({ EXTERNAL_ANONYMOUS_USERS_ENABLED: z.boolean(), SECURITY_MANUAL_LINKING_ENABLED: z.boolean(), SITE_URL: z.string().min(1, 'Must have a Site URL'), - SESSIONS_TIMEBOX: z - .preprocess( - (val) => (val === '' || val == null ? undefined : val), - z.coerce - .number({ - required_error: 'Must have a sessions timebox', - invalid_type_error: 'Must have a sessions timebox', - }) - .min(0, 'Must be greater than or equal to 0.') - ) - .optional(), - SESSIONS_INACTIVITY_TIMEOUT: z.number().min(0, 'Must be greater than or equal to 0').optional(), - SESSIONS_SINGLE_PER_USER: z.boolean().optional(), PASSWORD_MIN_LENGTH: z .preprocess( (val) => (val === '' || val == null ? undefined : val), @@ -142,9 +129,6 @@ export const ProtectionAuthSettingsForm = () => { SECURITY_CAPTCHA_ENABLED: false, SECURITY_CAPTCHA_SECRET: '', SECURITY_CAPTCHA_PROVIDER: 'hcaptcha', - SESSIONS_TIMEBOX: 0, - SESSIONS_INACTIVITY_TIMEOUT: 0, - SESSIONS_SINGLE_PER_USER: false, PASSWORD_MIN_LENGTH: 6, PASSWORD_REQUIRED_CHARACTERS: NO_REQUIRED_CHARACTERS, PASSWORD_HIBP_ENABLED: false, @@ -167,9 +151,6 @@ export const ProtectionAuthSettingsForm = () => { SECURITY_CAPTCHA_ENABLED: authConfig.SECURITY_CAPTCHA_ENABLED, SECURITY_CAPTCHA_SECRET: authConfig.SECURITY_CAPTCHA_SECRET || '', SECURITY_CAPTCHA_PROVIDER, - SESSIONS_TIMEBOX: authConfig.SESSIONS_TIMEBOX || 0, - SESSIONS_INACTIVITY_TIMEOUT: authConfig.SESSIONS_INACTIVITY_TIMEOUT || 0, - SESSIONS_SINGLE_PER_USER: authConfig.SESSIONS_SINGLE_PER_USER || false, PASSWORD_MIN_LENGTH: authConfig.PASSWORD_MIN_LENGTH || 6, PASSWORD_REQUIRED_CHARACTERS: authConfig.PASSWORD_REQUIRED_CHARACTERS || NO_REQUIRED_CHARACTERS, @@ -184,9 +165,6 @@ export const ProtectionAuthSettingsForm = () => { SECURITY_CAPTCHA_ENABLED: authConfig.SECURITY_CAPTCHA_ENABLED, SECURITY_CAPTCHA_SECRET: authConfig.SECURITY_CAPTCHA_SECRET || '', SECURITY_CAPTCHA_PROVIDER, - SESSIONS_TIMEBOX: authConfig.SESSIONS_TIMEBOX || 0, - SESSIONS_INACTIVITY_TIMEOUT: authConfig.SESSIONS_INACTIVITY_TIMEOUT || 0, - SESSIONS_SINGLE_PER_USER: authConfig.SESSIONS_SINGLE_PER_USER || false, PASSWORD_MIN_LENGTH: authConfig.PASSWORD_MIN_LENGTH || 6, PASSWORD_REQUIRED_CHARACTERS: authConfig.PASSWORD_REQUIRED_CHARACTERS || NO_REQUIRED_CHARACTERS, diff --git a/apps/studio/components/interfaces/Auth/SessionsAuthSettingsForm/SessionsAuthSettingsForm.test.tsx b/apps/studio/components/interfaces/Auth/SessionsAuthSettingsForm/SessionsAuthSettingsForm.test.tsx index bf389146c3b40..8c2688012838a 100644 --- a/apps/studio/components/interfaces/Auth/SessionsAuthSettingsForm/SessionsAuthSettingsForm.test.tsx +++ b/apps/studio/components/interfaces/Auth/SessionsAuthSettingsForm/SessionsAuthSettingsForm.test.tsx @@ -4,6 +4,12 @@ import { HttpResponse } from 'msw' import { describe, expect, test, vi } from 'vitest' import { SessionsAuthSettingsForm } from './SessionsAuthSettingsForm' +import { + MAX_REFRESH_TOKEN_REUSE_INTERVAL_MESSAGE, + MAX_SESSIONS_INACTIVITY_TIMEOUT_MESSAGE, + MAX_SESSIONS_TIMEBOX_HOURS, + MAX_SESSIONS_TIMEBOX_MESSAGE, +} from './SessionsAuthSettingsForm.utils' import { customRender } from '@/tests/lib/custom-render' import { addAPIMock } from '@/tests/lib/msw' @@ -104,3 +110,173 @@ describe('SessionsAuthSettingsForm — Access Tokens', () => { expect(patchCalled).toBe(false) }) }) + +async function saveForm(control: HTMLElement) { + const form = control.closest('form') as HTMLFormElement + const saveButton = within(form).getByRole('button', { name: 'Save changes' }) + await waitFor(() => expect(saveButton).toBeEnabled()) + fireEvent.click(saveButton) +} + +describe('SessionsAuthSettingsForm — User Sessions', () => { + test('blocks submit when the timebox exceeds the maximum', async () => { + mockAuthConfig({ SESSIONS_TIMEBOX: 0 }) + let patchCalled = false + mockUpdateAuthConfig(() => { + patchCalled = true + }) + + customRender() + + const input = await screen.findByLabelText('Time-box user sessions') + fireEvent.change(input, { target: { value: '9000' } }) + await saveForm(input) + + expect(await screen.findByText(MAX_SESSIONS_TIMEBOX_MESSAGE)).toBeInTheDocument() + expect(patchCalled).toBe(false) + }) + + test('accepts a timebox at the maximum', async () => { + mockAuthConfig({ SESSIONS_TIMEBOX: 0 }) + let patchBody: unknown + mockUpdateAuthConfig((body) => { + patchBody = body + }) + + customRender() + + const input = await screen.findByLabelText('Time-box user sessions') + fireEvent.change(input, { target: { value: String(MAX_SESSIONS_TIMEBOX_HOURS) } }) + await saveForm(input) + + await waitFor(() => + expect(patchBody).toMatchObject({ SESSIONS_TIMEBOX: MAX_SESSIONS_TIMEBOX_HOURS }) + ) + }) + + test('saves an over-limit timebox that is already stored', async () => { + mockAuthConfig({ SESSIONS_TIMEBOX: 20000 }) + let patchBody: unknown + mockUpdateAuthConfig((body) => { + patchBody = body + }) + + customRender() + + // Save is gated on isDirty, so change an unrelated field in the same card + const singleSessionSwitch = await screen.findByLabelText('Enforce single session per user') + fireEvent.click(singleSessionSwitch) + await saveForm(singleSessionSwitch) + + await waitFor(() => + expect(patchBody).toMatchObject({ SESSIONS_TIMEBOX: 20000, SESSIONS_SINGLE_PER_USER: true }) + ) + }) + + test('blocks a reduction that is still above the maximum', async () => { + mockAuthConfig({ SESSIONS_TIMEBOX: 20000 }) + let patchCalled = false + mockUpdateAuthConfig(() => { + patchCalled = true + }) + + customRender() + + const input = await screen.findByLabelText('Time-box user sessions') + fireEvent.change(input, { target: { value: '15000' } }) + await saveForm(input) + + expect(await screen.findByText(MAX_SESSIONS_TIMEBOX_MESSAGE)).toBeInTheDocument() + expect(patchCalled).toBe(false) + }) + + test('saves a reduction into the allowed range', async () => { + mockAuthConfig({ SESSIONS_TIMEBOX: 20000 }) + let patchBody: unknown + mockUpdateAuthConfig((body) => { + patchBody = body + }) + + customRender() + + const input = await screen.findByLabelText('Time-box user sessions') + fireEvent.change(input, { target: { value: '5000' } }) + await saveForm(input) + + await waitFor(() => expect(patchBody).toMatchObject({ SESSIONS_TIMEBOX: 5000 })) + }) + + test('blocks submit when the inactivity timeout exceeds the maximum', async () => { + mockAuthConfig({ SESSIONS_INACTIVITY_TIMEOUT: 0 }) + let patchCalled = false + mockUpdateAuthConfig(() => { + patchCalled = true + }) + + customRender() + + const input = await screen.findByLabelText('Inactivity timeout') + fireEvent.change(input, { target: { value: '10000' } }) + await saveForm(input) + + expect(await screen.findByText(MAX_SESSIONS_INACTIVITY_TIMEOUT_MESSAGE)).toBeInTheDocument() + expect(patchCalled).toBe(false) + }) +}) + +describe('SessionsAuthSettingsForm — Refresh Tokens', () => { + test('blocks submit when the reuse interval exceeds the maximum', async () => { + mockAuthConfig({ SECURITY_REFRESH_TOKEN_REUSE_INTERVAL: 10 }) + let patchCalled = false + mockUpdateAuthConfig(() => { + patchCalled = true + }) + + customRender() + + const input = await screen.findByLabelText('Refresh token reuse interval') + fireEvent.change(input, { target: { value: '600' } }) + await saveForm(input) + + expect(await screen.findByText(MAX_REFRESH_TOKEN_REUSE_INTERVAL_MESSAGE)).toBeInTheDocument() + expect(patchCalled).toBe(false) + }) + + test('saves an over-limit reuse interval that is already stored', async () => { + mockAuthConfig({ SECURITY_REFRESH_TOKEN_REUSE_INTERVAL: 600 }) + let patchBody: unknown + mockUpdateAuthConfig((body) => { + patchBody = body + }) + + customRender() + + const rotationSwitch = await screen.findByLabelText( + 'Detect and revoke potentially compromised refresh tokens' + ) + fireEvent.click(rotationSwitch) + await saveForm(rotationSwitch) + + await waitFor(() => + expect(patchBody).toMatchObject({ SECURITY_REFRESH_TOKEN_REUSE_INTERVAL: 600 }) + ) + }) + + test('saves a reduction into the allowed range', async () => { + mockAuthConfig({ SECURITY_REFRESH_TOKEN_REUSE_INTERVAL: 600 }) + let patchBody: unknown + mockUpdateAuthConfig((body) => { + patchBody = body + }) + + customRender() + + const input = await screen.findByLabelText('Refresh token reuse interval') + fireEvent.change(input, { target: { value: '10' } }) + await saveForm(input) + + await waitFor(() => + expect(patchBody).toMatchObject({ SECURITY_REFRESH_TOKEN_REUSE_INTERVAL: 10 }) + ) + }) +}) diff --git a/apps/studio/components/interfaces/Auth/SessionsAuthSettingsForm/SessionsAuthSettingsForm.tsx b/apps/studio/components/interfaces/Auth/SessionsAuthSettingsForm/SessionsAuthSettingsForm.tsx index 3f3aee161b346..8e8c21e29c2a3 100644 --- a/apps/studio/components/interfaces/Auth/SessionsAuthSettingsForm/SessionsAuthSettingsForm.tsx +++ b/apps/studio/components/interfaces/Auth/SessionsAuthSettingsForm/SessionsAuthSettingsForm.tsx @@ -1,7 +1,7 @@ import { zodResolver } from '@hookform/resolvers/zod' import { PermissionAction } from '@supabase/shared-types/out/constants' import { useParams } from 'common' -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { useForm } from 'react-hook-form' import { toast } from 'sonner' import { @@ -27,8 +27,16 @@ import { PageSectionTitle, } from 'ui-patterns/PageSection' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' -import * as z from 'zod' +import { + AccessTokenSchema, + createRefreshTokenSchema, + createUserSessionsSchema, + MAX_REFRESH_TOKEN_REUSE_INTERVAL_SECONDS, + MAX_SESSIONS_INACTIVITY_TIMEOUT_HOURS, + MAX_SESSIONS_TIMEBOX_HOURS, + type AccessTokenFormValues, +} from './SessionsAuthSettingsForm.utils' import { AlertError } from '@/components/ui/AlertError' import { NoPermission } from '@/components/ui/NoPermission' import { UpgradeToPro } from '@/components/ui/UpgradeToPro' @@ -48,30 +56,6 @@ function HoursOrNeverText({ value }: { value: number }) { } } -const MAX_JWT_EXP = 604800 - -const AccessTokenSchema = z.object({ - JWT_EXP: z.coerce - .number() - .int('Must be a whole number') - .positive('Must be greater than 0') - .max(MAX_JWT_EXP, `Must be less than ${MAX_JWT_EXP}`), -}) - -const RefreshTokenSchema = z.object({ - REFRESH_TOKEN_ROTATION_ENABLED: z.boolean(), - SECURITY_REFRESH_TOKEN_REUSE_INTERVAL: z.coerce.number().min(0, 'Must be a value more than 0'), -}) - -const UserSessionsSchema = z.object({ - SESSIONS_TIMEBOX: z.coerce.number().min(0, 'Must be a positive number'), - SESSIONS_INACTIVITY_TIMEOUT: z.coerce - .number() - .multipleOf(0.1) - .min(0, 'Must be a positive number'), - SESSIONS_SINGLE_PER_USER: z.boolean(), -}) - export const SessionsAuthSettingsForm = () => { const { ref: projectRef } = useParams() const { @@ -100,15 +84,40 @@ export const SessionsAuthSettingsForm = () => { useCheckEntitlements('auth.user_sessions') const promptProPlanUpgrade = IS_PLATFORM && !hasUserSessionsEntitlement - const accessTokenForm = useForm>({ + // NOTE(fm): The maximums below were introduced after these settings were unbounded, + // so they are validated against the currently saved value: a project already above a + // maximum can still save the section, but can only move the value into range. + // Normalized exactly as the reset() calls below, so an untouched field compares equal. + const savedRefreshTokenReuseInterval = authConfig?.SECURITY_REFRESH_TOKEN_REUSE_INTERVAL ?? 0 + const savedSessionsTimebox = authConfig?.SESSIONS_TIMEBOX || 0 + const savedSessionsInactivityTimeout = authConfig?.SESSIONS_INACTIVITY_TIMEOUT || 0 + + const refreshTokenResolver = useMemo( + () => + zodResolver(createRefreshTokenSchema({ savedReuseInterval: savedRefreshTokenReuseInterval })), + [savedRefreshTokenReuseInterval] + ) + + const userSessionsResolver = useMemo( + () => + zodResolver( + createUserSessionsSchema({ + savedTimebox: savedSessionsTimebox, + savedInactivityTimeout: savedSessionsInactivityTimeout, + }) + ), + [savedSessionsTimebox, savedSessionsInactivityTimeout] + ) + + const accessTokenForm = useForm({ resolver: zodResolver(AccessTokenSchema), defaultValues: { JWT_EXP: 3600, }, }) - const refreshTokenForm = useForm>({ - resolver: zodResolver(RefreshTokenSchema), + const refreshTokenForm = useForm({ + resolver: refreshTokenResolver, defaultValues: { REFRESH_TOKEN_ROTATION_ENABLED: false, SECURITY_REFRESH_TOKEN_REUSE_INTERVAL: 0, @@ -116,7 +125,7 @@ export const SessionsAuthSettingsForm = () => { }) const userSessionsForm = useForm({ - resolver: zodResolver(UserSessionsSchema), + resolver: userSessionsResolver, defaultValues: { SESSIONS_TIMEBOX: 0, SESSIONS_INACTIVITY_TIMEOUT: 0, @@ -136,21 +145,29 @@ export const SessionsAuthSettingsForm = () => { if (!isUpdatingRefreshTokens) { refreshTokenForm.reset({ REFRESH_TOKEN_ROTATION_ENABLED: authConfig.REFRESH_TOKEN_ROTATION_ENABLED || false, - SECURITY_REFRESH_TOKEN_REUSE_INTERVAL: authConfig.SECURITY_REFRESH_TOKEN_REUSE_INTERVAL, + SECURITY_REFRESH_TOKEN_REUSE_INTERVAL: savedRefreshTokenReuseInterval, }) } if (!isUpdatingUserSessions) { userSessionsForm.reset({ - SESSIONS_TIMEBOX: authConfig.SESSIONS_TIMEBOX || 0, - SESSIONS_INACTIVITY_TIMEOUT: authConfig.SESSIONS_INACTIVITY_TIMEOUT || 0, + SESSIONS_TIMEBOX: savedSessionsTimebox, + SESSIONS_INACTIVITY_TIMEOUT: savedSessionsInactivityTimeout, SESSIONS_SINGLE_PER_USER: authConfig.SESSIONS_SINGLE_PER_USER || false, }) } } - }, [authConfig, isUpdatingAccessToken, isUpdatingRefreshTokens, isUpdatingUserSessions]) - - const onSubmitAccessToken = (values: z.infer) => { + }, [ + authConfig, + isUpdatingAccessToken, + isUpdatingRefreshTokens, + isUpdatingUserSessions, + savedRefreshTokenReuseInterval, + savedSessionsTimebox, + savedSessionsInactivityTimeout, + ]) + + const onSubmitAccessToken = (values: AccessTokenFormValues) => { const payload = { ...values } setIsUpdatingAccessToken(true) @@ -259,11 +276,13 @@ export const SessionsAuthSettingsForm = () => { render={({ field }) => ( { render={({ field }) => ( { render={({ field }) => ( { render={({ field }) => ( { render={({ field }) => ( { render={({ field }) => ( , + saved: { savedTimebox: number; savedInactivityTimeout: number } = { + savedTimebox: 0, + savedInactivityTimeout: 0, + } +) { + return createUserSessionsSchema(saved).safeParse({ + SESSIONS_TIMEBOX: 0, + SESSIONS_INACTIVITY_TIMEOUT: 0, + SESSIONS_SINGLE_PER_USER: false, + ...values, + }) +} + +function parseRefreshToken(values: Record, savedReuseInterval = 0) { + return createRefreshTokenSchema({ savedReuseInterval }).safeParse({ + REFRESH_TOKEN_ROTATION_ENABLED: true, + SECURITY_REFRESH_TOKEN_REUSE_INTERVAL: 0, + ...values, + }) +} + +function errorFor( + result: ReturnType | ReturnType, + field: string +) { + return result.success + ? undefined + : result.error.issues.find((issue) => issue.path[0] === field)?.message +} + +describe('createUserSessionsSchema — SESSIONS_TIMEBOX', () => { + test('accepts a value below the maximum', () => { + expect(parseUserSessions({ SESSIONS_TIMEBOX: 24 }).success).toBe(true) + }) + + test('accepts a value exactly at the maximum', () => { + expect(parseUserSessions({ SESSIONS_TIMEBOX: MAX_SESSIONS_TIMEBOX_HOURS }).success).toBe(true) + }) + + test('rejects a value above the maximum', () => { + const result = parseUserSessions({ SESSIONS_TIMEBOX: MAX_SESSIONS_TIMEBOX_HOURS + 1 }) + + expect(result.success).toBe(false) + expect(errorFor(result, 'SESSIONS_TIMEBOX')).toBe(MAX_SESSIONS_TIMEBOX_MESSAGE) + }) + + test('accepts an over-limit value that matches the saved value', () => { + const result = parseUserSessions( + { SESSIONS_TIMEBOX: OVER_LIMIT_TIMEBOX }, + { savedTimebox: OVER_LIMIT_TIMEBOX, savedInactivityTimeout: 0 } + ) + + expect(result.success).toBe(true) + }) + + test('rejects a reduction that is still above the maximum', () => { + const result = parseUserSessions( + { SESSIONS_TIMEBOX: 15000 }, + { savedTimebox: OVER_LIMIT_TIMEBOX, savedInactivityTimeout: 0 } + ) + + expect(result.success).toBe(false) + expect(errorFor(result, 'SESSIONS_TIMEBOX')).toBe(MAX_SESSIONS_TIMEBOX_MESSAGE) + }) + + test('accepts a reduction into the allowed range', () => { + const result = parseUserSessions( + { SESSIONS_TIMEBOX: 5000 }, + { savedTimebox: OVER_LIMIT_TIMEBOX, savedInactivityTimeout: 0 } + ) + + expect(result.success).toBe(true) + }) + + test('rejects a negative value', () => { + const result = parseUserSessions({ SESSIONS_TIMEBOX: -1 }) + + expect(result.success).toBe(false) + expect(errorFor(result, 'SESSIONS_TIMEBOX')).toBe('Must be 0 or greater') + }) +}) + +describe('createUserSessionsSchema — SESSIONS_INACTIVITY_TIMEOUT', () => { + test('accepts a fractional value within the maximum', () => { + expect(parseUserSessions({ SESSIONS_INACTIVITY_TIMEOUT: 1.5 }).success).toBe(true) + }) + + test('rejects a value above the maximum', () => { + const result = parseUserSessions({ SESSIONS_INACTIVITY_TIMEOUT: 10000 }) + + expect(result.success).toBe(false) + expect(errorFor(result, 'SESSIONS_INACTIVITY_TIMEOUT')).toBe( + MAX_SESSIONS_INACTIVITY_TIMEOUT_MESSAGE + ) + }) + + test('accepts an over-limit value that matches the saved value', () => { + const result = parseUserSessions( + { SESSIONS_INACTIVITY_TIMEOUT: 10000 }, + { savedTimebox: 0, savedInactivityTimeout: 10000 } + ) + + expect(result.success).toBe(true) + }) + + test('rejects a reduction that is still above the maximum', () => { + const result = parseUserSessions( + { SESSIONS_INACTIVITY_TIMEOUT: 9500 }, + { savedTimebox: 0, savedInactivityTimeout: 10000 } + ) + + expect(result.success).toBe(false) + expect(errorFor(result, 'SESSIONS_INACTIVITY_TIMEOUT')).toBe( + MAX_SESSIONS_INACTIVITY_TIMEOUT_MESSAGE + ) + }) + + test('rejects a value that is not a multiple of 0.1', () => { + const result = parseUserSessions({ SESSIONS_INACTIVITY_TIMEOUT: 1.55 }) + + expect(result.success).toBe(false) + expect(errorFor(result, 'SESSIONS_INACTIVITY_TIMEOUT')).toBe('Must be a multiple of 0.1') + }) +}) + +describe('createRefreshTokenSchema — SECURITY_REFRESH_TOKEN_REUSE_INTERVAL', () => { + test('accepts a value exactly at the maximum', () => { + expect( + parseRefreshToken({ + SECURITY_REFRESH_TOKEN_REUSE_INTERVAL: MAX_REFRESH_TOKEN_REUSE_INTERVAL_SECONDS, + }).success + ).toBe(true) + }) + + test('rejects a value above the maximum', () => { + const result = parseRefreshToken({ SECURITY_REFRESH_TOKEN_REUSE_INTERVAL: 600 }) + + expect(result.success).toBe(false) + expect(errorFor(result, 'SECURITY_REFRESH_TOKEN_REUSE_INTERVAL')).toBe( + MAX_REFRESH_TOKEN_REUSE_INTERVAL_MESSAGE + ) + }) + + test('accepts an over-limit value that matches the saved value', () => { + expect(parseRefreshToken({ SECURITY_REFRESH_TOKEN_REUSE_INTERVAL: 600 }, 600).success).toBe( + true + ) + }) + + test('rejects a reduction that is still above the maximum', () => { + const result = parseRefreshToken({ SECURITY_REFRESH_TOKEN_REUSE_INTERVAL: 500 }, 600) + + expect(result.success).toBe(false) + expect(errorFor(result, 'SECURITY_REFRESH_TOKEN_REUSE_INTERVAL')).toBe( + MAX_REFRESH_TOKEN_REUSE_INTERVAL_MESSAGE + ) + }) + + test('accepts a reduction into the allowed range', () => { + expect(parseRefreshToken({ SECURITY_REFRESH_TOKEN_REUSE_INTERVAL: 10 }, 600).success).toBe(true) + }) + + test('rejects a negative value', () => { + const result = parseRefreshToken({ SECURITY_REFRESH_TOKEN_REUSE_INTERVAL: -1 }) + + expect(result.success).toBe(false) + expect(errorFor(result, 'SECURITY_REFRESH_TOKEN_REUSE_INTERVAL')).toBe('Must be 0 or greater') + }) +}) diff --git a/apps/studio/components/interfaces/Auth/SessionsAuthSettingsForm/SessionsAuthSettingsForm.utils.ts b/apps/studio/components/interfaces/Auth/SessionsAuthSettingsForm/SessionsAuthSettingsForm.utils.ts new file mode 100644 index 0000000000000..b2ebb672b0a20 --- /dev/null +++ b/apps/studio/components/interfaces/Auth/SessionsAuthSettingsForm/SessionsAuthSettingsForm.utils.ts @@ -0,0 +1,66 @@ +import * as z from 'zod' + +export const MAX_JWT_EXP = 604800 + +export const MAX_SESSIONS_TIMEBOX_HOURS = 8760 // 1 year +export const MAX_SESSIONS_INACTIVITY_TIMEOUT_HOURS = 8760 // 1 year +export const MAX_REFRESH_TOKEN_REUSE_INTERVAL_SECONDS = 300 // 5 mins + +export const MAX_SESSIONS_TIMEBOX_MESSAGE = `Must be ${MAX_SESSIONS_TIMEBOX_HOURS} hours (1 year) or less` +export const MAX_SESSIONS_INACTIVITY_TIMEOUT_MESSAGE = `Must be ${MAX_SESSIONS_INACTIVITY_TIMEOUT_HOURS} hours (1 year) or less` +export const MAX_REFRESH_TOKEN_REUSE_INTERVAL_MESSAGE = `Must be ${MAX_REFRESH_TOKEN_REUSE_INTERVAL_SECONDS} seconds (5 minutes) or less` + +const isWithinMaxOrUnchanged = (max: number, savedValue: number) => (value: number) => + value <= max || value === savedValue + +export const AccessTokenSchema = z.object({ + JWT_EXP: z.coerce + .number() + .int('Must be a whole number') + .positive('Must be greater than 0') + .max(MAX_JWT_EXP, `Must be less than ${MAX_JWT_EXP}`), +}) + +export type AccessTokenFormValues = z.infer + +export const createRefreshTokenSchema = ({ savedReuseInterval }: { savedReuseInterval: number }) => + z.object({ + REFRESH_TOKEN_ROTATION_ENABLED: z.boolean(), + SECURITY_REFRESH_TOKEN_REUSE_INTERVAL: z.coerce + .number() + .min(0, 'Must be 0 or greater') + .refine( + isWithinMaxOrUnchanged(MAX_REFRESH_TOKEN_REUSE_INTERVAL_SECONDS, savedReuseInterval), + MAX_REFRESH_TOKEN_REUSE_INTERVAL_MESSAGE + ), + }) + +export type RefreshTokenFormValues = z.infer> + +export const createUserSessionsSchema = ({ + savedTimebox, + savedInactivityTimeout, +}: { + savedTimebox: number + savedInactivityTimeout: number +}) => + z.object({ + SESSIONS_TIMEBOX: z.coerce + .number() + .min(0, 'Must be 0 or greater') + .refine( + isWithinMaxOrUnchanged(MAX_SESSIONS_TIMEBOX_HOURS, savedTimebox), + MAX_SESSIONS_TIMEBOX_MESSAGE + ), + SESSIONS_INACTIVITY_TIMEOUT: z.coerce + .number() + .multipleOf(0.1, 'Must be a multiple of 0.1') + .min(0, 'Must be 0 or greater') + .refine( + isWithinMaxOrUnchanged(MAX_SESSIONS_INACTIVITY_TIMEOUT_HOURS, savedInactivityTimeout), + MAX_SESSIONS_INACTIVITY_TIMEOUT_MESSAGE + ), + SESSIONS_SINGLE_PER_USER: z.boolean(), + }) + +export type UserSessionsFormValues = z.infer> diff --git a/apps/studio/components/interfaces/Explorer/ExplorerNotebookTab.tsx b/apps/studio/components/interfaces/Explorer/ExplorerNotebookTab.tsx index b0fb890f53ac8..696408859c214 100644 --- a/apps/studio/components/interfaces/Explorer/ExplorerNotebookTab.tsx +++ b/apps/studio/components/interfaces/Explorer/ExplorerNotebookTab.tsx @@ -11,10 +11,31 @@ import { sortableKeyboardCoordinates, verticalListSortingStrategy, } from '@dnd-kit/sortable' +import { acceptUntrustedSql } from '@supabase/pg-meta' import { useParams } from 'common' -import { FileText, Notebook, NotebookText, Play, Save, SquareCode } from 'lucide-react' +import { + FileText, + Loader2, + MoreVertical, + Notebook, + NotebookText, + Play, + Save, + SquareCode, + Trash, +} from 'lucide-react' +import { useRouter } from 'next/router' import { useRef, useState } from 'react' -import { AiIconAnimation, Button } from 'ui' +import { toast } from 'sonner' +import { + AiIconAnimation, + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from 'ui' +import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { EmptyStatePresentational } from 'ui-patterns/EmptyStatePresentational' import { @@ -24,28 +45,55 @@ import { ExplorerToolbarIcon, ExplorerToolbarTitle, } from './ExplorerToolbar' +import { useLoadNotebook } from './hooks' import { MarkdownCell } from './MarkdownCell' import { QueryCell } from './QueryCell' import { type QueryEditorHandle } from './QueryEditor' import { createMarkdownCellSkeleton, createQueryCellSkeleton } from './utils' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' -import { isQueryCell } from '@/data/content/notebooks/notebook-schema' +import { useContentDeleteMutation } from '@/data/content/content-delete-mutation' +import { + isQueryCell, + WritableCell, + WritableNotebook, +} from '@/data/content/notebooks/notebook-schema' +import { useUpsertNotebookMutation } from '@/data/content/notebooks/notebook-upsert-mutation' +import { acceptUntrustedLogsSql } from '@/data/logs/safe-analytics-sql' import { useCurrentNotebook, useNotebooksStateSnapshot } from '@/state/notebooks/notebooks-state' import { createTabId, useTabsStateSnapshot } from '@/state/tabs' export const ExplorerNotebookTab = () => { - const { id } = useParams() + const router = useRouter() + const { id, ref } = useParams() const tabs = useTabsStateSnapshot() const snap = useNotebooksStateSnapshot() const currentNotebook = useCurrentNotebook() const { name, content } = currentNotebook?.notebook ?? {} + const { isNotFound } = useLoadNotebook({ id, projectRef: ref }) const cells = content?.cells ?? [] const queryCellIds = cells.filter(isQueryCell).map((cell) => cell._id) const [isRunningNotebook, setIsRunningNotebook] = useState(false) + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false) const queryCellRefs = useRef(new Map()) + const { mutate: updateNotebook, isPending: isUpdating } = useUpsertNotebookMutation({ + onSuccess: () => toast.success('Successfully saved notebook!'), + }) + const { mutate: deleteNotebook, isPending: isDeleting } = useContentDeleteMutation({ + onSuccess: () => { + toast.success('Successfully deleted notebook') + if (id) { + tabs.removeTab(createTabId('notebook', { id })) + snap.removeNotebook({ id }) + } + setIsDeleteModalOpen(false) + router.push(`/project/${ref}/explorer`) + }, + onError: (error) => toast.error(`Failed to delete notebook: ${error.message}`), + }) + const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) @@ -70,6 +118,50 @@ export const ExplorerNotebookTab = () => { } } + const handleSaveNotebook = () => { + const notebookId = currentNotebook?.notebook.id + if (!ref || !notebookId || !name || !content) return + + const writableContent: WritableNotebook = { + schema_version: content.schema_version, + cells: content.cells.map((cell): WritableCell => { + switch (cell._tag) { + case 'markdown_cell': + return cell + case 'database_cell': { + const { unchecked_sql, chart, ...rest } = cell + return { + ...rest, + chart: chart ? { ...chart, y_series: [...chart.y_series] } : undefined, + sql: acceptUntrustedSql(unchecked_sql), + } + } + case 'log_cell': { + const { unchecked_sql, chart, ...rest } = cell + return { + ...rest, + chart: chart ? { ...chart, y_series: [...chart.y_series] } : undefined, + sql: acceptUntrustedLogsSql(unchecked_sql), + } + } + } + }), + } + + updateNotebook({ + projectRef: ref, + id: notebookId, + name, + description: currentNotebook?.notebook.description, + content: writableContent, + }) + } + + const handleConfirmDeleteNotebook = () => { + if (!ref || !id) return + deleteNotebook({ projectRef: ref, ids: [id] }) + } + const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event if (!id || !over || active.id === over.id) return @@ -87,6 +179,27 @@ export const ExplorerNotebookTab = () => { snap.insertCellAfter({ id: notebookId, cellId: lastCellId, cell }) } + if (isNotFound) { + return ( +
+ } + title="Notebook not found" + description="This notebook may have been deleted or does not exist." + contentClassName="[&>h3]:text-sm [&>p]:text-xs" + /> +
+ ) + } + + if (!content) { + return ( +
+ +
+ ) + } + return (
@@ -103,10 +216,29 @@ export const ExplorerNotebookTab = () => { icon={} tooltip="Run notebook" loading={isRunningNotebook} - disabled={isRunningNotebook || queryCellIds.length === 0} + disabled={queryCellIds.length === 0} onClick={handleRunNotebook} /> - } tooltip="Save changes" /> + } + tooltip="Save changes" + loading={isUpdating} + onClick={handleSaveNotebook} + /> + + + + } /> + + + setIsDeleteModalOpen(true)}> + + Delete notebook + + + + @@ -175,6 +307,22 @@ export const ExplorerNotebookTab = () => { )}
+ + setIsDeleteModalOpen(false)} + onConfirm={handleConfirmDeleteNotebook} + > +

+ This action cannot be undone. Are you sure you want to delete '{name}'? +

+
) } diff --git a/apps/studio/components/interfaces/Explorer/hooks.ts b/apps/studio/components/interfaces/Explorer/hooks.ts index 61449136ffa86..b5922c03ce7e7 100644 --- a/apps/studio/components/interfaces/Explorer/hooks.ts +++ b/apps/studio/components/interfaces/Explorer/hooks.ts @@ -1,6 +1,7 @@ import { useRouter } from 'next/router' +import { useEffect, useEffectEvent } from 'react' -import { createMarkdownCellSkeleton, createQueryCellSkeleton } from './utils' +import { useNotebookQuery } from '@/data/content/notebooks/notebook-query' import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { generateUuid } from '@/lib/api/snippets.browser' @@ -12,6 +13,39 @@ import { useNotebooksStateSnapshot } from '@/state/notebooks/notebooks-state' import { type Notebook } from '@/state/notebooks/types' import { Notebooks } from '@/types' +/** + * Fetches a notebook's content by id and merges it into the valtio store, so landing on + * a notebook any way other than creating it in this session (direct link, hard refresh, + * clicking it from the nav list) still hydrates `notebooksState`. + */ +export const useLoadNotebook = ({ id, projectRef }: { id?: string; projectRef?: string }) => { + const notebooksSnap = useNotebooksStateSnapshot() + const currentNotebook = id ? notebooksSnap.notebooks[id] : undefined + + const isCurrentProjectNotebook = currentNotebook?.projectRef === projectRef + const isNewLocalNotebook = isCurrentProjectNotebook && currentNotebook?.status === 'new' + const hasLoadedNotebook = + isCurrentProjectNotebook && currentNotebook?.notebook.content !== undefined + + const { data, error, isError } = useNotebookQuery( + { projectRef, id }, + { + retry: false, + enabled: !isNewLocalNotebook && !hasLoadedNotebook, + } + ) + + const mergeNotebook = useEffectEvent(() => { + if (projectRef && data) notebooksSnap.setNotebook({ projectRef, notebook: data }) + }) + + useEffect(() => { + mergeNotebook() + }, [projectRef, data]) + + return { isNotFound: isError && error.code === 404 } +} + export const useCreateNotebook = () => { const router = useRouter() const { profile } = useProfile() @@ -26,30 +60,6 @@ export const useCreateNotebook = () => { if (!profile) return console.error('Profile is required') if (!project) return console.error('Project is required') - const sampleMdCell1 = createMarkdownCellSkeleton({ - content: ` -# Title -A brief description on what this notebook is about -`.trim(), - }) - const sampleMdCell2 = createMarkdownCellSkeleton({ - content: ` -## Section -This is a sample paragraph to demonstrate the Markdown cells -1. List item 1 -2. List item 2 -3. List item 3 -`.trim(), - }) - const sampleQueryCell = createQueryCellSkeleton({ sql: 'select * from colors;' }) - - // [Joshen] Just adding sample data to play around with, keep for now - clean up at the end - const DEFAULT_CELLS = [ - sampleMdCell1, - sampleMdCell2, - sampleQueryCell, - ] as Notebooks.Content['cells'] - const id = idOverride ?? generateUuid() const notebook: Notebook = { @@ -61,7 +71,7 @@ This is a sample paragraph to demonstrate the Markdown cells favorite: false, content: { schema_version: 1, - cells: cells ?? DEFAULT_CELLS, + cells: cells ?? [], }, owner_id: profile.id, project_id: project.id, diff --git a/apps/studio/components/interfaces/Explorer/utils.ts b/apps/studio/components/interfaces/Explorer/utils.ts index 92a4cc2dcdbaa..7c85099024012 100644 --- a/apps/studio/components/interfaces/Explorer/utils.ts +++ b/apps/studio/components/interfaces/Explorer/utils.ts @@ -2,9 +2,12 @@ import { untrustedSql } from '@supabase/pg-meta' import { DEFAULT_CELL_ROW_LIMIT } from './QueryCell/QueryCell.utils' import { generateDraftId } from '@/data/content/notebooks/notebook-schema' +import { untrustedLogSql } from '@/data/logs/safe-analytics-sql' +import type { Notebooks } from '@/types' -export const createQueryCellSkeleton = ({ sql }: { sql?: string } = {}) => { +export const createQueryCellSkeleton = ({ title, sql }: { title?: string; sql?: string } = {}) => { return { + title, _tag: 'database_cell' as const, _id: generateDraftId(), view: 'table' as const, @@ -14,6 +17,28 @@ export const createQueryCellSkeleton = ({ sql }: { sql?: string } = {}) => { } } +const DEFAULT_LOG_TIME_RANGE: Notebooks.TimeRange = { + _tag: 'relative_time_range', + unit: 'hour', + amount: 1, +} + +export const createLogCellSkeleton = ({ + sql, + title, + time_range = DEFAULT_LOG_TIME_RANGE, +}: { sql?: string; title?: string; time_range?: Notebooks.TimeRange } = {}) => { + return { + title, + _tag: 'log_cell' as const, + id: generateDraftId(), + view: 'table' as const, + chart: undefined, + unchecked_sql: untrustedLogSql(sql ?? ''), + time_range, + } +} + const DEFAULT_MARKDOWN_CONTENT = ` # New section Add notes about your queries and results diff --git a/apps/studio/components/interfaces/Organization/AuditLogs/AuditLogs.tsx b/apps/studio/components/interfaces/Organization/AuditLogs/AuditLogs.tsx index f104b5ed19106..04af2231f0fc0 100644 --- a/apps/studio/components/interfaces/Organization/AuditLogs/AuditLogs.tsx +++ b/apps/studio/components/interfaces/Organization/AuditLogs/AuditLogs.tsx @@ -9,7 +9,12 @@ import { useEffect, useMemo, useState } from 'react' import { Alert, AlertDescription, AlertTitle, Button, WarningIcon } from 'ui' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' -import { filterByProjects, filterByUsers, sortAuditLogs } from './AuditLogs.utils' +import { + filterByProjects, + filterByUsers, + formatPartnerIfExists, + sortAuditLogs, +} from './AuditLogs.utils' import { LogDetailsPanel } from '@/components/interfaces/AuditLogs/LogDetailsPanel' import { LogsDatePicker } from '@/components/interfaces/Settings/Logs/Logs.DatePickers' import { ScaffoldContainer, ScaffoldSection } from '@/components/layouts/Scaffold' @@ -351,6 +356,12 @@ export const AuditLogs = () => { /> ) + const actorDisplayName = + user?.username || + log.actor.email || + formatPartnerIfExists(log.actor.partner, log.actor.partner_user_email) || + '-' + return ( {
{userIcon}
-

- {user?.username ?? log.actor.email ?? '-'} -

+

{actorDisplayName}

{role && (

{role?.name} diff --git a/apps/studio/components/interfaces/Organization/AuditLogs/AuditLogs.utils.ts b/apps/studio/components/interfaces/Organization/AuditLogs/AuditLogs.utils.ts index 92a61306543b7..d69f65a85d31b 100644 --- a/apps/studio/components/interfaces/Organization/AuditLogs/AuditLogs.utils.ts +++ b/apps/studio/components/interfaces/Organization/AuditLogs/AuditLogs.utils.ts @@ -50,3 +50,13 @@ export const formatSelectedDateRange = (value: DatePickerToFrom) => { return { from: from.utc().toISOString(), to: to.utc().toISOString() } } } + +export function formatPartnerIfExists( + partner: string | undefined, + partnerEmail: string | undefined +) { + if (!partner) return undefined + + const capitalized = `${partner[0].toUpperCase()}${partner.slice(1).toLowerCase()}` + return partnerEmail ? `${partnerEmail} (${capitalized})` : capitalized +} diff --git a/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.test.tsx b/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.test.tsx index 1113bd5bf0704..477a92297957d 100644 --- a/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.test.tsx +++ b/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.test.tsx @@ -34,6 +34,8 @@ const createSnippet = (id: string, name: string): SnippetWithContent => ({ const SNIPPET_A = createSnippet('snippet-a', 'First query') const SNIPPET_B = createSnippet('snippet-b', 'Second query') +const UNTITLED_A = createSnippet('untitled-a', 'Untitled query') +const UNTITLED_B = createSnippet('untitled-b', 'Untitled query') /** The modal renders the AI title generator, which checks for an OpenAI key when self-hosted. */ const mockOpenAIKeyCheck = () => @@ -108,6 +110,47 @@ describe('RenameQueryModal', () => { expect(screen.getByRole('button', { name: 'Rename query' })).toBeDisabled() }) + test('resets for the next snippet when both snippets share a name', async () => { + mockOpenAIKeyCheck() + const requests = mockUpsert() + const onComplete = vi.fn() + + const { rerender } = customRender( + + ) + + await userEvent.clear(getNameInput()) + await userEvent.type(getNameInput(), 'Renamed query') + fireEvent.click(screen.getByRole('button', { name: 'Rename query' })) + await waitFor(() => expect(onComplete).toHaveBeenCalledOnce()) + + // The parent keeps the renamed snippet selected while closing the modal, then reopens it for + // a second snippet that still carries the same original name + rerender( + + ) + rerender( + + ) + + await waitFor(() => expect(getNameInput()).toHaveValue('Untitled query')) + + await userEvent.clear(getNameInput()) + await userEvent.type(getNameInput(), 'Second renamed query') + fireEvent.click(screen.getByRole('button', { name: 'Rename query' })) + + await waitFor(() => expect(onComplete).toHaveBeenCalledTimes(2)) + expect(requests).toEqual([ + { id: 'untitled-a', name: 'Renamed query' }, + { id: 'untitled-b', name: 'Second renamed query' }, + ]) + }) + test('discards an abandoned edit when cancelled', async () => { mockOpenAIKeyCheck() const onCancel = vi.fn() diff --git a/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx b/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx index 1ae91dc20c121..8041ebe9b7d3b 100644 --- a/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx +++ b/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx @@ -49,12 +49,13 @@ const formSchema = z.object({ description: z.string().optional(), }) -export const RenameQueryModal = ({ - snippet = {} as any, - visible, - onCancel, - onComplete, -}: RenameQueryModalProps) => { +interface RenameQueryFormProps { + snippet: SqlSnippet | Snippet + onCancel: () => void + onComplete: () => void +} + +const RenameQueryForm = ({ snippet, onCancel, onComplete }: RenameQueryFormProps) => { const { ref } = useParams() const router = useRouter() @@ -145,7 +146,7 @@ export const RenameQueryModal = ({ } toast.success('Successfully renamed snippet!') - reset({ name, description }, { keepDirtyValues: false }) + reset({ name, description }) if (onComplete) onComplete() } catch (error: any) { // [Joshen] We probably need some rollback cause all the saving is async @@ -156,98 +157,105 @@ export const RenameQueryModal = ({ const form = useForm>({ resolver: zodResolver(formSchema), defaultValues: { name: name ?? '', description: description ?? '' }, - values: { name: name ?? '', description: description ?? '' }, - resetOptions: { keepDirtyValues: true }, }) const { reset, formState } = form const { isDirty, isSubmitting } = formState - const handleCancel = () => { - onCancel() - reset(undefined, { keepDirtyValues: false }) - } + return ( +

+ + + ( + + + + + + )} + /> +
+ generateTitle()} + size="tiny" + disabled={ + isTitleGenerationLoading || !isApiKeySet || isHipaaProjectDisallowed || isAiOptedOut + } + tooltip={{ + content: { + side: 'bottom', + text: isHipaaProjectDisallowed + ? 'This feature is not available for HIPAA projects.' + : isAiOptedOut + ? 'Your organization has opted out of AI features.' + : isApiKeySet + ? undefined + : 'Add your "OPENAI_API_KEY" to your environment variables to use this feature.', + }, + }} + > +
+
+ +
+ Rename with Supabase AI +
+
+
+ ( + + +