From 47d85e5235f4f547f9239ff8ea36acc7f389cf7d Mon Sep 17 00:00:00 2001 From: Alan Daniel Date: Wed, 27 May 2026 12:37:09 -0400 Subject: [PATCH 1/5] fix(marketing/forms): resolve CRM config server-side, not from client (#46239) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Bug fix (security hardening). ## What is the current behavior? [PRODSEC-120](https://linear.app/supabase/issue/PRODSEC-120/mythos-ant-2026-btrnt5a3-server-action-accepts-client-controlled-crm) — the marketing form server action accepts the full \`crm\` config (Notion \`database_id\`, HubSpot \`formGuid\`, Customer.io \`event\`, \`staticProperties\`, etc.) from the client, so a crafted submission can write to any Notion database the integration token reaches, post to any HubSpot form in the portal, or trigger arbitrary Customer.io events. ## What is the new behavior? The client now posts only \`{ slug, formId }\` plus the field values; \`submitFormAction\` validates the ref with Zod, looks the trusted CRM config up from the in-process \`_go/**\` page registry via a resolver wired up in \`instrumentation.ts\`, and fails closed if the form isn't found. \`SectionRenderer\` also strips \`crm\` from the section before it crosses into the client bundle (so \`database_id\` / \`formGuid\` no longer ship in page HTML), \`getAllGoPages\` rejects any form section with \`crm\` but no stable \`id\`, and per-submission size/character limits were tightened. ## Additional context Separate follow-ups (not in this PR): confirm \`NOTION_FORMS_API_KEY\` is write-only and scoped to the forms subtree, and chase down the \`NOTION_EVENTS_API_KEY\` validity issue raised on the Linear ticket. ## Summary by CodeRabbit * **New Features** - Forms now support unique identifiers for enhanced tracking and management - Server-side form configuration management for improved reliability * **Improvements** - Enhanced form validation during page initialization to catch configuration issues - Improved form submission handling with better error detection and reporting - Strengthened form operations with fail-safe configuration resolution [![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/46239?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack) --- apps/www/_go/lead-gen/example-lead-gen.tsx | 1 + apps/www/instrumentation.ts | 2 + apps/www/lib/go.ts | 9 ++ apps/www/lib/registerFormCrm.ts | 23 +++++ .../marketing/src/forms/MarketingForm.tsx | 39 +++++--- packages/marketing/src/forms/index.ts | 6 +- .../src/go/actions/formCrmResolver.ts | 32 ++++++ .../marketing/src/go/actions/submitForm.ts | 99 +++++++++++++++++-- packages/marketing/src/go/index.ts | 1 + packages/marketing/src/go/schemas.ts | 56 +++++++++++ .../marketing/src/go/sections/FormSection.tsx | 20 +++- .../src/go/sections/SectionRenderer.tsx | 12 ++- .../src/go/templates/LeadGenTemplate.tsx | 7 +- .../src/go/templates/LegalTemplate.tsx | 7 +- .../src/go/templates/ThankYouTemplate.tsx | 7 +- 15 files changed, 288 insertions(+), 33 deletions(-) create mode 100644 apps/www/lib/registerFormCrm.ts create mode 100644 packages/marketing/src/go/actions/formCrmResolver.ts diff --git a/apps/www/_go/lead-gen/example-lead-gen.tsx b/apps/www/_go/lead-gen/example-lead-gen.tsx index 81c1187744b1b..8c25c972e7fbf 100644 --- a/apps/www/_go/lead-gen/example-lead-gen.tsx +++ b/apps/www/_go/lead-gen/example-lead-gen.tsx @@ -224,6 +224,7 @@ alter table posts enable row level security;`, }, { type: 'form', + id: 'form', title: 'Get in touch', description: 'Fill out the form below and our team will get back to you shortly.', fields: [ diff --git a/apps/www/instrumentation.ts b/apps/www/instrumentation.ts index 3063091e693ee..ce4795aa99850 100644 --- a/apps/www/instrumentation.ts +++ b/apps/www/instrumentation.ts @@ -3,6 +3,8 @@ import * as Sentry from '@sentry/nextjs' export async function register() { if (process.env.NEXT_RUNTIME === 'nodejs') { await import('./sentry.server.config') + const { registerFormCrmResolver } = await import('./lib/registerFormCrm') + registerFormCrmResolver() } if (process.env.NEXT_RUNTIME === 'edge') { diff --git a/apps/www/lib/go.ts b/apps/www/lib/go.ts index 3876928737c05..2e0ac425ce4ee 100644 --- a/apps/www/lib/go.ts +++ b/apps/www/lib/go.ts @@ -1,3 +1,5 @@ +import { validateGoPageInvariants } from 'marketing' + import rawPages from '@/_go' import { goPageSchema, type GoPage } from '@/types/go' @@ -14,6 +16,13 @@ export function getAllGoPages(): GoPage[] { ) } + const invariantErrors = validateGoPageInvariants(result.data) + if (invariantErrors.length > 0) { + throw new Error( + `Invalid go page definition (slug: "${result.data.slug}"):\n${invariantErrors.map((m) => ` - ${m}`).join('\n')}` + ) + } + if (seenSlugs.has(result.data.slug)) { throw new Error(`Duplicate slug "${result.data.slug}" in _go registry`) } diff --git a/apps/www/lib/registerFormCrm.ts b/apps/www/lib/registerFormCrm.ts new file mode 100644 index 0000000000000..d18d8a097b4d3 --- /dev/null +++ b/apps/www/lib/registerFormCrm.ts @@ -0,0 +1,23 @@ +import 'server-only' + +import { setFormCrmResolver } from 'marketing' + +import { getGoPageBySlug } from './go' + +/** + * Wire the marketing form server action to look up the trusted CRM config for + * a `{ slug, formId }` pair from the in-process page registry. Without this, + * `submitFormAction` fails closed and rejects every submission. See + * PRODSEC-120 for why the CRM config must never come from the client. + */ +export function registerFormCrmResolver() { + setFormCrmResolver(({ slug, formId }) => { + const page = getGoPageBySlug(slug) + if (!page || !('sections' in page) || !page.sections) return undefined + + const section = page.sections.find((s) => s.type === 'form' && s.id === formId) + if (!section || section.type !== 'form') return undefined + + return section.crm + }) +} diff --git a/packages/marketing/src/forms/MarketingForm.tsx b/packages/marketing/src/forms/MarketingForm.tsx index 10022734a36e1..82e77495a5113 100644 --- a/packages/marketing/src/forms/MarketingForm.tsx +++ b/packages/marketing/src/forms/MarketingForm.tsx @@ -16,11 +16,20 @@ import { import type { z } from 'zod' import { submitFormAction } from '../go/actions/submitForm' -import { formCrmConfigSchema, formFieldSchema, type GoFormFieldShowWhen } from '../go/schemas' +import { formFieldSchema, type GoFormFieldShowWhen } from '../go/schemas' /** Input-shape field type — fields with Zod defaults (`half`, `required`) are optional here. */ export type MarketingFormField = z.input -export type MarketingFormCrmConfig = z.input + +/** + * Opaque reference the client posts back to the server action. The server + * resolves this to the trusted CRM config from the page registry; the client + * never sees or controls the actual CRM target (database id, form GUID, etc.). + */ +export interface MarketingFormRef { + slug: string + formId: string +} /** * Evaluate a `showWhen` rule against the current form values. All supplied @@ -52,8 +61,13 @@ export interface MarketingFormProps { successMessage?: string /** URL to redirect the user to after a successful submission. Overrides `successMessage`. */ successRedirect?: string - /** CRM fan-out config — submits to HubSpot, Customer.io, and/or Notion in parallel. */ - crm?: MarketingFormCrmConfig + /** + * Server-side form reference. When set, submissions are posted to + * `submitFormAction` with this ref; the server looks up the trusted CRM + * config from the page registry. When omitted, the form logs values in dev + * and does nothing in production (useful for previews). + */ + formRef?: MarketingFormRef /** Wraps the form in a styled card (border + padding). Defaults to `true`. */ card?: boolean /** Extra class names applied to the outer wrapper. */ @@ -63,10 +77,9 @@ export interface MarketingFormProps { type SubmitState = 'idle' | 'loading' | 'success' | 'error' /** Build the sessionStorage key used to block double-submits of the same email to the same form. */ -function dedupeKey(crm: MarketingFormCrmConfig | undefined, email: string): string | null { - const formId = crm?.hubspot?.formGuid ?? crm?.notion?.database_id - if (!formId || !email) return null - return `marketing-form-submitted:${formId}:${email.trim().toLowerCase()}` +function dedupeKey(formRef: MarketingFormRef | undefined, email: string): string | null { + if (!formRef || !email) return null + return `marketing-form-submitted:${formRef.slug}:${formRef.formId}:${email.trim().toLowerCase()}` } function FieldInput({ @@ -166,7 +179,7 @@ export default function MarketingForm({ disclaimer, successMessage, successRedirect, - crm, + formRef, card = true, className, }: MarketingFormProps) { @@ -210,9 +223,9 @@ export default function MarketingForm({ Object.entries(values).filter(([name]) => visibleFieldNames.has(name)) ) - if (!crm) { + if (!formRef) { if (process.env.NODE_ENV === 'development') { - console.log('[marketing/form] No CRM configured — form values:', submittedValues) + console.log('[marketing/form] No formRef configured — form values:', submittedValues) } return } @@ -226,7 +239,7 @@ export default function MarketingForm({ submittedValues['emailAddress'] ?? submittedValues['email_address'] ?? '' - const sessionKey = dedupeKey(crm, emailValue) + const sessionKey = dedupeKey(formRef, emailValue) if (sessionKey && typeof window !== 'undefined') { try { if (window.sessionStorage.getItem(sessionKey)) { @@ -250,7 +263,7 @@ export default function MarketingForm({ const honeypot = honeypotRef.current?.value ?? '' try { - const result = await submitFormAction(crm, submittedValues, { + const result = await submitFormAction(formRef, submittedValues, { pageUri, pageName, honeypot, diff --git a/packages/marketing/src/forms/index.ts b/packages/marketing/src/forms/index.ts index 974e7091b4bae..a7de322a30840 100644 --- a/packages/marketing/src/forms/index.ts +++ b/packages/marketing/src/forms/index.ts @@ -1,9 +1,5 @@ export { default as MarketingForm } from './MarketingForm' -export type { - MarketingFormCrmConfig, - MarketingFormField, - MarketingFormProps, -} from './MarketingForm' +export type { MarketingFormField, MarketingFormProps, MarketingFormRef } from './MarketingForm' export { default as HubSpotFormEmbed } from './HubSpotFormEmbed' export type { HubSpotFormEmbedProps } from './HubSpotFormEmbed' diff --git a/packages/marketing/src/go/actions/formCrmResolver.ts b/packages/marketing/src/go/actions/formCrmResolver.ts new file mode 100644 index 0000000000000..994040a2b469e --- /dev/null +++ b/packages/marketing/src/go/actions/formCrmResolver.ts @@ -0,0 +1,32 @@ +import 'server-only' + +import type { GoFormCrmConfig } from '../schemas' + +export interface FormRef { + slug: string + formId: string +} + +export type FormCrmResolver = ( + ref: FormRef +) => GoFormCrmConfig | undefined | Promise + +let resolver: FormCrmResolver | null = null + +/** + * Register the function used by `submitFormAction` to look up the trusted CRM + * config for a form. The consuming app must call this at server startup so the + * server action can resolve a `{ slug, formId }` posted from the client back to + * the same config that lives in the page registry. + * + * The CRM config must never be sourced from the client — see + * `submitFormAction` for the security rationale. + */ +export function setFormCrmResolver(fn: FormCrmResolver): void { + resolver = fn +} + +export async function resolveFormCrmConfig(ref: FormRef): Promise { + if (!resolver) return undefined + return await resolver(ref) +} diff --git a/packages/marketing/src/go/actions/submitForm.ts b/packages/marketing/src/go/actions/submitForm.ts index c1770bb73ecc3..dddbc498b483a 100644 --- a/packages/marketing/src/go/actions/submitForm.ts +++ b/packages/marketing/src/go/actions/submitForm.ts @@ -1,7 +1,10 @@ 'use server' +import { z } from 'zod' + import { CRMClient, type CRMConfig } from '../../crm' import type { GoFormCrmConfig } from '../schemas' +import { resolveFormCrmConfig } from './formCrmResolver' export interface FormSubmitResult { success: boolean @@ -14,6 +17,11 @@ const HONEYPOT_FIELD = 'website' /** Minimum milliseconds a form must be on the page before a submission is accepted. */ const MIN_FORM_RENDER_MS = 3000 +/** Per-submission limits. Keeps any single payload from blowing through CRM rate limits. */ +const MAX_FIELD_NAME_LENGTH = 200 +const MAX_FIELD_VALUE_LENGTH = 10_000 +const MAX_FIELDS_PER_SUBMISSION = 100 + // Enable debug logging in local dev and on Vercel preview/development deployments const isDebug = process.env.NODE_ENV === 'development' || @@ -29,6 +37,39 @@ function debug(message: string, data?: unknown) { } } +/** + * Restricted character set for the form reference. Slugs follow the page + * registry shape (`a/b-c`), formIds match `formIdSchema` in schemas.ts. We + * keep these strict because they flow straight into a registry lookup. + */ +const formRefSchema = z.object({ + slug: z + .string() + .min(1) + .max(200) + .regex(/^[a-z0-9][a-z0-9/_-]*$/i, 'Invalid slug'), + formId: z + .string() + .min(1) + .max(120) + .regex(/^[a-z0-9][a-z0-9_-]*$/i, 'Invalid formId'), +}) + +const valuesSchema = z + .record(z.string().min(1).max(MAX_FIELD_NAME_LENGTH), z.string().max(MAX_FIELD_VALUE_LENGTH)) + .refine((v) => Object.keys(v).length <= MAX_FIELDS_PER_SUBMISSION, { + message: 'Too many fields', + }) + +const contextSchema = z + .object({ + pageUri: z.string().max(2048).optional(), + pageName: z.string().max(500).optional(), + honeypot: z.string().max(MAX_FIELD_VALUE_LENGTH).optional(), + formMountedAt: z.number().int().nonnegative().optional(), + }) + .optional() + function buildCrmConfig(crm: GoFormCrmConfig): CRMConfig { const config: CRMConfig = {} @@ -56,21 +97,49 @@ function buildCrmConfig(crm: GoFormCrmConfig): CRMConfig { } /** - * Submit form values to the configured CRM providers (HubSpot, Customer.io, Notion). + * Submit a form to its configured CRM providers (HubSpot, Customer.io, Notion). + * + * SECURITY: the client only sends `formRef = { slug, formId }` and the field + * values. The trusted CRM config (which database, which form GUID, which + * event, which static properties) is resolved on the server from the page + * registry via the resolver wired up in `setFormCrmResolver`. Never accept + * the CRM config from the wire — a client that controls it can write to any + * Notion database the integration token can reach, submit to any HubSpot form + * on the portal, or trigger arbitrary Customer.io events. See PRODSEC-120. * * Credentials are read from environment variables: * - HubSpot: HUBSPOT_PORTAL_ID * - Customer.io: CUSTOMERIO_SITE_ID, CUSTOMERIO_API_KEY * - Notion: NOTION_FORMS_API_KEY - * - * Per-form config (formGuid, event name, database_id, field mappings) lives in the page definition. */ export async function submitFormAction( - crm: GoFormCrmConfig, - values: Record, - context?: { pageUri?: string; pageName?: string; honeypot?: string; formMountedAt?: number } + rawFormRef: unknown, + rawValues: unknown, + rawContext?: unknown ): Promise { - debug('Form submission received', { crm, values, context }) + const parsedRef = formRefSchema.safeParse(rawFormRef) + if (!parsedRef.success) { + debug('Submission rejected: invalid formRef', parsedRef.error.issues) + return { success: false, errors: ['Invalid form reference.'] } + } + + const parsedValues = valuesSchema.safeParse(rawValues) + if (!parsedValues.success) { + debug('Submission rejected: invalid values', parsedValues.error.issues) + return { success: false, errors: ['Invalid form values.'] } + } + + const parsedContext = contextSchema.safeParse(rawContext) + if (!parsedContext.success) { + debug('Submission rejected: invalid context', parsedContext.error.issues) + return { success: false, errors: ['Invalid submission context.'] } + } + + const formRef = parsedRef.data + const values = parsedValues.data + const context = parsedContext.data + + debug('Form submission received', { formRef, values, context }) // Anti-spam: honeypot tripped or form submitted suspiciously fast. Return a // fake success so bots think they got through and don't retry with variations. @@ -93,6 +162,22 @@ export async function submitFormAction( return { success: true, errors: [] } } + // Trusted CRM config — sourced from the server-side page registry, NOT the + // client. If the resolver isn't registered or the form isn't found, fail + // closed. + let crm: GoFormCrmConfig | undefined + try { + crm = await resolveFormCrmConfig(formRef) + } catch (err: any) { + console.error('[go/form] CRM resolver threw', err) + return { success: false, errors: ['Form configuration unavailable.'] } + } + + if (!crm) { + console.warn('[go/form] Rejected submission: form not found in registry', formRef) + return { success: false, errors: ['Form not found.'] } + } + try { // The honeypot field is never part of the real payload, even if a real // form happens to include a `website` field name. diff --git a/packages/marketing/src/go/index.ts b/packages/marketing/src/go/index.ts index 24186b54d8382..beeadf6cde8f1 100644 --- a/packages/marketing/src/go/index.ts +++ b/packages/marketing/src/go/index.ts @@ -2,3 +2,4 @@ export * from './schemas' export * from './sections' export * from './templates' export * from './actions/submitForm' +export * from './actions/formCrmResolver' diff --git a/packages/marketing/src/go/schemas.ts b/packages/marketing/src/go/schemas.ts index d9bec8effedc0..77bfc03840775 100644 --- a/packages/marketing/src/go/schemas.ts +++ b/packages/marketing/src/go/schemas.ts @@ -256,6 +256,19 @@ export const formCrmConfigSchema = z message: 'At least one CRM provider (hubspot, customerio, or notion) must be configured', }) +/** + * Form `id` doubles as the lookup key the server uses to resolve the trusted + * CRM config at submit time. The client only posts `{ slug, formId }`; the + * server pulls `crm` from the in-process page registry. Keep this restricted + * to a plain identifier — it is round-tripped through the action payload. + * + * Enforcement happens post-parse in `validateGoPage` rather than via a Zod + * `.superRefine` so `formSectionSchema` stays a plain `ZodObject` and remains + * usable as a member of the section `discriminatedUnion`. + */ +export const FORM_ID_PATTERN = /^[a-z0-9][a-z0-9_-]*$/i +export const FORM_ID_MAX_LENGTH = 120 + export const formSectionSchema = z.object({ ...sectionBase, type: z.literal('form'), @@ -430,6 +443,49 @@ export const goPageSchema = z.discriminatedUnion('template', [ legalPageSchema, ]) +/** + * Post-parse checks that can't be expressed in the Zod schema without breaking + * the section `discriminatedUnion`. Returns an array of human-readable errors + * (empty if the page is valid). Intended to be called by the page-registry + * loader alongside `goPageSchema.safeParse`. + * + * Currently checks: + * - Every form section that configures `crm` has a stable `id` (used as the + * server-side lookup key — see `submitFormAction`). + * - Form ids match `FORM_ID_PATTERN` so they're safe to round-trip through + * the action payload. + * - Form ids are unique within a page so resolution is deterministic. + */ +export function validateGoPageInvariants(page: z.infer): string[] { + const errors: string[] = [] + const sections = 'sections' in page ? (page.sections ?? []) : [] + const formIds = new Set() + + sections.forEach((section, index) => { + if (section.type !== 'form') return + + if (section.crm && !section.id) { + errors.push( + `sections[${index}]: form sections that configure \`crm\` must declare an \`id\` — the server uses it to look up the trusted CRM config.` + ) + } + + if (section.id) { + if (section.id.length > FORM_ID_MAX_LENGTH || !FORM_ID_PATTERN.test(section.id)) { + errors.push( + `sections[${index}].id: "${section.id}" is not a valid form id (must be ≤${FORM_ID_MAX_LENGTH} chars, alphanumeric/underscore/dash, starting with alphanumeric).` + ) + } + if (formIds.has(section.id)) { + errors.push(`sections[${index}].id: duplicate form id "${section.id}" on this page.`) + } + formIds.add(section.id) + } + }) + + return errors +} + // ----- Inferred types ----- export type GoImage = z.infer diff --git a/packages/marketing/src/go/sections/FormSection.tsx b/packages/marketing/src/go/sections/FormSection.tsx index c4147eb12519a..d34c97652343b 100644 --- a/packages/marketing/src/go/sections/FormSection.tsx +++ b/packages/marketing/src/go/sections/FormSection.tsx @@ -3,7 +3,23 @@ import MarketingForm from '../../forms/MarketingForm' import type { GoFormSection } from '../schemas' -export default function FormSection({ section }: { section: GoFormSection }) { +/** + * Form props that are safe to ship to the client. The `crm` config from the + * page registry stays on the server — the client only learns the `{ slug, + * formId }` it needs to post back. The action then re-resolves `crm` from the + * trusted registry. See `submitFormAction` and PRODSEC-120. + */ +export type ClientFormSection = Omit + +export default function FormSection({ + section, + slug, +}: { + section: ClientFormSection + slug: string +}) { + const formRef = section.id ? { slug, formId: section.id } : undefined + return (
@@ -15,7 +31,7 @@ export default function FormSection({ section }: { section: GoFormSection }) { disclaimer={section.disclaimer} successMessage={section.successMessage} successRedirect={section.successRedirect} - crm={section.crm} + formRef={formRef} />
diff --git a/packages/marketing/src/go/sections/SectionRenderer.tsx b/packages/marketing/src/go/sections/SectionRenderer.tsx index 7fb6b0b5dc6a3..905810be0841d 100644 --- a/packages/marketing/src/go/sections/SectionRenderer.tsx +++ b/packages/marketing/src/go/sections/SectionRenderer.tsx @@ -21,10 +21,12 @@ export type CustomSectionRenderers = { interface SectionRendererProps { section: GoSection + /** Page slug — threaded through so form sections can build a server-resolvable formRef. */ + slug: string customRenderers?: CustomSectionRenderers } -export default function SectionRenderer({ section, customRenderers }: SectionRendererProps) { +export default function SectionRenderer({ section, slug, customRenderers }: SectionRendererProps) { // Check for a custom renderer first const CustomRenderer = customRenderers?.[section.type] as | React.ComponentType<{ section: typeof section }> @@ -45,9 +47,13 @@ export default function SectionRenderer({ section, customRenderers }: SectionRen case 'three-column': content = break - case 'form': - content = + case 'form': { + // Drop CRM config before the section crosses into the client bundle — + // the server action re-resolves it from the registry by formRef. + const { crm: _crm, ...clientSection } = section + content = break + } case 'feature-grid': content = break diff --git a/packages/marketing/src/go/templates/LeadGenTemplate.tsx b/packages/marketing/src/go/templates/LeadGenTemplate.tsx index 125676c578b2f..7af08c4186d8b 100644 --- a/packages/marketing/src/go/templates/LeadGenTemplate.tsx +++ b/packages/marketing/src/go/templates/LeadGenTemplate.tsx @@ -13,7 +13,12 @@ export default function LeadGenTemplate({
{page.sections?.map((section, i) => ( - + ))}
) diff --git a/packages/marketing/src/go/templates/LegalTemplate.tsx b/packages/marketing/src/go/templates/LegalTemplate.tsx index 66637efc98db3..d0095c3839d89 100644 --- a/packages/marketing/src/go/templates/LegalTemplate.tsx +++ b/packages/marketing/src/go/templates/LegalTemplate.tsx @@ -25,7 +25,12 @@ export default function LegalTemplate({ {page.sections?.map((section, i) => ( - + ))} ) diff --git a/packages/marketing/src/go/templates/ThankYouTemplate.tsx b/packages/marketing/src/go/templates/ThankYouTemplate.tsx index 35cbce99583d1..02dc573be5ade 100644 --- a/packages/marketing/src/go/templates/ThankYouTemplate.tsx +++ b/packages/marketing/src/go/templates/ThankYouTemplate.tsx @@ -17,7 +17,12 @@ export default function ThankYouTemplate({ {page.sections?.map((section, i) => ( - + ))} ) From 0ab0106758afb3a960b2f99c9eadd9468177b748 Mon Sep 17 00:00:00 2001 From: Charis <26616127+charislam@users.noreply.github.com> Date: Wed, 27 May 2026 13:11:39 -0400 Subject: [PATCH 2/5] feat(logs): brand Reports logs presets with SafeLogSqlFragment (#46403) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Refactor / security hardening (part of a stacked series applying compile-time SQL provenance tracking to analytics call sites). ## What is the current behavior? The `queryType: 'logs'` presets in `PRESET_CONFIG` (API ×8, Storage ×2) build BigQuery SQL by splicing filter keys and values via plain string interpolation through `generateRegexpWhere`, with no compile-time guarantee that the output is injection-safe. `ReportQueryLogs.sql` returns `string` and `getLogsSql` returns `string`. ## What is the new behavior? - `generateRegexpWhereSafe` added to `Reports.constants.ts`: routes filter keys through `quotedIdent` (dropping predicates whose identifier fails the `[A-Za-z_][A-Za-z0-9_]*` regex) and values through `analyticsLiteral`. Values must be raw/unquoted — the function handles all quoting and escaping itself. - All ten `queryType: 'logs'` presets migrated to use the `safeLogSql` template tag and `generateRegexpWhereSafe`. - `ReportQueryLogs.sql` return type tightened from `string` to `SafeLogSqlFragment`; `getLogsSql` return type updated to match. - Manual pre-quoting of the `identifier` filter removed in `useApiReport` and `useStorageReport` (`value: \`'${identifier}'\`` → `value: identifier`), since `analyticsLiteral` now handles quoting. ## Additional context Smoke test: `/observability/api-overview`, `/observability/storage`. To exercise the replica `identifier` filter, select a replica on `/observability/database` first, then navigate to those pages. --- .../interfaces/Reports/Reports.constants.ts | 101 ++++++++++++++---- .../interfaces/Reports/Reports.types.ts | 3 +- .../interfaces/Reports/Reports.utils.tsx | 3 +- apps/studio/data/reports/api-report-query.ts | 2 +- .../data/reports/storage-report-query.ts | 2 +- 5 files changed, 87 insertions(+), 24 deletions(-) diff --git a/apps/studio/components/interfaces/Reports/Reports.constants.ts b/apps/studio/components/interfaces/Reports/Reports.constants.ts index 6a48c807fa9b7..11b0dd34cbf62 100644 --- a/apps/studio/components/interfaces/Reports/Reports.constants.ts +++ b/apps/studio/components/interfaces/Reports/Reports.constants.ts @@ -3,6 +3,13 @@ import dayjs from 'dayjs' import type { DatetimeHelper } from '../Settings/Logs/Logs.types' import { PresetConfig, Presets, ReportFilterItem } from './Reports.types' +import { + analyticsLiteral, + joinSqlFragments, + quotedIdent, + safeSql as safeLogSql, + type SafeLogSqlFragment, +} from '@/data/logs/safe-analytics-sql' import { PlanId } from '@/data/subscriptions/types' export const LAYOUT_COLUMN_COUNT = 2 @@ -133,13 +140,67 @@ export const generateRegexpWhere = (filters: ReportFilterItem[], prepend = true) } } +// Unlike the legacy `generateRegexpWhere`, this function requires filter values +// to be raw (unquoted) strings or numbers. `analyticsLiteral` handles all +// quoting and escaping — callers must NOT pre-wrap values in single quotes. +export function generateRegexpWhereSafe( + filters: ReportFilterItem[], + prepend = true +): SafeLogSqlFragment { + if (filters.length === 0) return safeLogSql`` + + const conditions = filters + .map((filter) => { + const splitKey = filter.key.split('.') + const normalizedKey = [splitKey[splitKey.length - 2], splitKey[splitKey.length - 1]].join('.') + const keyToQuote = filter.key.includes('.') ? normalizedKey : filter.key + + let col: SafeLogSqlFragment + try { + col = quotedIdent(keyToQuote) + } catch { + return null + } + + const valueIsNumber = !isNaN(Number(filter.value)) + const lit = valueIsNumber + ? analyticsLiteral(Number(filter.value)) + : analyticsLiteral(String(filter.value).toLowerCase()) + + switch (filter.compare) { + case 'matches': + return safeLogSql`REGEXP_CONTAINS(${col}, ${lit})` + case 'is': + return safeLogSql`${col} = ${lit}` + case '!=': + return safeLogSql`${col} != ${lit}` + case '>=': + return safeLogSql`${col} >= ${lit}` + case '<=': + return safeLogSql`${col} <= ${lit}` + case '>': + return safeLogSql`${col} > ${lit}` + case '<': + return safeLogSql`${col} < ${lit}` + default: + return safeLogSql`${col} = ${lit}` + } + }) + .filter((c) => c !== null) + + if (conditions.length === 0) return safeLogSql`` + + const joined = joinSqlFragments(conditions, ' AND ') + return prepend ? safeLogSql`WHERE ${joined}` : safeLogSql`AND ${joined}` +} + export const PRESET_CONFIG: Record = { [Presets.API]: { title: 'API', queries: { totalRequests: { queryType: 'logs', - sql: (filters) => ` + sql: (filters) => safeLogSql` -- reports-api-total-requests select cast(timestamp_trunc(t.timestamp, hour) as datetime) as timestamp, @@ -149,7 +210,7 @@ export const PRESET_CONFIG: Record = { cross join unnest(m.response) as response cross join unnest(m.request) as request cross join unnest(request.headers) as headers - ${generateRegexpWhere(filters)} + ${generateRegexpWhereSafe(filters)} GROUP BY timestamp ORDER BY @@ -157,7 +218,7 @@ export const PRESET_CONFIG: Record = { }, topRoutes: { queryType: 'logs', - sql: (filters) => ` + sql: (filters) => safeLogSql` -- reports-api-top-routes select request.path as path, @@ -170,7 +231,7 @@ export const PRESET_CONFIG: Record = { cross join unnest(m.response) as response cross join unnest(m.request) as request cross join unnest(request.headers) as headers - ${generateRegexpWhere(filters)} + ${generateRegexpWhereSafe(filters)} group by request.path, request.method, request.search, response.status_code order by @@ -180,7 +241,7 @@ export const PRESET_CONFIG: Record = { }, errorCounts: { queryType: 'logs', - sql: (filters) => ` + sql: (filters) => safeLogSql` -- reports-api-error-counts select cast(timestamp_trunc(t.timestamp, hour) as datetime) as timestamp, @@ -192,7 +253,7 @@ export const PRESET_CONFIG: Record = { cross join unnest(request.headers) as headers WHERE response.status_code >= 400 - ${generateRegexpWhere(filters, false)} + ${generateRegexpWhereSafe(filters, false)} GROUP BY timestamp ORDER BY @@ -201,7 +262,7 @@ export const PRESET_CONFIG: Record = { }, topErrorRoutes: { queryType: 'logs', - sql: (filters) => ` + sql: (filters) => safeLogSql` -- reports-api-top-error-routes select request.path as path, @@ -216,7 +277,7 @@ export const PRESET_CONFIG: Record = { cross join unnest(request.headers) as headers where response.status_code >= 400 - ${generateRegexpWhere(filters, false)} + ${generateRegexpWhereSafe(filters, false)} group by request.path, request.method, request.search, response.status_code order by @@ -226,7 +287,7 @@ export const PRESET_CONFIG: Record = { }, responseSpeed: { queryType: 'logs', - sql: (filters) => ` + sql: (filters) => safeLogSql` -- reports-api-response-speed select cast(timestamp_trunc(t.timestamp, hour) as datetime) as timestamp, @@ -237,7 +298,7 @@ export const PRESET_CONFIG: Record = { cross join unnest(m.response) as response cross join unnest(m.request) as request cross join unnest(request.headers) as headers - ${generateRegexpWhere(filters)} + ${generateRegexpWhereSafe(filters)} GROUP BY timestamp ORDER BY @@ -246,7 +307,7 @@ export const PRESET_CONFIG: Record = { }, topSlowRoutes: { queryType: 'logs', - sql: (filters) => ` + sql: (filters) => safeLogSql` -- reports-api-top-slow-routes select request.path as path, @@ -260,7 +321,7 @@ export const PRESET_CONFIG: Record = { cross join unnest(m.response) as response cross join unnest(m.request) as request cross join unnest(request.headers) as headers - ${generateRegexpWhere(filters)} + ${generateRegexpWhereSafe(filters)} group by request.path, request.method, request.search, response.status_code order by @@ -270,7 +331,7 @@ export const PRESET_CONFIG: Record = { }, networkTraffic: { queryType: 'logs', - sql: (filters) => ` + sql: (filters) => safeLogSql` -- reports-api-network-traffic select cast(timestamp_trunc(t.timestamp, hour) as datetime) as timestamp, @@ -299,7 +360,7 @@ export const PRESET_CONFIG: Record = { cross join unnest(m.request) as request cross join unnest(request.headers) as headers cross join unnest(response.headers) as resp_headers - ${generateRegexpWhere(filters)} + ${generateRegexpWhereSafe(filters)} GROUP BY timestamp ORDER BY @@ -308,7 +369,7 @@ export const PRESET_CONFIG: Record = { }, requestsByCountry: { queryType: 'logs', - sql: (filters) => ` + sql: (filters) => safeLogSql` -- reports-api-requests-by-country select cf.country as country, @@ -321,7 +382,7 @@ export const PRESET_CONFIG: Record = { cross join unnest(request.cf) as cf where cf.country is not null - ${generateRegexpWhere(filters, false)} + ${generateRegexpWhereSafe(filters, false)} group by cf.country `, @@ -338,7 +399,7 @@ export const PRESET_CONFIG: Record = { cacheHitRate: { queryType: 'logs', // storage report does not perform any filtering - sql: (filters) => ` + sql: (filters) => safeLogSql` -- reports-storage-cache-hit-rate SELECT timestamp_trunc(timestamp, hour) as timestamp, @@ -350,7 +411,7 @@ from edge_logs f 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' - ${generateRegexpWhere(filters, false)} + ${generateRegexpWhereSafe(filters, false)} group by timestamp order by timestamp desc `, @@ -358,7 +419,7 @@ order by timestamp desc topCacheMisses: { queryType: 'logs', // storage report does not perform any filtering - sql: (filters) => ` + sql: (filters) => safeLogSql` -- reports-storage-top-cache-misses SELECT r.path as path, @@ -372,7 +433,7 @@ from edge_logs f where starts_with(r.path, '/storage/v1/object') and r.method = 'GET' and h.cf_cache_status in ('MISS', 'NONE/UNKNOWN', 'EXPIRED', 'BYPASS', 'DYNAMIC') - ${generateRegexpWhere(filters, false)} + ${generateRegexpWhereSafe(filters, false)} group by path, search order by count desc limit 12 diff --git a/apps/studio/components/interfaces/Reports/Reports.types.ts b/apps/studio/components/interfaces/Reports/Reports.types.ts index 6eb68e2aab424..33fb035fcface 100644 --- a/apps/studio/components/interfaces/Reports/Reports.types.ts +++ b/apps/studio/components/interfaces/Reports/Reports.types.ts @@ -1,5 +1,6 @@ import type { SafeSqlFragment } from '@supabase/pg-meta' +import type { SafeLogSqlFragment } from '@/data/logs/safe-analytics-sql' import type { ResponseError } from '@/types' export enum Presets { @@ -31,7 +32,7 @@ export interface ReportQueryLogs { filterIndexAdvisor?: boolean, page?: number, pageSize?: number - ) => string + ) => SafeLogSqlFragment } export interface ReportQueryDb { diff --git a/apps/studio/components/interfaces/Reports/Reports.utils.tsx b/apps/studio/components/interfaces/Reports/Reports.utils.tsx index fa37349f58272..064c3ee9ec9e8 100644 --- a/apps/studio/components/interfaces/Reports/Reports.utils.tsx +++ b/apps/studio/components/interfaces/Reports/Reports.utils.tsx @@ -10,6 +10,7 @@ import { isUnixMicro, unixMicroToIsoTimestamp, } from '@/components/interfaces/Settings/Logs/Logs.utils' +import type { SafeLogSqlFragment } from '@/data/logs/safe-analytics-sql' import { REPORT_STATUS_CODE_COLORS } from '@/data/reports/report.utils' import useDbQuery, { DbQueryHook } from '@/hooks/analytics/useDbQuery' import useLogsQuery, { LogsQueryHook } from '@/hooks/analytics/useLogsQuery' @@ -49,7 +50,7 @@ export const queriesFactory = ( return hooks } -export function getLogsSql(query: ReportQuery, filters: ReportFilterItem[]): string { +export function getLogsSql(query: ReportQuery, filters: ReportFilterItem[]): SafeLogSqlFragment { if (query.queryType !== 'logs') { throw new Error(`Expected logs query, got ${query.queryType}`) } diff --git a/apps/studio/data/reports/api-report-query.ts b/apps/studio/data/reports/api-report-query.ts index 4745aa5fca412..56e8c01859047 100644 --- a/apps/studio/data/reports/api-report-query.ts +++ b/apps/studio/data/reports/api-report-query.ts @@ -66,7 +66,7 @@ export const useApiReport = () => { const formattedFilters: ReportFilterItem[] = [ ...filters, ...(identifier !== undefined - ? [{ key: 'identifier', value: `'${identifier}'`, compare: 'is' } as ReportFilterItem] + ? [{ key: 'identifier', value: identifier, compare: 'is' } as ReportFilterItem] : []), ] diff --git a/apps/studio/data/reports/storage-report-query.ts b/apps/studio/data/reports/storage-report-query.ts index cd6d2433462e0..56f2432bb34cb 100644 --- a/apps/studio/data/reports/storage-report-query.ts +++ b/apps/studio/data/reports/storage-report-query.ts @@ -80,7 +80,7 @@ export const useStorageReport = () => { const formattedFilters: ReportFilterItem[] = [ ...filters, ...(identifier !== undefined - ? [{ key: 'identifier', value: `'${identifier}'`, compare: 'is' } as ReportFilterItem] + ? [{ key: 'identifier', value: identifier, compare: 'is' } as ReportFilterItem] : []), ] From 42f1f19fdd051934642e0b5a05e99e759b9123d6 Mon Sep 17 00:00:00 2001 From: Charis <26616127+charislam@users.noreply.github.com> Date: Wed, 27 May 2026 13:22:42 -0400 Subject: [PATCH 3/5] feat(logs): brand SharedAPIReport SQL with SafeLogSqlFragment (#46405) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Security / refactor — migrates `SharedAPIReport.constants.ts` to the proven-authorship model (`SafeLogSqlFragment`). ## What is the current behavior? All seven SQL builders in `SHARED_API_REPORT_SQL` return plain `string` and interpolate filter values via `generateRegexpWhere`, which performs manual quoting without sanitization. The source table name (`edge_logs` / `function_edge_logs`) is also interpolated as a raw string. Queries are executed via a local `fetchLogs` function that calls `get()` directly, bypassing the `executeAnalyticsSql` wire boundary. ## What is the new behavior? - Each SQL builder is rewritten with the `safeLogSql` template tag and returns `SafeLogSqlFragment`. - Filter keys route through `quotedIdent` (predicates with invalid identifiers are dropped); values route through `analyticsLiteral` (single quotes and backslashes are escaped). - A `SOURCE_TABLE` branded map covers the two possible source tables; `sourceTable()` looks up the branded fragment instead of interpolating a raw string. - `fetchLogs` is removed; `useQueries` calls `executeAnalyticsSql` directly with `method: 'get'`, routing through the shared wire boundary. - The `queryFn` wraps the call in a try/catch that also checks `data?.error`, preserving the original Sentry capture behaviour (`'Shared API Report Error'`) for both network and API-level errors. ## Additional context --- .../SharedAPIReport.constants.ts | 126 ++++++++---------- 1 file changed, 59 insertions(+), 67 deletions(-) diff --git a/apps/studio/components/interfaces/Reports/SharedAPIReport/SharedAPIReport.constants.ts b/apps/studio/components/interfaces/Reports/SharedAPIReport/SharedAPIReport.constants.ts index 999dbcedf6072..72f7006907a1f 100644 --- a/apps/studio/components/interfaces/Reports/SharedAPIReport/SharedAPIReport.constants.ts +++ b/apps/studio/components/interfaces/Reports/SharedAPIReport/SharedAPIReport.constants.ts @@ -4,24 +4,36 @@ import { useParams } from 'common' import { isEqual } from 'lodash' import { useState } from 'react' -import { generateRegexpWhere } from '../Reports.constants' +import { generateRegexpWhereSafe } from '../Reports.constants' import { ReportFilterItem } from '../Reports.types' -import { get } from '@/data/fetchers' +import { executeAnalyticsSql } from '@/data/logs/execute-analytics-sql' +import { safeSql, type SafeLogSqlFragment } from '@/data/logs/safe-analytics-sql' + +const SOURCE_TABLE: Record = { + edge_logs: safeSql`edge_logs`, + function_edge_logs: safeSql`function_edge_logs`, +} + +/** Returns a branded source table fragment, falling back to `edge_logs`. */ +function sourceTable(src: string): SafeLogSqlFragment { + return SOURCE_TABLE[src] ?? SOURCE_TABLE.edge_logs +} export const SHARED_API_REPORT_SQL = { totalRequests: { queryType: 'logs', - sql: (filters: ReportFilterItem[], src = 'edge_logs') => ` + sql: (filters: ReportFilterItem[], src = 'edge_logs'): SafeLogSqlFragment => + safeSql` --reports-api-total-requests select cast(timestamp_trunc(t.timestamp, hour) as datetime) as timestamp, count(t.id) as count - FROM ${src} t + FROM ${sourceTable(src)} t cross join unnest(metadata) as m cross join unnest(m.response) as response cross join unnest(m.request) as request cross join unnest(request.headers) as headers - ${generateRegexpWhere(filters)} + ${generateRegexpWhereSafe(filters)} GROUP BY timestamp ORDER BY @@ -29,7 +41,8 @@ export const SHARED_API_REPORT_SQL = { }, topRoutes: { queryType: 'logs', - sql: (filters: ReportFilterItem[], src = 'edge_logs') => ` + sql: (filters: ReportFilterItem[], src = 'edge_logs'): SafeLogSqlFragment => + safeSql` -- reports-api-top-routes select request.path as path, @@ -37,12 +50,12 @@ export const SHARED_API_REPORT_SQL = { request.search as search, response.status_code as status_code, count(t.id) as count - from ${src} t + from ${sourceTable(src)} t cross join unnest(metadata) as m cross join unnest(m.response) as response cross join unnest(m.request) as request cross join unnest(request.headers) as headers - ${generateRegexpWhere(filters)} + ${generateRegexpWhereSafe(filters)} group by request.path, request.method, request.search, response.status_code order by @@ -52,19 +65,20 @@ export const SHARED_API_REPORT_SQL = { }, errorCounts: { queryType: 'logs', - sql: (filters: ReportFilterItem[], src = 'edge_logs') => ` + sql: (filters: ReportFilterItem[], src = 'edge_logs'): SafeLogSqlFragment => + safeSql` -- reports-api-error-counts select cast(timestamp_trunc(t.timestamp, hour) as datetime) as timestamp, count(t.id) as count - FROM ${src} t + FROM ${sourceTable(src)} t cross join unnest(metadata) as m cross join unnest(m.response) as response cross join unnest(m.request) as request cross join unnest(request.headers) as headers WHERE response.status_code >= 400 - ${generateRegexpWhere(filters, false)} + ${generateRegexpWhereSafe(filters, false)} GROUP BY timestamp ORDER BY @@ -73,7 +87,8 @@ export const SHARED_API_REPORT_SQL = { }, topErrorRoutes: { queryType: 'logs', - sql: (filters: ReportFilterItem[], src = 'edge_logs') => ` + sql: (filters: ReportFilterItem[], src = 'edge_logs'): SafeLogSqlFragment => + safeSql` -- reports-api-top-error-routes select request.path as path, @@ -81,14 +96,14 @@ export const SHARED_API_REPORT_SQL = { request.search as search, response.status_code as status_code, count(t.id) as count - from ${src} t + from ${sourceTable(src)} t cross join unnest(metadata) as m cross join unnest(m.response) as response cross join unnest(m.request) as request cross join unnest(request.headers) as headers where response.status_code >= 400 - ${generateRegexpWhere(filters, false)} + ${generateRegexpWhereSafe(filters, false)} group by request.path, request.method, request.search, response.status_code order by @@ -98,18 +113,19 @@ export const SHARED_API_REPORT_SQL = { }, responseSpeed: { queryType: 'logs', - sql: (filters: ReportFilterItem[], src = 'edge_logs') => ` + sql: (filters: ReportFilterItem[], src = 'edge_logs'): SafeLogSqlFragment => + safeSql` -- reports-api-response-speed select cast(timestamp_trunc(t.timestamp, hour) as datetime) as timestamp, avg(response.origin_time) as avg FROM - ${src} t + ${sourceTable(src)} t cross join unnest(metadata) as m cross join unnest(m.response) as response cross join unnest(m.request) as request cross join unnest(request.headers) as headers - ${generateRegexpWhere(filters)} + ${generateRegexpWhereSafe(filters)} GROUP BY timestamp ORDER BY @@ -118,7 +134,8 @@ export const SHARED_API_REPORT_SQL = { }, topSlowRoutes: { queryType: 'logs', - sql: (filters: ReportFilterItem[], src = 'edge_logs') => ` + sql: (filters: ReportFilterItem[], src = 'edge_logs'): SafeLogSqlFragment => + safeSql` -- reports-api-top-slow-routes select request.path as path, @@ -127,12 +144,12 @@ export const SHARED_API_REPORT_SQL = { response.status_code as status_code, count(t.id) as count, avg(response.origin_time) as avg - from ${src} t + from ${sourceTable(src)} t cross join unnest(metadata) as m cross join unnest(m.response) as response cross join unnest(m.request) as request cross join unnest(request.headers) as headers - ${generateRegexpWhere(filters)} + ${generateRegexpWhereSafe(filters)} group by request.path, request.method, request.search, response.status_code order by @@ -142,7 +159,8 @@ export const SHARED_API_REPORT_SQL = { }, networkTraffic: { queryType: 'logs', - sql: (filters: ReportFilterItem[], src = 'edge_logs') => ` + sql: (filters: ReportFilterItem[], src = 'edge_logs'): SafeLogSqlFragment => + safeSql` -- reports-api-network-traffic select cast(timestamp_trunc(t.timestamp, hour) as datetime) as timestamp, @@ -165,13 +183,13 @@ export const SHARED_API_REPORT_SQL = { 0 ) as egress_mb, FROM - ${src} t + ${sourceTable(src)} t cross join unnest(metadata) as m cross join unnest(m.response) as response cross join unnest(m.request) as request cross join unnest(request.headers) as headers cross join unnest(response.headers) as resp_headers - ${generateRegexpWhere(filters)} + ${generateRegexpWhereSafe(filters)} GROUP BY timestamp ORDER BY @@ -182,42 +200,6 @@ export const SHARED_API_REPORT_SQL = { export type SharedAPIReportKey = keyof typeof SHARED_API_REPORT_SQL -const fetchLogs = async ({ - projectRef, - sql, - start, - end, -}: { - projectRef: string - sql: string - start: string - end: string -}) => { - const { data, error } = await get(`/platform/projects/{ref}/analytics/endpoints/logs.all`, { - params: { - path: { ref: projectRef }, - query: { - sql, - iso_timestamp_start: start, - iso_timestamp_end: end, - }, - }, - }) - - if (error || data?.error) { - Sentry.captureException({ - message: 'Shared API Report Error', - data: { - error, - data, - }, - }) - throw error || data?.error - } - - return data -} - const DEFAULT_KEYS = ['shared-api-report'] export type SharedAPIReportFilterBy = @@ -282,13 +264,23 @@ export const useSharedAPIReport = ({ ref, ], enabled: enabled && !!ref && !!filterBy, - queryFn: () => - fetchLogs({ - projectRef: ref, - sql: value.sql(allFilters, filterByMapSource[filterBy]), - start, - end, - }), + queryFn: async () => { + try { + const data = await executeAnalyticsSql({ + projectRef: ref, + endpoint: '/platform/projects/{ref}/analytics/endpoints/logs.all', + sql: value.sql(allFilters, filterByMapSource[filterBy]), + iso_timestamp_start: start, + iso_timestamp_end: end, + method: 'get', + }) + if (data?.error) throw data.error + return data + } catch (err) { + Sentry.captureException({ message: 'Shared API Report Error', data: { error: err } }) + throw err + } + }, })), }) @@ -341,7 +333,7 @@ export const useSharedAPIReport = ({ const isLoadingData = Object.values(isLoading).some(Boolean) - const SQLMap: Record = { + const SQLMap: Record = { totalRequests: SHARED_API_REPORT_SQL.totalRequests.sql(allFilters, filterByMapSource[filterBy]), topRoutes: SHARED_API_REPORT_SQL.topRoutes.sql(allFilters, filterByMapSource[filterBy]), errorCounts: SHARED_API_REPORT_SQL.errorCounts.sql(allFilters, filterByMapSource[filterBy]), From 894cb531d13f6b0893a249dfc5c3dfe3bbfc2835 Mon Sep 17 00:00:00 2001 From: Rodrigo Mansueli Date: Wed, 27 May 2026 15:34:23 -0300 Subject: [PATCH 4/5] feat(docs): add resumable WebSockets + Edge Functions troubleshooting guides (#46178) ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Docs update (new guides + follow-up documentation fix from review feedback). ## What is the current behavior? There was no consolidated docs example for resumable WebSockets with Edge Functions, and no dedicated troubleshooting guide for worker timeouts / WebSocket drops. ## What is the new behavior? - Adds a resumable WebSockets guide for Edge Functions, including: - session persistence - event replay - idempotency pattern and schema examples - client/server example flow - Adds an Edge Functions troubleshooting guide for worker timeouts and WebSocket drops. - Updates docs navigation to surface the new guides. - Follow-up fix from review feedback: the browser client example now stores `sessionId` and `lastEventId` in `sessionStorage` (instead of `localStorage`). ## Additional context - Branch has been updated with latest `origin/master`. - This PR remains documentation-focused; no production runtime code changes were introduced. ## Summary by CodeRabbit * **Documentation** * Added a guide on resumable WebSockets covering session persistence, event replay, idempotency patterns, SQL schema examples, and client/server usage. * Added a troubleshooting guide on Edge Functions worker timeouts and WebSocket drops with scenarios, symptoms, and practical workarounds. * Enhanced WebSocket docs with a production note on worker lifecycle and keeping runtime promises open to avoid premature shutdown. * Navigation updated to surface the new guides. [![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/46178?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack) --------- Co-authored-by: Lakshan Perera Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: CodeRabbit Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- .../NavigationMenu.constants.ts | 8 + .../examples/resumable-websockets.mdx | 225 ++++++++++++++++++ .../content/guides/functions/websockets.mdx | 4 + ...ns-worker-timeouts-and-websocket-drops.mdx | 169 +++++++++++++ 4 files changed, 406 insertions(+) create mode 100644 apps/docs/content/guides/functions/examples/resumable-websockets.mdx create mode 100644 apps/docs/content/troubleshooting/edge-functions-worker-timeouts-and-websocket-drops.mdx diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts index 7c57edecbf697..76847319beac2 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts @@ -1656,6 +1656,10 @@ export const functions: NavMenuConstant = { name: 'Troubleshooting', url: '/guides/functions/troubleshooting' as `/${string}`, }, + { + name: 'Worker timeouts and WebSocket drops', + url: '/troubleshooting/edge-functions-worker-timeouts-and-websocket-drops' as `/${string}`, + }, ], }, { @@ -1783,6 +1787,10 @@ export const functions: NavMenuConstant = { name: 'Image Transformation & Optimization', url: '/guides/functions/examples/image-manipulation' as `/${string}`, }, + { + name: 'Resumable WebSockets with replay', + url: '/guides/functions/examples/resumable-websockets' as `/${string}`, + }, ], }, { diff --git a/apps/docs/content/guides/functions/examples/resumable-websockets.mdx b/apps/docs/content/guides/functions/examples/resumable-websockets.mdx new file mode 100644 index 0000000000000..0c63efdb2f03d --- /dev/null +++ b/apps/docs/content/guides/functions/examples/resumable-websockets.mdx @@ -0,0 +1,225 @@ +--- +title: 'Resumable WebSockets with Edge Functions' +description: 'Build reconnect-safe WebSockets with event replay, idempotency keys, and graceful restarts.' +--- + +This example shows how to build a reconnect-safe chat stream on Supabase Edge Functions using: + +- WebSocket upgrade + JWT auth +- Postgres-backed session and event persistence +- Event replay with `lastEventId` +- Idempotent user messages with `idempotency_key` +- Graceful client reconnects during worker restarts + +Reference implementation: [Building Resumable WebSockets with Supabase Edge Functions and Postgres](https://blog.mansueli.com/building-resumable-websockets-with-supabase-edge-functions-and-postgres) + +## Architecture + +1. Client connects with a user JWT, plus optional `sessionId` and `lastEventId`. +2. Function verifies auth and either resumes or creates a session. +3. Every message is written to `ws_events` with an incrementing `id`. +4. On reconnect, server replays events where `id > lastEventId`. +5. Client updates local `lastEventId` and resumes without losing messages. + +## Database schema + +```sql +create extension if not exists pgcrypto; + +create table ws_sessions ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null, + created_at timestamptz default now(), + updated_at timestamptz default now(), + last_event_id bigint default 0 +); + +create table ws_events ( + id bigint generated by default as identity primary key, + session_id uuid not null references ws_sessions(id) on delete cascade, + event_type text not null, + payload jsonb not null, + created_at timestamptz default now() +); +create index ws_events_session_id_id_idx on ws_events(session_id, id); + +create table ws_idempotency_keys ( + session_id uuid not null references ws_sessions(id) on delete cascade, + idempotency_key uuid not null, + primary key(session_id, idempotency_key) +); + +create unlogged table ws_live_connections ( + session_id uuid primary key, + connected_at timestamptz default now(), + last_seen_at timestamptz default now(), + edge_region text +); +``` + +## Edge Function (WebSocket proxy) + +Use `supabase functions serve --no-verify-jwt` and validate JWT inside the function. + +```ts +import { createAdminClient, createContextClient, verifyCredentials } from '@supabase/server/core' + +const PREEMPTIVE_RESTART_MS = 340_000 + +function send(socket: WebSocket, payload: unknown) { + if (socket.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify(payload)) + } +} + +Deno.serve(async (req) => { + const url = new URL(req.url) + const token = url.searchParams.get('token') + if (!token) return new Response('Missing token', { status: 401 }) + + const { data: auth, error } = await verifyCredentials({ token, apikey: null }, { auth: 'user' }) + if (error || !auth?.userClaims?.id) { + return new Response('Unauthorized', { status: 401 }) + } + + const admin = createAdminClient() + const { socket, response } = Deno.upgradeWebSocket(req, { idleTimeout: 0 }) + + // Prevent EarlyDrop by keeping a pending promise until socket close. + let resolveClosed!: () => void + const closed = new Promise((resolve) => { + resolveClosed = resolve + }) + // @ts-ignore + EdgeRuntime.waitUntil(closed) + + const requestedSessionId = url.searchParams.get('sessionId') + const lastEventId = Number(url.searchParams.get('lastEventId') || 0) + const sessionId = requestedSessionId ?? crypto.randomUUID() + + socket.onclose = () => { + resolveClosed() + } + + socket.onmessage = async (event) => { + const msg = JSON.parse(event.data) + + if (msg.type === 'user_message') { + const { error: idempotencyError } = await admin.from('ws_idempotency_keys').upsert( + { + session_id: sessionId, + idempotency_key: msg.idempotency_key, + }, + { onConflict: 'session_id,idempotency_key', ignoreDuplicates: true } + ) + + let userEvent + + if (idempotencyError) { + // Conflict detected - this is a retry, fetch the existing event + const { data: existingEvent } = await admin + .from('ws_events') + .select() + .eq('session_id', sessionId) + .eq('idempotency_key', msg.idempotency_key) + .single() + + userEvent = existingEvent + } else { + // New idempotency key - insert the event + const { data: newEvent } = await admin + .from('ws_events') + .insert({ + session_id: sessionId, + event_type: 'user_message', + payload: { content: msg.content }, + }) + .select() + .single() + + userEvent = newEvent + } + + send(socket, { + type: 'user_message', + payload: userEvent?.payload, + event_id: userEvent?.id, + }) + } + } + + send(socket, { type: 'session_init', session_id: sessionId }) + + queueMicrotask(async () => { + const { data: replayEvents } = await admin + .from('ws_events') + .select('*') + .eq('session_id', sessionId) + .gt('id', lastEventId) + .order('id') + + for (const event of replayEvents ?? []) { + send(socket, { + type: event.event_type, + payload: event.payload, + event_id: event.id, + replay: true, + }) + } + }) + + setTimeout(() => { + send(socket, { type: 'server_restarting' }) + socket.close(1012, 'Service restart') + }, PREEMPTIVE_RESTART_MS) + + return response +}) +``` + +## Browser client + +The client stores `sessionId` and `lastEventId` in session storage, then reconnects with exponential backoff. + +```ts +let sessionId = sessionStorage.getItem('ws_session_id') +let lastEventId = Number(sessionStorage.getItem('last_event_id') || 0) + +function connect(token: string) { + const url = + `wss://YOUR_PROJECT.functions.supabase.co/websocket-proxy` + + `?token=${encodeURIComponent(token)}` + + `&lastEventId=${lastEventId}` + + (sessionId ? `&sessionId=${sessionId}` : '') + + const ws = new WebSocket(url) + + ws.onmessage = (e) => { + const msg = JSON.parse(e.data) + + if (msg.event_id) { + lastEventId = Math.max(lastEventId, msg.event_id) + sessionStorage.setItem('last_event_id', String(lastEventId)) + } + + if (msg.type === 'session_init') { + sessionId = msg.session_id + sessionStorage.setItem('ws_session_id', sessionId) + } + } +} +``` + +## Why this pattern works + +- If the worker restarts, the client reconnects with the same session. +- Replay closes delivery gaps caused by reconnect windows. +- Idempotency keys prevent duplicate inserts when clients retry. +- `EdgeRuntime.waitUntil()` prevents unexpected early termination of idle-looking WebSocket workers. + +## Next steps + +- Add row-level security policies for all `ws_*` tables. +- Add a heartbeat and cleanup policy for stale sessions. +- Add structured event payload types and input validation. +- Add observability dashboards for disconnect rate and replay lag. diff --git a/apps/docs/content/guides/functions/websockets.mdx b/apps/docs/content/guides/functions/websockets.mdx index 96bbc615c376b..05ce306d4dffc 100644 --- a/apps/docs/content/guides/functions/websockets.mdx +++ b/apps/docs/content/guides/functions/websockets.mdx @@ -13,6 +13,8 @@ This allows you to: - Create WebSocket relay servers for external APIs - Establish both incoming and outgoing WebSocket connections +For a production-ready reconnect pattern with session persistence and replay, see [Resumable WebSockets with Edge Functions](/docs/guides/functions/examples/resumable-websockets). + --- ## Creating WebSocket servers @@ -249,6 +251,8 @@ The maximum duration is capped based on the wall-clock, CPU, and memory limits. +When using WebSockets, keep in mind that the HTTP request is considered complete after `Deno.upgradeWebSocket(req)` returns the response. To prevent early worker retirement while the socket is still open, keep an unresolved `EdgeRuntime.waitUntil()` promise that resolves in `socket.onclose`. + --- ## Testing WebSockets locally diff --git a/apps/docs/content/troubleshooting/edge-functions-worker-timeouts-and-websocket-drops.mdx b/apps/docs/content/troubleshooting/edge-functions-worker-timeouts-and-websocket-drops.mdx new file mode 100644 index 0000000000000..f5b57659afa7a --- /dev/null +++ b/apps/docs/content/troubleshooting/edge-functions-worker-timeouts-and-websocket-drops.mdx @@ -0,0 +1,169 @@ +--- +title = "Edge Functions worker timeouts and WebSocket drops" +topics = [ "functions" ] +keywords = [ "websocket", "timeout", "earlydrop", "wall clock", "cpu limit", "streaming", "cold start" ] +teams = [ "team-functions", "team-support" ] +types = [ "support" ] +--- + +## Background + +Edge Functions run inside V8 isolates managed by a supervisor in `edge-runtime`. + +The supervisor enforces resource limits: + +- Wall clock time (`worker_timeout_ms`) +- CPU time (soft + hard limits) +- Memory usage + +It may retire early (`EarlyDrop` event) if the isolate is idle Isolate is considered idle if following conditions are met: + +- The HTTP response has already been returned. +- All `EdgeRuntime.waitUntil()` promises have resolved. + +If both are true during a resource check, the isolate can be terminated even with open WebSocket connections. + +## Scenario 1: WebSocket drops around half of the wall clock limit + +### Symptoms + +- WebSocket closes at a consistent interval (often around half of the configured wall clock limit). +- Logs include `EarlyDrop` or wall clock warning events. + +### Root cause + +After `Deno.upgradeWebSocket(req)` returns a response, the HTTP request is considered acknowledged. If there is no unresolved `waitUntil` work, the worker may look idle and be retired early. + +### What to check + +1. Compare connection lifetime to your wall clock limit. +2. Inspect logs for `EarlyDrop` around disconnect time. +3. Confirm there is no unresolved `EdgeRuntime.waitUntil()` promise tied to socket lifecycle. + +### Workaround + +Keep a promise pending until the socket closes. + +```ts +Deno.serve((req) => { + const { socket, response } = Deno.upgradeWebSocket(req) + + const socketClosedPromise = new Promise((resolve) => { + socket.onclose = () => resolve() + }) + + EdgeRuntime.waitUntil(socketClosedPromise) + + socket.onmessage = (event) => { + socket.send(event.data) + } + + return response +}) +``` + +`EdgeRuntime.waitUntil()` prevents early retirement, but it does not extend the hard wall clock limit. + +## Scenario 2: Function killed at a consistent duration + +### Symptoms + +- Function fails at a predictable runtime (for example, always around the same second mark). +- Logs include wall clock shutdown reasons. +- Clients may receive status `546` or cancellation errors. + +### Root cause + +The function exceeded the configured wall clock budget. + +### Workarounds + +- Split work into smaller units. +- Move long work to async/background processing and return early. +- Use streaming from upstream APIs where possible. +- Use queues (`pg_net`, `pgmq`, or webhooks) for chunked processing. + +## Scenario 3: Function killed by CPU limit + +### Symptoms + +- Failures during compute-heavy tasks. +- Logs include CPU soft/hard limit events. + +### Root cause + +CPU budget and wall clock budget are independent. A function can run out of CPU time long before wall clock is exhausted. + +### Workarounds + +- Break large synchronous loops into async chunks. +- Optimize expensive paths and avoid repeated recalculation. +- Move heavy compute to systems designed for long CPU-bound workloads. + +## Scenario 4: SSE or AI streams end before completion + +### Symptoms + +- Streaming starts but ends prematurely. +- No final `[DONE]` token or normal close marker. + +### Root cause + +The worker hits wall clock or early retirement conditions while forwarding a long stream. + +### Workaround + +Keep the isolate alive for the stream piping lifecycle: + +```ts +Deno.serve(async (_req) => { + const upstream = await fetch('https://api.openai.com/v1/chat/completions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${Deno.env.get('OPENAI_API_KEY')}`, + }, + body: JSON.stringify({ stream: true }), + }) + + const { readable, writable } = new TransformStream() + + EdgeRuntime.waitUntil(upstream.body!.pipeTo(writable)) + + return new Response(readable, { + headers: { 'Content-Type': 'text/event-stream' }, + }) +}) +``` + +## Scenario 5: Cold starts fail before first response + +### Symptoms + +- First request after idle fails (for example, `504` or worker creation timeout). +- Subsequent requests may succeed. + +### Root cause + +Large dependency trees or expensive top-level initialization can exceed startup budget. + +### Workarounds + +- Avoid slow top-level `await` work. +- Lazy-initialize heavy clients inside request handlers. +- Reduce bundle and dependency size. +- Keep critical functions warm if needed. + +## Key distinctions + +- `EarlyDrop`: early retirement when worker appears idle. +- `WallClockTime`: hard runtime ceiling reached. +- CPU and wall clock limits are independent. +- WebSockets are not request-tracked by the supervisor after upgrade. + +## Related resources + +- [Edge Functions limits](/docs/guides/functions/limits) +- [Edge Function shutdown reasons explained](./edge-function-shutdown-reasons-explained) +- [Monitoring Edge Function resource usage](./edge-function-monitoring-resource-usage) +- [Handling WebSockets in Edge Functions](/docs/guides/functions/websockets) From f5c732b4578764d3a8d99449e28e6d97c995a1bd Mon Sep 17 00:00:00 2001 From: Steven Eubank <47563310+smeubank@users.noreply.github.com> Date: Wed, 27 May 2026 20:37:16 +0200 Subject: [PATCH 5/5] feat(docs)add Logs Ingest + Logs Query manage-usage pages (#46095) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds documentation for the new Logs pricing SKUs (Ingest and Query) ahead of the July 1 launch. Part of the [O11Y Logs Pricing RFC](https://linear.app/supabase/project/rfc-supabase-observability-product-packaging-and-pricing-77990c05a767) rollout (PRD R6). **This is the docs PR.** Pricing page changes (`apps/www`) and Studio dashboard changes are separate PRs. ### New pages - **Logs overview** (`manage-your-usage/logs.mdx`) — both SKUs at a glance, summary pricing table, Logs vs Log Drains clarification - **Logs Ingest detail** (`manage-your-usage/logs-ingest.mdx`) — full billing details, invoice examples, optimization tips - **Logs Query detail** (`manage-your-usage/logs-query.mdx`) — full billing details, invoice examples, optimization tips - **Pricing partials** for both SKUs (`pricing_logs_ingest.mdx`, `pricing_logs_query.mdx`) ### Updated pages - **Cost control** — added Logs Ingest + Logs Query to "Usage items covered by the Spend Cap" list - **Telemetry/logs** — added link to the new manage-usage overview page - **Navigation sidebar** — added Logs, Logs Ingest, Logs Query entries before Log Drains ### Notes - Screenshots are marked as TODO placeholders — will be added once Studio surfaces are live - Follows the existing manage-usage page pattern (storage-size, MAU, etc.) - Canonical pricing: Ingest $0.50/GB over 5 GB, Query $0.002/GB over 1,000 GB (Free/Pro/Team) ## Test plan - [x] Verify pages render at `/docs/guides/platform/manage-your-usage/logs`, `/logs-ingest`, `/logs-query` - [x] Verify sidebar navigation shows new entries - [x] Verify cost-control page lists both items under "covered by Spend Cap" - [x] Verify `<$Partial />` pricing tables render correctly - [x] Verify telemetry/logs page shows new billing link - [x] Verify no broken links 🤖 Generated with [Claude Code](https://claude.com/claude-code) ## Summary by CodeRabbit * **Documentation** * Added guides for managing Logs, Logs Ingest, and Logs Query usage with pricing, billing scenarios, quota examples, and optimization tips * Added Platform → Billing navigation items: Logs, Logs Ingest, Logs Query * Included overage pricing tables, Spend Cap coverage updates, “Coming soon” billing caveats, clarified Logs vs. Log Drains, and linked usage management from the Logging guide * **Chore** * Whitelisted "Better Stack" in spelling checks [![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/46095?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack) --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Chris Chinchilla --- .../NavigationMenu.constants.ts | 12 +++ .../billing/pricing/pricing_logs_ingest.mdx | 9 +++ .../billing/pricing/pricing_logs_query.mdx | 9 +++ .../content/guides/platform/cost-control.mdx | 2 + .../manage-your-usage/logs-ingest.mdx | 73 +++++++++++++++++++ .../platform/manage-your-usage/logs-query.mdx | 70 ++++++++++++++++++ .../platform/manage-your-usage/logs.mdx | 32 ++++++++ apps/docs/content/guides/telemetry/logs.mdx | 2 +- supa-mdx-lint/Rule003Spelling.toml | 1 + 9 files changed, 209 insertions(+), 1 deletion(-) create mode 100644 apps/docs/content/_partials/billing/pricing/pricing_logs_ingest.mdx create mode 100644 apps/docs/content/_partials/billing/pricing/pricing_logs_query.mdx create mode 100644 apps/docs/content/guides/platform/manage-your-usage/logs-ingest.mdx create mode 100644 apps/docs/content/guides/platform/manage-your-usage/logs-query.mdx create mode 100644 apps/docs/content/guides/platform/manage-your-usage/logs.mdx diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts index 76847319beac2..9c8f760b6128e 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts @@ -2789,6 +2789,18 @@ export const platform: NavMenuConstant = { name: 'Branching', url: '/guides/platform/manage-your-usage/branching' as `/${string}`, }, + { + name: 'Logs', + url: '/guides/platform/manage-your-usage/logs' as `/${string}`, + }, + { + name: 'Logs Ingest', + url: '/guides/platform/manage-your-usage/logs-ingest' as `/${string}`, + }, + { + name: 'Logs Query', + url: '/guides/platform/manage-your-usage/logs-query' as `/${string}`, + }, { name: 'Log Drains', url: '/guides/platform/manage-your-usage/log-drains' as `/${string}`, diff --git a/apps/docs/content/_partials/billing/pricing/pricing_logs_ingest.mdx b/apps/docs/content/_partials/billing/pricing/pricing_logs_ingest.mdx new file mode 100644 index 0000000000000..f089dfa717af1 --- /dev/null +++ b/apps/docs/content/_partials/billing/pricing/pricing_logs_ingest.mdx @@ -0,0 +1,9 @@ + per GB. You are only charged for usage exceeding your subscription plan's +quota. + +| Plan | Quota | Over-Usage per GB | +| ---------- | ------ | ---------------------- | +| Free | 5 GB | - | +| Pro | 5 GB | | +| Team | 5 GB | | +| Enterprise | Custom | Custom | diff --git a/apps/docs/content/_partials/billing/pricing/pricing_logs_query.mdx b/apps/docs/content/_partials/billing/pricing/pricing_logs_query.mdx new file mode 100644 index 0000000000000..1301aadb7a40c --- /dev/null +++ b/apps/docs/content/_partials/billing/pricing/pricing_logs_query.mdx @@ -0,0 +1,9 @@ + per GB. You are only charged for usage exceeding your subscription plan's +quota. + +| Plan | Quota | Over-Usage per GB | +| ---------- | -------- | ----------------------- | +| Free | 1,000 GB | - | +| Pro | 1,000 GB | | +| Team | 1,000 GB | | +| Enterprise | Custom | Custom | diff --git a/apps/docs/content/guides/platform/cost-control.mdx b/apps/docs/content/guides/platform/cost-control.mdx index 95a247925d4ad..580e67fcb77a4 100644 --- a/apps/docs/content/guides/platform/cost-control.mdx +++ b/apps/docs/content/guides/platform/cost-control.mdx @@ -34,6 +34,8 @@ When the Spend Cap is off, we recommend monitoring your usage and costs on the [ - [Disk Size](/docs/guides/platform/manage-your-usage/disk-size) - [Egress](/docs/guides/platform/manage-your-usage/egress) - [Edge Function Invocations](/docs/guides/platform/manage-your-usage/edge-function-invocations) +- [Logs Ingest](/docs/guides/platform/manage-your-usage/logs-ingest) +- [Logs Query](/docs/guides/platform/manage-your-usage/logs-query) - [Monthly Active Users](/docs/guides/platform/manage-your-usage/monthly-active-users) - [Monthly Active SSO Users](/docs/guides/platform/manage-your-usage/monthly-active-users-sso) - [Monthly Active Third Party Users](/docs/guides/platform/manage-your-usage/monthly-active-users-third-party) diff --git a/apps/docs/content/guides/platform/manage-your-usage/logs-ingest.mdx b/apps/docs/content/guides/platform/manage-your-usage/logs-ingest.mdx new file mode 100644 index 0000000000000..af40d0e967820 --- /dev/null +++ b/apps/docs/content/guides/platform/manage-your-usage/logs-ingest.mdx @@ -0,0 +1,73 @@ +--- +id: 'manage-usage-logs-ingest' +title: 'Manage Logs Ingest usage' +--- + + + +Logs pricing is being rolled out. Quotas and rates on this page are confirmed but billing enforcement is not yet live. This page will be updated when the rollout is complete. + + + +## What you are charged for + +You are charged for the total volume of log data that Supabase ingests across all your project's services (Postgres, API gateway, Auth, Storage, Realtime, Edge Functions, etc.) during the billing cycle, measured in GB. + +## How charges are calculated + +Logs Ingest is charged per GB of log data ingested during the billing cycle. + +### Usage on your invoice + +Usage is shown as "Logs Ingest" on your invoice. + +## Pricing + +<$Partial path="billing/pricing/pricing_logs_ingest.mdx" /> + +## Billing examples + +### Within quota + +The organization's Logs Ingest usage is within the quota, so no charges for Logs Ingest apply. + +| Line Item | Units | Costs | +| ------------------- | --------- | ------------------------ | +| Pro Plan | 1 | | +| Compute Hours Micro | 744 hours | | +| Logs Ingest | 2 GB | | +| **Subtotal** | | **** | +| Compute Credits | | - | +| **Total** | | **** | + +### Exceeding quota + +The organization's Logs Ingest usage exceeds the quota by 7 GB, incurring charges for this additional usage. + +| Line Item | Units | Costs | +| ------------------- | --------- | -------------------------- | +| Pro Plan | 1 | | +| Compute Hours Micro | 744 hours | | +| Logs Ingest | 12 GB | | +| **Subtotal** | | **** | +| Compute Credits | | - | +| **Total** | | **** | + +## View usage + +You can view Logs Ingest usage on the [organization's usage page](/dashboard/org/_/usage) of the Dashboard. The page shows the usage of all projects by default. To view the usage for a specific project, select it from the dropdown. You can also select a different time period. + +{/* TODO: Add screenshots once Studio surfaces are live */} + +## Optimize usage + +Every service in your Supabase project automatically generates Logs — you don't write them directly. Log volume scales with your application's traffic and behavior. To reduce ingest volume: + +- **Reduce log-level verbosity** in your Edge Functions and server-side code (for example, `info` → `warn` in production). +- **Audit verbose logging in your application code.** Application-level logs forwarded to Supabase services count toward ingest. +- **Cap log payload size.** Large structured payloads inflate GB-billed volume quickly. +- **Investigate spikes.** Use the [**Logs Explorer**](/dashboard/project/_/logs-explorer) section of the Dashboard to find services or endpoints producing unusually high volume. + +## Exceeding Quotas + +<$Partial path="billing/exceeding_usage_quotas.mdx" /> diff --git a/apps/docs/content/guides/platform/manage-your-usage/logs-query.mdx b/apps/docs/content/guides/platform/manage-your-usage/logs-query.mdx new file mode 100644 index 0000000000000..6983989b27f76 --- /dev/null +++ b/apps/docs/content/guides/platform/manage-your-usage/logs-query.mdx @@ -0,0 +1,70 @@ +--- +id: 'manage-usage-logs-query' +title: 'Manage Logs Query usage' +--- + + + +Logs pricing is being rolled out. Quotas and rates on this page are confirmed but billing enforcement is not yet live. This page will be updated when the rollout is complete. + + + +## What you are charged for + +You are charged for the volume of log data scanned when you read logs via the Studio UI, the Management API, the CLI, or any other interface, measured in GB. + +## How charges are calculated + +Logs Query is charged per GB of log data scanned during the billing cycle. + +### Usage on your invoice + +Usage is shown as "Logs Query" on your invoice. + +## Pricing + +<$Partial path="billing/pricing/pricing_logs_query.mdx" /> + +## Billing examples + +### Within quota + +The organization's Logs Query usage is within the quota, so no charges for Logs Query apply. + +| Line Item | Units | Costs | +| ------------------- | --------- | ------------------------ | +| Pro Plan | 1 | | +| Compute Hours Micro | 744 hours | | +| Logs Query | 300 GB | | +| **Subtotal** | | **** | +| Compute Credits | | - | +| **Total** | | **** | + +### Exceeding quota + +The organization's Logs Query usage exceeds the quota by 4,000 GB, incurring charges for this additional usage. + +| Line Item | Units | Costs | +| ------------------- | --------- | ------------------------ | +| Pro Plan | 1 | | +| Compute Hours Micro | 744 hours | | +| Logs Query | 5,000 GB | | +| **Subtotal** | | **** | +| Compute Credits | | - | +| **Total** | | **** | + +## View usage + +You can view Logs Query usage on the [organization's usage page](/dashboard/org/_/usage) of the Dashboard. The page shows the usage of all projects by default. To view the usage for a specific project, select it from the dropdown. You can also select a different time period. + +{/* TODO: Add screenshots once Studio surfaces are live */} + +## Optimize usage + +- **Narrow your query time ranges.** Querying a 7-day window scans 7× more data than a 1-day window. +- **Use filters early.** Adding service or endpoint filters reduces the volume scanned. +- **Be cautious with programmatic log access.** Polling the logs API frequently from scripts, agents, or third-party integrations can inflate query GB rapidly. If you need to stream logs continuously, [Log Drains](/docs/guides/platform/manage-your-usage/log-drains) are a more cost-effective option than repeated queries. + +## Exceeding Quotas + +<$Partial path="billing/exceeding_usage_quotas.mdx" /> diff --git a/apps/docs/content/guides/platform/manage-your-usage/logs.mdx b/apps/docs/content/guides/platform/manage-your-usage/logs.mdx new file mode 100644 index 0000000000000..26a334c289eec --- /dev/null +++ b/apps/docs/content/guides/platform/manage-your-usage/logs.mdx @@ -0,0 +1,32 @@ +--- +id: 'manage-usage-logs' +title: 'Manage Logs usage' +--- + + + +Logs pricing is being rolled out. Quotas and rates on this page are confirmed but billing enforcement is not yet live. This page will be updated when the rollout is complete. + + + +Logs usage is metered on two SKUs: + +- **Logs Ingest** — the total GB of log data Supabase ingests across all your project's services (Postgres, API gateway, Auth, Storage, Realtime, Edge Functions, etc.) during the billing cycle. +- **Logs Query** — the total GB of log data scanned when you read logs via the Studio UI, the Management API, the CLI, or any other interface. + +Each plan includes a free quota for both. Usage beyond the quota is billed per GB. + +| SKU | Included (per month) | Over-Usage (Pro, Team) | +| ----------- | -------------------- | ------------------------------ | +| Logs Ingest | 5 GB | per GB | +| Logs Query | 1,000 GB | per GB | +| Enterprise | Custom | Custom | + +For full billing details, invoice examples, and optimization tips, see the per-SKU pages: + +- [Manage Logs Ingest usage](/docs/guides/platform/manage-your-usage/logs-ingest) +- [Manage Logs Query usage](/docs/guides/platform/manage-your-usage/logs-query) + +## Logs vs log drains + +[Log Drains](/docs/guides/platform/manage-your-usage/log-drains) stream logs out of Supabase to external destinations (Datadog, Better Stack, your own S3 bucket, etc.) and are billed separately on drain hours and events. Draining logs does not replace or reduce Logs Ingest charges — ingest is metered when Supabase processes your logs, drains are metered when Supabase streams them out. These are separate billing primitives, not overlapping charges. diff --git a/apps/docs/content/guides/telemetry/logs.mdx b/apps/docs/content/guides/telemetry/logs.mdx index c8957dd2465be..ad999526a22f2 100644 --- a/apps/docs/content/guides/telemetry/logs.mdx +++ b/apps/docs/content/guides/telemetry/logs.mdx @@ -4,7 +4,7 @@ title: 'Logging' description: 'Getting started with Supabase Log Browser' --- -The Supabase Platform includes a Logs Explorer that allows log tracing and debugging. Log retention is based on your [project's pricing plan](/pricing). +The Supabase Platform includes a Logs Explorer that allows log tracing and debugging. Log retention is based on your [project's pricing plan](/pricing). For details on how Logs usage is billed, see [Manage Logs usage](/docs/guides/platform/manage-your-usage/logs). ## Product logs diff --git a/supa-mdx-lint/Rule003Spelling.toml b/supa-mdx-lint/Rule003Spelling.toml index 69eedcf26613a..09cbdba464052 100644 --- a/supa-mdx-lint/Rule003Spelling.toml +++ b/supa-mdx-lint/Rule003Spelling.toml @@ -172,6 +172,7 @@ allow_list = [ "Authn", "Authy", "B-tree", + "Better Stack", "Basejump", "BigQuery", "Bitbucket",