diff --git a/apps/docs/components/ContentListings/ContentListings.client.tsx b/apps/docs/components/ContentListings/ContentListings.client.tsx index f8b3d4ee3107a..956220b94bf82 100644 --- a/apps/docs/components/ContentListings/ContentListings.client.tsx +++ b/apps/docs/components/ContentListings/ContentListings.client.tsx @@ -13,6 +13,8 @@ import { Badge } from 'ui' import { GlassPanel } from 'ui-patterns/GlassPanel' import { Heading } from 'ui/src/components/CustomHTMLElements' +import { resolveContentListingIcon } from './iconChip' + const GRID_ITEM_CLASS = { 2: 'col-span-12 md:col-span-6', 3: 'col-span-12 md:col-span-4', @@ -77,8 +79,8 @@ function ContentListingsGroup({ group }: { group: ContentListingGroup }) { > {item.badge} : undefined} > {item.description} diff --git a/apps/docs/components/ContentListings/iconChip.tsx b/apps/docs/components/ContentListings/iconChip.tsx new file mode 100644 index 0000000000000..8fe440b18bf6f --- /dev/null +++ b/apps/docs/components/ContentListings/iconChip.tsx @@ -0,0 +1,37 @@ +'use client' + +import { Axiom, Datadog, Grafana, Last9, Otlp, Sentry } from 'icons' +import { Braces, Cloud, Server } from 'lucide-react' +import type { ReactNode } from 'react' + +import type { ContentListingIcon } from '~/lib/content-listings.schema' + +type IconKind = Extract['kind'] + +const ICON_KIND_COMPONENTS: Record = { + braces: , + otlp: , + datadog: , + grafana: , + cloud: , + sentry: , + axiom: , + last9: , + server: , +} + +export function resolveContentListingIcon( + icon: ContentListingIcon | undefined +): ReactNode | string | undefined { + if (icon && typeof icon === 'object') { + return ( + + {ICON_KIND_COMPONENTS[icon.kind]} + + ) + } + return icon +} diff --git a/apps/docs/content/guides/telemetry/log-drains.mdx b/apps/docs/content/guides/telemetry/log-drains.mdx index f1f5f8d54e73c..3934c7b5d7d16 100644 --- a/apps/docs/content/guides/telemetry/log-drains.mdx +++ b/apps/docs/content/guides/telemetry/log-drains.mdx @@ -6,60 +6,50 @@ description: 'Getting started with Supabase Log Drains' Log drains send all logs of the Supabase stack to one or more desired destinations. It is only available for customers on Pro, Team and Enterprise Plans. Log drains are available in the dashboard under [Project Settings > Log Drains](/dashboard/project/_/settings/log-drains). -You can read about the initial announcement [here](/blog/log-drains) and vote for your preferred drains in [this discussion](https://github.com/orgs/supabase/discussions/28324?sort=top). +## What you can do with log drains -## Supported destinations +- Route Supabase logs (Postgres, Auth, Storage, Edge Functions, and more) to any observability platform. +- Combine Supabase logs with application-level traces — see [Tracing with the JS SDK](/docs/guides/telemetry/client-side-tracing) to extend your traces into Supabase. +- Archive logs to S3 for long-term retention and compliance. +- Build alerts and dashboards on top of Supabase log data in your preferred vendor. -The following table lists the supported destinations and the required setup configuration: + -| Destination | Transport Method | Configuration | -| --------------------- | ---------------- | -------------------------------------------------------------------------------------- | -| Generic HTTP endpoint | HTTP | URL
HTTP Version
Gzip
Headers | -| Datadog | HTTP | API Key
Region | -| Loki | HTTP | URL
Headers | -| Sentry | HTTP | DSN | -| Amazon S3 | AWS SDK | S3 Bucket
Region
Access Key ID
Secret Access Key
Batch Timeout | -| OTLP | HTTP | Endpoint
Protocol
Gzip
Headers | +HTTP destinations receive logs as batched POST requests with a maximum of 250 events or 1-second intervals, whichever comes first. -HTTP requests are batched with a max of 250 logs or 1 second intervals, whichever happens first. Logs are compressed via Gzip if the destination supports it. +## Custom endpoint -### Generic HTTP endpoint +Logs are delivered as a JSON array via HTTP POST. Both HTTP/1 and HTTP/2 are supported. Custom headers can be added to every request for authentication or routing. -Logs are sent as a POST request with a JSON body. Both HTTP/1 and HTTP/2 protocols are supported. -Custom headers can optionally be configured for all requests. +**Required configuration:** -Note that requests are **unsigned**. +- URL — your endpoint URL (`http://` or `https://`) +- HTTP Version — `HTTP/1` or `HTTP/2` +- Gzip — enable to compress the payload before sending +- Headers — optional key/value pairs added to every request -Unsigned requests to HTTP endpoints are temporary and all requests will signed in the near future. +Requests to custom endpoints are currently unsigned. Signed requests are coming in a future release. - - + + -1. Create and deploy the edge function - -Generate a new edge function template and update it to log out the received JSON payload. For simplicity, we will accept any request with a Publishable Key. +1. Create and deploy an Edge Function to receive the drain: ```bash -supabase functions new hello-world +supabase functions new log-receiver ``` -You can use this example snippet as an illustration of how the received request will be like. +Update the function body to log the incoming payload: ```ts import 'npm:@supabase/functions-js/edge-runtime.d.ts' Deno.serve(async (req) => { const data = await req.json() - console.log(`Received ${data.length} logs, first log:\n ${JSON.stringify(data[0])}`) return new Response(JSON.stringify({ message: 'ok' }), { headers: { 'Content-Type': 'application/json' }, @@ -67,65 +57,48 @@ Deno.serve(async (req) => { }) ``` -And then deploy it with: +Deploy it: ```bash -supabase functions deploy hello-world --project-ref [PROJECT REF] +supabase functions deploy log-receiver --project-ref [PROJECT REF] ``` -This will create an infinite loop, as we are generating an additional log event that will eventually trigger a new request to this edge function. However, due to the batching nature of how Log Drain events are dispatched, the rate of edge function triggers will not increase greatly and will have an upper bound. +Deploying an Edge Function as a log drain target will create a feedback loop — each drain event generates a new Edge Function log, which triggers another drain event. The batching behavior limits how fast this escalates, but it will run continuously. -2. Configure the HTTP Drain - -Create an HTTP drain under the [Project Settings > Log Drains](/dashboard/project/_/settings/log-drains). +2. Create the drain in [Project Settings > Log Drains](/dashboard/project/_/settings/log-drains): -- Disable the Gzip, as we want to receive the payload without compression. -- Under URL, set it to your edge function URL `https://[PROJECT REF].supabase.co/functions/v1/hello-world` -- Under Headers, set the `Authorization: Bearer [PUBLISHABLE KEY]` +- Disable Gzip. +- Set the URL to `https://[PROJECT REF].supabase.co/functions/v1/log-receiver`. +- Add the header `Authorization: Bearer [PUBLISHABLE KEY]`. - + - + -Gzip payloads can be decompressed using native in-built APIs. Refer to the Edge Function [compression guide](/docs/guides/functions/compression) +Gzip payloads can be decompressed using Node-compatible built-in APIs. See the Edge Function [compression guide](/docs/guides/functions/compression) for more details. ```ts import { gunzipSync } from 'node:zlib' Deno.serve(async (req) => { try { - // Check if the request body is gzip compressed const contentEncoding = req.headers.get('content-encoding') if (contentEncoding !== 'gzip') { - return new Response('Request body is not gzip compressed', { - status: 400, - }) + return new Response('Request body is not gzip compressed', { status: 400 }) } - // Read the compressed body const compressedBody = await req.arrayBuffer() - - // Decompress the body const decompressedBody = gunzipSync(new Uint8Array(compressedBody)) - - // Convert the decompressed body to a string - const decompressedString = new TextDecoder().decode(decompressedBody) - const data = JSON.parse(decompressedString) - // Process the decompressed body as needed + const data = JSON.parse(new TextDecoder().decode(decompressedBody)) console.log(`Received: ${data.length} logs.`) - return new Response('ok', { - headers: { 'Content-Type': 'text/plain' }, - }) + return new Response('ok', { headers: { 'Content-Type': 'text/plain' } }) } catch (error) { console.error('Error:', error) return new Response('Error processing request', { status: 500 }) @@ -133,222 +106,234 @@ Deno.serve(async (req) => { }) ``` - - + -### Datadog logs +## OpenTelemetry (OTLP) -Logs sent to Datadog have the name of the log source set on the `service` field of the event and the source set to `Supabase`. Logs are gzipped before they are sent to Datadog. +Logs are sent to any OTLP-compatible endpoint using the OpenTelemetry Protocol over HTTP with Protocol Buffers encoding, following the [OpenTelemetry Logs specification](https://opentelemetry.io/docs/specs/otel/logs/). -The payload message is a JSON string of the raw log event, prefixed with the event timestamp. +**Required configuration:** -To setup Datadog log drain, generate a Datadog API key [here](https://app.datadoghq.com/organization-settings/api-keys) and the location of your Datadog site. +- Endpoint — full URL of your OTLP HTTP endpoint (typically ends in `/v1/logs`) +- Protocol — `http/protobuf` (the only supported protocol) +- Gzip — enable to reduce bandwidth (recommended) +- Headers — optional authentication headers - - + - 1. Generate API Key in [Datadog dashboard](https://app.datadoghq.com/organization-settings/api-keys) - 2. Create log drain in [Supabase dashboard](/dashboard/project/_/settings/log-drains) - 3. Watch for events in the [Datadog Logs page](https://app.datadoghq.com/logs) +Your OTLP endpoint must accept logs at the `/v1/logs` path with `application/x-protobuf` content type. - + - +Compatible platforms include OpenTelemetry Collector, Grafana Cloud, New Relic, Honeycomb, Datadog (OTLP ingestion), Elastic, and any other OTLP-compatible observability tool. - [Grok parser](https://docs.datadoghq.com/service_management/events/pipelines_and_processors/grok_parser?tab=matchers) matcher for extracting the timestamp to a `date` field - ``` - %{date("yyyy-MM-dd'T'HH:mm:ss.SSSSSSZZ"):date} - ``` + + - [Grok parser](https://docs.datadoghq.com/service_management/events/pipelines_and_processors/grok_parser?tab=matchers) matcher for converting stringified JSON to structured JSON on the `json` field. - ``` - %{data::json} - ``` +Configure an OTLP HTTP receiver in your Collector config: - [Remapper](https://docs.datadoghq.com/service_management/events/pipelines_and_processors/remapper) for setting the log level. - ``` - metadata.parsed.error_severity, metadata.level - ``` +```yaml +receivers: + otlp: + protocols: + http: + endpoint: 0.0.0.0:4318 - +processors: + batch: - +exporters: + logging: + loglevel: debug -If you are interested in other log drains, upvote them [here](https://github.com/orgs/supabase/discussions/28324) +service: + pipelines: + logs: + receivers: [otlp] + processors: [batch] + exporters: [logging] +``` -### Loki +Then create a log drain in [Project Settings > Log Drains](/dashboard/project/_/settings/log-drains) with the endpoint set to `https://your-collector:4318/v1/logs`. -Logs sent to the Loki HTTP API are specifically formatted according to the HTTP API requirements. See the official Loki HTTP API documentation for [more details](https://grafana.com/docs/loki/latest/reference/loki-http-api/#ingest-logs). + -Events are batched with a maximum of 250 events per request. + -The log source and product name will be used as stream labels. +Different OTLP platforms use different authentication methods. Add the appropriate header to your drain configuration: -The `event_message` and `timestamp` fields will be dropped from the events to avoid duplicate data. +**API Key:** -Loki must be configured to accept **structured metadata**, and it is advised to increase the default maximum number of structured metadata fields to at least 500 to accommodate large log event payloads of different products. +``` +X-API-Key: your-api-key +``` -### Sentry +**Bearer Token:** -Logs are sent to Sentry as part of [Sentry's Logging Product](https://docs.sentry.io/product/explore/logs/). Ingesting Supabase logs as Sentry errors is currently not supported. +``` +Authorization: Bearer your-token +``` -To setup the Sentry log drain, you need to do the following: +**Basic Auth:** -1. Grab your DSN from your [Sentry project settings](https://docs.sentry.io/concepts/key-terms/dsn-explainer/). It should be of the format `{PROTOCOL}://{PUBLIC_KEY}:{SECRET_KEY}@{HOST}{PATH}/{PROJECT_ID}`. -2. Create log drain in [Supabase dashboard](/dashboard/project/_/settings/log-drains) -3. Watch for events in the [Sentry Logs page](https://sentry.io/explore/logs/) +``` +Authorization: Basic base64(username:password) +``` + + + -All fields from the log event are attached as attributes to the Sentry log, which can be used for filtering and grouping in the Sentry UI. There are no limits to cardinality or the number of attributes that can be attached to a log. +## Datadog -If you are self-hosting Sentry, Sentry Logs are only supported in self-hosted version [25.9.0](https://github.com/getsentry/self-hosted/releases/tag/25.9.0) and later. +Logs are batched and sent to Datadog with Gzip compression. Each event's log source is mapped to the `service` field, and the source is set to `Supabase`. The payload message is a JSON string of the raw log event, prefixed with the event timestamp. -### Axiom +**Required configuration:** -Logs sent to a specified Axiom's dataset as JSON of a raw log event, -with timestamp modified to be parsed by ingestion endpoint. +- API Key — from [Datadog Organization Settings](https://app.datadoghq.com/organization-settings/api-keys) +- Region — the Datadog site your account uses (US1, US3, US5, EU, AP1, AP2, US1-FED) -To set up the Axiom log drain, you have to: +**Steps:** -1. Create a dataset for ingestion in Axiom Console -> Datasets -2. Generate an Axiom API Token with permission to ingest into the created dataset (see [Axiom docs](https://axiom.co/docs/reference/tokens#create-basic-api-token)) -3. Create log drain in [Supabase dashboard](/dashboard/project/_/settings/log-drains), providing: - - Name of the dataset - - API token -4. Watch for events in the Stream panel of Axiom Console +1. Generate an API key in the [Datadog dashboard](https://app.datadoghq.com/organization-settings/api-keys). +2. Create the drain in [Project Settings > Log Drains](/dashboard/project/_/settings/log-drains). +3. Watch incoming events on the [Datadog Logs page](https://app.datadoghq.com/logs). -### Amazon S3 + + -Logs are written to an existing S3 bucket that you own. +[Grok parser](https://docs.datadoghq.com/service_management/events/pipelines_and_processors/grok_parser?tab=matchers) — extract the timestamp into a `date` field: -Required configuration when creating an S3 Log Drain: +``` +%{date("yyyy-MM-dd'T'HH:mm:ss.SSSSSSZZ"):date} +``` -- S3 Bucket: the name of an existing S3 bucket. -- Region: the AWS region where the bucket is located. -- Access Key ID: used for authentication. -- Secret Access Key: used for authentication. -- Batch Timeout (ms): maximum time to wait before flushing a batch. Recommended 2000-5000ms. +[Grok parser](https://docs.datadoghq.com/service_management/events/pipelines_and_processors/grok_parser?tab=matchers) — convert stringified JSON to structured JSON on the `json` field: + +``` +%{data::json} +``` + +[Remapper](https://docs.datadoghq.com/service_management/events/pipelines_and_processors/remapper) — set the log level: + +``` +metadata.parsed.error_severity, metadata.level +``` + + + + +## Loki + +Logs are formatted and sent to the Loki HTTP push API. The log source and product name are used as stream labels. The `event_message` and `timestamp` fields are dropped from events to avoid duplicate data. Events are batched with a maximum of 250 events per request. + +**Required configuration:** + +- URL — your Loki push endpoint (e.g. `https://my-logs.grafana.net/loki/api/v1/push`) +- Username — optional, required for Grafana Cloud and other authenticated Loki instances +- Password — optional, required for Grafana Cloud and other authenticated Loki instances +- Headers — optional additional headers -Ensure the AWS account tied to the Access Key ID has permissions to write to the specified S3 bucket. +Loki must be configured to accept **structured metadata**. Increase the default maximum number of structured metadata fields to at least 500 to accommodate large log event payloads across different Supabase products. -### OpenTelemetry protocol (OTLP) +See the official [Loki HTTP API documentation](https://grafana.com/docs/loki/latest/reference/loki-http-api/#ingest-logs) for more details on the push API format. + +## Amazon S3 + +Logs are written as batched files to an existing S3 bucket that you own. -Logs are sent to any OTLP-compatible endpoint using the OpenTelemetry Protocol over HTTP with Protocol Buffers encoding. +**Required configuration:** -OTLP is an open-standard protocol for telemetry data, making it compatible with many observability platforms including: +- S3 Bucket — name of an existing S3 bucket +- Region — AWS region where the bucket is located +- Access Key ID — used for authentication +- Secret Access Key — used for authentication +- Batch Timeout (ms) — maximum wait before flushing a batch (recommended: 2000–5000ms) -
    -
  • OpenTelemetry Collector
  • -
  • Grafana Cloud
  • -
  • New Relic
  • -
  • Honeycomb
  • -
  • Datadog (OTLP ingestion)
  • -
  • Elastic
  • -
  • And many more
  • -
+ + +The AWS account tied to the Access Key ID must have write permissions on the specified S3 bucket. -Required configuration when creating an OTLP Log Drain: + -
    -
  • Endpoint: The full URL of your OTLP HTTP endpoint (typically ending in `/v1/logs`)
  • -
  • Protocol: Currently only `http/protobuf` is supported
  • -
  • Gzip: Enable compression to reduce bandwidth (recommended: enabled)
  • -
  • Headers: Optional authentication headers (e.g., `Authorization`, `X-API-Key`)
  • -
+## Sentry -Logs are sent as OTLP log record messages using Protocol Buffers encoding, following the [OpenTelemetry Logs specification](https://opentelemetry.io/docs/specs/otel/logs/). +Logs are sent to [Sentry's Logging product](https://docs.sentry.io/product/explore/logs/). All log event fields are attached as Sentry log attributes, which can be used for filtering and grouping. There are no cardinality limits on the number of attributes. + +**Required configuration:** + +- DSN — your Sentry project DSN in the format `{PROTOCOL}://{PUBLIC_KEY}@{HOST}/{PROJECT_ID}` + +**Steps:** + +1. Get your DSN from [Sentry project settings](https://docs.sentry.io/concepts/key-terms/dsn-explainer/). +2. Create the drain in [Project Settings > Log Drains](/dashboard/project/_/settings/log-drains). +3. Watch incoming logs on the [Sentry Logs page](https://sentry.io/explore/logs/). -Ensure your OTLP endpoint is configured to accept logs at the `/v1/logs` path with `application/x-protobuf` content type. +Ingesting Supabase logs as Sentry _errors_ is not supported. If you are self-hosting Sentry, Sentry Logs requires self-hosted version [25.9.0](https://github.com/getsentry/self-hosted/releases/tag/25.9.0) or later. - - - -To receive Supabase logs with the OpenTelemetry Collector, configure an OTLP HTTP receiver: +## Axiom -```yaml -receivers: - otlp: - protocols: - http: - endpoint: 0.0.0.0:4318 +Logs are sent to an Axiom dataset as JSON, with the timestamp adjusted for Axiom's ingestion format. -processors: - batch: +**Required configuration:** -exporters: - logging: - loglevel: debug +- Dataset Name — name of the target dataset in Axiom +- API Token — an Axiom API token with ingest permissions on the dataset -service: - pipelines: - logs: - receivers: [otlp] - processors: [batch] - exporters: [logging] -``` +**Steps:** -Then create a log drain in [Supabase dashboard](/dashboard/project/_/settings/log-drains) with: +1. Create a dataset in Axiom Console under **Datasets**. +2. Generate an API token with ingest access (see [Axiom token docs](https://axiom.co/docs/reference/tokens#create-basic-api-token)). +3. Create the drain in [Project Settings > Log Drains](/dashboard/project/_/settings/log-drains). +4. Watch incoming events in the Axiom Console **Stream** panel. -
    -
  • Endpoint: `https://your-collector:4318/v1/logs`
  • -
  • Add authentication headers as needed for your setup
  • -
+## Last9 -
+Logs are sent to Last9 using its OpenTelemetry-native ingestion endpoint. Credentials are obtained from the Last9 OTEL integration panel. - +- Region — your Last9 cluster region (US West 1 or AP South 1) +- Username — from the Last9 OTEL integration panel +- Password — from the Last9 OTEL integration panel -Different OTLP platforms use different authentication methods. Add headers accordingly: +**Steps:** -**API Key Authentication:** +1. In the Last9 dashboard, open the OTEL integration panel and note your region, username, and password. +2. Create the drain in [Project Settings > Log Drains](/dashboard/project/_/settings/log-drains). -``` -X-API-Key: your-api-key -``` +## Syslog -**Bearer Token:** +Logs are forwarded to a remote Syslog receiver using TCP or TLS, adhering to [RFC 5424](https://datatracker.ietf.org/doc/html/rfc5424). -``` -Authorization: Bearer your-token -``` +**Required configuration:** -**Basic Authentication:** +- Host — hostname or IP address of the Syslog receiver +- Port — port of the Syslog receiver (0–65535) +- TLS — enable to connect via SSL/TLS instead of plain TCP -``` -Authorization: Basic base64(username:password) -``` +**Optional configuration:** -Refer to your observability platform's documentation for specific authentication requirements. +- Structured Data — static RFC 5424 structured data included in every log frame (e.g. `[exampleSDID@32473 iut="3"]`) +- Cipher Key — base64-encoded 32-byte key for AES-256-GCM encryption of the log body - +**TLS-only options:** -
+- CA Certificate — PEM-encoded CA certificate for server verification (falls back to the system CA bundle if omitted) +- Client Certificate — PEM-encoded client certificate for mutual TLS (mTLS) +- Client Key — PEM-encoded client private key (required when a client certificate is provided) -### Pricing +## Additional resources -For a detailed breakdown of how charges are calculated, refer to [Manage Log Drain usage](/docs/guides/platform/manage-your-usage/log-drains). +- [Log Drains pricing breakdown](/docs/guides/platform/manage-your-usage/log-drains) — cost per drain, per million events, and egress charges. +- [Metrics API](/docs/guides/telemetry/metrics) — export Postgres performance metrics alongside your logs. +- [Tracing with the JS SDK](/docs/guides/telemetry/client-side-tracing) — instrument your application and combine traces with Supabase logs. diff --git a/apps/docs/data/content-listings/index.ts b/apps/docs/data/content-listings/index.ts index c0e242c02a506..ed3ea87006798 100644 --- a/apps/docs/data/content-listings/index.ts +++ b/apps/docs/data/content-listings/index.ts @@ -10,6 +10,7 @@ import { functionsExamplesWebhooksPayments, functionsGetStarted, } from './functions.data' +import { logDrainsDestinations } from './log-drains.data' import { realtimeExamples, realtimeGetStarted, realtimeResources } from './realtime.data' import { selfHostingCommunity, @@ -32,6 +33,7 @@ const ALL_GROUPS: readonly ContentListingGroup[] = [ functionsExamplesAiMedia, functionsExamplesMessaging, functionsExamplesOperations, + logDrainsDestinations, realtimeGetStarted, realtimeExamples, realtimeResources, diff --git a/apps/docs/data/content-listings/log-drains.data.ts b/apps/docs/data/content-listings/log-drains.data.ts new file mode 100644 index 0000000000000..d828ff2e9233f --- /dev/null +++ b/apps/docs/data/content-listings/log-drains.data.ts @@ -0,0 +1,64 @@ +import type { ContentListingGroup } from '~/lib/content-listings.schema' + +export const logDrainsDestinations: ContentListingGroup = { + id: 'log-drains-destinations', + heading: 'Choose your destination', + type: 'grid', + columns: 3, + items: [ + { + title: 'Custom Endpoint', + description: 'Forward logs as a POST request to any custom HTTP endpoint.', + href: '/guides/telemetry/log-drains#custom-endpoint', + icon: { kind: 'braces', color: '#3ECF8E', bg: 'rgba(62,207,142,0.1)' }, + }, + { + title: 'OpenTelemetry (OTLP)', + description: 'Send logs to any OTLP-compatible endpoint using Protocol Buffers over HTTP.', + href: '/guides/telemetry/log-drains#opentelemetry-otlp', + icon: { kind: 'otlp', color: '#F5A623', bg: 'rgba(245,166,35,0.1)' }, + }, + { + title: 'Datadog', + description: 'Stream logs directly into Datadog for monitoring and analysis.', + href: '/guides/telemetry/log-drains#datadog', + icon: { kind: 'datadog', color: '#632CA6', bg: 'rgba(99,44,166,0.1)' }, + }, + { + title: 'Loki', + description: 'Ingest logs into Grafana Loki using the HTTP push API.', + href: '/guides/telemetry/log-drains#loki', + icon: { kind: 'grafana', color: '#F05A28', bg: 'rgba(240,90,40,0.1)' }, + }, + { + title: 'Amazon S3', + description: 'Write batched log files directly to an S3 bucket you own.', + href: '/guides/telemetry/log-drains#amazon-s3', + icon: { kind: 'cloud', color: '#FF9900', bg: 'rgba(255,153,0,0.1)' }, + }, + { + title: 'Sentry', + description: "Send logs to Sentry's Logging product for filtering and grouping.", + href: '/guides/telemetry/log-drains#sentry', + icon: { kind: 'sentry', color: '#362D59', bg: 'rgba(54,45,89,0.1)' }, + }, + { + title: 'Axiom', + description: 'Forward logs to an Axiom dataset for storage and analysis.', + href: '/guides/telemetry/log-drains#axiom', + icon: { kind: 'axiom', color: '#6366F1', bg: 'rgba(99,102,241,0.1)' }, + }, + { + title: 'Last9', + description: 'Stream logs to Last9 for OpenTelemetry-native observability.', + href: '/guides/telemetry/log-drains#last9', + icon: { kind: 'last9', color: '#00B4A0', bg: 'rgba(0,180,160,0.1)' }, + }, + { + title: 'Syslog', + description: 'Forward logs to a remote Syslog receiver over TCP or TLS (RFC 5424).', + href: '/guides/telemetry/log-drains#syslog', + icon: { kind: 'server', color: '#64748B', bg: 'rgba(100,116,139,0.1)' }, + }, + ], +} diff --git a/apps/docs/lib/content-listings.schema.ts b/apps/docs/lib/content-listings.schema.ts index df48028bf00da..472fb19522cf0 100644 --- a/apps/docs/lib/content-listings.schema.ts +++ b/apps/docs/lib/content-listings.schema.ts @@ -5,6 +5,7 @@ import { contentListingGroupSchema, contentListingGroupTypeSchema, contentListingHeadingLevelSchema, + contentListingIconSchema, contentListingItemSchema, } from './content-listings.zod.mjs' @@ -13,8 +14,10 @@ export { contentListingGroupSchema, contentListingGroupTypeSchema, contentListingHeadingLevelSchema, + contentListingIconSchema, contentListingItemSchema, } +export type ContentListingIcon = z.infer export type ContentListingItem = z.infer export type ContentListingGroup = z.infer diff --git a/apps/docs/lib/content-listings.test.ts b/apps/docs/lib/content-listings.test.ts index b22f0ffc7dbb5..37d54128d73a4 100644 --- a/apps/docs/lib/content-listings.test.ts +++ b/apps/docs/lib/content-listings.test.ts @@ -4,6 +4,7 @@ import { ContentListings as ContentListingsMarkdownHandler, serializeContentListingGroupToMarkdown, } from '~/internals/markdown-schema/ContentListings' +import { contentListingItemSchema } from '~/lib/content-listings.schema' import { isExternalContentListingHref } from '~/lib/content-listings.utils' import { describe, expect, it } from 'vitest' @@ -185,6 +186,46 @@ describe('dashboard content listing hrefs', () => { }) }) +describe('contentListingItemSchema icon', () => { + const baseItem = { + title: 'Datadog', + href: '/guides/telemetry/log-drains#datadog', + description: 'Stream logs directly into Datadog for monitoring and analysis.', + } + + it('accepts a plain string icon path', () => { + const result = contentListingItemSchema.safeParse({ + ...baseItem, + icon: '/docs/img/icons/github-icon', + }) + expect(result.success).toBe(true) + }) + + it('accepts a well-formed icon chip object', () => { + const result = contentListingItemSchema.safeParse({ + ...baseItem, + icon: { kind: 'datadog', color: '#632CA6', bg: 'rgba(99,44,166,0.1)' }, + }) + expect(result.success).toBe(true) + }) + + it('rejects an icon chip object missing bg', () => { + const result = contentListingItemSchema.safeParse({ + ...baseItem, + icon: { kind: 'datadog', color: '#632CA6' }, + }) + expect(result.success).toBe(false) + }) + + it('rejects an icon chip object with an unknown kind', () => { + const result = contentListingItemSchema.safeParse({ + ...baseItem, + icon: { kind: 'not-a-real-kind', color: '#632CA6', bg: 'rgba(99,44,166,0.1)' }, + }) + expect(result.success).toBe(false) + }) +}) + describe('TelemetryEvent union', () => { it('includes docs_content_listing_clicked', () => { const event = { diff --git a/apps/docs/lib/content-listings.zod.mjs b/apps/docs/lib/content-listings.zod.mjs index 990eb7f1c3785..8726d71ba9fc9 100644 --- a/apps/docs/lib/content-listings.zod.mjs +++ b/apps/docs/lib/content-listings.zod.mjs @@ -1,10 +1,30 @@ import { z } from 'zod' +const contentListingIconKindSchema = z.enum([ + 'braces', + 'otlp', + 'datadog', + 'grafana', + 'cloud', + 'sentry', + 'axiom', + 'last9', + 'server', +]) + +const contentListingIconChipSchema = z.object({ + kind: contentListingIconKindSchema, + color: z.string().min(1), + bg: z.string().min(1), +}) + +export const contentListingIconSchema = z.union([z.string().min(1), contentListingIconChipSchema]) + export const contentListingItemSchema = z.object({ title: z.string().min(1), href: z.string().min(1), description: z.string().min(1), - icon: z.string().min(1).optional(), + icon: contentListingIconSchema.optional(), hasLightIcon: z.boolean().optional(), badge: z.string().min(1).optional(), }) diff --git a/apps/docs/public/humans.txt b/apps/docs/public/humans.txt index 64bf216b7906f..43e4768008df4 100644 --- a/apps/docs/public/humans.txt +++ b/apps/docs/public/humans.txt @@ -302,6 +302,7 @@ Utkarash Singh Victor Farazdagi Warwick Mitchell Wen Bo Xie +Wendie Cheung Yorvi Arias Yuliya Marinova Zach Marinov diff --git a/apps/studio/components/interfaces/Database/Roles/RolesList.tsx b/apps/studio/components/interfaces/Database/Roles/RolesList.tsx index 3378e5ef6b4e0..28c8f5162b121 100644 --- a/apps/studio/components/interfaces/Database/Roles/RolesList.tsx +++ b/apps/studio/components/interfaces/Database/Roles/RolesList.tsx @@ -178,7 +178,7 @@ export const RolesList = () => {
-
+
{ return null } +const getBadgeVariant = (activity: DatabaseActivity) => { + const { state } = activity + if (state === 'active') return 'success' + if (state === 'idle in transaction' || state === 'idle in transaction (aborted)') return 'warning' + return 'default' +} + const DEFAULT_ROLES_FILTER = ['anon', 'authenticated', 'postgres'] interface ActivityProps { @@ -72,25 +86,28 @@ interface ActivityProps { export const Activity = ({ live }: ActivityProps) => { const { data: project } = useSelectedProjectQuery() - const [, setNow] = useState(() => dayjs()) const [selectedPid] = useQueryState('pid', parseAsInteger) - const [stateFilter, setStateFilter] = useQueryState( - 'state', - parseAsJson(selectFilterSchema.parse).withDefault([]) - ) - const [rolesFilter, setRolesFilter] = useQueryState( - 'roles', - parseAsArrayOf(parseAsString, ',').withDefault(DEFAULT_ROLES_FILTER) - ) - const hasNoFiltersApplied = stateFilter.length === 0 && isEqual(rolesFilter, DEFAULT_ROLES_FILTER) + const [ + { states: statesFilter, applications: applicationsFilter, roles: rolesFilter }, + setQueryStates, + ] = useQueryStates({ + states: parseAsArrayOf(parseAsString, ',').withDefault([]), + applications: parseAsArrayOf(parseAsString, ',').withDefault([]), + roles: parseAsArrayOf(parseAsString, ',').withDefault(DEFAULT_ROLES_FILTER), + }) + + const hasNoFiltersApplied = + statesFilter.length === 0 && + applicationsFilter.length === 0 && + isEqual(rolesFilter, DEFAULT_ROLES_FILTER) const { data, isPending, isSuccess } = useDatabaseActivityQuery( { projectRef: project?.ref, connectionString: project?.connectionString, }, - { refetchInterval: live ? 3000 : false } + { refetchOnWindowFocus: live, refetchInterval: live ? 3000 : false } ) const { data: roles } = useDatabaseRolesQuery({ @@ -100,11 +117,13 @@ export const Activity = ({ live }: ActivityProps) => { const activities = data?.filter((activity) => { const matchesState = - !stateFilter || - stateFilter.length === 0 || - (activity.state !== null && stateFilter.includes(activity.state)) + !statesFilter || + statesFilter.length === 0 || + (activity.state !== null && statesFilter.includes(activity.state)) const matchesRole = rolesFilter.length === 0 || rolesFilter.includes(activity.role_name) - return matchesState && matchesRole + const matchesApplication = + applicationsFilter.length === 0 || applicationsFilter.includes(activity.application_name) + return matchesState && matchesRole && matchesApplication }) const stateOptions = [ @@ -120,10 +139,27 @@ export const Activity = ({ live }: ActivityProps) => { quantity: data?.filter( (y) => y.state === x.toLowerCase() && - (rolesFilter.length === 0 || rolesFilter.includes(y.role_name)) + (rolesFilter.length === 0 || rolesFilter.includes(y.role_name)) && + (applicationsFilter.length === 0 || applicationsFilter.includes(y.application_name)) ).length, })) + const applicationOptions = Array.from(new Set(data?.map((x) => x.application_name) ?? [])) + .sort() + .map((x) => ({ + label: x, + value: x, + quantity: data?.filter( + (y) => + y.application_name === x && + (rolesFilter.length === 0 || rolesFilter.includes(y.role_name)) && + (!statesFilter || + statesFilter.length === 0 || + (y.state !== null && statesFilter.includes(y.state))) + ).length, + })) + .filter((x) => !!x.value) + const priorityRoles = ['anon', 'authenticated', 'postgres'] const roleOptions = (roles ?? []) @@ -133,9 +169,10 @@ export const Activity = ({ live }: ActivityProps) => { quantity: data?.filter( (y) => y.role_name === x.name && - (!stateFilter || - stateFilter.length === 0 || - (y.state !== null && stateFilter.includes(y.state))) + (!statesFilter || + statesFilter.length === 0 || + (y.state !== null && statesFilter.includes(y.state))) && + (applicationsFilter.length === 0 || applicationsFilter.includes(y.application_name)) ).length, })) .sort((a, b) => { @@ -147,12 +184,6 @@ export const Activity = ({ live }: ActivityProps) => { return 0 }) - // [Joshen] Just to trigger a UI re-render for the duration to be "live" - useEffect(() => { - const interval = setInterval(() => setNow(dayjs()), 1000) - return () => clearInterval(interval) - }, []) - useEffect(() => { if (selectedPid && isSuccess) { document @@ -166,23 +197,44 @@ export const Activity = ({ live }: ActivityProps) => {

Sessions

+ setQueryStates({ states })} + isLoading={isPending} + popoverClassName="w-60" + /> setQueryStates({ roles })} isLoading={isPending} popoverClassName="w-72" /> setQueryStates({ applications })} isLoading={isPending} popoverClassName="w-60" /> + {!hasNoFiltersApplied && ( + } + onClick={() => + setQueryStates({ states: [], roles: DEFAULT_ROLES_FILTER, applications: [] }) + } + aria-label="Reset filters" + tooltip={{ content: { side: 'bottom', text: 'Reset filters' } }} + /> + )}
@@ -237,8 +289,11 @@ export const Activity = ({ live }: ActivityProps) => { variant="default" className="mt-2" onClick={() => { - setStateFilter([]) - setRolesFilter(DEFAULT_ROLES_FILTER) + setQueryStates({ + states: [], + roles: DEFAULT_ROLES_FILTER, + applications: [], + }) }} > Reset filters @@ -280,13 +335,8 @@ const ActivityRow = ({ activity }: { activity: DatabaseActivity }) => { }, }) - const getBadgeVariant = (state: DatabaseActivity['state']) => { - if (state === 'active') return 'success' - if (state === 'idle in transaction') return 'warning' - return 'default' - } - const durationSeconds = getDuration(activity) + const badgeVariant = getBadgeVariant(activity) /** * Queries in "active state": 30s threshold is long enough (most CRUD queries should be quick) @@ -294,8 +344,10 @@ const ActivityRow = ({ activity }: { activity: DatabaseActivity }) => { */ const queryRunningLongWarning = !!durationSeconds && - ((activity.state === 'active' && durationSeconds >= 30) || - (activity.state === 'idle in transaction' && durationSeconds >= 10)) + ((activity.state === 'active' && durationSeconds >= WARN_DURATION_ACTIVE_QUERY) || + ((activity.state === 'idle in transaction' || + activity.state === 'idle in transaction (aborted)') && + durationSeconds >= WARN_DURATION_IDLE_TXN)) const onConfirmTerminate = async () => { try { @@ -314,18 +366,25 @@ const ActivityRow = ({ activity }: { activity: DatabaseActivity }) => { {selectedPid === activity.pid && (
)} - {activity.state} + + + {activity.state} + + {activity.state && ( + {QUERY_STATE_TOOLTIP[activity.state]} + )} + - +

- {activity.query ?? 'No query'} + {!!activity.query ? activity.query : 'No query'}

{activity.query && ( @@ -343,8 +402,19 @@ const ActivityRow = ({ activity }: { activity: DatabaseActivity }) => { )}
-

- PID: {activity.pid} +

+ + { + toast.success('Copied PID') + copyToClipboard(activity.pid.toString()) + }} + > + PID: {activity.pid} + + Click to copy + · {activity.role_name} {activity.application_name && ( @@ -353,14 +423,18 @@ const ActivityRow = ({ activity }: { activity: DatabaseActivity }) => { {activity.application_name} )} -

+

{durationSeconds !== null ? ( diff --git a/apps/studio/components/interfaces/Observability/DatabaseConnections/DatabaseConnections.ai.ts b/apps/studio/components/interfaces/Observability/DatabaseConnections/DatabaseConnections.ai.ts new file mode 100644 index 0000000000000..b70b5aa9e6887 --- /dev/null +++ b/apps/studio/components/interfaces/Observability/DatabaseConnections/DatabaseConnections.ai.ts @@ -0,0 +1,35 @@ +import { type DatabaseActivity } from '@/data/database/activity-query' + +export const buildDatabaseConnectionsSummaryPrompt = ({ + activities, + timestamp, +}: { + activities: DatabaseActivity[] + timestamp: string +}) => { + const prompt = ` +Data from \`pg_stat_activity\` captured at: ${timestamp} +\`\`\` +${JSON.stringify(activities)} +\`\`\` + +Summarize the sessions in the above data for a developer who isn't a DBA. Cover, in this order, only the categories that have at least one match: +1. Idle-in-transaction sessions — cite the PID and role; note it's almost always a missing commit/rollback in their app. +2. Blocked sessions — cite the blocked PID and the PID/query blocking it. +3. Active queries running longer than 30 seconds — cite the PID and duration. + +Rules: +- Only include a bullet for a category if it has at least one match. Do not mention categories with nothing to report. +- If none of the four apply, respond with a single plain sentence confirming things look healthy — no bullets, no "if you want..." offer, no restating the data. +- Reference every finding by PID so the user can find the row in the table. +- Compute the duration based on the timestamps provided against the captured at timestamp and blocked_by fields from the data + - Rows with status as "active" should be computed using the "query_start" property + - Rows with status as "idle in transaction" or "idle in transaction (aborted) should be computed using the "transaction_start" property + +Format: +- Bold every PID (e.g. **PID 511**). +- Display the data for each category in a table format. +- If anything was flagged, end with one short line inviting the user to ask for more detail on a specific PID. If everything is healthy, stop after the health sentence — do not ask for more data. +` + return prompt +} diff --git a/apps/studio/components/interfaces/Observability/DatabaseConnections/DatabaseConnections.constants.ts b/apps/studio/components/interfaces/Observability/DatabaseConnections/DatabaseConnections.constants.ts new file mode 100644 index 0000000000000..be3a075cdc149 --- /dev/null +++ b/apps/studio/components/interfaces/Observability/DatabaseConnections/DatabaseConnections.constants.ts @@ -0,0 +1,18 @@ +// [Joshen] These are opinionated thresholds for when things might need attention +// For queries in "active" state - show warning variant if running longer than 30 seconds +// - Typically Not a problem unless its running longer than expected +// For queries in "idle in transaction" state - show warning variant if running longer than 30 seconds +// - Shorter threshold as it indicates a lock +export const WARN_DURATION_ACTIVE_QUERY = 30 // seconds +export const WARN_DURATION_IDLE_TXN = 10 // seconds + +export const QUERY_STATE_TOOLTIP = { + ['active']: 'Currently executing a query.', + ['idle']: 'Connected, but not currently running a query.', + ['disabled']: 'Activity tracking is disabled for this session.', + ['idle in transaction']: 'Has an open transaction but isn’t currently running a query.', + ['idle in transaction (aborted)']: + 'The last statement in this transaction failed and hasn’t been rolled back yet.', + ['fastpath function call']: + 'Executing a function call via Postgres’s low-level fastpath protocol.', +} diff --git a/apps/studio/components/interfaces/Observability/DatabaseConnections/Overview.tsx b/apps/studio/components/interfaces/Observability/DatabaseConnections/Overview.tsx new file mode 100644 index 0000000000000..d03ee79e09f8a --- /dev/null +++ b/apps/studio/components/interfaces/Observability/DatabaseConnections/Overview.tsx @@ -0,0 +1,255 @@ +import dayjs from 'dayjs' +import { parseAsInteger, useQueryState } from 'nuqs' +import { cn } from 'ui' +import { + MetricCard, + MetricCardContent, + MetricCardHeader, + MetricCardLabel, + MetricCardValue, +} from 'ui-patterns/MetricCard' + +import { WARN_DURATION_ACTIVE_QUERY, WARN_DURATION_IDLE_TXN } from './DatabaseConnections.constants' +import { formatDuration } from '@/components/interfaces/QueryPerformance/QueryPerformance.utils' +import { useDatabaseRolesQuery } from '@/data/database-roles/database-roles-query' +import { useDatabaseActivityQuery, type DatabaseActivity } from '@/data/database/activity-query' +import { useMaxConnectionsQuery } from '@/data/database/max-connections-query' +import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' + +const LONG_RUNNING_STATES: (DatabaseActivity['state'] | undefined)[] = [ + 'active', + 'idle in transaction', + 'idle in transaction (aborted)', +] + +interface OverviewProps { + live?: boolean +} + +/** + * [Joshen] Couple of nuances worth calling out to provide better signals for the user + * - Idle in transaction: + * - Only considers queries in that state, but running for longer than 10 seconds + * - Could otherwise be a query in mid-flight + * - Longest running: + * - Only considers queries that are active or idle in transaction + */ +export const Overview = ({ live }: OverviewProps) => { + const { data: project } = useSelectedProjectQuery() + const [, setSelectedPid] = useQueryState('pid', parseAsInteger) + + const { data, isPending: isLoadingActivity } = useDatabaseActivityQuery( + { + projectRef: project?.ref, + connectionString: project?.connectionString, + }, + { refetchOnWindowFocus: live, refetchInterval: live ? 3000 : false } + ) + const activeQueries = (data ?? []).filter((x) => x.state === 'active') + const blockedQueries = (data ?? []).filter((x) => x.blocked_by.length > 0) + const idleInTransactionQueries = (data ?? []).filter((x) => { + const isIdleInTransaction = + x.state === 'idle in transaction' || x.state === 'idle in transaction (aborted)' + if (!isIdleInTransaction || !x.transaction_start) return false + return dayjs().utc().diff(dayjs(x.transaction_start).utc(), 'second') > WARN_DURATION_IDLE_TXN + }) + + const longestRunningQuery = (data ?? []) + .filter((x) => LONG_RUNNING_STATES.includes(x.state)) + .reduce<{ activity: DatabaseActivity; duration: number } | null>((longest, activity) => { + const start = activity.state === 'active' ? activity.query_start : activity.transaction_start + if (!start) return longest + const duration = Math.max(dayjs().utc().diff(dayjs(start).utc(), 'second'), 0) + return longest === null || duration > longest.duration ? { activity, duration } : longest + }, null) + const warnLongestRunningQuery = + (longestRunningQuery?.activity.state === 'active' && + longestRunningQuery.duration >= WARN_DURATION_ACTIVE_QUERY) || + ((longestRunningQuery?.activity.state === 'idle in transaction' || + longestRunningQuery?.activity.state === 'idle in transaction (aborted)') && + longestRunningQuery.duration >= WARN_DURATION_IDLE_TXN) + + const { data: roles, isPending: isLoadingRoles } = useDatabaseRolesQuery( + { + projectRef: project?.ref, + connectionString: project?.connectionString, + }, + { refetchOnWindowFocus: live, refetchInterval: live ? 3000 : false } + ) + const rolesWithActiveConnections = (roles ?? []).filter((role) => role.activeConnections) + const totalActiveConnections = (roles ?? []) + .map((role) => role.activeConnections) + .reduce((a, b) => a + b, 0) + + const { data: maxConnectionLimit, isPending: isLoadingMaxConnections } = useMaxConnectionsQuery( + { + projectRef: project?.ref, + connectionString: project?.connectionString, + }, + { + select: (data) => data.maxConnections, + refetchInterval: live ? 3000 : false, + } + ) + + return ( +

+
+

Overview

+
+ +
+
+ + + +

Connections by roles:

+ {rolesWithActiveConnections.map((role) => ( +
+

{role.name}:

{role.activeConnections} +
+ ))} +
+ } + > + Connections + + + + + {totalActiveConnections} + / + {maxConnectionLimit} + + + + + + + + Longest running + + + + + {longestRunningQuery === null ? ( + '-' + ) : ( + <> + {formatDuration(longestRunningQuery.duration * 1000, 0)} + · + setSelectedPid(longestRunningQuery.activity.pid)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + setSelectedPid(longestRunningQuery.activity.pid) + } + }} + > + PID: {longestRunningQuery.activity.pid} + + + )} + + + +
+ +
+ + + + Active queries + + + + {activeQueries?.length} + + + + + + +

+ Queries waiting on a lock held by another session - stalls everything queued + behind it. +

+

+ Typically caused by an uncommitted transaction, a long-running migration, or a + stuck idle-in-transaction session. +

+ + } + > + Blocked queries +
+
+ + + {blockedQueries.length} + + +
+ + + + +

+ Transactions left open without running a query, which can hold locks and block + table cleanup for as long as it stays open +

+

+ Typically indicates an app issue, such as a forgotten COMMIT or ROLLBACK. +

+ + } + > + Idle in transaction +
+
+ + 0 && 'text-warning')} + > + {idleInTransactionQueries.length} + + +
+
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/Reports/ReportPadding.tsx b/apps/studio/components/interfaces/Reports/ReportPadding.tsx index 759dd8ce151ad..3a483ce86c6ff 100644 --- a/apps/studio/components/interfaces/Reports/ReportPadding.tsx +++ b/apps/studio/components/interfaces/Reports/ReportPadding.tsx @@ -4,11 +4,15 @@ import { cn } from 'ui' /** * Standardized padding and width layout for non-custom reports */ -const ReportPadding = ({ children }: PropsWithChildren<{}>) => { +export const ReportPadding = ({ + children, + className, +}: PropsWithChildren<{ className?: string }>) => { return (
{children} diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorMenu.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorMenu.tsx index 2dc29b01f0d8a..a27febc9d524c 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorMenu.tsx +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorMenu.tsx @@ -1,6 +1,6 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { useDebounce } from '@uidotdev/usehooks' -import { LOCAL_STORAGE_KEYS, useParams } from 'common' +import { LOCAL_STORAGE_KEYS, useFlag, useParams } from 'common' import { FilePlus, FolderPlus, Plus, X } from 'lucide-react' import { useRouter } from 'next/router' import { useEffect, useState } from 'react' @@ -38,6 +38,8 @@ export const SQLEditorMenu = () => { const { data: project } = useSelectedProjectQuery() const snapV2 = useSqlEditorV2StateSnapshot() + const topForPostgres = useFlag('topForPostgres') + const [search, setSearch] = useState('') const [showSearch, setShowSearch] = useState(false) const [sort, setSort] = useLocalStorage<'name' | 'inserted_at'>( @@ -165,11 +167,13 @@ export const SQLEditorMenu = () => { {showSearch ? : }
-
- -
+ {!topForPostgres && ( +
+ +
+ )}
) } diff --git a/apps/studio/pages/project/[ref]/observability/connections.tsx b/apps/studio/pages/project/[ref]/observability/connections.tsx index 96efd9538ca14..c8c5c5ed58e6f 100644 --- a/apps/studio/pages/project/[ref]/observability/connections.tsx +++ b/apps/studio/pages/project/[ref]/observability/connections.tsx @@ -1,18 +1,62 @@ +import dayjs from 'dayjs' import { Pause, Play } from 'lucide-react' -import { useState } from 'react' +import { useEffect, useState } from 'react' import { Badge, Button, Tooltip, TooltipContent, TooltipTrigger } from 'ui' import { Activity } from '@/components/interfaces/Observability/DatabaseConnections/Activity' -import ReportPadding from '@/components/interfaces/Reports/ReportPadding' +import { buildDatabaseConnectionsSummaryPrompt } from '@/components/interfaces/Observability/DatabaseConnections/DatabaseConnections.ai' +import { Overview } from '@/components/interfaces/Observability/DatabaseConnections/Overview' +import { ReportPadding } from '@/components/interfaces/Reports/ReportPadding' import { DefaultLayout } from '@/components/layouts/DefaultLayout' import ObservabilityLayout from '@/components/layouts/ObservabilityLayout/ObservabilityLayout' +import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' +import { AiAssistantDropdown } from '@/components/ui/AiAssistantDropdown' +import { useDatabaseActivityQuery } from '@/data/database/activity-query' +import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' +import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state' +import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state' import type { NextPageWithLayout } from '@/types' export const DatabaseConnections: NextPageWithLayout = () => { + const { data: project } = useSelectedProjectQuery() + const { openSidebar } = useSidebarManagerSnapshot() + const aiSnap = useAiAssistantStateSnapshot() + const [live, setLive] = useState(true) + const [now, setNow] = useState(() => dayjs.utc()) + + const { data, isPending: isLoadingActivity } = useDatabaseActivityQuery( + { + projectRef: project?.ref, + connectionString: project?.connectionString, + }, + { refetchOnWindowFocus: live, refetchInterval: live ? 3000 : false } + ) + + const buildPrompt = () => { + return buildDatabaseConnectionsSummaryPrompt({ + activities: data ?? [], + timestamp: now.toISOString(), + }) + } + + const handleSummarizeActivity = () => { + openSidebar(SIDEBAR_KEYS.AI_ASSISTANT) + const prompt = buildPrompt() + aiSnap.newChat({ + name: `DB Connections Summary ${dayjs().format('DD/MM/YYYY HH:mm')}`, + initialMessage: prompt, + }) + } + + // [Joshen] Just to trigger a UI re-render for the duration to be "live" + useEffect(() => { + const interval = setInterval(() => setNow(dayjs.utc()), 1000) + return () => clearInterval(interval) + }, []) return ( - +

Database Connections

@@ -38,9 +82,21 @@ export const DatabaseConnections: NextPageWithLayout = () => { > {live ? 'Pause' : 'Live'} +
+
) diff --git a/apps/studio/routes/project/$ref/observability/connections.tsx b/apps/studio/routes/project/$ref/observability/connections.tsx index eebbb30806d30..f5b0bd77ee5bf 100644 --- a/apps/studio/routes/project/$ref/observability/connections.tsx +++ b/apps/studio/routes/project/$ref/observability/connections.tsx @@ -5,7 +5,7 @@ import DatabaseConnections from '@/pages/project/[ref]/observability/connections export const Route = createFileRoute('/project/$ref/observability/connections')({ component: ObservabilityConnectionsRoute, staticData: { - observabilityLayoutTitle: 'API Gateway', + observabilityLayoutTitle: 'Database Connections', }, }) diff --git a/packages/ui-patterns/src/MetricCard/index.tsx b/packages/ui-patterns/src/MetricCard/index.tsx index e2fd1477ae9ac..2c66582288ec0 100644 --- a/packages/ui-patterns/src/MetricCard/index.tsx +++ b/packages/ui-patterns/src/MetricCard/index.tsx @@ -142,7 +142,7 @@ const MetricCardIcon = React.forwardRef { - tooltip?: string + tooltip?: React.ReactNode children: React.ReactNode } diff --git a/supa-mdx-lint/Rule003Spelling.toml b/supa-mdx-lint/Rule003Spelling.toml index 5ff03ca7c62ff..45a1b6c9c896f 100644 --- a/supa-mdx-lint/Rule003Spelling.toml +++ b/supa-mdx-lint/Rule003Spelling.toml @@ -389,6 +389,8 @@ allow_list = [ "Spotify", "Sqitch", "Supabase", + "[Ss]yslog(s)?", + "mTLS", "Supavisor", "SvelteKit", "SwiftUI",