From c36c091eb2a119667c71c2a4a392135395997a2e Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Thu, 15 Oct 2026 19:45:03 +0200 Subject: [PATCH 1/4] feat(gcp): tabbed Google Cloud dashboard with project, region and workload filters The Google Cloud template put every chart on one page. It now groups them into one tab per service (Cloud Run, Cloud Functions, GKE, Compute Engine, Cloud SQL, Pub/Sub, Load Balancing), fills out the thin tabs, and filters by Project, Region and Workload. Query variables could only list values from traces and logs, which a metrics-only integration never writes. A new `metric` variable source lists a data-point or resource attribute's values from named metrics of one type, read from the raw metric table like the query-builder autocomplete. The option query, the board's provider, the share path, the variables dialog and the v2 API all take it. Template previews now show the root canvas and each section's first tab, since a widget's layout is relative to its tab. --- apps/web/src/api/warehouse/metrics.ts | 5 +- .../config/variables-manager-dialog.tsx | 95 ++- .../dashboard-variables-context.tsx | 13 +- .../src/dashboard-templates/helpers.ts | 4 + .../src/dashboard-templates/index.test.ts | 86 ++- .../backend/src/dashboard-templates/index.ts | 33 +- .../infrastructure/gcp.test.ts | 38 ++ .../dashboard-templates/infrastructure/gcp.ts | 574 ++++++++++++------ packages/domain/src/http/v2/dashboards.ts | 14 + packages/domain/src/query-engine.ts | 7 +- .../src/ch/queries/attribute-keys.test.ts | 17 + .../src/ch/queries/attribute-keys.ts | 16 +- .../src/dashboard-variables/options.ts | 10 + .../query-engine/src/runtime/query-engine.ts | 11 +- .../widgets/src/dashboard/shared/variables.ts | 11 + 15 files changed, 695 insertions(+), 239 deletions(-) diff --git a/apps/web/src/api/warehouse/metrics.ts b/apps/web/src/api/warehouse/metrics.ts index 58c98fc3fd..bc7fa21286 100644 --- a/apps/web/src/api/warehouse/metrics.ts +++ b/apps/web/src/api/warehouse/metrics.ts @@ -248,7 +248,9 @@ const GetMetricAttributeValuesInputSchema = Schema.Struct({ endTime: Schema.optional(WarehouseDateTimeString), attributeKey: Schema.String, metricName: Schema.optional(Schema.String), + metricNames: Schema.optional(Schema.Array(Schema.String)), metricType: Schema.optional(MetricTypeSchema), + scope: Schema.optional(Schema.Literals(["metric", "resource"])), }) export type GetMetricAttributeValuesInput = Schema.Schema.Type @@ -283,9 +285,10 @@ const getMetricAttributeValuesEffect = Effect.fn("QueryEngine.getMetricAttribute query: { kind: "attributeValues" as const, source: "metrics" as const, - scope: "metric" as const, + scope: input.scope ?? "metric", attributeKey: input.attributeKey, metricName: input.metricName, + metricNames: input.metricNames, metricType: input.metricType, }, }), diff --git a/apps/web/src/components/dashboard-builder/config/variables-manager-dialog.tsx b/apps/web/src/components/dashboard-builder/config/variables-manager-dialog.tsx index 6e63962f74..cd9176cf53 100644 --- a/apps/web/src/components/dashboard-builder/config/variables-manager-dialog.tsx +++ b/apps/web/src/components/dashboard-builder/config/variables-manager-dialog.tsx @@ -13,6 +13,11 @@ import { Textarea } from "@maple/ui/components/ui/textarea" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@maple/ui/components/ui/select" import { Schema } from "effect" import { DashboardVariableName } from "@maple/domain/http" +import { + QUERY_BUILDER_METRIC_TYPES, + toQueryBuilderMetricType, + type QueryBuilderMetricType, +} from "@maple/query-model" import { ArrowDownIcon, ArrowUpIcon, PencilIcon, PlusIcon, TrashIcon } from "@/components/icons" import type { DashboardVariable } from "@/components/dashboard-builder/types" @@ -30,6 +35,13 @@ const FACET_LABELS: Record = { log_severity: "Log severity", } satisfies Record +const METRIC_TYPE_LABELS = { + sum: "Sum", + gauge: "Gauge", + histogram: "Histogram", + exponential_histogram: "Exponential histogram", +} satisfies Record + const TYPE_LABELS: Record = { query: "Query", custom: "Custom", @@ -49,9 +61,15 @@ function sourceSummary(variable: DashboardVariable): string { if (variable.type === "textbox") { return "Free text" } - return variable.source.kind === "facet" - ? FACET_LABELS[variable.source.facet] - : `${variable.source.scope} attribute: ${variable.source.attributeKey || "…"}` + const source = variable.source + switch (source.kind) { + case "facet": + return FACET_LABELS[source.facet] + case "attribute": + return `${source.scope} attribute: ${source.attributeKey || "…"}` + case "metric": + return `${source.scope} attribute of ${source.metricNames.join(", ")}: ${source.attributeKey || "…"}` + } } function newVariable(): DashboardVariable { @@ -88,9 +106,12 @@ function draftError(draft: DashboardVariable, index: number, drafts: DashboardVa if (drafts.some((other, i) => i !== index && other.name === draft.name)) { return `Another variable is already named "${draft.name}".` } - if (draft.type === "query" && draft.source.kind === "attribute" && !draft.source.attributeKey.trim()) { + if (draft.type === "query" && draft.source.kind !== "facet" && !draft.source.attributeKey.trim()) { return "Pick the attribute key to list values for." } + if (draft.type === "query" && draft.source.kind === "metric" && draft.source.metricNames.some((name) => name === "")) { + return "Name at least one metric to list values from." + } return null } @@ -445,23 +466,33 @@ function QuerySourceFields({ ), "attribute:span": "Span attribute…", "attribute:resource": "Resource attribute…", + "metric:metric": "Metric attribute…", + "metric:resource": "Metric resource attribute…", }} - value={source.kind === "facet" ? `facet:${source.facet}` : `attribute:${source.scope}`} + value={source.kind === "facet" ? `facet:${source.facet}` : `${source.kind}:${source.scope}`} onValueChange={(value) => { if (typeof value !== "string") return const [kind, detail] = value.split(":") + const attributeKey = source.kind === "facet" ? "" : source.attributeKey if (kind === "facet") { onChange({ ...variable, source: { kind: "facet", facet: detail as QueryFacet }, }) - } else if (detail === "span" || detail === "resource") { + } else if (kind === "attribute" && (detail === "span" || detail === "resource")) { + onChange({ + ...variable, + source: { kind: "attribute", scope: detail, attributeKey }, + }) + } else if (kind === "metric" && (detail === "metric" || detail === "resource")) { onChange({ ...variable, source: { - kind: "attribute", + kind: "metric", scope: detail, - attributeKey: source.kind === "attribute" ? source.attributeKey : "", + attributeKey, + metricNames: source.kind === "metric" ? source.metricNames : [""], + metricType: source.kind === "metric" ? source.metricType : "gauge", }, }) } @@ -478,10 +509,12 @@ function QuerySourceFields({ ))} Span attribute… Resource attribute… + Metric attribute… + Metric resource attribute… - {source.kind === "attribute" && ( + {source.kind !== "facet" && ( Attribute key )} + {source.kind === "metric" && ( + <> + + Metrics + + onChange({ + ...variable, + source: { + ...source, + // Empty entries stay while typing ("a, "); validation flags them. + metricNames: event.target.value.split(",").map((name) => name.trim()), + }, + }) + } + /> + + + Metric type + + + + )} ) } diff --git a/apps/web/src/components/dashboard-builder/dashboard-variables-context.tsx b/apps/web/src/components/dashboard-builder/dashboard-variables-context.tsx index 74a4b1cabd..bfcd963eff 100644 --- a/apps/web/src/components/dashboard-builder/dashboard-variables-context.tsx +++ b/apps/web/src/components/dashboard-builder/dashboard-variables-context.tsx @@ -14,6 +14,7 @@ import { createContext, useCallback, useContext, useMemo, type ReactNode } from import { Atom, Result, useAtomValue } from "@/lib/effect-atom" import { getLogsFacetValuesResultAtom, + getMetricAttributeValuesResultAtom, getResourceAttributeValuesResultAtom, getSpanAttributeValuesResultAtom, getTracesFacetValuesResultAtom, @@ -83,7 +84,17 @@ function readVariableOptions( if (query.kind === "attributeValues") { const atom = - query.scope === "resource" + query.source === "metrics" + ? getMetricAttributeValuesResultAtom({ + data: { + ...window, + attributeKey: query.attributeKey, + metricNames: query.metricNames, + metricType: query.metricType, + scope: query.scope === "resource" ? "resource" : "metric", + }, + }) + : query.scope === "resource" ? getResourceAttributeValuesResultAtom({ data: { ...window, attributeKey: query.attributeKey }, }) diff --git a/packages/backend/src/dashboard-templates/helpers.ts b/packages/backend/src/dashboard-templates/helpers.ts index 56e603bbce..a3a6fef09c 100644 --- a/packages/backend/src/dashboard-templates/helpers.ts +++ b/packages/backend/src/dashboard-templates/helpers.ts @@ -213,6 +213,8 @@ export function buildPortableDashboard(opts: { tags?: readonly string[] timeRange?: string widgets: WidgetDef[] + sections?: PortableDashboardDocument["sections"] + variables?: PortableDashboardDocument["variables"] }): PortableDashboardDocument { return decodePortableDashboard({ name: opts.name, @@ -220,6 +222,8 @@ export function buildPortableDashboard(opts: { ...(opts.tags && opts.tags.length > 0 && { tags: opts.tags }), timeRange: { type: "relative", value: opts.timeRange ?? "1h" }, widgets: opts.widgets, + ...(opts.sections && { sections: opts.sections }), + ...(opts.variables && { variables: opts.variables }), }) } diff --git a/packages/backend/src/dashboard-templates/index.test.ts b/packages/backend/src/dashboard-templates/index.test.ts index 3ebd83614b..74021020fa 100644 --- a/packages/backend/src/dashboard-templates/index.test.ts +++ b/packages/backend/src/dashboard-templates/index.test.ts @@ -1,15 +1,50 @@ import { describe, expect, it } from "@effect/vitest" -import { DashboardTemplatePreviewKind } from "@maple/domain/http" +import { DashboardTemplatePreviewKind, type PortableDashboardDocument } from "@maple/domain/http" import { DASHBOARD_TEMPLATES, buildTemplatePreview, listTemplateMetadata } from "./index" +import type { WidgetDef } from "./types" const PREVIEW_KINDS = new Set(DashboardTemplatePreviewKind.literals) +/** The root canvas plus each section's first tab: what a viewer sees on opening. */ +const firstTabWidgets = ({ widgets, sections = [] }: PortableDashboardDocument) => + widgets.filter( + (w) => + w.sectionId === undefined || + w.tabId === sections.find((section) => section.id === w.sectionId)?.tabs[0]?.id, + ) + +function expectGaplessRows(templateId: string, widgets: ReadonlyArray) { + const rows = new Map>() + for (const widget of widgets) { + rows.set(widget.layout.y, [...(rows.get(widget.layout.y) ?? []), widget]) + } + + let expectedY = 0 + for (const [y, row] of [...rows].sort(([a], [b]) => a - b)) { + expect(y, `${templateId}: row starts at ${y}, expected ${expectedY}`).toBe(expectedY) + + const heights = new Set(row.map((w) => w.layout.h)) + expect(heights.size, `${templateId}: row y=${y} mixes heights`).toBe(1) + + const width = row.reduce((sum, w) => sum + w.layout.w, 0) + expect(width, `${templateId}: row y=${y} spans ${width} of 12 columns`).toBe(12) + + const spans = [...row].sort((a, b) => a.layout.x - b.layout.x) + spans.reduce((edge, w) => { + expect(w.layout.x, `${templateId}: row y=${y} overlaps at x=${w.layout.x}`).toBe(edge) + return edge + w.layout.w + }, 0) + + expectedY += row[0].layout.h + } +} + describe("dashboard template previews", () => { - it("derives one preview widget per built widget, for every template", () => { + it("derives one preview widget per widget on the first tab, for every template", () => { for (const template of DASHBOARD_TEMPLATES) { const preview = buildTemplatePreview(template) const built = template.build({}) - expect(preview.length, template.id).toBe(built.widgets.length) + expect(preview.length, template.id).toBe(firstTabWidgets(built).length) for (const widget of preview) { expect(PREVIEW_KINDS.has(widget.kind), `${template.id}: ${widget.kind}`).toBe(true) expect(widget.w, template.id).toBeGreaterThan(0) @@ -26,44 +61,33 @@ describe("dashboard template previews", () => { // tiles stacked on top of each other. it("lays every template out as gapless, non-overlapping full-width rows", () => { for (const template of DASHBOARD_TEMPLATES) { - const widgets = template.build({}).widgets - if (widgets.length === 0) continue - - const rows = new Map() - for (const widget of widgets) { - const row = rows.get(widget.layout.y) ?? [] - row.push(widget) - rows.set(widget.layout.y, row) - } - - let expectedY = 0 - for (const [y, row] of [...rows].sort(([a], [b]) => a - b)) { - expect(y, `${template.id}: row starts at ${y}, expected ${expectedY}`).toBe(expectedY) - - const heights = new Set(row.map((w) => w.layout.h)) - expect(heights.size, `${template.id}: row y=${y} mixes heights`).toBe(1) - - const width = row.reduce((sum, w) => sum + w.layout.w, 0) - expect(width, `${template.id}: row y=${y} spans ${width} of 12 columns`).toBe(12) - - const spans = [...row].sort((a, b) => a.layout.x - b.layout.x) - spans.reduce((edge, w) => { - expect(w.layout.x, `${template.id}: row y=${y} overlaps at x=${w.layout.x}`).toBe(edge) - return edge + w.layout.w - }, 0) - - expectedY += row[0].layout.h + // `layout.y` is relative to the widget's container: the root canvas or one tab. + const containers = new Map>() + for (const widget of template.build({}).widgets) { + const key = `${widget.sectionId ?? ""}/${widget.tabId ?? ""}` + containers.set(key, [...(containers.get(key) ?? []), widget]) } + for (const widgets of containers.values()) expectGaplessRows(template.id, widgets) } }) + it("stacks a tabbed template's preview below the root canvas, first tab only", () => { + const gcp = DASHBOARD_TEMPLATES.find((t) => t.id === "gcp")! + const built = gcp.build({}) + const preview = buildTemplatePreview(gcp) + const firstTab = built.sections?.[0]?.tabs[0]?.id + expect(preview.map((w) => w.title)).toEqual( + built.widgets.filter((w) => w.tabId === firstTab).map((w) => w.display.title), + ) + }) + it("exposes previews through listTemplateMetadata", () => { const metadata = listTemplateMetadata() expect(metadata.length).toBe(DASHBOARD_TEMPLATES.length) for (const meta of metadata) { const template = DASHBOARD_TEMPLATES.find((t) => t.id === meta.id) expect(template).toBeDefined() - expect(meta.preview.length, meta.id).toBe(template!.build({}).widgets.length) + expect(meta.preview.length, meta.id).toBe(firstTabWidgets(template!.build({})).length) } }) diff --git a/packages/backend/src/dashboard-templates/index.ts b/packages/backend/src/dashboard-templates/index.ts index fd0f3a4378..3151453e2a 100644 --- a/packages/backend/src/dashboard-templates/index.ts +++ b/packages/backend/src/dashboard-templates/index.ts @@ -81,15 +81,32 @@ function previewKindForWidget(visualization: string, chartId: unknown): Dashboar return widgetTypeByVisualization(visualization)?.panelType ?? "line" } +/** + * What a viewer first sees: the root canvas, then each section's first tab, stacked + * in order. A widget's `layout.y` is relative to its container, so each container + * starts below the previous one. + */ export function buildTemplatePreview(template: TemplateDefinition): TemplatePreviewWidget[] { - return template.build({}).widgets.map((w) => ({ - x: w.layout.x, - y: w.layout.y, - w: w.layout.w, - h: w.layout.h, - kind: previewKindForWidget(w.visualization, w.display.chartId), - title: w.display.title ?? "", - })) + const { widgets, sections = [] } = template.build({}) + const containers = [ + widgets.filter((w) => w.sectionId === undefined), + ...sections.map((section) => + widgets.filter((w) => w.sectionId === section.id && w.tabId === section.tabs[0]?.id), + ), + ] + let top = 0 + return containers.flatMap((container) => { + const offset = top + top += Math.max(0, ...container.map((w) => w.layout.y + w.layout.h)) + return container.map((w) => ({ + x: w.layout.x, + y: w.layout.y + offset, + w: w.layout.w, + h: w.layout.h, + kind: previewKindForWidget(w.visualization, w.display.chartId), + title: w.display.title ?? "", + })) + }) } // Previews are derived from the (pure, deterministic) template builds; compute once, on first use. diff --git a/packages/backend/src/dashboard-templates/infrastructure/gcp.test.ts b/packages/backend/src/dashboard-templates/infrastructure/gcp.test.ts index 46758b8115..1b58ca45d3 100644 --- a/packages/backend/src/dashboard-templates/infrastructure/gcp.test.ts +++ b/packages/backend/src/dashboard-templates/infrastructure/gcp.test.ts @@ -80,4 +80,42 @@ describe("Google Cloud dashboard template", () => { } expect(checked).toBe(widgets.length) }) + + it("lists every filter's values from stored gauges that carry the filtered attribute", () => { + const { variables = [], widgets } = gcpTemplate.build({}) + for (const variable of variables) { + const source = variable.type === "query" ? variable.source : undefined + expect(source?.kind, variable.name).toBe("metric") + if (source?.kind !== "metric") continue + const { metricNames, metricType, scope, attributeKey } = source + expect(scope, variable.name).toBe("resource") + for (const name of metricNames) { + const stored = STORED.get(name) + expect(stored?.metricType, `${variable.name}: ${name}`).toBe(metricType) + expect(Object.keys(stored?.rows[0]?.resource_attributes ?? {}), `${variable.name}: ${name}`).toContain( + attributeKey, + ) + } + } + + // A widget filters on a variable only where its own series carry the attribute, so + // picking a region never empties a chart of global resources. + const declared = new Set(variables.map((variable) => variable.name)) + for (const widget of widgets) { + if (widget.dataSource.kind !== "query") continue + for (const query of widget.dataSource.queries) { + for (const [, name] of query.whereClause.matchAll(/\$([A-Za-z]\w*)/g)) { + expect(declared, `${widget.id}: $${name}`).toContain(name) + const variable = variables.find((candidate) => candidate.name === name) + if (variable?.type !== "query" || variable.source.kind !== "metric") continue + if (query.dataSource !== "metrics") continue + const stored = STORED.get(query.metricName) + expect( + Object.keys(stored?.rows[0]?.resource_attributes ?? {}), + `${widget.id}: $${name}`, + ).toContain(variable.source.attributeKey) + } + } + } + }) }) diff --git a/packages/backend/src/dashboard-templates/infrastructure/gcp.ts b/packages/backend/src/dashboard-templates/infrastructure/gcp.ts index a87bec5703..2a459d9674 100644 --- a/packages/backend/src/dashboard-templates/infrastructure/gcp.ts +++ b/packages/backend/src/dashboard-templates/infrastructure/gcp.ts @@ -1,11 +1,11 @@ -import { GCP_METRIC_GROUPS } from "@maple/domain/gcp-metrics" +import { GCP_METRIC_GROUPS, type GcpMetricGroup } from "@maple/domain/gcp-metrics" +import type { DashboardVariable } from "@maple/domain/http" import { CHART_DISPLAY_AREA, CHART_DISPLAY_BAR, CHART_DISPLAY_LINE, buildPortableDashboard, combineWhere, - escapeMetricStringLiteral, metricsTimeseries, paramKey, paramValue, @@ -20,6 +20,9 @@ import type { TemplateDefinition, WidgetDef } from "@maple/backend/dashboard-tem // by the `quantile` attribute. Workloads chart by `service.name`. A chart grouped by an attribute // keeps one service's value per group, so those are used only where `service.name` is the // constant `gcp/` (see `gcpResourceAttributes`). +const GROUP_BY_METRIC = new Map( + GCP_METRIC_GROUPS.flatMap((group) => group.metrics.map((metric) => [metric.name, group] as const)), +) const STORED = new Map( GCP_METRIC_GROUPS.flatMap((group) => group.metrics.map((metric) => [metric.name, metric] as const)), ) @@ -36,6 +39,47 @@ const WORKLOAD = "service.name" const label = (name: string) => `resource.gcp.resource.labels.${name}` const P95 = `attr.quantile = "0.95"` +// `cloud.region` is set only for resources Cloud Monitoring labels with a location. +const isRegional = (group: GcpMetricGroup) => + group.resourceLabels.some((name) => name === "location" || name === "region" || name === "zone") + +// Resource types whose `service.name` is a workload rather than `gcp/`. +const WORKLOAD_RESOURCES = new Set(["cloud_run_revision", "cloud_function", "k8s_container", "gce_instance"]) + +/** + * A variable listing a resource attribute's values from one gauge per resource type, so every + * project, region or workload the poller has stored shows up. Gauges, because only they live in + * one table across every group (distributions are stored as quantile gauges). + */ +function resourceVariable(opts: { + readonly name: string + readonly label: string + readonly attributeKey: string + readonly groups: ReadonlyArray + readonly defaultValue?: string +}): DashboardVariable { + return { + name: opts.name, + label: opts.label, + type: "query", + includeAll: true, + ...(opts.defaultValue && { defaultValue: opts.defaultValue }), + source: { + kind: "metric", + metricType: "gauge", + metricNames: opts.groups.flatMap( + (group) => group.metrics.find((metric) => metric.kind !== "sum")?.name ?? [], + ), + scope: "resource", + attributeKey: opts.attributeKey, + }, + } +} + +const PROJECT_FILTER = `resource.cloud.account.id = "$project"` +const REGION_FILTER = `resource.cloud.region = "$region"` +const WORKLOAD_FILTER = `service.name = "$workload"` + interface Chart { readonly id: string readonly title: string @@ -45,207 +89,346 @@ interface Chart { readonly display?: WidgetDef["display"] } -const CHARTS: ReadonlyArray = [ - // Cloud Run, and Cloud Functions (2nd gen), which run on it. - { - id: "run-requests", - title: "Cloud Run Requests by Service", - metric: "gcp.run.request_count", - by: WORKLOAD, - }, - { - id: "run-errors", - title: "Cloud Run 5xx Responses by Service", - metric: "gcp.run.request_count", - by: WORKLOAD, - where: `attr.response_code_class = "5xx"`, - display: CHART_DISPLAY_BAR, - }, - { - id: "run-latency", - title: "Cloud Run Request Latency p95 by Service", - metric: "gcp.run.request_latencies", - by: WORKLOAD, - where: P95, - }, - { - id: "run-instances", - title: "Cloud Run Active Instances by Service", - metric: "gcp.run.container.instance_count", - by: WORKLOAD, - where: `attr.state = "active"`, - }, - { - id: "run-cpu", - title: "Cloud Run CPU Utilization p95 by Service", - metric: "gcp.run.container.cpu.utilizations", - by: WORKLOAD, - where: P95, - }, - { - id: "run-memory", - title: "Cloud Run Memory Utilization p95 by Service", - metric: "gcp.run.container.memory.utilizations", - by: WORKLOAD, - where: P95, - }, - - { - id: "functions-executions", - title: "Cloud Functions (1st gen) Executions by Function", - metric: "gcp.cloudfunctions.function.execution_count", - by: WORKLOAD, - }, - { - id: "functions-execution-time", - title: "Cloud Functions (1st gen) Execution Time p95 by Function", - metric: "gcp.cloudfunctions.function.execution_times", - by: WORKLOAD, - where: P95, - }, - - { - id: "gke-cpu", - title: "GKE CPU Limit Utilization by Container", - metric: "gcp.kubernetes.container.cpu.limit_utilization", - by: WORKLOAD, - }, - { - // The kernel reclaims evictable memory under pressure, so non-evictable is the one to watch. - id: "gke-memory", - title: "GKE Memory Limit Utilization by Container", - metric: "gcp.kubernetes.container.memory.limit_utilization", - by: WORKLOAD, - where: `attr.memory_type = "non-evictable"`, - }, - { - id: "gke-restarts", - title: "GKE Container Restarts", - metric: "gcp.kubernetes.container.restart_count", - by: WORKLOAD, - display: CHART_DISPLAY_BAR, - }, - { - id: "gke-node-cpu", - title: "GKE Node CPU Utilization by Cluster", - metric: "gcp.kubernetes.node.cpu.allocatable_utilization", - by: "resource.k8s.cluster.name", - }, - - { - id: "compute-cpu", - title: "Compute Engine CPU Utilization by Instance", - metric: "gcp.compute.instance.cpu.utilization", - by: WORKLOAD, - }, - { - id: "compute-network-sent", - title: "Compute Engine Network Bytes Sent by Instance", - metric: "gcp.compute.instance.network.sent_bytes_count", - by: WORKLOAD, - }, +interface Tab { + readonly id: string + readonly title: string + readonly charts: ReadonlyArray +} +const TABS: ReadonlyArray = [ { - id: "sql-cpu", - title: "Cloud SQL CPU Utilization by Instance", - metric: "gcp.cloudsql.database.cpu.utilization", - by: label("database_id"), - }, - { - id: "sql-memory", - title: "Cloud SQL Memory Utilization by Instance", - metric: "gcp.cloudsql.database.memory.utilization", - by: label("database_id"), - }, - { - id: "sql-connections", - title: "Cloud SQL Connections (MySQL, SQL Server)", - metric: "gcp.cloudsql.database.network.connections", - by: label("database_id"), - }, - { - id: "sql-connections-postgres", - title: "Cloud SQL Connections (PostgreSQL)", - metric: "gcp.cloudsql.database.postgresql.num_backends", - by: label("database_id"), + // Cloud Run, and Cloud Functions (2nd gen), which run on it. + id: "cloud-run", + title: "Cloud Run", + charts: [ + { + id: "run-requests", + title: "Requests by Service", + metric: "gcp.run.request_count", + by: WORKLOAD, + }, + { + id: "run-errors", + title: "5xx Responses by Service", + metric: "gcp.run.request_count", + by: WORKLOAD, + where: `attr.response_code_class = "5xx"`, + display: CHART_DISPLAY_BAR, + }, + { + id: "run-latency", + title: "Request Latency p95 by Service", + metric: "gcp.run.request_latencies", + by: WORKLOAD, + where: P95, + }, + { + id: "run-instances", + title: "Active Instances by Service", + metric: "gcp.run.container.instance_count", + by: WORKLOAD, + where: `attr.state = "active"`, + }, + { + id: "run-cpu", + title: "CPU Utilization p95 by Service", + metric: "gcp.run.container.cpu.utilizations", + by: WORKLOAD, + where: P95, + }, + { + id: "run-memory", + title: "Memory Utilization p95 by Service", + metric: "gcp.run.container.memory.utilizations", + by: WORKLOAD, + where: P95, + }, + ], }, - { - id: "pubsub-backlog", - title: "Pub/Sub Unacknowledged Messages by Subscription", - metric: "gcp.pubsub.subscription.num_undelivered_messages", - by: label("subscription_id"), + id: "cloud-functions", + title: "Cloud Functions (1st gen)", + charts: [ + { + id: "functions-executions", + title: "Executions by Function", + metric: "gcp.cloudfunctions.function.execution_count", + by: WORKLOAD, + }, + { + id: "functions-execution-time", + title: "Execution Time p95 by Function", + metric: "gcp.cloudfunctions.function.execution_times", + by: WORKLOAD, + where: P95, + }, + { + id: "functions-instances", + title: "Active Instances by Function", + metric: "gcp.cloudfunctions.function.instance_count", + by: WORKLOAD, + where: `attr.state = "active"`, + }, + { + id: "functions-memory", + title: "Peak Memory per Execution p95 by Function", + metric: "gcp.cloudfunctions.function.user_memory_bytes", + by: WORKLOAD, + where: P95, + }, + ], }, { - id: "pubsub-oldest-unacked", - title: "Pub/Sub Oldest Unacknowledged Message Age", - metric: "gcp.pubsub.subscription.oldest_unacked_message_age", - by: label("subscription_id"), + id: "gke", + title: "GKE", + charts: [ + { + id: "gke-cpu", + title: "CPU Limit Utilization by Container", + metric: "gcp.kubernetes.container.cpu.limit_utilization", + by: WORKLOAD, + }, + { + // The kernel reclaims evictable memory under pressure, so non-evictable is the one to watch. + id: "gke-memory", + title: "Memory Limit Utilization by Container", + metric: "gcp.kubernetes.container.memory.limit_utilization", + by: WORKLOAD, + where: `attr.memory_type = "non-evictable"`, + }, + { + id: "gke-restarts", + title: "Container Restarts", + metric: "gcp.kubernetes.container.restart_count", + by: WORKLOAD, + display: CHART_DISPLAY_BAR, + }, + { + id: "gke-memory-used", + title: "Memory in Use by Container", + metric: "gcp.kubernetes.container.memory.used_bytes", + by: WORKLOAD, + where: `attr.memory_type = "non-evictable"`, + }, + { + id: "gke-node-cpu", + title: "Node CPU Utilization by Cluster", + metric: "gcp.kubernetes.node.cpu.allocatable_utilization", + by: "resource.k8s.cluster.name", + }, + { + id: "gke-node-memory", + title: "Node Memory Utilization by Cluster", + metric: "gcp.kubernetes.node.memory.allocatable_utilization", + by: "resource.k8s.cluster.name", + where: `attr.memory_type = "non-evictable"`, + }, + ], }, - - // Global external Application Load Balancers. { - id: "lb-requests", - title: "Load Balancer Requests by URL Map", - metric: "gcp.loadbalancing.https.request_count", - by: label("url_map_name"), + id: "compute-engine", + title: "Compute Engine", + charts: [ + { + id: "compute-cpu", + title: "CPU Utilization by Instance", + metric: "gcp.compute.instance.cpu.utilization", + by: WORKLOAD, + }, + { + id: "compute-memory", + title: "Memory in Use by Instance (E2)", + metric: "gcp.compute.instance.memory.balloon.ram_used", + by: WORKLOAD, + }, + { + id: "compute-network-received", + title: "Network Bytes Received by Instance", + metric: "gcp.compute.instance.network.received_bytes_count", + by: WORKLOAD, + }, + { + id: "compute-network-sent", + title: "Network Bytes Sent by Instance", + metric: "gcp.compute.instance.network.sent_bytes_count", + by: WORKLOAD, + }, + { + id: "compute-disk-read", + title: "Disk Bytes Read by Instance", + metric: "gcp.compute.instance.disk.read_bytes_count", + by: WORKLOAD, + }, + { + id: "compute-disk-write", + title: "Disk Bytes Written by Instance", + metric: "gcp.compute.instance.disk.write_bytes_count", + by: WORKLOAD, + }, + ], }, { - id: "lb-response-classes", - title: "Load Balancer Requests by Response Class", - metric: "gcp.loadbalancing.https.request_count", - by: "attr.response_code_class", + id: "cloud-sql", + title: "Cloud SQL", + charts: [ + { + id: "sql-cpu", + title: "CPU Utilization by Instance", + metric: "gcp.cloudsql.database.cpu.utilization", + by: label("database_id"), + }, + { + id: "sql-memory", + title: "Memory Utilization by Instance", + metric: "gcp.cloudsql.database.memory.utilization", + by: label("database_id"), + }, + { + id: "sql-connections", + title: "Connections (MySQL, SQL Server)", + metric: "gcp.cloudsql.database.network.connections", + by: label("database_id"), + }, + { + id: "sql-connections-postgres", + title: "Connections (PostgreSQL)", + metric: "gcp.cloudsql.database.postgresql.num_backends", + by: label("database_id"), + }, + { + id: "sql-disk", + title: "Disk Utilization by Instance", + metric: "gcp.cloudsql.database.disk.utilization", + by: label("database_id"), + }, + { + id: "sql-replica-lag", + title: "Replica Lag by Instance", + metric: "gcp.cloudsql.database.replication.replica_lag", + by: label("database_id"), + }, + ], }, { - id: "lb-total-latency", - title: "Load Balancer Total Latency p95 by URL Map", - metric: "gcp.loadbalancing.https.total_latencies", - by: label("url_map_name"), - where: P95, + id: "pubsub", + title: "Pub/Sub", + charts: [ + { + id: "pubsub-backlog", + title: "Unacknowledged Messages by Subscription", + metric: "gcp.pubsub.subscription.num_undelivered_messages", + by: label("subscription_id"), + }, + { + id: "pubsub-oldest-unacked", + title: "Oldest Unacknowledged Message Age", + metric: "gcp.pubsub.subscription.oldest_unacked_message_age", + by: label("subscription_id"), + }, + { + id: "pubsub-sent", + title: "Messages Sent by Subscription", + metric: "gcp.pubsub.subscription.sent_message_count", + by: label("subscription_id"), + }, + { + id: "pubsub-acked", + title: "Messages Acknowledged by Subscription", + metric: "gcp.pubsub.subscription.ack_message_count", + by: label("subscription_id"), + }, + { + id: "pubsub-dead-lettered", + title: "Dead-Lettered Messages by Subscription", + metric: "gcp.pubsub.subscription.dead_letter_message_count", + by: label("subscription_id"), + display: CHART_DISPLAY_BAR, + }, + { + id: "pubsub-publish-requests", + title: "Publish Requests by Topic", + metric: "gcp.pubsub.topic.send_request_count", + by: label("topic_id"), + }, + ], }, { - id: "lb-backend-latency", - title: "Load Balancer Backend Latency p95 by URL Map", - metric: "gcp.loadbalancing.https.backend_latencies", - by: label("url_map_name"), - where: P95, + // Global external Application Load Balancers. + id: "load-balancing", + title: "Load Balancing", + charts: [ + { + id: "lb-requests", + title: "Requests by URL Map", + metric: "gcp.loadbalancing.https.request_count", + by: label("url_map_name"), + }, + { + id: "lb-response-classes", + title: "Requests by Response Class", + metric: "gcp.loadbalancing.https.request_count", + by: "attr.response_code_class", + }, + { + id: "lb-total-latency", + title: "Total Latency p95 by URL Map", + metric: "gcp.loadbalancing.https.total_latencies", + by: label("url_map_name"), + where: P95, + }, + { + id: "lb-backend-latency", + title: "Backend Latency p95 by URL Map", + metric: "gcp.loadbalancing.https.backend_latencies", + by: label("url_map_name"), + where: P95, + }, + ], }, ] -function widgets(projectId?: string): WidgetDef[] { - const project = projectId ? `resource.cloud.account.id = "${escapeMetricStringLiteral(projectId)}"` : "" - return CHARTS.map((chart, index): WidgetDef => { - // An unknown name still builds, so gcp.test.ts can report it by name. - const stored = STORED.get(chart.metric) - const counter = stored?.kind === "sum" - return { +const SECTION_ID = "gcp-services" + +function widget(tab: Tab, chart: Chart, index: number): WidgetDef { + // An unknown name still builds, so gcp.test.ts can report it by name. + const stored = STORED.get(chart.metric) + const group = GROUP_BY_METRIC.get(chart.metric) + const counter = stored?.kind === "sum" + return { + id: chart.id, + visualization: "chart", + dataSource: metricsTimeseries({ id: chart.id, - visualization: "chart", - dataSource: metricsTimeseries({ - id: chart.id, - name: chart.title, - metricName: chart.metric, - metricType: counter ? "sum" : "gauge", - aggregation: counter ? "sum" : "avg", - whereClause: combineWhere(chart.where, project), - groupBy: [chart.by], - }), - display: { - title: chart.title, - ...(chart.display ?? (counter ? CHART_DISPLAY_AREA : CHART_DISPLAY_LINE)), - unit: DISPLAY_UNITS.get(stored?.unit ?? "") ?? "number", - }, - // Two charts per row, in list order. - layout: { x: (index % 2) * 6, y: Math.floor(index / 2) * 6, w: 6, h: 6 }, - } - }) + name: chart.title, + metricName: chart.metric, + metricType: counter ? "sum" : "gauge", + aggregation: counter ? "sum" : "avg", + // A variable left on "All" drops its clause. + whereClause: combineWhere( + chart.where, + PROJECT_FILTER, + group && isRegional(group) ? REGION_FILTER : undefined, + chart.by === WORKLOAD ? WORKLOAD_FILTER : undefined, + ), + groupBy: [chart.by], + }), + display: { + title: chart.title, + ...(chart.display ?? (counter ? CHART_DISPLAY_AREA : CHART_DISPLAY_LINE)), + unit: DISPLAY_UNITS.get(stored?.unit ?? "") ?? "number", + }, + // Two charts per row, in list order, within the tab. + layout: { x: (index % 2) * 6, y: Math.floor(index / 2) * 6, w: 6, h: 6 }, + sectionId: SECTION_ID, + tabId: tab.id, + } } export const gcpTemplate: TemplateDefinition = { id: templateId("gcp"), name: "Google Cloud", description: - "Metrics from the Google Cloud integration: Cloud Run requests, errors, latency and instances, Cloud Functions (1st gen) executions, GKE container and node utilization, Compute Engine CPU and network, Cloud SQL utilization and connections, Pub/Sub backlog, and load balancer requests and latency.", + "Metrics from the Google Cloud integration, one tab per service: Cloud Run requests, errors, latency and instances, Cloud Functions (1st gen) executions, GKE container and node utilization, Compute Engine CPU, memory, network and disk, Cloud SQL utilization, connections and replica lag, Pub/Sub backlog and throughput, and load balancer requests and latency. Filter by project, region and workload.", category: "infrastructure", tags: ["gcp", "google cloud", "cloud run", "gke", "cloud sql", "pubsub"], requirement: { @@ -260,7 +443,7 @@ export const gcpTemplate: TemplateDefinition = { { key: paramKey("project_id"), label: "Project ID", - description: "Optional. Scope every widget to one Google Cloud project.", + description: "Optional. The project the Project filter starts on; it can be changed on the dashboard.", required: false, placeholder: "acme-prod", }, @@ -273,7 +456,36 @@ export const gcpTemplate: TemplateDefinition = { "Cloud Run, Cloud Functions, GKE, Compute Engine, Cloud SQL, Pub/Sub and load balancer metrics from Cloud Monitoring.", tags: ["gcp"], timeRange: "24h", - widgets: widgets(projectId), + variables: [ + resourceVariable({ + name: "project", + label: "Project", + attributeKey: "cloud.account.id", + groups: GCP_METRIC_GROUPS, + defaultValue: projectId, + }), + resourceVariable({ + name: "region", + label: "Region", + attributeKey: "cloud.region", + groups: GCP_METRIC_GROUPS.filter(isRegional), + }), + resourceVariable({ + name: "workload", + label: "Workload", + attributeKey: "service.name", + groups: GCP_METRIC_GROUPS.filter((group) => WORKLOAD_RESOURCES.has(group.resourceType)), + }), + ], + sections: [ + { + id: SECTION_ID, + title: "Services", + collapsible: false, + tabs: TABS.map((tab) => ({ id: tab.id, title: tab.title })), + }, + ], + widgets: TABS.flatMap((tab) => tab.charts.map((chart, index) => widget(tab, chart, index))), }) }, } diff --git a/packages/domain/src/http/v2/dashboards.ts b/packages/domain/src/http/v2/dashboards.ts index c12597627f..97ac304e42 100644 --- a/packages/domain/src/http/v2/dashboards.ts +++ b/packages/domain/src/http/v2/dashboards.ts @@ -43,6 +43,7 @@ import { PathsAnchor, PathsDirection, PathsInclude, + QUERY_BUILDER_METRIC_TYPES, QUERY_RESULT_KINDS, QueryBuilderFormulaSchema, QueryBuilderQueryDraftSchema, @@ -538,6 +539,19 @@ const V2DashboardVariableSource = Schema.Union([ scope: Schema.Literals(["span", "resource"]), attributeKey: Schema.String, }).pipe(Schema.encodeKeys({ attributeKey: "attribute_key" })), + Schema.Struct({ + kind: Schema.Literal("metric"), + metricType: Schema.Literals(QUERY_BUILDER_METRIC_TYPES), + metricNames: Schema.Array(Schema.String).check(Schema.isMinLength(1), Schema.isMaxLength(20)), + scope: Schema.Literals(["metric", "resource"]), + attributeKey: Schema.String, + }).pipe( + Schema.encodeKeys({ + metricType: "metric_type", + metricNames: "metric_names", + attributeKey: "attribute_key", + }), + ), ]) const variableBase = { diff --git a/packages/domain/src/query-engine.ts b/packages/domain/src/query-engine.ts index 9fa2bb6fb2..1ba377e162 100644 --- a/packages/domain/src/query-engine.ts +++ b/packages/domain/src/query-engine.ts @@ -478,8 +478,13 @@ export const AttributeValuesQuery = Schema.Struct({ source: Schema.Literals(["traces", "logs", "metrics", "product_events"]), scope: Schema.Literals(["span", "resource", "log", "metric", "event"]), attributeKey: Schema.String, - // Scope metrics-source discovery to a single metric (see AttributeKeysQuery). + // Scope metrics-source discovery to a single metric (see AttributeKeysQuery), + // or to several metrics of one `metricType` with `metricNames`. Scoped metric + // discovery reads `ResourceAttributes` for the `resource` scope. metricName: Schema.optional(Schema.String), + metricNames: Schema.optional( + Schema.Array(Schema.String).check(Schema.isMinLength(1), Schema.isMaxLength(20)), + ), metricType: Schema.optional(MetricType), limit: Schema.optional( Schema.Number.check(Schema.isInt(), Schema.isGreaterThan(0), Schema.isLessThanOrEqualTo(500)), diff --git a/packages/query-engine/src/ch/queries/attribute-keys.test.ts b/packages/query-engine/src/ch/queries/attribute-keys.test.ts index de84559939..db34dcfb8d 100644 --- a/packages/query-engine/src/ch/queries/attribute-keys.test.ts +++ b/packages/query-engine/src/ch/queries/attribute-keys.test.ts @@ -177,6 +177,23 @@ describe("metricScopedAttributeValuesQuery", () => { expect(sql).toContain("LIMIT 50") expect(sql).not.toMatch(/__PARAM_\w+__/) }) + + it("reads resource attributes across several named metrics", () => { + const q = metricScopedAttributeValuesQuery({ + metricType: "gauge", + scope: "resource", + metricNames: ["gcp.run.container.instance_count", "gcp.cloudsql.database.cpu.utilization"], + attributeKey: "cloud.account.id", + }) + const { sql } = compileUnsafe(q, baseParams) + expect(sql).toContain("FROM metrics_gauge") + expect(sql).toContain("ResourceAttributes['cloud.account.id'] AS attributeValue") + expect(sql).toContain( + "MetricName IN ('gcp.run.container.instance_count', 'gcp.cloudsql.database.cpu.utilization')", + ) + expect(sql).not.toMatch(/\bAttributes\['/) + expect(sql).not.toMatch(/__PARAM_\w+__/) + }) }) describe("serviceScopedAttributeKeysQuery", () => { diff --git a/packages/query-engine/src/ch/queries/attribute-keys.ts b/packages/query-engine/src/ch/queries/attribute-keys.ts index 7996fac22c..46cd9e6101 100644 --- a/packages/query-engine/src/ch/queries/attribute-keys.ts +++ b/packages/query-engine/src/ch/queries/attribute-keys.ts @@ -1,7 +1,7 @@ import type { MetricType } from "@maple/domain/query-engine" import * as CH from "@maple-dev/effect-orm/expr" import { param } from "@maple-dev/effect-orm/clickhouse" -import { from, fromQuery } from "@maple-dev/effect-orm/clickhouse" +import { from, fromQuery, type ColumnAccessor } from "@maple-dev/effect-orm/clickhouse" import { AttributeKeysHourly, AttributeValuesHourly, @@ -143,23 +143,31 @@ export function metricScopedAttributeKeysQuery(opts: MetricScopedAttributeKeysOp export interface MetricScopedAttributeValuesOpts { metricType: MetricType attributeKey: string + /** `resource` reads `ResourceAttributes`; absent or `metric` reads the data-point `Attributes`. */ + scope?: "metric" | "resource" + /** Several metrics of `metricType` in place of the `metricName` param. */ + metricNames?: ReadonlyArray serviceName?: string limit?: number } export function metricScopedAttributeValuesQuery(opts: MetricScopedAttributeValuesOpts) { const { tbl } = resolveMetricTable(opts.metricType) + const attributes = ($: ColumnAccessor) => + opts.scope === "resource" ? $.ResourceAttributes : $.Attributes return from(tbl as typeof MetricsSum) .select(($) => ({ - attributeValue: $.Attributes.get(opts.attributeKey), + attributeValue: attributes($).get(opts.attributeKey), usageCount: CH.count(), })) .where(($) => [ $.OrgId.eq(orgIdParam), - $.MetricName.eq(param.string("metricName")), + opts.metricNames + ? CH.inList($.MetricName, opts.metricNames) + : $.MetricName.eq(param.string("metricName")), $.TimeUnix.gte(param.dateTime("startTime")), $.TimeUnix.lte(param.dateTime("endTime")), - $.Attributes.get(opts.attributeKey).neq(""), + attributes($).get(opts.attributeKey).neq(""), CH.when(opts.serviceName, (v: string) => $.ServiceName.eq(v)), ]) .groupBy("attributeValue") diff --git a/packages/query-engine/src/dashboard-variables/options.ts b/packages/query-engine/src/dashboard-variables/options.ts index e433daea52..d14fd91028 100644 --- a/packages/query-engine/src/dashboard-variables/options.ts +++ b/packages/query-engine/src/dashboard-variables/options.ts @@ -87,6 +87,16 @@ export function dashboardVariableOptionsQuery( ): DashboardVariableOptionsQuery | null { if (variable.type !== "query") return null const source = variable.source + if (source.kind === "metric") { + return { + kind: "attributeValues", + source: "metrics", + scope: source.scope, + attributeKey: source.attributeKey, + metricNames: source.metricNames, + metricType: source.metricType, + } + } if (source.kind === "attribute") { return { kind: "attributeValues", diff --git a/packages/query-engine/src/runtime/query-engine.ts b/packages/query-engine/src/runtime/query-engine.ts index f800b8e5e1..f3de0c3a2c 100644 --- a/packages/query-engine/src/runtime/query-engine.ts +++ b/packages/query-engine/src/runtime/query-engine.ts @@ -2320,9 +2320,10 @@ export const makeQueryEngineExecute = (warehouse: QueryEn if (request.query.kind === "attributeValues") { // Per-metric scoping reads the raw metric table (see attributeKeys above). + const { metricName, metricNames, metricType } = request.query const metricScoped = - request.query.source === "metrics" && request.query.metricName && request.query.metricType - ? { metricName: request.query.metricName, metricType: request.query.metricType } + request.query.source === "metrics" && (metricName || metricNames) && metricType + ? { metricName, metricNames, metricType } : undefined const queryFn = Match.value(request.query.scope).pipe( Match.when("resource", () => CH.resourceAttributeValuesQuery), @@ -2336,6 +2337,8 @@ export const makeQueryEngineExecute = (warehouse: QueryEn metricScoped ? CH.metricScopedAttributeValuesQuery({ metricType: metricScoped.metricType, + metricNames: metricScoped.metricNames, + scope: request.query.scope === "resource" ? "resource" : "metric", attributeKey: request.query.attributeKey, limit: request.query.limit, }) @@ -2344,7 +2347,9 @@ export const makeQueryEngineExecute = (warehouse: QueryEn orgId: tenant.orgId, startTime: request.startTime, endTime: request.endTime, - ...(metricScoped ? { metricName: metricScoped.metricName } : undefined), + ...(metricScoped?.metricName && !metricScoped.metricNames + ? { metricName: metricScoped.metricName } + : undefined), }, metricScoped ? "attributeValues:metric-scoped" : `attributeValues:${request.query.scope}`, "discovery", diff --git a/packages/widgets/src/dashboard/shared/variables.ts b/packages/widgets/src/dashboard/shared/variables.ts index 52df693708..5c4ee7837d 100644 --- a/packages/widgets/src/dashboard/shared/variables.ts +++ b/packages/widgets/src/dashboard/shared/variables.ts @@ -1,4 +1,5 @@ import { Schema } from "effect" +import { QUERY_BUILDER_METRIC_TYPES } from "@maple/query-model" // Dashboard variables @@ -40,6 +41,16 @@ export const DashboardQueryVariableSourceSchema = Schema.Union([ scope: Schema.Literals(["span", "resource"]), attributeKey: Schema.String, }), + // Values of an attribute on the named metrics, for boards built on metrics + // alone (cloud integrations, collectors), whose resources never appear in + // traces. Every metric is of `metricType`, which picks the table read. + Schema.Struct({ + kind: Schema.Literal("metric"), + metricType: Schema.Literals(QUERY_BUILDER_METRIC_TYPES), + metricNames: Schema.Array(Schema.String).check(Schema.isMinLength(1), Schema.isMaxLength(20)), + scope: Schema.Literals(["metric", "resource"]), + attributeKey: Schema.String, + }), ]) export type DashboardQueryVariableSource = typeof DashboardQueryVariableSourceSchema.Type From 98522699a4e7135a834b1412ce2d55c7a1ffce77 Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Thu, 15 Oct 2026 19:54:38 +0200 Subject: [PATCH 2/4] feat(lab): Google Cloud dashboard lab with live filters over fixture series /lab/gcp-dashboard renders the built template through the read-only canvas: its tabs, the Project / Region / Workload chips over fixture options, and the variables dialog. Series follow the chips through each widget's own where clause, so a filter a widget leaves out (Region on Pub/Sub) changes nothing. Also shortens the dialog's summary of a metric-sourced variable and formats the template files. --- .../config/variables-manager-dialog.tsx | 12 +- .../dashboard-variables-context.tsx | 12 +- apps/web/src/lab/gcp-dashboard-fixture.json | 2428 +++++++++++++++++ apps/web/src/lab/gcp-dashboard-lab.tsx | 213 ++ apps/web/src/lab/registry.ts | 8 + apps/web/src/routeTree.gen.ts | 21 + apps/web/src/routes/lab/gcp-dashboard.tsx | 5 + .../infrastructure/gcp.test.ts | 7 +- .../dashboard-templates/infrastructure/gcp.ts | 3 +- 9 files changed, 2697 insertions(+), 12 deletions(-) create mode 100644 apps/web/src/lab/gcp-dashboard-fixture.json create mode 100644 apps/web/src/lab/gcp-dashboard-lab.tsx create mode 100644 apps/web/src/routes/lab/gcp-dashboard.tsx diff --git a/apps/web/src/components/dashboard-builder/config/variables-manager-dialog.tsx b/apps/web/src/components/dashboard-builder/config/variables-manager-dialog.tsx index cd9176cf53..cf1f9867d2 100644 --- a/apps/web/src/components/dashboard-builder/config/variables-manager-dialog.tsx +++ b/apps/web/src/components/dashboard-builder/config/variables-manager-dialog.tsx @@ -68,7 +68,7 @@ function sourceSummary(variable: DashboardVariable): string { case "attribute": return `${source.scope} attribute: ${source.attributeKey || "…"}` case "metric": - return `${source.scope} attribute of ${source.metricNames.join(", ")}: ${source.attributeKey || "…"}` + return `metric ${source.scope} attribute: ${source.attributeKey || "…"} (${source.metricNames.length === 1 ? source.metricNames[0] : `${source.metricNames.length} metrics`})` } } @@ -109,7 +109,11 @@ function draftError(draft: DashboardVariable, index: number, drafts: DashboardVa if (draft.type === "query" && draft.source.kind !== "facet" && !draft.source.attributeKey.trim()) { return "Pick the attribute key to list values for." } - if (draft.type === "query" && draft.source.kind === "metric" && draft.source.metricNames.some((name) => name === "")) { + if ( + draft.type === "query" && + draft.source.kind === "metric" && + draft.source.metricNames.some((name) => name === "") + ) { return "Name at least one metric to list values from." } return null @@ -469,7 +473,9 @@ function QuerySourceFields({ "metric:metric": "Metric attribute…", "metric:resource": "Metric resource attribute…", }} - value={source.kind === "facet" ? `facet:${source.facet}` : `${source.kind}:${source.scope}`} + value={ + source.kind === "facet" ? `facet:${source.facet}` : `${source.kind}:${source.scope}` + } onValueChange={(value) => { if (typeof value !== "string") return const [kind, detail] = value.split(":") diff --git a/apps/web/src/components/dashboard-builder/dashboard-variables-context.tsx b/apps/web/src/components/dashboard-builder/dashboard-variables-context.tsx index bfcd963eff..01e358b9bc 100644 --- a/apps/web/src/components/dashboard-builder/dashboard-variables-context.tsx +++ b/apps/web/src/components/dashboard-builder/dashboard-variables-context.tsx @@ -42,7 +42,7 @@ export interface DashboardVariablesContextValue { setValue: (name: string, value: string) => void } -const DashboardVariablesContext = createContext(null) +export const DashboardVariablesContext = createContext(null) const EMPTY_CONTEXT: DashboardVariablesContextValue = { variables: [], @@ -95,10 +95,12 @@ function readVariableOptions( }, }) : query.scope === "resource" - ? getResourceAttributeValuesResultAtom({ - data: { ...window, attributeKey: query.attributeKey }, - }) - : getSpanAttributeValuesResultAtom({ data: { ...window, attributeKey: query.attributeKey } }) + ? getResourceAttributeValuesResultAtom({ + data: { ...window, attributeKey: query.attributeKey }, + }) + : getSpanAttributeValuesResultAtom({ + data: { ...window, attributeKey: query.attributeKey }, + }) const result = get(atom) if (!Result.isSuccess(result)) { return Result.isFailure(result) ? NO_OPTIONS : LOADING_OPTIONS diff --git a/apps/web/src/lab/gcp-dashboard-fixture.json b/apps/web/src/lab/gcp-dashboard-fixture.json new file mode 100644 index 0000000000..3c7b5b1fef --- /dev/null +++ b/apps/web/src/lab/gcp-dashboard-fixture.json @@ -0,0 +1,2428 @@ +{ + "name": "Google Cloud", + "description": "Cloud Run, Cloud Functions, GKE, Compute Engine, Cloud SQL, Pub/Sub and load balancer metrics from Cloud Monitoring.", + "tags": ["gcp"], + "timeRange": { + "type": "relative", + "value": "24h" + }, + "widgets": [ + { + "id": "run-requests", + "visualization": "chart", + "dataSource": { + "kind": "query", + "resultShape": "timeseries", + "queries": [ + { + "id": "run-requests", + "name": "Requests by Service", + "enabled": true, + "whereClause": "resource.cloud.account.id = \"$project\" AND resource.cloud.region = \"$region\" AND service.name = \"$workload\"", + "aggregation": "sum", + "stepInterval": "", + "orderByDirection": "desc", + "addOns": { + "groupBy": true, + "having": false, + "orderBy": false, + "limit": false, + "legend": false + }, + "groupBy": ["service.name"], + "having": "", + "orderBy": "", + "limit": "", + "legend": "", + "dataSource": "metrics", + "signalSource": "default", + "metricName": "gcp.run.request_count", + "metricType": "sum", + "isMonotonic": false + } + ], + "formulas": [], + "comparison": { + "mode": "none", + "includePercentChange": true + } + }, + "display": { + "title": "Requests by Service", + "chartId": "query-builder-area", + "chartPresentation": { + "legend": "visible", + "seriesStats": false, + "showPoints": false + }, + "stacked": true, + "curveType": "monotone", + "unit": "number" + }, + "layout": { + "x": 0, + "y": 0, + "w": 6, + "h": 6 + }, + "sectionId": "gcp-services", + "tabId": "cloud-run" + }, + { + "id": "run-errors", + "visualization": "chart", + "dataSource": { + "kind": "query", + "resultShape": "timeseries", + "queries": [ + { + "id": "run-errors", + "name": "5xx Responses by Service", + "enabled": true, + "whereClause": "attr.response_code_class = \"5xx\" AND resource.cloud.account.id = \"$project\" AND resource.cloud.region = \"$region\" AND service.name = \"$workload\"", + "aggregation": "sum", + "stepInterval": "", + "orderByDirection": "desc", + "addOns": { + "groupBy": true, + "having": false, + "orderBy": false, + "limit": false, + "legend": false + }, + "groupBy": ["service.name"], + "having": "", + "orderBy": "", + "limit": "", + "legend": "", + "dataSource": "metrics", + "signalSource": "default", + "metricName": "gcp.run.request_count", + "metricType": "sum", + "isMonotonic": false + } + ], + "formulas": [], + "comparison": { + "mode": "none", + "includePercentChange": true + } + }, + "display": { + "title": "5xx Responses by Service", + "chartId": "query-builder-bar", + "chartPresentation": { + "legend": "visible", + "seriesStats": false, + "showPoints": false + }, + "stacked": true, + "curveType": "linear", + "unit": "number" + }, + "layout": { + "x": 6, + "y": 0, + "w": 6, + "h": 6 + }, + "sectionId": "gcp-services", + "tabId": "cloud-run" + }, + { + "id": "run-latency", + "visualization": "chart", + "dataSource": { + "kind": "query", + "resultShape": "timeseries", + "queries": [ + { + "id": "run-latency", + "name": "Request Latency p95 by Service", + "enabled": true, + "whereClause": "attr.quantile = \"0.95\" AND resource.cloud.account.id = \"$project\" AND resource.cloud.region = \"$region\" AND service.name = \"$workload\"", + "aggregation": "avg", + "stepInterval": "", + "orderByDirection": "desc", + "addOns": { + "groupBy": true, + "having": false, + "orderBy": false, + "limit": false, + "legend": false + }, + "groupBy": ["service.name"], + "having": "", + "orderBy": "", + "limit": "", + "legend": "", + "dataSource": "metrics", + "signalSource": "default", + "metricName": "gcp.run.request_latencies", + "metricType": "gauge", + "isMonotonic": false + } + ], + "formulas": [], + "comparison": { + "mode": "none", + "includePercentChange": true + } + }, + "display": { + "title": "Request Latency p95 by Service", + "chartId": "query-builder-line", + "chartPresentation": { + "legend": "visible", + "seriesStats": true, + "showPoints": false + }, + "stacked": false, + "curveType": "monotone", + "unit": "duration_ms" + }, + "layout": { + "x": 0, + "y": 6, + "w": 6, + "h": 6 + }, + "sectionId": "gcp-services", + "tabId": "cloud-run" + }, + { + "id": "run-instances", + "visualization": "chart", + "dataSource": { + "kind": "query", + "resultShape": "timeseries", + "queries": [ + { + "id": "run-instances", + "name": "Active Instances by Service", + "enabled": true, + "whereClause": "attr.state = \"active\" AND resource.cloud.account.id = \"$project\" AND resource.cloud.region = \"$region\" AND service.name = \"$workload\"", + "aggregation": "avg", + "stepInterval": "", + "orderByDirection": "desc", + "addOns": { + "groupBy": true, + "having": false, + "orderBy": false, + "limit": false, + "legend": false + }, + "groupBy": ["service.name"], + "having": "", + "orderBy": "", + "limit": "", + "legend": "", + "dataSource": "metrics", + "signalSource": "default", + "metricName": "gcp.run.container.instance_count", + "metricType": "gauge", + "isMonotonic": false + } + ], + "formulas": [], + "comparison": { + "mode": "none", + "includePercentChange": true + } + }, + "display": { + "title": "Active Instances by Service", + "chartId": "query-builder-line", + "chartPresentation": { + "legend": "visible", + "seriesStats": true, + "showPoints": false + }, + "stacked": false, + "curveType": "monotone", + "unit": "number" + }, + "layout": { + "x": 6, + "y": 6, + "w": 6, + "h": 6 + }, + "sectionId": "gcp-services", + "tabId": "cloud-run" + }, + { + "id": "run-cpu", + "visualization": "chart", + "dataSource": { + "kind": "query", + "resultShape": "timeseries", + "queries": [ + { + "id": "run-cpu", + "name": "CPU Utilization p95 by Service", + "enabled": true, + "whereClause": "attr.quantile = \"0.95\" AND resource.cloud.account.id = \"$project\" AND resource.cloud.region = \"$region\" AND service.name = \"$workload\"", + "aggregation": "avg", + "stepInterval": "", + "orderByDirection": "desc", + "addOns": { + "groupBy": true, + "having": false, + "orderBy": false, + "limit": false, + "legend": false + }, + "groupBy": ["service.name"], + "having": "", + "orderBy": "", + "limit": "", + "legend": "", + "dataSource": "metrics", + "signalSource": "default", + "metricName": "gcp.run.container.cpu.utilizations", + "metricType": "gauge", + "isMonotonic": false + } + ], + "formulas": [], + "comparison": { + "mode": "none", + "includePercentChange": true + } + }, + "display": { + "title": "CPU Utilization p95 by Service", + "chartId": "query-builder-line", + "chartPresentation": { + "legend": "visible", + "seriesStats": true, + "showPoints": false + }, + "stacked": false, + "curveType": "monotone", + "unit": "percent" + }, + "layout": { + "x": 0, + "y": 12, + "w": 6, + "h": 6 + }, + "sectionId": "gcp-services", + "tabId": "cloud-run" + }, + { + "id": "run-memory", + "visualization": "chart", + "dataSource": { + "kind": "query", + "resultShape": "timeseries", + "queries": [ + { + "id": "run-memory", + "name": "Memory Utilization p95 by Service", + "enabled": true, + "whereClause": "attr.quantile = \"0.95\" AND resource.cloud.account.id = \"$project\" AND resource.cloud.region = \"$region\" AND service.name = \"$workload\"", + "aggregation": "avg", + "stepInterval": "", + "orderByDirection": "desc", + "addOns": { + "groupBy": true, + "having": false, + "orderBy": false, + "limit": false, + "legend": false + }, + "groupBy": ["service.name"], + "having": "", + "orderBy": "", + "limit": "", + "legend": "", + "dataSource": "metrics", + "signalSource": "default", + "metricName": "gcp.run.container.memory.utilizations", + "metricType": "gauge", + "isMonotonic": false + } + ], + "formulas": [], + "comparison": { + "mode": "none", + "includePercentChange": true + } + }, + "display": { + "title": "Memory Utilization p95 by Service", + "chartId": "query-builder-line", + "chartPresentation": { + "legend": "visible", + "seriesStats": true, + "showPoints": false + }, + "stacked": false, + "curveType": "monotone", + "unit": "percent" + }, + "layout": { + "x": 6, + "y": 12, + "w": 6, + "h": 6 + }, + "sectionId": "gcp-services", + "tabId": "cloud-run" + }, + { + "id": "functions-executions", + "visualization": "chart", + "dataSource": { + "kind": "query", + "resultShape": "timeseries", + "queries": [ + { + "id": "functions-executions", + "name": "Executions by Function", + "enabled": true, + "whereClause": "resource.cloud.account.id = \"$project\" AND resource.cloud.region = \"$region\" AND service.name = \"$workload\"", + "aggregation": "sum", + "stepInterval": "", + "orderByDirection": "desc", + "addOns": { + "groupBy": true, + "having": false, + "orderBy": false, + "limit": false, + "legend": false + }, + "groupBy": ["service.name"], + "having": "", + "orderBy": "", + "limit": "", + "legend": "", + "dataSource": "metrics", + "signalSource": "default", + "metricName": "gcp.cloudfunctions.function.execution_count", + "metricType": "sum", + "isMonotonic": false + } + ], + "formulas": [], + "comparison": { + "mode": "none", + "includePercentChange": true + } + }, + "display": { + "title": "Executions by Function", + "chartId": "query-builder-area", + "chartPresentation": { + "legend": "visible", + "seriesStats": false, + "showPoints": false + }, + "stacked": true, + "curveType": "monotone", + "unit": "number" + }, + "layout": { + "x": 0, + "y": 0, + "w": 6, + "h": 6 + }, + "sectionId": "gcp-services", + "tabId": "cloud-functions" + }, + { + "id": "functions-execution-time", + "visualization": "chart", + "dataSource": { + "kind": "query", + "resultShape": "timeseries", + "queries": [ + { + "id": "functions-execution-time", + "name": "Execution Time p95 by Function", + "enabled": true, + "whereClause": "attr.quantile = \"0.95\" AND resource.cloud.account.id = \"$project\" AND resource.cloud.region = \"$region\" AND service.name = \"$workload\"", + "aggregation": "avg", + "stepInterval": "", + "orderByDirection": "desc", + "addOns": { + "groupBy": true, + "having": false, + "orderBy": false, + "limit": false, + "legend": false + }, + "groupBy": ["service.name"], + "having": "", + "orderBy": "", + "limit": "", + "legend": "", + "dataSource": "metrics", + "signalSource": "default", + "metricName": "gcp.cloudfunctions.function.execution_times", + "metricType": "gauge", + "isMonotonic": false + } + ], + "formulas": [], + "comparison": { + "mode": "none", + "includePercentChange": true + } + }, + "display": { + "title": "Execution Time p95 by Function", + "chartId": "query-builder-line", + "chartPresentation": { + "legend": "visible", + "seriesStats": true, + "showPoints": false + }, + "stacked": false, + "curveType": "monotone", + "unit": "duration_ms" + }, + "layout": { + "x": 6, + "y": 0, + "w": 6, + "h": 6 + }, + "sectionId": "gcp-services", + "tabId": "cloud-functions" + }, + { + "id": "functions-instances", + "visualization": "chart", + "dataSource": { + "kind": "query", + "resultShape": "timeseries", + "queries": [ + { + "id": "functions-instances", + "name": "Active Instances by Function", + "enabled": true, + "whereClause": "attr.state = \"active\" AND resource.cloud.account.id = \"$project\" AND resource.cloud.region = \"$region\" AND service.name = \"$workload\"", + "aggregation": "avg", + "stepInterval": "", + "orderByDirection": "desc", + "addOns": { + "groupBy": true, + "having": false, + "orderBy": false, + "limit": false, + "legend": false + }, + "groupBy": ["service.name"], + "having": "", + "orderBy": "", + "limit": "", + "legend": "", + "dataSource": "metrics", + "signalSource": "default", + "metricName": "gcp.cloudfunctions.function.instance_count", + "metricType": "gauge", + "isMonotonic": false + } + ], + "formulas": [], + "comparison": { + "mode": "none", + "includePercentChange": true + } + }, + "display": { + "title": "Active Instances by Function", + "chartId": "query-builder-line", + "chartPresentation": { + "legend": "visible", + "seriesStats": true, + "showPoints": false + }, + "stacked": false, + "curveType": "monotone", + "unit": "number" + }, + "layout": { + "x": 0, + "y": 6, + "w": 6, + "h": 6 + }, + "sectionId": "gcp-services", + "tabId": "cloud-functions" + }, + { + "id": "functions-memory", + "visualization": "chart", + "dataSource": { + "kind": "query", + "resultShape": "timeseries", + "queries": [ + { + "id": "functions-memory", + "name": "Peak Memory per Execution p95 by Function", + "enabled": true, + "whereClause": "attr.quantile = \"0.95\" AND resource.cloud.account.id = \"$project\" AND resource.cloud.region = \"$region\" AND service.name = \"$workload\"", + "aggregation": "avg", + "stepInterval": "", + "orderByDirection": "desc", + "addOns": { + "groupBy": true, + "having": false, + "orderBy": false, + "limit": false, + "legend": false + }, + "groupBy": ["service.name"], + "having": "", + "orderBy": "", + "limit": "", + "legend": "", + "dataSource": "metrics", + "signalSource": "default", + "metricName": "gcp.cloudfunctions.function.user_memory_bytes", + "metricType": "gauge", + "isMonotonic": false + } + ], + "formulas": [], + "comparison": { + "mode": "none", + "includePercentChange": true + } + }, + "display": { + "title": "Peak Memory per Execution p95 by Function", + "chartId": "query-builder-line", + "chartPresentation": { + "legend": "visible", + "seriesStats": true, + "showPoints": false + }, + "stacked": false, + "curveType": "monotone", + "unit": "bytes" + }, + "layout": { + "x": 6, + "y": 6, + "w": 6, + "h": 6 + }, + "sectionId": "gcp-services", + "tabId": "cloud-functions" + }, + { + "id": "gke-cpu", + "visualization": "chart", + "dataSource": { + "kind": "query", + "resultShape": "timeseries", + "queries": [ + { + "id": "gke-cpu", + "name": "CPU Limit Utilization by Container", + "enabled": true, + "whereClause": "resource.cloud.account.id = \"$project\" AND resource.cloud.region = \"$region\" AND service.name = \"$workload\"", + "aggregation": "avg", + "stepInterval": "", + "orderByDirection": "desc", + "addOns": { + "groupBy": true, + "having": false, + "orderBy": false, + "limit": false, + "legend": false + }, + "groupBy": ["service.name"], + "having": "", + "orderBy": "", + "limit": "", + "legend": "", + "dataSource": "metrics", + "signalSource": "default", + "metricName": "gcp.kubernetes.container.cpu.limit_utilization", + "metricType": "gauge", + "isMonotonic": false + } + ], + "formulas": [], + "comparison": { + "mode": "none", + "includePercentChange": true + } + }, + "display": { + "title": "CPU Limit Utilization by Container", + "chartId": "query-builder-line", + "chartPresentation": { + "legend": "visible", + "seriesStats": true, + "showPoints": false + }, + "stacked": false, + "curveType": "monotone", + "unit": "percent" + }, + "layout": { + "x": 0, + "y": 0, + "w": 6, + "h": 6 + }, + "sectionId": "gcp-services", + "tabId": "gke" + }, + { + "id": "gke-memory", + "visualization": "chart", + "dataSource": { + "kind": "query", + "resultShape": "timeseries", + "queries": [ + { + "id": "gke-memory", + "name": "Memory Limit Utilization by Container", + "enabled": true, + "whereClause": "attr.memory_type = \"non-evictable\" AND resource.cloud.account.id = \"$project\" AND resource.cloud.region = \"$region\" AND service.name = \"$workload\"", + "aggregation": "avg", + "stepInterval": "", + "orderByDirection": "desc", + "addOns": { + "groupBy": true, + "having": false, + "orderBy": false, + "limit": false, + "legend": false + }, + "groupBy": ["service.name"], + "having": "", + "orderBy": "", + "limit": "", + "legend": "", + "dataSource": "metrics", + "signalSource": "default", + "metricName": "gcp.kubernetes.container.memory.limit_utilization", + "metricType": "gauge", + "isMonotonic": false + } + ], + "formulas": [], + "comparison": { + "mode": "none", + "includePercentChange": true + } + }, + "display": { + "title": "Memory Limit Utilization by Container", + "chartId": "query-builder-line", + "chartPresentation": { + "legend": "visible", + "seriesStats": true, + "showPoints": false + }, + "stacked": false, + "curveType": "monotone", + "unit": "percent" + }, + "layout": { + "x": 6, + "y": 0, + "w": 6, + "h": 6 + }, + "sectionId": "gcp-services", + "tabId": "gke" + }, + { + "id": "gke-restarts", + "visualization": "chart", + "dataSource": { + "kind": "query", + "resultShape": "timeseries", + "queries": [ + { + "id": "gke-restarts", + "name": "Container Restarts", + "enabled": true, + "whereClause": "resource.cloud.account.id = \"$project\" AND resource.cloud.region = \"$region\" AND service.name = \"$workload\"", + "aggregation": "sum", + "stepInterval": "", + "orderByDirection": "desc", + "addOns": { + "groupBy": true, + "having": false, + "orderBy": false, + "limit": false, + "legend": false + }, + "groupBy": ["service.name"], + "having": "", + "orderBy": "", + "limit": "", + "legend": "", + "dataSource": "metrics", + "signalSource": "default", + "metricName": "gcp.kubernetes.container.restart_count", + "metricType": "sum", + "isMonotonic": false + } + ], + "formulas": [], + "comparison": { + "mode": "none", + "includePercentChange": true + } + }, + "display": { + "title": "Container Restarts", + "chartId": "query-builder-bar", + "chartPresentation": { + "legend": "visible", + "seriesStats": false, + "showPoints": false + }, + "stacked": true, + "curveType": "linear", + "unit": "number" + }, + "layout": { + "x": 0, + "y": 6, + "w": 6, + "h": 6 + }, + "sectionId": "gcp-services", + "tabId": "gke" + }, + { + "id": "gke-memory-used", + "visualization": "chart", + "dataSource": { + "kind": "query", + "resultShape": "timeseries", + "queries": [ + { + "id": "gke-memory-used", + "name": "Memory in Use by Container", + "enabled": true, + "whereClause": "attr.memory_type = \"non-evictable\" AND resource.cloud.account.id = \"$project\" AND resource.cloud.region = \"$region\" AND service.name = \"$workload\"", + "aggregation": "avg", + "stepInterval": "", + "orderByDirection": "desc", + "addOns": { + "groupBy": true, + "having": false, + "orderBy": false, + "limit": false, + "legend": false + }, + "groupBy": ["service.name"], + "having": "", + "orderBy": "", + "limit": "", + "legend": "", + "dataSource": "metrics", + "signalSource": "default", + "metricName": "gcp.kubernetes.container.memory.used_bytes", + "metricType": "gauge", + "isMonotonic": false + } + ], + "formulas": [], + "comparison": { + "mode": "none", + "includePercentChange": true + } + }, + "display": { + "title": "Memory in Use by Container", + "chartId": "query-builder-line", + "chartPresentation": { + "legend": "visible", + "seriesStats": true, + "showPoints": false + }, + "stacked": false, + "curveType": "monotone", + "unit": "bytes" + }, + "layout": { + "x": 6, + "y": 6, + "w": 6, + "h": 6 + }, + "sectionId": "gcp-services", + "tabId": "gke" + }, + { + "id": "gke-node-cpu", + "visualization": "chart", + "dataSource": { + "kind": "query", + "resultShape": "timeseries", + "queries": [ + { + "id": "gke-node-cpu", + "name": "Node CPU Utilization by Cluster", + "enabled": true, + "whereClause": "resource.cloud.account.id = \"$project\" AND resource.cloud.region = \"$region\"", + "aggregation": "avg", + "stepInterval": "", + "orderByDirection": "desc", + "addOns": { + "groupBy": true, + "having": false, + "orderBy": false, + "limit": false, + "legend": false + }, + "groupBy": ["resource.k8s.cluster.name"], + "having": "", + "orderBy": "", + "limit": "", + "legend": "", + "dataSource": "metrics", + "signalSource": "default", + "metricName": "gcp.kubernetes.node.cpu.allocatable_utilization", + "metricType": "gauge", + "isMonotonic": false + } + ], + "formulas": [], + "comparison": { + "mode": "none", + "includePercentChange": true + } + }, + "display": { + "title": "Node CPU Utilization by Cluster", + "chartId": "query-builder-line", + "chartPresentation": { + "legend": "visible", + "seriesStats": true, + "showPoints": false + }, + "stacked": false, + "curveType": "monotone", + "unit": "percent" + }, + "layout": { + "x": 0, + "y": 12, + "w": 6, + "h": 6 + }, + "sectionId": "gcp-services", + "tabId": "gke" + }, + { + "id": "gke-node-memory", + "visualization": "chart", + "dataSource": { + "kind": "query", + "resultShape": "timeseries", + "queries": [ + { + "id": "gke-node-memory", + "name": "Node Memory Utilization by Cluster", + "enabled": true, + "whereClause": "attr.memory_type = \"non-evictable\" AND resource.cloud.account.id = \"$project\" AND resource.cloud.region = \"$region\"", + "aggregation": "avg", + "stepInterval": "", + "orderByDirection": "desc", + "addOns": { + "groupBy": true, + "having": false, + "orderBy": false, + "limit": false, + "legend": false + }, + "groupBy": ["resource.k8s.cluster.name"], + "having": "", + "orderBy": "", + "limit": "", + "legend": "", + "dataSource": "metrics", + "signalSource": "default", + "metricName": "gcp.kubernetes.node.memory.allocatable_utilization", + "metricType": "gauge", + "isMonotonic": false + } + ], + "formulas": [], + "comparison": { + "mode": "none", + "includePercentChange": true + } + }, + "display": { + "title": "Node Memory Utilization by Cluster", + "chartId": "query-builder-line", + "chartPresentation": { + "legend": "visible", + "seriesStats": true, + "showPoints": false + }, + "stacked": false, + "curveType": "monotone", + "unit": "percent" + }, + "layout": { + "x": 6, + "y": 12, + "w": 6, + "h": 6 + }, + "sectionId": "gcp-services", + "tabId": "gke" + }, + { + "id": "compute-cpu", + "visualization": "chart", + "dataSource": { + "kind": "query", + "resultShape": "timeseries", + "queries": [ + { + "id": "compute-cpu", + "name": "CPU Utilization by Instance", + "enabled": true, + "whereClause": "resource.cloud.account.id = \"$project\" AND resource.cloud.region = \"$region\" AND service.name = \"$workload\"", + "aggregation": "avg", + "stepInterval": "", + "orderByDirection": "desc", + "addOns": { + "groupBy": true, + "having": false, + "orderBy": false, + "limit": false, + "legend": false + }, + "groupBy": ["service.name"], + "having": "", + "orderBy": "", + "limit": "", + "legend": "", + "dataSource": "metrics", + "signalSource": "default", + "metricName": "gcp.compute.instance.cpu.utilization", + "metricType": "gauge", + "isMonotonic": false + } + ], + "formulas": [], + "comparison": { + "mode": "none", + "includePercentChange": true + } + }, + "display": { + "title": "CPU Utilization by Instance", + "chartId": "query-builder-line", + "chartPresentation": { + "legend": "visible", + "seriesStats": true, + "showPoints": false + }, + "stacked": false, + "curveType": "monotone", + "unit": "percent" + }, + "layout": { + "x": 0, + "y": 0, + "w": 6, + "h": 6 + }, + "sectionId": "gcp-services", + "tabId": "compute-engine" + }, + { + "id": "compute-memory", + "visualization": "chart", + "dataSource": { + "kind": "query", + "resultShape": "timeseries", + "queries": [ + { + "id": "compute-memory", + "name": "Memory in Use by Instance (E2)", + "enabled": true, + "whereClause": "resource.cloud.account.id = \"$project\" AND resource.cloud.region = \"$region\" AND service.name = \"$workload\"", + "aggregation": "avg", + "stepInterval": "", + "orderByDirection": "desc", + "addOns": { + "groupBy": true, + "having": false, + "orderBy": false, + "limit": false, + "legend": false + }, + "groupBy": ["service.name"], + "having": "", + "orderBy": "", + "limit": "", + "legend": "", + "dataSource": "metrics", + "signalSource": "default", + "metricName": "gcp.compute.instance.memory.balloon.ram_used", + "metricType": "gauge", + "isMonotonic": false + } + ], + "formulas": [], + "comparison": { + "mode": "none", + "includePercentChange": true + } + }, + "display": { + "title": "Memory in Use by Instance (E2)", + "chartId": "query-builder-line", + "chartPresentation": { + "legend": "visible", + "seriesStats": true, + "showPoints": false + }, + "stacked": false, + "curveType": "monotone", + "unit": "bytes" + }, + "layout": { + "x": 6, + "y": 0, + "w": 6, + "h": 6 + }, + "sectionId": "gcp-services", + "tabId": "compute-engine" + }, + { + "id": "compute-network-received", + "visualization": "chart", + "dataSource": { + "kind": "query", + "resultShape": "timeseries", + "queries": [ + { + "id": "compute-network-received", + "name": "Network Bytes Received by Instance", + "enabled": true, + "whereClause": "resource.cloud.account.id = \"$project\" AND resource.cloud.region = \"$region\" AND service.name = \"$workload\"", + "aggregation": "sum", + "stepInterval": "", + "orderByDirection": "desc", + "addOns": { + "groupBy": true, + "having": false, + "orderBy": false, + "limit": false, + "legend": false + }, + "groupBy": ["service.name"], + "having": "", + "orderBy": "", + "limit": "", + "legend": "", + "dataSource": "metrics", + "signalSource": "default", + "metricName": "gcp.compute.instance.network.received_bytes_count", + "metricType": "sum", + "isMonotonic": false + } + ], + "formulas": [], + "comparison": { + "mode": "none", + "includePercentChange": true + } + }, + "display": { + "title": "Network Bytes Received by Instance", + "chartId": "query-builder-area", + "chartPresentation": { + "legend": "visible", + "seriesStats": false, + "showPoints": false + }, + "stacked": true, + "curveType": "monotone", + "unit": "bytes" + }, + "layout": { + "x": 0, + "y": 6, + "w": 6, + "h": 6 + }, + "sectionId": "gcp-services", + "tabId": "compute-engine" + }, + { + "id": "compute-network-sent", + "visualization": "chart", + "dataSource": { + "kind": "query", + "resultShape": "timeseries", + "queries": [ + { + "id": "compute-network-sent", + "name": "Network Bytes Sent by Instance", + "enabled": true, + "whereClause": "resource.cloud.account.id = \"$project\" AND resource.cloud.region = \"$region\" AND service.name = \"$workload\"", + "aggregation": "sum", + "stepInterval": "", + "orderByDirection": "desc", + "addOns": { + "groupBy": true, + "having": false, + "orderBy": false, + "limit": false, + "legend": false + }, + "groupBy": ["service.name"], + "having": "", + "orderBy": "", + "limit": "", + "legend": "", + "dataSource": "metrics", + "signalSource": "default", + "metricName": "gcp.compute.instance.network.sent_bytes_count", + "metricType": "sum", + "isMonotonic": false + } + ], + "formulas": [], + "comparison": { + "mode": "none", + "includePercentChange": true + } + }, + "display": { + "title": "Network Bytes Sent by Instance", + "chartId": "query-builder-area", + "chartPresentation": { + "legend": "visible", + "seriesStats": false, + "showPoints": false + }, + "stacked": true, + "curveType": "monotone", + "unit": "bytes" + }, + "layout": { + "x": 6, + "y": 6, + "w": 6, + "h": 6 + }, + "sectionId": "gcp-services", + "tabId": "compute-engine" + }, + { + "id": "compute-disk-read", + "visualization": "chart", + "dataSource": { + "kind": "query", + "resultShape": "timeseries", + "queries": [ + { + "id": "compute-disk-read", + "name": "Disk Bytes Read by Instance", + "enabled": true, + "whereClause": "resource.cloud.account.id = \"$project\" AND resource.cloud.region = \"$region\" AND service.name = \"$workload\"", + "aggregation": "sum", + "stepInterval": "", + "orderByDirection": "desc", + "addOns": { + "groupBy": true, + "having": false, + "orderBy": false, + "limit": false, + "legend": false + }, + "groupBy": ["service.name"], + "having": "", + "orderBy": "", + "limit": "", + "legend": "", + "dataSource": "metrics", + "signalSource": "default", + "metricName": "gcp.compute.instance.disk.read_bytes_count", + "metricType": "sum", + "isMonotonic": false + } + ], + "formulas": [], + "comparison": { + "mode": "none", + "includePercentChange": true + } + }, + "display": { + "title": "Disk Bytes Read by Instance", + "chartId": "query-builder-area", + "chartPresentation": { + "legend": "visible", + "seriesStats": false, + "showPoints": false + }, + "stacked": true, + "curveType": "monotone", + "unit": "bytes" + }, + "layout": { + "x": 0, + "y": 12, + "w": 6, + "h": 6 + }, + "sectionId": "gcp-services", + "tabId": "compute-engine" + }, + { + "id": "compute-disk-write", + "visualization": "chart", + "dataSource": { + "kind": "query", + "resultShape": "timeseries", + "queries": [ + { + "id": "compute-disk-write", + "name": "Disk Bytes Written by Instance", + "enabled": true, + "whereClause": "resource.cloud.account.id = \"$project\" AND resource.cloud.region = \"$region\" AND service.name = \"$workload\"", + "aggregation": "sum", + "stepInterval": "", + "orderByDirection": "desc", + "addOns": { + "groupBy": true, + "having": false, + "orderBy": false, + "limit": false, + "legend": false + }, + "groupBy": ["service.name"], + "having": "", + "orderBy": "", + "limit": "", + "legend": "", + "dataSource": "metrics", + "signalSource": "default", + "metricName": "gcp.compute.instance.disk.write_bytes_count", + "metricType": "sum", + "isMonotonic": false + } + ], + "formulas": [], + "comparison": { + "mode": "none", + "includePercentChange": true + } + }, + "display": { + "title": "Disk Bytes Written by Instance", + "chartId": "query-builder-area", + "chartPresentation": { + "legend": "visible", + "seriesStats": false, + "showPoints": false + }, + "stacked": true, + "curveType": "monotone", + "unit": "bytes" + }, + "layout": { + "x": 6, + "y": 12, + "w": 6, + "h": 6 + }, + "sectionId": "gcp-services", + "tabId": "compute-engine" + }, + { + "id": "sql-cpu", + "visualization": "chart", + "dataSource": { + "kind": "query", + "resultShape": "timeseries", + "queries": [ + { + "id": "sql-cpu", + "name": "CPU Utilization by Instance", + "enabled": true, + "whereClause": "resource.cloud.account.id = \"$project\" AND resource.cloud.region = \"$region\"", + "aggregation": "avg", + "stepInterval": "", + "orderByDirection": "desc", + "addOns": { + "groupBy": true, + "having": false, + "orderBy": false, + "limit": false, + "legend": false + }, + "groupBy": ["resource.gcp.resource.labels.database_id"], + "having": "", + "orderBy": "", + "limit": "", + "legend": "", + "dataSource": "metrics", + "signalSource": "default", + "metricName": "gcp.cloudsql.database.cpu.utilization", + "metricType": "gauge", + "isMonotonic": false + } + ], + "formulas": [], + "comparison": { + "mode": "none", + "includePercentChange": true + } + }, + "display": { + "title": "CPU Utilization by Instance", + "chartId": "query-builder-line", + "chartPresentation": { + "legend": "visible", + "seriesStats": true, + "showPoints": false + }, + "stacked": false, + "curveType": "monotone", + "unit": "percent" + }, + "layout": { + "x": 0, + "y": 0, + "w": 6, + "h": 6 + }, + "sectionId": "gcp-services", + "tabId": "cloud-sql" + }, + { + "id": "sql-memory", + "visualization": "chart", + "dataSource": { + "kind": "query", + "resultShape": "timeseries", + "queries": [ + { + "id": "sql-memory", + "name": "Memory Utilization by Instance", + "enabled": true, + "whereClause": "resource.cloud.account.id = \"$project\" AND resource.cloud.region = \"$region\"", + "aggregation": "avg", + "stepInterval": "", + "orderByDirection": "desc", + "addOns": { + "groupBy": true, + "having": false, + "orderBy": false, + "limit": false, + "legend": false + }, + "groupBy": ["resource.gcp.resource.labels.database_id"], + "having": "", + "orderBy": "", + "limit": "", + "legend": "", + "dataSource": "metrics", + "signalSource": "default", + "metricName": "gcp.cloudsql.database.memory.utilization", + "metricType": "gauge", + "isMonotonic": false + } + ], + "formulas": [], + "comparison": { + "mode": "none", + "includePercentChange": true + } + }, + "display": { + "title": "Memory Utilization by Instance", + "chartId": "query-builder-line", + "chartPresentation": { + "legend": "visible", + "seriesStats": true, + "showPoints": false + }, + "stacked": false, + "curveType": "monotone", + "unit": "percent" + }, + "layout": { + "x": 6, + "y": 0, + "w": 6, + "h": 6 + }, + "sectionId": "gcp-services", + "tabId": "cloud-sql" + }, + { + "id": "sql-connections", + "visualization": "chart", + "dataSource": { + "kind": "query", + "resultShape": "timeseries", + "queries": [ + { + "id": "sql-connections", + "name": "Connections (MySQL, SQL Server)", + "enabled": true, + "whereClause": "resource.cloud.account.id = \"$project\" AND resource.cloud.region = \"$region\"", + "aggregation": "avg", + "stepInterval": "", + "orderByDirection": "desc", + "addOns": { + "groupBy": true, + "having": false, + "orderBy": false, + "limit": false, + "legend": false + }, + "groupBy": ["resource.gcp.resource.labels.database_id"], + "having": "", + "orderBy": "", + "limit": "", + "legend": "", + "dataSource": "metrics", + "signalSource": "default", + "metricName": "gcp.cloudsql.database.network.connections", + "metricType": "gauge", + "isMonotonic": false + } + ], + "formulas": [], + "comparison": { + "mode": "none", + "includePercentChange": true + } + }, + "display": { + "title": "Connections (MySQL, SQL Server)", + "chartId": "query-builder-line", + "chartPresentation": { + "legend": "visible", + "seriesStats": true, + "showPoints": false + }, + "stacked": false, + "curveType": "monotone", + "unit": "number" + }, + "layout": { + "x": 0, + "y": 6, + "w": 6, + "h": 6 + }, + "sectionId": "gcp-services", + "tabId": "cloud-sql" + }, + { + "id": "sql-connections-postgres", + "visualization": "chart", + "dataSource": { + "kind": "query", + "resultShape": "timeseries", + "queries": [ + { + "id": "sql-connections-postgres", + "name": "Connections (PostgreSQL)", + "enabled": true, + "whereClause": "resource.cloud.account.id = \"$project\" AND resource.cloud.region = \"$region\"", + "aggregation": "avg", + "stepInterval": "", + "orderByDirection": "desc", + "addOns": { + "groupBy": true, + "having": false, + "orderBy": false, + "limit": false, + "legend": false + }, + "groupBy": ["resource.gcp.resource.labels.database_id"], + "having": "", + "orderBy": "", + "limit": "", + "legend": "", + "dataSource": "metrics", + "signalSource": "default", + "metricName": "gcp.cloudsql.database.postgresql.num_backends", + "metricType": "gauge", + "isMonotonic": false + } + ], + "formulas": [], + "comparison": { + "mode": "none", + "includePercentChange": true + } + }, + "display": { + "title": "Connections (PostgreSQL)", + "chartId": "query-builder-line", + "chartPresentation": { + "legend": "visible", + "seriesStats": true, + "showPoints": false + }, + "stacked": false, + "curveType": "monotone", + "unit": "number" + }, + "layout": { + "x": 6, + "y": 6, + "w": 6, + "h": 6 + }, + "sectionId": "gcp-services", + "tabId": "cloud-sql" + }, + { + "id": "sql-disk", + "visualization": "chart", + "dataSource": { + "kind": "query", + "resultShape": "timeseries", + "queries": [ + { + "id": "sql-disk", + "name": "Disk Utilization by Instance", + "enabled": true, + "whereClause": "resource.cloud.account.id = \"$project\" AND resource.cloud.region = \"$region\"", + "aggregation": "avg", + "stepInterval": "", + "orderByDirection": "desc", + "addOns": { + "groupBy": true, + "having": false, + "orderBy": false, + "limit": false, + "legend": false + }, + "groupBy": ["resource.gcp.resource.labels.database_id"], + "having": "", + "orderBy": "", + "limit": "", + "legend": "", + "dataSource": "metrics", + "signalSource": "default", + "metricName": "gcp.cloudsql.database.disk.utilization", + "metricType": "gauge", + "isMonotonic": false + } + ], + "formulas": [], + "comparison": { + "mode": "none", + "includePercentChange": true + } + }, + "display": { + "title": "Disk Utilization by Instance", + "chartId": "query-builder-line", + "chartPresentation": { + "legend": "visible", + "seriesStats": true, + "showPoints": false + }, + "stacked": false, + "curveType": "monotone", + "unit": "percent" + }, + "layout": { + "x": 0, + "y": 12, + "w": 6, + "h": 6 + }, + "sectionId": "gcp-services", + "tabId": "cloud-sql" + }, + { + "id": "sql-replica-lag", + "visualization": "chart", + "dataSource": { + "kind": "query", + "resultShape": "timeseries", + "queries": [ + { + "id": "sql-replica-lag", + "name": "Replica Lag by Instance", + "enabled": true, + "whereClause": "resource.cloud.account.id = \"$project\" AND resource.cloud.region = \"$region\"", + "aggregation": "avg", + "stepInterval": "", + "orderByDirection": "desc", + "addOns": { + "groupBy": true, + "having": false, + "orderBy": false, + "limit": false, + "legend": false + }, + "groupBy": ["resource.gcp.resource.labels.database_id"], + "having": "", + "orderBy": "", + "limit": "", + "legend": "", + "dataSource": "metrics", + "signalSource": "default", + "metricName": "gcp.cloudsql.database.replication.replica_lag", + "metricType": "gauge", + "isMonotonic": false + } + ], + "formulas": [], + "comparison": { + "mode": "none", + "includePercentChange": true + } + }, + "display": { + "title": "Replica Lag by Instance", + "chartId": "query-builder-line", + "chartPresentation": { + "legend": "visible", + "seriesStats": true, + "showPoints": false + }, + "stacked": false, + "curveType": "monotone", + "unit": "duration_s" + }, + "layout": { + "x": 6, + "y": 12, + "w": 6, + "h": 6 + }, + "sectionId": "gcp-services", + "tabId": "cloud-sql" + }, + { + "id": "pubsub-backlog", + "visualization": "chart", + "dataSource": { + "kind": "query", + "resultShape": "timeseries", + "queries": [ + { + "id": "pubsub-backlog", + "name": "Unacknowledged Messages by Subscription", + "enabled": true, + "whereClause": "resource.cloud.account.id = \"$project\"", + "aggregation": "avg", + "stepInterval": "", + "orderByDirection": "desc", + "addOns": { + "groupBy": true, + "having": false, + "orderBy": false, + "limit": false, + "legend": false + }, + "groupBy": ["resource.gcp.resource.labels.subscription_id"], + "having": "", + "orderBy": "", + "limit": "", + "legend": "", + "dataSource": "metrics", + "signalSource": "default", + "metricName": "gcp.pubsub.subscription.num_undelivered_messages", + "metricType": "gauge", + "isMonotonic": false + } + ], + "formulas": [], + "comparison": { + "mode": "none", + "includePercentChange": true + } + }, + "display": { + "title": "Unacknowledged Messages by Subscription", + "chartId": "query-builder-line", + "chartPresentation": { + "legend": "visible", + "seriesStats": true, + "showPoints": false + }, + "stacked": false, + "curveType": "monotone", + "unit": "number" + }, + "layout": { + "x": 0, + "y": 0, + "w": 6, + "h": 6 + }, + "sectionId": "gcp-services", + "tabId": "pubsub" + }, + { + "id": "pubsub-oldest-unacked", + "visualization": "chart", + "dataSource": { + "kind": "query", + "resultShape": "timeseries", + "queries": [ + { + "id": "pubsub-oldest-unacked", + "name": "Oldest Unacknowledged Message Age", + "enabled": true, + "whereClause": "resource.cloud.account.id = \"$project\"", + "aggregation": "avg", + "stepInterval": "", + "orderByDirection": "desc", + "addOns": { + "groupBy": true, + "having": false, + "orderBy": false, + "limit": false, + "legend": false + }, + "groupBy": ["resource.gcp.resource.labels.subscription_id"], + "having": "", + "orderBy": "", + "limit": "", + "legend": "", + "dataSource": "metrics", + "signalSource": "default", + "metricName": "gcp.pubsub.subscription.oldest_unacked_message_age", + "metricType": "gauge", + "isMonotonic": false + } + ], + "formulas": [], + "comparison": { + "mode": "none", + "includePercentChange": true + } + }, + "display": { + "title": "Oldest Unacknowledged Message Age", + "chartId": "query-builder-line", + "chartPresentation": { + "legend": "visible", + "seriesStats": true, + "showPoints": false + }, + "stacked": false, + "curveType": "monotone", + "unit": "duration_s" + }, + "layout": { + "x": 6, + "y": 0, + "w": 6, + "h": 6 + }, + "sectionId": "gcp-services", + "tabId": "pubsub" + }, + { + "id": "pubsub-sent", + "visualization": "chart", + "dataSource": { + "kind": "query", + "resultShape": "timeseries", + "queries": [ + { + "id": "pubsub-sent", + "name": "Messages Sent by Subscription", + "enabled": true, + "whereClause": "resource.cloud.account.id = \"$project\"", + "aggregation": "sum", + "stepInterval": "", + "orderByDirection": "desc", + "addOns": { + "groupBy": true, + "having": false, + "orderBy": false, + "limit": false, + "legend": false + }, + "groupBy": ["resource.gcp.resource.labels.subscription_id"], + "having": "", + "orderBy": "", + "limit": "", + "legend": "", + "dataSource": "metrics", + "signalSource": "default", + "metricName": "gcp.pubsub.subscription.sent_message_count", + "metricType": "sum", + "isMonotonic": false + } + ], + "formulas": [], + "comparison": { + "mode": "none", + "includePercentChange": true + } + }, + "display": { + "title": "Messages Sent by Subscription", + "chartId": "query-builder-area", + "chartPresentation": { + "legend": "visible", + "seriesStats": false, + "showPoints": false + }, + "stacked": true, + "curveType": "monotone", + "unit": "number" + }, + "layout": { + "x": 0, + "y": 6, + "w": 6, + "h": 6 + }, + "sectionId": "gcp-services", + "tabId": "pubsub" + }, + { + "id": "pubsub-acked", + "visualization": "chart", + "dataSource": { + "kind": "query", + "resultShape": "timeseries", + "queries": [ + { + "id": "pubsub-acked", + "name": "Messages Acknowledged by Subscription", + "enabled": true, + "whereClause": "resource.cloud.account.id = \"$project\"", + "aggregation": "sum", + "stepInterval": "", + "orderByDirection": "desc", + "addOns": { + "groupBy": true, + "having": false, + "orderBy": false, + "limit": false, + "legend": false + }, + "groupBy": ["resource.gcp.resource.labels.subscription_id"], + "having": "", + "orderBy": "", + "limit": "", + "legend": "", + "dataSource": "metrics", + "signalSource": "default", + "metricName": "gcp.pubsub.subscription.ack_message_count", + "metricType": "sum", + "isMonotonic": false + } + ], + "formulas": [], + "comparison": { + "mode": "none", + "includePercentChange": true + } + }, + "display": { + "title": "Messages Acknowledged by Subscription", + "chartId": "query-builder-area", + "chartPresentation": { + "legend": "visible", + "seriesStats": false, + "showPoints": false + }, + "stacked": true, + "curveType": "monotone", + "unit": "number" + }, + "layout": { + "x": 6, + "y": 6, + "w": 6, + "h": 6 + }, + "sectionId": "gcp-services", + "tabId": "pubsub" + }, + { + "id": "pubsub-dead-lettered", + "visualization": "chart", + "dataSource": { + "kind": "query", + "resultShape": "timeseries", + "queries": [ + { + "id": "pubsub-dead-lettered", + "name": "Dead-Lettered Messages by Subscription", + "enabled": true, + "whereClause": "resource.cloud.account.id = \"$project\"", + "aggregation": "sum", + "stepInterval": "", + "orderByDirection": "desc", + "addOns": { + "groupBy": true, + "having": false, + "orderBy": false, + "limit": false, + "legend": false + }, + "groupBy": ["resource.gcp.resource.labels.subscription_id"], + "having": "", + "orderBy": "", + "limit": "", + "legend": "", + "dataSource": "metrics", + "signalSource": "default", + "metricName": "gcp.pubsub.subscription.dead_letter_message_count", + "metricType": "sum", + "isMonotonic": false + } + ], + "formulas": [], + "comparison": { + "mode": "none", + "includePercentChange": true + } + }, + "display": { + "title": "Dead-Lettered Messages by Subscription", + "chartId": "query-builder-bar", + "chartPresentation": { + "legend": "visible", + "seriesStats": false, + "showPoints": false + }, + "stacked": true, + "curveType": "linear", + "unit": "number" + }, + "layout": { + "x": 0, + "y": 12, + "w": 6, + "h": 6 + }, + "sectionId": "gcp-services", + "tabId": "pubsub" + }, + { + "id": "pubsub-publish-requests", + "visualization": "chart", + "dataSource": { + "kind": "query", + "resultShape": "timeseries", + "queries": [ + { + "id": "pubsub-publish-requests", + "name": "Publish Requests by Topic", + "enabled": true, + "whereClause": "resource.cloud.account.id = \"$project\"", + "aggregation": "sum", + "stepInterval": "", + "orderByDirection": "desc", + "addOns": { + "groupBy": true, + "having": false, + "orderBy": false, + "limit": false, + "legend": false + }, + "groupBy": ["resource.gcp.resource.labels.topic_id"], + "having": "", + "orderBy": "", + "limit": "", + "legend": "", + "dataSource": "metrics", + "signalSource": "default", + "metricName": "gcp.pubsub.topic.send_request_count", + "metricType": "sum", + "isMonotonic": false + } + ], + "formulas": [], + "comparison": { + "mode": "none", + "includePercentChange": true + } + }, + "display": { + "title": "Publish Requests by Topic", + "chartId": "query-builder-area", + "chartPresentation": { + "legend": "visible", + "seriesStats": false, + "showPoints": false + }, + "stacked": true, + "curveType": "monotone", + "unit": "number" + }, + "layout": { + "x": 6, + "y": 12, + "w": 6, + "h": 6 + }, + "sectionId": "gcp-services", + "tabId": "pubsub" + }, + { + "id": "lb-requests", + "visualization": "chart", + "dataSource": { + "kind": "query", + "resultShape": "timeseries", + "queries": [ + { + "id": "lb-requests", + "name": "Requests by URL Map", + "enabled": true, + "whereClause": "resource.cloud.account.id = \"$project\"", + "aggregation": "sum", + "stepInterval": "", + "orderByDirection": "desc", + "addOns": { + "groupBy": true, + "having": false, + "orderBy": false, + "limit": false, + "legend": false + }, + "groupBy": ["resource.gcp.resource.labels.url_map_name"], + "having": "", + "orderBy": "", + "limit": "", + "legend": "", + "dataSource": "metrics", + "signalSource": "default", + "metricName": "gcp.loadbalancing.https.request_count", + "metricType": "sum", + "isMonotonic": false + } + ], + "formulas": [], + "comparison": { + "mode": "none", + "includePercentChange": true + } + }, + "display": { + "title": "Requests by URL Map", + "chartId": "query-builder-area", + "chartPresentation": { + "legend": "visible", + "seriesStats": false, + "showPoints": false + }, + "stacked": true, + "curveType": "monotone", + "unit": "number" + }, + "layout": { + "x": 0, + "y": 0, + "w": 6, + "h": 6 + }, + "sectionId": "gcp-services", + "tabId": "load-balancing" + }, + { + "id": "lb-response-classes", + "visualization": "chart", + "dataSource": { + "kind": "query", + "resultShape": "timeseries", + "queries": [ + { + "id": "lb-response-classes", + "name": "Requests by Response Class", + "enabled": true, + "whereClause": "resource.cloud.account.id = \"$project\"", + "aggregation": "sum", + "stepInterval": "", + "orderByDirection": "desc", + "addOns": { + "groupBy": true, + "having": false, + "orderBy": false, + "limit": false, + "legend": false + }, + "groupBy": ["attr.response_code_class"], + "having": "", + "orderBy": "", + "limit": "", + "legend": "", + "dataSource": "metrics", + "signalSource": "default", + "metricName": "gcp.loadbalancing.https.request_count", + "metricType": "sum", + "isMonotonic": false + } + ], + "formulas": [], + "comparison": { + "mode": "none", + "includePercentChange": true + } + }, + "display": { + "title": "Requests by Response Class", + "chartId": "query-builder-area", + "chartPresentation": { + "legend": "visible", + "seriesStats": false, + "showPoints": false + }, + "stacked": true, + "curveType": "monotone", + "unit": "number" + }, + "layout": { + "x": 6, + "y": 0, + "w": 6, + "h": 6 + }, + "sectionId": "gcp-services", + "tabId": "load-balancing" + }, + { + "id": "lb-total-latency", + "visualization": "chart", + "dataSource": { + "kind": "query", + "resultShape": "timeseries", + "queries": [ + { + "id": "lb-total-latency", + "name": "Total Latency p95 by URL Map", + "enabled": true, + "whereClause": "attr.quantile = \"0.95\" AND resource.cloud.account.id = \"$project\"", + "aggregation": "avg", + "stepInterval": "", + "orderByDirection": "desc", + "addOns": { + "groupBy": true, + "having": false, + "orderBy": false, + "limit": false, + "legend": false + }, + "groupBy": ["resource.gcp.resource.labels.url_map_name"], + "having": "", + "orderBy": "", + "limit": "", + "legend": "", + "dataSource": "metrics", + "signalSource": "default", + "metricName": "gcp.loadbalancing.https.total_latencies", + "metricType": "gauge", + "isMonotonic": false + } + ], + "formulas": [], + "comparison": { + "mode": "none", + "includePercentChange": true + } + }, + "display": { + "title": "Total Latency p95 by URL Map", + "chartId": "query-builder-line", + "chartPresentation": { + "legend": "visible", + "seriesStats": true, + "showPoints": false + }, + "stacked": false, + "curveType": "monotone", + "unit": "duration_ms" + }, + "layout": { + "x": 0, + "y": 6, + "w": 6, + "h": 6 + }, + "sectionId": "gcp-services", + "tabId": "load-balancing" + }, + { + "id": "lb-backend-latency", + "visualization": "chart", + "dataSource": { + "kind": "query", + "resultShape": "timeseries", + "queries": [ + { + "id": "lb-backend-latency", + "name": "Backend Latency p95 by URL Map", + "enabled": true, + "whereClause": "attr.quantile = \"0.95\" AND resource.cloud.account.id = \"$project\"", + "aggregation": "avg", + "stepInterval": "", + "orderByDirection": "desc", + "addOns": { + "groupBy": true, + "having": false, + "orderBy": false, + "limit": false, + "legend": false + }, + "groupBy": ["resource.gcp.resource.labels.url_map_name"], + "having": "", + "orderBy": "", + "limit": "", + "legend": "", + "dataSource": "metrics", + "signalSource": "default", + "metricName": "gcp.loadbalancing.https.backend_latencies", + "metricType": "gauge", + "isMonotonic": false + } + ], + "formulas": [], + "comparison": { + "mode": "none", + "includePercentChange": true + } + }, + "display": { + "title": "Backend Latency p95 by URL Map", + "chartId": "query-builder-line", + "chartPresentation": { + "legend": "visible", + "seriesStats": true, + "showPoints": false + }, + "stacked": false, + "curveType": "monotone", + "unit": "duration_ms" + }, + "layout": { + "x": 6, + "y": 6, + "w": 6, + "h": 6 + }, + "sectionId": "gcp-services", + "tabId": "load-balancing" + } + ], + "sections": [ + { + "id": "gcp-services", + "title": "Services", + "collapsible": false, + "tabs": [ + { + "id": "cloud-run", + "title": "Cloud Run" + }, + { + "id": "cloud-functions", + "title": "Cloud Functions (1st gen)" + }, + { + "id": "gke", + "title": "GKE" + }, + { + "id": "compute-engine", + "title": "Compute Engine" + }, + { + "id": "cloud-sql", + "title": "Cloud SQL" + }, + { + "id": "pubsub", + "title": "Pub/Sub" + }, + { + "id": "load-balancing", + "title": "Load Balancing" + } + ] + } + ], + "variables": [ + { + "name": "project", + "label": "Project", + "includeAll": true, + "type": "query", + "source": { + "kind": "metric", + "metricType": "gauge", + "metricNames": [ + "gcp.run.request_latencies", + "gcp.cloudfunctions.function.execution_times", + "gcp.kubernetes.container.cpu.limit_utilization", + "gcp.kubernetes.node.cpu.allocatable_utilization", + "gcp.compute.instance.cpu.utilization", + "gcp.cloudsql.database.cpu.utilization", + "gcp.pubsub.subscription.num_undelivered_messages", + "gcp.loadbalancing.https.total_latencies" + ], + "scope": "resource", + "attributeKey": "cloud.account.id" + } + }, + { + "name": "region", + "label": "Region", + "includeAll": true, + "type": "query", + "source": { + "kind": "metric", + "metricType": "gauge", + "metricNames": [ + "gcp.run.request_latencies", + "gcp.cloudfunctions.function.execution_times", + "gcp.kubernetes.container.cpu.limit_utilization", + "gcp.kubernetes.node.cpu.allocatable_utilization", + "gcp.compute.instance.cpu.utilization", + "gcp.cloudsql.database.cpu.utilization" + ], + "scope": "resource", + "attributeKey": "cloud.region" + } + }, + { + "name": "workload", + "label": "Workload", + "includeAll": true, + "type": "query", + "source": { + "kind": "metric", + "metricType": "gauge", + "metricNames": [ + "gcp.run.request_latencies", + "gcp.cloudfunctions.function.execution_times", + "gcp.kubernetes.container.cpu.limit_utilization", + "gcp.compute.instance.cpu.utilization" + ], + "scope": "resource", + "attributeKey": "service.name" + } + } + ] +} diff --git a/apps/web/src/lab/gcp-dashboard-lab.tsx b/apps/web/src/lab/gcp-dashboard-lab.tsx new file mode 100644 index 0000000000..1996a46841 --- /dev/null +++ b/apps/web/src/lab/gcp-dashboard-lab.tsx @@ -0,0 +1,213 @@ +import { useMemo, useState } from "react" +import { Schema } from "effect" +import { PortableDashboardDocument } from "@maple/domain/http" +import { ALL_VALUE, resolveDashboardVariableValue, type VariableValues } from "@maple/query-engine" + +import { DashboardPage } from "@/components/layout/dashboard-page" +import { VariablesManagerDialog } from "@/components/dashboard-builder/config/variables-manager-dialog" +import { + DashboardVariablesContext, + useDashboardVariables, + type DashboardVariablesContextValue, + type VariableOptionsState, +} from "@/components/dashboard-builder/dashboard-variables-context" +import { ReadOnlyDashboardView } from "@/components/dashboard-builder/read-only-dashboard-view" +import { VariableSelects } from "@/components/dashboard-builder/toolbar/variable-selects" +import type { DashboardVariable, WidgetDataState } from "@/components/dashboard-builder/types" +import { visualizationFor } from "@/components/dashboard-builder/widgets/types" +import fixture from "@/lab/gcp-dashboard-fixture.json" + +// `gcpTemplate.build({})` from @maple/backend, which the web app cannot import. Regenerate after +// changing the template: +// cd packages/backend && bun -e 'import { gcpTemplate } from "@maple/backend/dashboard-templates/infrastructure/gcp"; await Bun.write("../../apps/web/src/lab/gcp-dashboard-fixture.json", JSON.stringify(gcpTemplate.build({}), null, "\t") + "\n")' +const DASHBOARD = Schema.decodeUnknownSync(PortableDashboardDocument)(fixture) + +type Widget = (typeof DASHBOARD.widgets)[number] + +interface Resource { + readonly name: string + readonly project: string + readonly region?: string +} + +const run = (name: string, project: string, region: string): Resource => ({ name, project, region }) +const global = (name: string, project: string): Resource => ({ name, project }) + +// Workloads differ per tab, so a `service.name` series list is the tab's. +const WORKLOADS = new Map>( + Object.entries({ + "cloud-run": [ + run("checkout", "acme-prod", "us-central1"), + run("storefront", "acme-prod", "europe-west1"), + run("checkout", "acme-staging", "us-central1"), + ], + "cloud-functions": [ + run("resize-image", "acme-prod", "us-central1"), + run("send-receipt", "acme-prod", "europe-west1"), + ], + gke: [ + run("orders-api", "acme-prod", "us-central1"), + run("worker", "acme-prod", "us-central1"), + run("metrics-server", "acme-staging", "europe-west1"), + ], + "compute-engine": [ + run("bastion", "acme-prod", "us-central1"), + run("ci-runner", "acme-staging", "europe-west1"), + ], + }), +) + +const GROUPS = new Map>( + Object.entries({ + "resource.k8s.cluster.name": [ + run("prod-us", "acme-prod", "us-central1"), + run("staging-eu", "acme-staging", "europe-west1"), + ], + "resource.gcp.resource.labels.database_id": [ + run("acme-prod:orders-db", "acme-prod", "us-central1"), + run("acme-prod:orders-db-replica", "acme-prod", "europe-west1"), + ], + "resource.gcp.resource.labels.subscription_id": [ + global("orders-fulfilment", "acme-prod"), + global("receipts", "acme-prod"), + global("orders-fulfilment", "acme-staging"), + ], + "resource.gcp.resource.labels.topic_id": [ + global("orders", "acme-prod"), + global("orders", "acme-staging"), + ], + "resource.gcp.resource.labels.url_map_name": [ + global("storefront-lb", "acme-prod"), + global("api-lb", "acme-prod"), + ], + "attr.response_code_class": [ + global("2xx", "acme-prod"), + global("4xx", "acme-prod"), + global("5xx", "acme-prod"), + ], + }), +) + +const ALL_RESOURCES = [...WORKLOADS.values(), ...GROUPS.values()].flat() +const unique = (values: ReadonlyArray) => + [...new Set(values.filter((value): value is string => value !== undefined))].sort() + +// What the metric-sourced variables would list against this data. +const OPTIONS = new Map([ + ["project", { options: unique(ALL_RESOURCES.map((r) => r.project)), loading: false }], + ["region", { options: unique(ALL_RESOURCES.map((r) => r.region)), loading: false }], + ["workload", { options: unique([...WORKLOADS.values()].flat().map((r) => r.name)), loading: false }], +]) + +// A resource passes a variable only when the widget filters on it; a widget that leaves a +// variable out (Region on Pub/Sub) keeps every series whatever is picked. +const ATTRIBUTE = new Map string | undefined>([ + ["project", (resource) => resource.project], + ["region", (resource) => resource.region], + ["workload", (resource) => resource.name], +]) + +const RANGES = new Map([ + ["percent", [0.15, 0.85]], + ["bytes", [2e7, 6e8]], + ["duration_ms", [40, 900]], + ["duration_s", [2, 180]], +]) + +const BUCKETS = Array.from({ length: 48 }, (_, i) => new Date(Date.UTC(2026, 9, 14, 0, i * 30)).toISOString()) + +function hash(text: string): number { + let h = 2166136261 + for (const char of text) h = Math.imul(h ^ char.charCodeAt(0), 16777619) + return (h >>> 0) / 4294967296 +} + +function fixtureRows(widget: Widget, values: VariableValues): Array> { + if (widget.dataSource.kind !== "query") return [] + const query = widget.dataSource.queries[0] + const groupBy = query?.groupBy[0] ?? "" + const resources = + (groupBy === "service.name" ? WORKLOADS.get(widget.tabId ?? "") : GROUPS.get(groupBy)) ?? [] + const filters = [...ATTRIBUTE.keys()].filter((name) => query?.whereClause.includes(`$${name}`)) + const kept = resources.filter((resource) => + filters.every((name) => { + const selected = values[name] + return ( + selected === undefined || selected.isAll || ATTRIBUTE.get(name)?.(resource) === selected.value + ) + }), + ) + const series = unique(kept.map((resource) => resource.name)) + const [low, high] = RANGES.get(String(widget.display.unit)) ?? [5, 1800] + return BUCKETS.map((bucket, i) => ({ + bucket, + ...Object.fromEntries( + series.map((name) => { + const seed = hash(`${widget.id}/${name}`) + const wave = 0.5 + 0.35 * Math.sin(i / 5 + seed * 6) + 0.15 * (hash(`${seed}/${i}`) - 0.5) + return [name, low + (high - low) * Math.min(1, Math.max(0, wave * (0.4 + seed)))] + }), + ), + })) +} + +function LabWidget({ widget }: { widget: Widget }) { + const { values } = useDashboardVariables() + const Visualization = visualizationFor(widget.visualization) + const dataState = useMemo( + () => ({ status: "ready", data: fixtureRows(widget, values) }), + [widget, values], + ) + return +} + +/** + * `/lab/gcp-dashboard` — the Google Cloud template as a board: its tabs, its Project / Region / + * Workload chips over fixture options, and the variables dialog, with no API. + */ +export function GcpDashboardLab() { + const [variables, setVariables] = useState( + () => structuredClone(DASHBOARD.variables ?? []) as DashboardVariable[], + ) + const [selected, setSelected] = useState>({}) + const [managing, setManaging] = useState(false) + + const context = useMemo(() => { + const values: VariableValues = {} + for (const variable of variables) { + const options = OPTIONS.get(variable.name) ?? { options: [], loading: false } + const value = resolveDashboardVariableValue(variable, selected[variable.name], options) + if (value !== undefined) { + values[variable.name] = { value, isAll: value === ALL_VALUE, options: options.options } + } + } + return { + variables, + values, + optionsByName: Object.fromEntries(OPTIONS), + setValue: (name, value) => setSelected((prev) => ({ ...prev, [name]: value })), + } + }, [variables, selected]) + + return ( + + setManaging(true)} />} + fill + > + + + + + ) +} diff --git a/apps/web/src/lab/registry.ts b/apps/web/src/lab/registry.ts index 16b7be250b..e05f1eceb5 100644 --- a/apps/web/src/lab/registry.ts +++ b/apps/web/src/lab/registry.ts @@ -68,6 +68,14 @@ export const LAB_ENTRIES: ReadonlyArray = [ kind: "lab", session: "none", }, + { + path: "/lab/gcp-dashboard", + title: "Google Cloud dashboard", + description: + "The Google Cloud template as a board: tabs, Project / Region / Workload filters over fixture series, and the variables dialog.", + kind: "lab", + session: "none", + }, { path: "/lab/widgets", title: "Widgets", diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index a57f389c5b..f63fabe63b 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -58,6 +58,7 @@ import { Route as LabCloudflareRouteImport } from './routes/lab/cloudflare' import { Route as LabCodeReviewReposRouteImport } from './routes/lab/code-review-repos' import { Route as LabErrorsRouteImport } from './routes/lab/errors' import { Route as LabFlowRouteImport } from './routes/lab/flow' +import { Route as LabGcpDashboardRouteImport } from './routes/lab/gcp-dashboard' import { Route as LabInfraRouteImport } from './routes/lab/infra' import { Route as LabLoadersRouteImport } from './routes/lab/loaders' import { Route as LabLogsRouteImport } from './routes/lab/logs' @@ -365,6 +366,11 @@ const LabFlowRoute = LabFlowRouteImport.update({ path: '/flow', getParentRoute: () => LabRouteRoute, } as any) +const LabGcpDashboardRoute = LabGcpDashboardRouteImport.update({ + id: '/gcp-dashboard', + path: '/gcp-dashboard', + getParentRoute: () => LabRouteRoute, +} as any) const LabInfraRoute = LabInfraRouteImport.update({ id: '/infra', path: '/infra', @@ -725,6 +731,7 @@ export interface FileRoutesByFullPath { '/lab/code-review-repos': typeof LabCodeReviewReposRoute '/lab/errors': typeof LabErrorsRoute '/lab/flow': typeof LabFlowRoute + '/lab/gcp-dashboard': typeof LabGcpDashboardRoute '/lab/infra': typeof LabInfraRoute '/lab/loaders': typeof LabLoadersRoute '/lab/logs': typeof LabLogsRoute @@ -836,6 +843,7 @@ export interface FileRoutesByTo { '/lab/code-review-repos': typeof LabCodeReviewReposRoute '/lab/errors': typeof LabErrorsRoute '/lab/flow': typeof LabFlowRoute + '/lab/gcp-dashboard': typeof LabGcpDashboardRoute '/lab/infra': typeof LabInfraRoute '/lab/loaders': typeof LabLoadersRoute '/lab/logs': typeof LabLogsRoute @@ -949,6 +957,7 @@ export interface FileRoutesById { '/lab/code-review-repos': typeof LabCodeReviewReposRoute '/lab/errors': typeof LabErrorsRoute '/lab/flow': typeof LabFlowRoute + '/lab/gcp-dashboard': typeof LabGcpDashboardRoute '/lab/infra': typeof LabInfraRoute '/lab/loaders': typeof LabLoadersRoute '/lab/logs': typeof LabLogsRoute @@ -1063,6 +1072,7 @@ export interface FileRouteTypes { | '/lab/code-review-repos' | '/lab/errors' | '/lab/flow' + | '/lab/gcp-dashboard' | '/lab/infra' | '/lab/loaders' | '/lab/logs' @@ -1174,6 +1184,7 @@ export interface FileRouteTypes { | '/lab/code-review-repos' | '/lab/errors' | '/lab/flow' + | '/lab/gcp-dashboard' | '/lab/infra' | '/lab/loaders' | '/lab/logs' @@ -1286,6 +1297,7 @@ export interface FileRouteTypes { | '/lab/code-review-repos' | '/lab/errors' | '/lab/flow' + | '/lab/gcp-dashboard' | '/lab/infra' | '/lab/loaders' | '/lab/logs' @@ -1786,6 +1798,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LabFlowRouteImport parentRoute: typeof LabRouteRoute } + '/lab/gcp-dashboard': { + id: '/lab/gcp-dashboard' + path: '/gcp-dashboard' + fullPath: '/lab/gcp-dashboard' + preLoaderRoute: typeof LabGcpDashboardRouteImport + parentRoute: typeof LabRouteRoute + } '/lab/infra': { id: '/lab/infra' path: '/infra' @@ -2226,6 +2245,7 @@ interface LabRouteRouteChildren { LabCodeReviewReposRoute: typeof LabCodeReviewReposRoute LabErrorsRoute: typeof LabErrorsRoute LabFlowRoute: typeof LabFlowRoute + LabGcpDashboardRoute: typeof LabGcpDashboardRoute LabInfraRoute: typeof LabInfraRoute LabLoadersRoute: typeof LabLoadersRoute LabLogsRoute: typeof LabLogsRoute @@ -2260,6 +2280,7 @@ const LabRouteRouteChildren: LabRouteRouteChildren = { LabCodeReviewReposRoute: LabCodeReviewReposRoute, LabErrorsRoute: LabErrorsRoute, LabFlowRoute: LabFlowRoute, + LabGcpDashboardRoute: LabGcpDashboardRoute, LabInfraRoute: LabInfraRoute, LabLoadersRoute: LabLoadersRoute, LabLogsRoute: LabLogsRoute, diff --git a/apps/web/src/routes/lab/gcp-dashboard.tsx b/apps/web/src/routes/lab/gcp-dashboard.tsx new file mode 100644 index 0000000000..d2f9ed0468 --- /dev/null +++ b/apps/web/src/routes/lab/gcp-dashboard.tsx @@ -0,0 +1,5 @@ +import { createFileRoute } from "@tanstack/react-router" + +import { GcpDashboardLab } from "@/lab/gcp-dashboard-lab" + +export const Route = createFileRoute("/lab/gcp-dashboard")({ component: GcpDashboardLab }) diff --git a/packages/backend/src/dashboard-templates/infrastructure/gcp.test.ts b/packages/backend/src/dashboard-templates/infrastructure/gcp.test.ts index 1b58ca45d3..6a36c2c79e 100644 --- a/packages/backend/src/dashboard-templates/infrastructure/gcp.test.ts +++ b/packages/backend/src/dashboard-templates/infrastructure/gcp.test.ts @@ -92,9 +92,10 @@ describe("Google Cloud dashboard template", () => { for (const name of metricNames) { const stored = STORED.get(name) expect(stored?.metricType, `${variable.name}: ${name}`).toBe(metricType) - expect(Object.keys(stored?.rows[0]?.resource_attributes ?? {}), `${variable.name}: ${name}`).toContain( - attributeKey, - ) + expect( + Object.keys(stored?.rows[0]?.resource_attributes ?? {}), + `${variable.name}: ${name}`, + ).toContain(attributeKey) } } diff --git a/packages/backend/src/dashboard-templates/infrastructure/gcp.ts b/packages/backend/src/dashboard-templates/infrastructure/gcp.ts index 2a459d9674..1f8a99f2f9 100644 --- a/packages/backend/src/dashboard-templates/infrastructure/gcp.ts +++ b/packages/backend/src/dashboard-templates/infrastructure/gcp.ts @@ -443,7 +443,8 @@ export const gcpTemplate: TemplateDefinition = { { key: paramKey("project_id"), label: "Project ID", - description: "Optional. The project the Project filter starts on; it can be changed on the dashboard.", + description: + "Optional. The project the Project filter starts on; it can be changed on the dashboard.", required: false, placeholder: "acme-prod", }, From b5378d607152f170e70ab4b1cbe20e3093915532 Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Thu, 15 Oct 2026 19:31:28 +0200 Subject: [PATCH 3/4] feat(service-map): show Google Cloud as the platform for services hosted on GCP Google Cloud's resource detector sets faas.name on Cloud Run and Cloud Functions, so those services were drawn as AWS Lambda, and GKE services as plain Kubernetes. cloud.provider=gcp now classifies as a gcp platform with the Google Cloud mark, tooltip, colour and legend entry. --- .../src/routes/internal/query-engine.http.ts | 22 +++++++++++-------- .../src/hooks/use-local-service-map.test.ts | 15 +++++++++++++ .../src/hooks/use-local-service-map.ts | 2 ++ apps/web/src/api/warehouse/service-map.ts | 2 +- apps/web/src/components/icons/index.ts | 3 ++- apps/web/src/lab/bench/service-map-bench.tsx | 2 +- packages/domain/src/http/query-engine.ts | 9 +++++++- .../ui}/src/components/icons/google-cloud.tsx | 0 packages/ui/src/components/icons/index.ts | 1 + .../service-map/service-map-canvas.tsx | 13 ++++++++--- .../service-map/service-map-node.tsx | 3 +++ .../service-map/service-map-types.ts | 2 +- .../service-map/service-map-utils.ts | 1 + 13 files changed, 58 insertions(+), 17 deletions(-) rename {apps/web => packages/ui}/src/components/icons/google-cloud.tsx (100%) diff --git a/apps/api/src/routes/internal/query-engine.http.ts b/apps/api/src/routes/internal/query-engine.http.ts index 7a71bf8860..d16c98d4e6 100644 --- a/apps/api/src/routes/internal/query-engine.http.ts +++ b/apps/api/src/routes/internal/query-engine.http.ts @@ -250,17 +250,21 @@ const toServicePlatformRow = (row: CH.ServicePlatformsOutput) => { const processRuntimeName = row.processRuntimeName // cluster.name alone does not prove the service runs in Kubernetes. const isKubernetes = k8sPodName !== "" || k8sDeploymentName !== "" - // Host infrastructure takes precedence over SDK self-report. - const platform: "kubernetes" | "cloudflare" | "lambda" | "web" | "unknown" = + // Host infrastructure takes precedence over SDK self-report. Google Cloud's + // detector sets `faas.name` on Cloud Run and Cloud Functions, so it is checked + // before the Lambda rule. + const platform: "kubernetes" | "cloudflare" | "gcp" | "lambda" | "web" | "unknown" = cloudPlatform === "cloudflare.workers" || cloudProvider === "cloudflare" ? "cloudflare" - : faasName !== "" || cloudPlatform === "aws_lambda" - ? "lambda" - : isKubernetes - ? "kubernetes" - : mapleSdkType === "client" - ? "web" - : "unknown" + : cloudProvider === "gcp" + ? "gcp" + : faasName !== "" || cloudPlatform === "aws_lambda" + ? "lambda" + : isKubernetes + ? "kubernetes" + : mapleSdkType === "client" + ? "web" + : "unknown" return { serviceName: decodeServiceName(row.serviceName), platform, diff --git a/apps/local-ui/src/hooks/use-local-service-map.test.ts b/apps/local-ui/src/hooks/use-local-service-map.test.ts index 50de28f87a..e6cfdb772a 100644 --- a/apps/local-ui/src/hooks/use-local-service-map.test.ts +++ b/apps/local-ui/src/hooks/use-local-service-map.test.ts @@ -82,6 +82,21 @@ describe("classifyPlatform", () => { "cloudflare", ) expect(classifyPlatform(platformRow({ faasName: "fn", k8sPodName: "pod" }))).toBe("lambda") + // Cloud Run and GKE: Google Cloud's detector also sets `faas.name` / the k8s attributes. + expect( + classifyPlatform( + platformRow({ cloudProvider: "gcp", cloudPlatform: "gcp_cloud_run", faasName: "checkout" }), + ), + ).toBe("gcp") + expect( + classifyPlatform( + platformRow({ + cloudProvider: "gcp", + cloudPlatform: "gcp_kubernetes_engine", + k8sPodName: "pod", + }), + ), + ).toBe("gcp") expect(classifyPlatform(platformRow({ k8sDeploymentName: "api" }))).toBe("kubernetes") expect(classifyPlatform(platformRow({ mapleSdkType: "client" }))).toBe("web") }) diff --git a/apps/local-ui/src/hooks/use-local-service-map.ts b/apps/local-ui/src/hooks/use-local-service-map.ts index 7677a27195..e12a4a43f6 100644 --- a/apps/local-ui/src/hooks/use-local-service-map.ts +++ b/apps/local-ui/src/hooks/use-local-service-map.ts @@ -107,6 +107,8 @@ function toDbEdge(row: CH.ServiceDbEdgesOutput, durationSeconds: number): Servic /** Same precedence as the cloud API's platform classifier: host infrastructure beats SDK self-report. */ export function classifyPlatform(row: CH.ServicePlatformsOutput): ServicePlatform { if (row.cloudPlatform === "cloudflare.workers" || row.cloudProvider === "cloudflare") return "cloudflare" + // Before the Lambda rule: Google Cloud's detector sets `faas.name` on Cloud Run and Cloud Functions. + if (row.cloudProvider === "gcp") return "gcp" if (row.faasName !== "" || row.cloudPlatform === "aws_lambda") return "lambda" // `k8s.cluster.name` alone does not prove the service runs in Kubernetes. if (row.k8sPodName !== "" || row.k8sDeploymentName !== "") return "kubernetes" diff --git a/apps/web/src/api/warehouse/service-map.ts b/apps/web/src/api/warehouse/service-map.ts index bce16feaae..173fb283c6 100644 --- a/apps/web/src/api/warehouse/service-map.ts +++ b/apps/web/src/api/warehouse/service-map.ts @@ -96,7 +96,7 @@ export interface ServiceDbQuerySummaryResponse { topQueries: ReadonlyArray } -export type ServicePlatform = "kubernetes" | "cloudflare" | "lambda" | "web" | "unknown" +export type ServicePlatform = "kubernetes" | "cloudflare" | "gcp" | "lambda" | "web" | "unknown" const GetServiceMapInputSchema = Schema.Struct({ startTime: Schema.optional(WarehouseDateTimeString), diff --git a/apps/web/src/components/icons/index.ts b/apps/web/src/components/icons/index.ts index 459385827d..c47c48e3c9 100644 --- a/apps/web/src/components/icons/index.ts +++ b/apps/web/src/components/icons/index.ts @@ -42,6 +42,8 @@ export { FolderOpenIcon, GlobeIcon, GlobePointerIcon, + GoogleCloudIcon, + GoogleCloudMonoIcon, KubernetesIcon, LineHeightIcon, LinkIcon, @@ -137,7 +139,6 @@ export { GearIcon } from "./gear" export { GeminiIcon } from "./gemini" export { GithubIcon } from "./github" export { GoogleIcon } from "./google" -export { GoogleCloudIcon, GoogleCloudMonoIcon } from "./google-cloud" export { GrokIcon } from "./grok" export { HaystackIcon } from "./haystack" export { HuggingFaceIcon } from "./huggingface" diff --git a/apps/web/src/lab/bench/service-map-bench.tsx b/apps/web/src/lab/bench/service-map-bench.tsx index 9fc5a533a4..f61c5d90de 100644 --- a/apps/web/src/lab/bench/service-map-bench.tsx +++ b/apps/web/src/lab/bench/service-map-bench.tsx @@ -74,7 +74,7 @@ function makeRng(seed: number): () => number { } } -const PLATFORMS: ServicePlatform[] = ["kubernetes", "cloudflare", "lambda", "web", "unknown"] +const PLATFORMS: ServicePlatform[] = ["kubernetes", "cloudflare", "gcp", "lambda", "web", "unknown"] const RUNTIMES = ["nodejs", "bun", "deno", "edge-light", "workerd"] const DB_SYSTEMS = ["postgresql", "mysql", "clickhouse", "redis", "mongodb"] diff --git a/packages/domain/src/http/query-engine.ts b/packages/domain/src/http/query-engine.ts index 4ace0d12d3..85a74ed22a 100644 --- a/packages/domain/src/http/query-engine.ts +++ b/packages/domain/src/http/query-engine.ts @@ -932,7 +932,14 @@ export class ServiceDbQuerySummaryResponse extends Schema.Class("ServicePlatformsRequest")( { diff --git a/apps/web/src/components/icons/google-cloud.tsx b/packages/ui/src/components/icons/google-cloud.tsx similarity index 100% rename from apps/web/src/components/icons/google-cloud.tsx rename to packages/ui/src/components/icons/google-cloud.tsx diff --git a/packages/ui/src/components/icons/index.ts b/packages/ui/src/components/icons/index.ts index 159b0caa61..325cc09279 100644 --- a/packages/ui/src/components/icons/index.ts +++ b/packages/ui/src/components/icons/index.ts @@ -56,6 +56,7 @@ export { FireIcon } from "./fire" export { FolderOpenIcon } from "./folder-open" export { GaugeIcon } from "./gauge" export { GlobeIcon } from "./globe" +export { GoogleCloudIcon, GoogleCloudMonoIcon } from "./google-cloud" export { GlobePointerIcon } from "./globe-pointer" export { IndustryIcon } from "./industry" export { KubernetesIcon } from "./kubernetes" diff --git a/packages/ui/src/components/service-map/service-map-canvas.tsx b/packages/ui/src/components/service-map/service-map-canvas.tsx index 66cb16a559..9c11543b38 100644 --- a/packages/ui/src/components/service-map/service-map-canvas.tsx +++ b/packages/ui/src/components/service-map/service-map-canvas.tsx @@ -974,7 +974,14 @@ export function ServiceMapFlowCanvas({ ) } -const LEGEND_PLATFORMS = ["kubernetes", "cloudflare", "lambda", "web", "unknown"] as const +const LEGEND_PLATFORMS = [ + ["kubernetes", "Kubernetes"], + ["cloudflare", "Cloudflare"], + ["gcp", "Google Cloud"], + ["lambda", "Lambda"], + ["web", "Web"], + ["unknown", "Unknown"], +] as const const serviceSwatchColor = (service: string) => getServiceMapNodeColor({ label: service, kind: "service", errorRate: 0 }, "service") @@ -1025,13 +1032,13 @@ function ServiceMapLegend({ colorMode, services }: { colorMode: ServiceMapColorM {colorMode === "platform" && ( <> | - {LEGEND_PLATFORMS.map((p) => ( + {LEGEND_PLATFORMS.map(([p, label]) => (
- {p} + {label}
))} diff --git a/packages/ui/src/components/service-map/service-map-node.tsx b/packages/ui/src/components/service-map/service-map-node.tsx index 76e8468fc2..7fcdaf1cc1 100644 --- a/packages/ui/src/components/service-map/service-map-node.tsx +++ b/packages/ui/src/components/service-map/service-map-node.tsx @@ -15,6 +15,7 @@ import { CloudflareBrandIcon, CubeIcon, GlobeIcon, + GoogleCloudIcon, type IconComponent, KubernetesIcon, ServerIcon, @@ -34,6 +35,8 @@ function getPlatformIcon(platform: ServicePlatform | undefined): { return { Icon: KubernetesIcon, label: "Kubernetes", branded: true } case "cloudflare": return { Icon: CloudflareBrandIcon, label: "Cloudflare Workers", branded: true } + case "gcp": + return { Icon: GoogleCloudIcon, label: "Google Cloud", branded: true } case "lambda": return { Icon: AwsLambdaIcon, label: "AWS Lambda", branded: true } case "web": diff --git a/packages/ui/src/components/service-map/service-map-types.ts b/packages/ui/src/components/service-map/service-map-types.ts index 83009b7bbb..ae1d4fa90f 100644 --- a/packages/ui/src/components/service-map/service-map-types.ts +++ b/packages/ui/src/components/service-map/service-map-types.ts @@ -2,7 +2,7 @@ // Local) maps its own query results onto these shapes, so the map itself never // learns which backend answered. -export type ServicePlatform = "kubernetes" | "cloudflare" | "lambda" | "web" | "unknown" +export type ServicePlatform = "kubernetes" | "cloudflare" | "gcp" | "lambda" | "web" | "unknown" /** One service-to-service edge over the window. */ export interface ServiceMapEdgeRow { diff --git a/packages/ui/src/components/service-map/service-map-utils.ts b/packages/ui/src/components/service-map/service-map-utils.ts index c7e97dfe08..8f40193178 100644 --- a/packages/ui/src/components/service-map/service-map-utils.ts +++ b/packages/ui/src/components/service-map/service-map-utils.ts @@ -114,6 +114,7 @@ export interface ServiceNodeData { const PLATFORM_COLORS: Record = { kubernetes: "oklch(0.62 0.16 250)", cloudflare: CLOUDFLARE_COLOR, + gcp: "oklch(0.63 0.2 27)", lambda: "oklch(0.7 0.18 60)", web: "oklch(0.65 0.15 145)", unknown: "oklch(0.55 0.02 270)", From 5aea93a19039ed93cbd06a7327d05d544a39849a Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Thu, 15 Oct 2026 20:12:32 +0200 Subject: [PATCH 4/4] test(query-engine): use the semconv GCP metric names in the multi-metric lookup test --- packages/query-engine/src/ch/queries/attribute-keys.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/query-engine/src/ch/queries/attribute-keys.test.ts b/packages/query-engine/src/ch/queries/attribute-keys.test.ts index db34dcfb8d..f8f993dea2 100644 --- a/packages/query-engine/src/ch/queries/attribute-keys.test.ts +++ b/packages/query-engine/src/ch/queries/attribute-keys.test.ts @@ -182,14 +182,14 @@ describe("metricScopedAttributeValuesQuery", () => { const q = metricScopedAttributeValuesQuery({ metricType: "gauge", scope: "resource", - metricNames: ["gcp.run.container.instance_count", "gcp.cloudsql.database.cpu.utilization"], + metricNames: ["gcp.cloud_run.container.instance.count", "gcp.cloud_sql.cpu.utilization"], attributeKey: "cloud.account.id", }) const { sql } = compileUnsafe(q, baseParams) expect(sql).toContain("FROM metrics_gauge") expect(sql).toContain("ResourceAttributes['cloud.account.id'] AS attributeValue") expect(sql).toContain( - "MetricName IN ('gcp.run.container.instance_count', 'gcp.cloudsql.database.cpu.utilization')", + "MetricName IN ('gcp.cloud_run.container.instance.count', 'gcp.cloud_sql.cpu.utilization')", ) expect(sql).not.toMatch(/\bAttributes\['/) expect(sql).not.toMatch(/__PARAM_\w+__/)