diff --git a/apps/api/src/routes/v2/share.http.ts b/apps/api/src/routes/v2/share.http.ts index 812b438ca1..f43321e3ce 100644 --- a/apps/api/src/routes/v2/share.http.ts +++ b/apps/api/src/routes/v2/share.http.ts @@ -302,6 +302,16 @@ export const HttpV2SharePublicLive = HttpApiBuilder.group(MapleApiV2, "sharePubl }) }) + const promQlQueries = document.widgets.flatMap((widget) => { + const source = widget.dataSource as { + readonly kind?: unknown + readonly query?: unknown + } + return source.kind === "promql" && typeof source.query === "string" + ? [source.query] + : [] + }) + // Query-variable options are listed server-side over the same window, // so the board's ladder (selection → default → All → first option) // and its "All" expansion resolve to the same values here. @@ -312,6 +322,7 @@ export const HttpV2SharePublicLive = HttpApiBuilder.group(MapleApiV2, "sharePubl payload.variableValues ?? {}, queryOptions, whereClauses, + promQlQueries, ) const results: Array = [] diff --git a/apps/landing/src/content/docs/dashboards/build-dashboards.md b/apps/landing/src/content/docs/dashboards/build-dashboards.md index 973292338c..62de85b8e3 100644 --- a/apps/landing/src/content/docs/dashboards/build-dashboards.md +++ b/apps/landing/src/content/docs/dashboards/build-dashboards.md @@ -65,10 +65,11 @@ Most types come with presets you can start from. Pick one, then edit its query. ### Queries -A widget's editor has two tabs: +A widget's editor has these source tabs: - **Query Builder** reads **Traces**, **Logs**, **Metrics** or **Product events**. Pick an aggregation, add filters and a group-by, and combine queries with a formula. - **Raw SQL** runs your own SQL. The query must reference `$__orgFilter`. See the [SQL reference](/docs/reference/sql). +- **Google Cloud** charts a PromQL query over your Google Cloud Monitoring metrics, read live from Google. It is there for line, area, bar, stat and gauge widgets and needs a [Google Cloud connection](/docs/integrations/gcp). See [Live queries](/docs/integrations/gcp#live-queries). The preview updates as you edit. Save the widget to put it on the dashboard. @@ -112,7 +113,7 @@ Open **⋮ → Share…** and pick who can open the dashboard's link: | Anyone in this organization | Signed-in members of your organization, from the link. | | Anyone with the link | Anyone who has the link, without signing in. They see the dashboard and its data. | -**Replace** issues a new link and stops the old one working. Shared views render query builder charts, raw SQL, notes, funnels, paths, and the errors, service and logs widgets. The dialog names any widget that will not render for viewers. +**Replace** issues a new link and stops the old one working. Shared views render query builder charts, raw SQL, Google Cloud PromQL charts, notes, funnels, paths, and the errors, service and logs widgets. The dialog names any widget that will not render for viewers. To put a single chart into another page, see [Embed dashboard charts](/docs/dashboards/embed-charts). diff --git a/apps/landing/src/content/docs/integrations/gcp.md b/apps/landing/src/content/docs/integrations/gcp.md index 0122264a08..5da649c46b 100644 --- a/apps/landing/src/content/docs/integrations/gcp.md +++ b/apps/landing/src/content/docs/integrations/gcp.md @@ -514,6 +514,30 @@ Until a connection's setup script has run, the page reads **Finish setting up Go With **Key metrics** off on every connection, the page leaves the sidebar and the overview. Its address still opens it: for a time range with data it shows the service tabs with what Maple collected before, under the notice **Google Cloud metrics are switched off**, and otherwise it reads **Turn on metrics for Google Cloud**. The **Resources** tab is back once a connection collects again. +## Live queries + +A dashboard widget can chart any Cloud Monitoring metric with a PromQL query, read from Google each time the widget loads. Nothing is stored in Maple. Live queries work on every connection, with **Key metrics** on or off, once its setup script has run. + +To add one, edit a widget, choose a line, area, bar, stat or gauge panel, and pick **Google Cloud** as the source. **Start from a metric** searches the metric names of your projects and writes a starter query for the one you pick: a rate for counters, an average for gauges, the 95th percentile for distributions. Click **Run preview** to read it. + +Write metric names in PromQL's UTF-8 form, in quotes inside the braces: + +```promql +sum by (project_id, service_name) (rate({"run.googleapis.com/request_count", monitored_resource="cloud_run_revision"}[5m])) +``` + +- A metric written by more than one resource type needs a `monitored_resource` matcher, as above. Without it Google refuses the query and the widget shows Google's message. +- A distribution metric is three series: `_bucket`, `_count` and `_sum`. For a percentile, use `histogram_quantile(0.95, sum by (project_id, le) (rate({"run.googleapis.com/request_latencies_bucket"}[5m])))`. +- **Legend** names each series from its labels, for example `{{service_name}}`. Without one, a series is named after all its labels. +- Dashboard variables go inside a quoted regex matcher: `service_name=~"$service"`. **All** becomes `checkout|cart|…`, which `=~` matches. +- A stat or gauge reduces the series to one number by its **Aggregate**: write a query that returns one series, such as `sum(…)` without `by`. + +Maple runs the query once per project and every series carries a `project_id` label, so an aggregation such as `sum` adds up within a project, never across projects. A query reads up to 20 projects: with more, list the projects to read in **Projects**. An answer holds up to 500 series across its projects; past that the widget asks you to aggregate with `sum by` or `avg by`, or to narrow the selector. When some projects could not be read, the widget shows the rest and names the ones missing under the chart. + +Data arrives in Cloud Monitoring within about 2 minutes. How far back a query reaches is Google's retention: 24 months for Compute Engine, GKE, Cloud SQL and a few other services, six weeks for most other Google Cloud metrics. See **Data retention** in [Cloud Monitoring quotas and limits](https://cloud.google.com/monitoring/quotas). A widget shared through a link runs its saved query; viewers can change the time range and dashboard variables, not the query. + +Google bills the time series each read returns to your Google Cloud project, separately from your Maple plan. Maple caches each answer for a minute, and older time ranges for 10 minutes. Aggregating with `sum by` or `avg by` returns fewer series and costs less. See [Google Cloud Observability pricing](https://cloud.google.com/stackdriver/pricing). + ## Google Cloud costs Google bills Pub/Sub and Cloud Monitoring usage to your account, separately from your Maple plan. diff --git a/apps/web/perf/check-bundle-budget.ts b/apps/web/perf/check-bundle-budget.ts index e61f92a544..c39c0bfc38 100644 --- a/apps/web/perf/check-bundle-budget.ts +++ b/apps/web/perf/check-bundle-budget.ts @@ -115,9 +115,10 @@ const gzipBytes = chunks.reduce((total, chunk) => total + chunk.gzipBytes, 0) // /infra/gcp pages stay in their route chunks. // 702.5 KB from the PromQL query contract (#1475, 2026-10-15), measured in CI at 701.3. // 703.5 KB from the connection access settings (#1477, 2026-10-15), measured in CI at 702.1. -// 707.5 KB with the Google Cloud page redesign (#1336) merged up. -// 708.5 KB with main's 1 KB from #1494, #1500, #1502 merged up the stack. -const maxGzipBytes = 708.5 * 1024 +// 706 KB from the PromQL dashboard widget (#1480, 2026-10-15), measured in CI at 704.4. +// 710 KB with the Google Cloud page redesign (#1336) merged up. +// 711 KB with main's 1 KB from #1494, #1500, #1502 merged up the stack. +const maxGzipBytes = 711 * 1024 const budgetLabel = `${(maxGzipBytes / 1024).toFixed(1)} KB` // Anything lazy-only: chat, replay, and every dev-only lab surface. The diff --git a/apps/web/src/api/warehouse/gcp-promql.ts b/apps/web/src/api/warehouse/gcp-promql.ts new file mode 100644 index 0000000000..2360636225 --- /dev/null +++ b/apps/web/src/api/warehouse/gcp-promql.ts @@ -0,0 +1,67 @@ +import { Effect, Schema } from "effect" +import { GcpPromQlRangeRequest } from "@maple/domain/http" +import { promQlRangeInput, promQlWidgetData } from "@maple/query-engine" +import { MapleInternalAtomClient } from "@/lib/services/common/internal-atom-client" +import { + WarehouseDateTimeString, + decodeInput, + invalidWarehouseInput, + runWarehouseQuery, +} from "@/api/warehouse/effect-utils" + +// Live Google Cloud Monitoring reads through the API's PromQL proxy (widget data source +// `gcp_promql`). Nothing comes from the warehouse; the API edge-caches each answer. + +const PromQlChartInput = Schema.Struct({ + promql: Schema.String, + projects: Schema.optional(Schema.Array(Schema.String)), + legend: Schema.optional(Schema.String), + startTime: WarehouseDateTimeString, + endTime: WarehouseDateTimeString, +}) + +/** A PromQL range query as chart rows, plus a one-line notice when some projects could not be read. */ +export const getGcpPromQlChart = Effect.fn("Integrations.getGcpPromQlChart")(function* ({ + data, +}: { + data: (typeof PromQlChartInput)["Encoded"] +}) { + const input = yield* decodeInput(PromQlChartInput, data, "getGcpPromQlChart") + // A bad project id or an over-long query is the author's to fix, not a support case. + const payload = yield* Schema.decodeUnknownEffect(GcpPromQlRangeRequest)( + promQlRangeInput(input, input), + ).pipe(Effect.catch((error) => invalidWarehouseInput("gcpPromQlRangeRequest", error.message))) + const result = yield* runWarehouseQuery("gcpPromQlQueryRange", () => + Effect.gen(function* () { + const client = yield* MapleInternalAtomClient + return yield* client.integrations.gcpPromQlQueryRange({ payload }) + }), + ) + return promQlWidgetData(result, input.legend, true) +}) + +/** Metric names containing `search`, for the PromQL editor's metric picker. */ +export const getGcpMetricSearch = Effect.fn("Integrations.getGcpMetricSearch")(function* ({ + data, +}: { + data: { readonly search: string } +}) { + return yield* runWarehouseQuery("gcpMetricSearch", () => + Effect.gen(function* () { + const client = yield* MapleInternalAtomClient + return yield* client.integrations.gcpMetricSearch({ query: { search: data.search, limit: 50 } }) + }), + ) +}) + +/** What a starter query needs to know about a metric. */ +export const getGcpMetricDescriptor = Effect.fn("Integrations.getGcpMetricDescriptor")(function* ( + type: string, +) { + return yield* runWarehouseQuery("gcpMetricDescribe", () => + Effect.gen(function* () { + const client = yield* MapleInternalAtomClient + return yield* client.integrations.gcpMetricDescribe({ query: { type } }) + }), + ) +}) diff --git a/apps/web/src/components/dashboard-builder/config/promql-editor-panel.tsx b/apps/web/src/components/dashboard-builder/config/promql-editor-panel.tsx new file mode 100644 index 0000000000..7862253c79 --- /dev/null +++ b/apps/web/src/components/dashboard-builder/config/promql-editor-panel.tsx @@ -0,0 +1,207 @@ +import { type Dispatch, type SetStateAction, useDeferredValue, useState } from "react" +import { Effect, Exit } from "effect" +import { promQlStarterQuery } from "@maple/query-engine" +import { Badge } from "@maple/ui/components/ui/badge" +import { Button } from "@maple/ui/components/ui/button" +import { + Combobox, + ComboboxContent, + ComboboxInput, + ComboboxItem, + ComboboxList, +} from "@maple/ui/components/ui/combobox" +import { EmptyMessage } from "@maple/ui/components/ui/empty" +import { Field, FieldLabel } from "@maple/ui/components/ui/field" +import { Input } from "@maple/ui/components/ui/input" +import { TONE_TEXT } from "@maple/ui/lib/tone" +import { cn } from "@maple/ui/lib/utils" +import { getGcpMetricDescriptor } from "@/api/warehouse/gcp-promql" +import { useAsyncAction } from "@/hooks/use-mutation-action" +import { displayError } from "@/lib/error-messages" +import { Result, useAtomValue } from "@/lib/effect-atom" +import { disabledResultAtom } from "@/lib/services/atoms/disabled-result-atom" +import { gcpMetricSearchResultAtom } from "@/lib/services/atoms/warehouse-query-atoms" + +/** The editor's form. `projects` stays the typed text until it is saved. */ +export interface PromQlDraft { + query: string + projects: string + legend: string +} + +/** Project ids from the comma- or space-separated field; none means every project. */ +export const parsePromQlProjects = (text: string): ReadonlyArray => + text.split(/[\s,]+/u).filter((projectId) => projectId !== "") + +interface PromQlEditorPanelProps { + draft: PromQlDraft + onDraftChange: Dispatch> + onRunPreview: () => void + /** The draft differs from what the preview last ran. */ + stale: boolean + /** The preview's failure, e.g. Google's message for a query it refused. */ + error?: { title: string; message?: string } +} + +export function PromQlEditorPanel({ + draft, + onDraftChange, + onRunPreview, + stale, + error, +}: PromQlEditorPanelProps) { + const [search, setSearch] = useState("") + const deferredSearch = useDeferredValue(search.trim()) + const searchResult = useAtomValue( + deferredSearch === "" + ? disabledResultAtom() + : gcpMetricSearchResultAtom({ data: { search: deferredSearch } }), + ) + const metrics = Result.builder(searchResult) + .onSuccess((response) => (response as { metrics: ReadonlyArray }).metrics) + .orElse((): ReadonlyArray => []) + const searchHint = + deferredSearch === "" + ? "Type part of a metric name." + : Result.builder(searchResult) + .onError((searchError) => displayError(searchError).message) + .onSuccess(() => `No metric name contains "${deferredSearch}".`) + .orElse(() => "Searching...") + + const [starterError, setStarterError] = useState(null) + const [insertStarter, inserting] = useAsyncAction(async (metric: string) => { + setStarterError(null) + const exit = await Effect.runPromiseExit(getGcpMetricDescriptor(metric)) + if (Exit.isFailure(exit)) { + setStarterError(displayError(exit.cause).message) + return + } + const query = promQlStarterQuery(metric, exit.value) + onDraftChange((current) => ({ ...current, query })) + }) + + return ( +
+
+
+ + promql + + + Google Cloud Monitoring, live + +
+ {error && ( + + {error.title} + + )} +
+ +
+ + + Start from a metric: picking one replaces the query below + + + value={null} + onValueChange={(metric) => { + if (typeof metric === "string") void insertStarter(metric) + }} + > + setSearch(e.target.value)} + /> + + {metrics.length === 0 ? ( + {searchHint} + ) : ( + + {metrics.map((metric) => ( + + {metric} + + ))} + + )} + + + {starterError !== null && ( + {starterError} + )} + + +