Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
4d61ed6
feat(dashboards): chart Google Cloud metrics live with PromQL
JeremyFunk Oct 14, 2026
9d824ec
Merge remote-tracking branch 'origin/feat/gcp-promql-query' into feat…
JeremyFunk Oct 14, 2026
f962626
docs(gcp): the 500-series cap on live queries
JeremyFunk Oct 14, 2026
128dc95
Merge remote-tracking branch 'origin/feat/gcp-live-query-access' into…
JeremyFunk Oct 14, 2026
1849d85
docs(gcp): live queries follow the Key metrics naming
JeremyFunk Oct 14, 2026
d84c296
fix(dashboards): review fixes for PromQL widgets
JeremyFunk Oct 15, 2026
36f50ad
Merge remote-tracking branch 'origin/feat/gcp-live-query-access' into…
JeremyFunk Oct 15, 2026
7c45912
perf(web): load the PromQL widget module on first use, not at startup
JeremyFunk Oct 15, 2026
3372d50
feat(dashboards): PromQL starter queries keep each project apart
JeremyFunk Oct 15, 2026
24cd5c7
Merge remote-tracking branch 'origin/feat/gcp-live-query-access' into…
JeremyFunk Oct 15, 2026
bad06da
Merge branch 'feat/gcp-live-query-access' into feat/gcp-promql-widget
JeremyFunk Oct 15, 2026
d512231
Merge branch 'feat/gcp-live-query-access' into feat/gcp-promql-widget
JeremyFunk Oct 15, 2026
bc3e84b
Merge branch 'feat/gcp-live-query-access' into feat/gcp-promql-widget
JeremyFunk Oct 15, 2026
830d90a
Merge branch 'feat/gcp-live-query-access' into feat/gcp-promql-widget
JeremyFunk Oct 15, 2026
b4105f3
Merge branch 'feat/gcp-live-query-access' into feat/gcp-promql-widget
JeremyFunk Oct 15, 2026
b30cd15
Merge feat/gcp-live-query-access into feat/gcp-promql-widget
JeremyFunk Oct 15, 2026
7a655ed
Merge remote-tracking branch 'origin/feat/gcp-live-query-access' into…
JeremyFunk Oct 15, 2026
ba9ef96
Merge remote-tracking branch 'origin/feat/gcp-live-query-access' into…
JeremyFunk Oct 15, 2026
a06cada
fix(ci): raise the web bundle budget for #1480
JeremyFunk Oct 15, 2026
75e4b14
fix(ci): 1 KB headroom on the bundle budget; CI measures the PR merge…
JeremyFunk Oct 15, 2026
27385c0
fix(ci): resolve the bundle budget merge
JeremyFunk Oct 15, 2026
65b0807
Merge branch 'feat/gcp-live-query-access' into feat/gcp-promql-widget
JeremyFunk Oct 15, 2026
bac2dcc
Merge branch 'feat/gcp-live-query-access' into feat/gcp-promql-widget
JeremyFunk Oct 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions apps/api/src/routes/v2/share.http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,16 @@ export const HttpV2SharePublicLive = HttpApiBuilder.group(MapleApiV2, "sharePubl
})
})

const promQlQueries = document.widgets.flatMap((widget) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

promQlQueries skips sparkline sources, so a variable can escape the guard

F1 · Warning · security

The share executes widget.display?.sparkline?.dataSource when the request asks for source: "sparkline" (DashboardWidgetDataService.ts:267, and the source is whatever the unauthenticated caller sent at share.http.ts:349), but this list walks widget.dataSource alone, so a variable referenced only by a sparkline's PromQL never enters promQlNames and promQlUnsafe stays false (share-variables.ts:150). The viewer's value then needs only to be one of the options the warehouse listed, and a telemetry value containing a quote reaches the promql branch of interpolateString unescaped — plainly, or as every option of an All joined with | — rewriting the author's matcher on a public link. Collect both data sources here.

Build the list from `widget.dataSource` and `widget.display?.sparkline?.dataSource` alike before mapping out the query strings, and add a sparkline-only reference to the `share-variables.test.ts` case.
🤖 Prompt to fix with an AI agent
In `apps/api/src/routes/v2/share.http.ts:305-313`: `promQlQueries` skips sparkline sources, so a variable can escape the guard.

The share executes `widget.display?.sparkline?.dataSource` when the request asks for `source: "sparkline"` (`DashboardWidgetDataService.ts:267`, and the source is whatever the unauthenticated caller sent at `share.http.ts:349`), but this list walks `widget.dataSource` alone, so a variable referenced only by a sparkline's PromQL never enters `promQlNames` and `promQlUnsafe` stays false (`share-variables.ts:150`). The viewer's value then needs only to be one of the options the warehouse listed, and a telemetry value containing a quote reaches the `promql` branch of `interpolateString` unescaped — plainly, or as every option of an All joined with `|` — rewriting the author's matcher on a public link. Collect both data sources here.

Suggested fix: Build the list from `widget.dataSource` and `widget.display?.sparkline?.dataSource` alike before mapping out the query strings, and add a sparkline-only reference to the `share-variables.test.ts` case.

Verify the problem exists at that location before changing it, and keep the fix to those lines.

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.
Expand All @@ -312,6 +322,7 @@ export const HttpV2SharePublicLive = HttpApiBuilder.group(MapleApiV2, "sharePubl
payload.variableValues ?? {},
queryOptions,
whereClauses,
promQlQueries,
)

const results: Array<ShareWidgetDataOutcome> = []
Expand Down
5 changes: 3 additions & 2 deletions apps/landing/src/content/docs/dashboards/build-dashboards.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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).

Expand Down
24 changes: 24 additions & 0 deletions apps/landing/src/content/docs/integrations/gcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 3 additions & 2 deletions apps/web/perf/check-bundle-budget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,9 @@ 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.
const maxGzipBytes = 707.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.
const maxGzipBytes = 710 * 1024
const budgetLabel = `${(maxGzipBytes / 1024).toFixed(1)} KB`

// Anything lazy-only: chat, replay, and every dev-only lab surface. The
Expand Down
67 changes: 67 additions & 0 deletions apps/web/src/api/warehouse/gcp-promql.ts
Original file line number Diff line number Diff line change
@@ -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 } })
}),
)
})
Original file line number Diff line number Diff line change
@@ -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<string> =>
text.split(/[\s,]+/u).filter((projectId) => projectId !== "")

interface PromQlEditorPanelProps {
draft: PromQlDraft
onDraftChange: Dispatch<SetStateAction<PromQlDraft>>
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<string> }).metrics)
.orElse((): ReadonlyArray<string> => [])
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<string | null>(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 (
<div className="space-y-3">
<div className="border rounded-md">
<div className="flex items-center gap-2 px-3 py-2 bg-muted/30">
<Badge
variant="outline"
className="font-mono text-2xs text-white border-0 shrink-0 bg-primary/80"
>
promql
</Badge>
<span className="text-2xs text-muted-foreground shrink-0">
Google Cloud Monitoring, live
</span>
<div className="flex-1" />
{error && (
<span
className={cn("text-2xs truncate", TONE_TEXT.crit)}
title={error.message ?? error.title}
>
{error.title}
</span>
)}
</div>

<div className="p-3 space-y-3">
<Field className="gap-1.5">
<FieldLabel className="font-normal text-2xs text-muted-foreground">
Start from a metric: picking one replaces the query below
</FieldLabel>
<Combobox<string | null>
value={null}
onValueChange={(metric) => {
if (typeof metric === "string") void insertStarter(metric)
}}
>
<ComboboxInput
placeholder={
inserting
? "Writing a starter query..."
: "Search metrics, e.g. run.googleapis.com"
}
className="h-8 text-xs"
disabled={inserting}
onChange={(e) => setSearch(e.target.value)}
/>
<ComboboxContent>
{metrics.length === 0 ? (
<EmptyMessage className="px-3 py-4">{searchHint}</EmptyMessage>
) : (
<ComboboxList>
{metrics.map((metric) => (
<ComboboxItem
key={metric}
value={metric}
className="font-mono text-xs"
>
{metric}
</ComboboxItem>
))}
</ComboboxList>
)}
</ComboboxContent>
</Combobox>
{starterError !== null && (
<span className={cn("text-2xs", TONE_TEXT.crit)}>{starterError}</span>
)}
</Field>

<textarea
aria-label="PromQL query"
value={draft.query}
onChange={(e) => onDraftChange({ ...draft, query: e.target.value })}
spellCheck={false}
placeholder={'sum by (service) (rate({"run.googleapis.com/request_count"}[5m]))'}
className="w-full bg-transparent font-mono text-xs leading-5 border border-border rounded-sm px-2 py-1.5 min-h-[120px] resize-y outline-none focus:ring-1 focus:ring-foreground/20"
/>
{error?.message && (
<p className="font-mono text-2xs text-muted-foreground whitespace-pre-wrap break-words">
{error.message}
</p>
)}

<div className="grid grid-cols-2 gap-3">
<Field className="gap-1.5">
<FieldLabel className="font-normal text-2xs text-muted-foreground">
Projects (empty: every project, up to 20)
</FieldLabel>
<Input
value={draft.projects}
onChange={(e) => onDraftChange({ ...draft, projects: e.target.value })}
placeholder="shop-prod, shop-staging"
className="h-8 font-mono text-xs"
/>
</Field>
<Field className="gap-1.5">
<FieldLabel className="font-normal text-2xs text-muted-foreground">
Legend (labels as {"{{label}}"})
</FieldLabel>
<Input
value={draft.legend}
onChange={(e) => onDraftChange({ ...draft, legend: e.target.value })}
placeholder="{{service}}"
className="h-8 font-mono text-xs"
/>
</Field>
</div>
</div>
</div>

<div className="flex items-center gap-3">
<Button size="sm" onClick={onRunPreview} disabled={draft.query.trim() === ""}>
Run preview
</Button>
{stale && (
<span className={cn("text-2xs", TONE_TEXT.warn)}>
Edited since the last run: the preview applies on Run preview
</span>
)}
<span className="text-2xs text-muted-foreground ml-auto">
Google bills the series a read returns to your project.
</span>
</div>
</div>
)
}
Loading
Loading