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/landing/src/content/docs/integrations/gcp.md b/apps/landing/src/content/docs/integrations/gcp.md index 8118fb3add..0a39beb6b7 100644 --- a/apps/landing/src/content/docs/integrations/gcp.md +++ b/apps/landing/src/content/docs/integrations/gcp.md @@ -197,7 +197,7 @@ Then check the data: 1. Open [Logs](/docs/explore/logs) and look for a service named after one of your Cloud Run services, functions or instances. 2. In the [metrics explorer](/docs/explore/metrics), search for the prefix `gcp`. -3. Under **Dashboards → Browse templates**, select **Google Cloud** and click **Create dashboard**. To chart one project only, first fill in **Project ID** under **Parameters**, below the preview. +3. Under **Dashboards → Browse templates**, select **Google Cloud** and click **Create dashboard**. It has one tab per service. Use the **Project**, **Region** and **Workload** filters in the toolbar to narrow every chart. To open it on one project, first fill in **Project ID** under **Parameters**, below the preview. ## Permissions 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/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/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/dashboard-builder/config/variables-manager-dialog.tsx b/apps/web/src/components/dashboard-builder/config/variables-manager-dialog.tsx index 6e63962f74..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 @@ -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 `metric ${source.scope} attribute: ${source.attributeKey || "…"} (${source.metricNames.length === 1 ? source.metricNames[0] : `${source.metricNames.length} metrics`})` + } } function newVariable(): DashboardVariable { @@ -88,9 +106,16 @@ 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 +470,35 @@ 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 +515,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..01e358b9bc 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, @@ -41,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: [], @@ -83,11 +84,23 @@ function readVariableOptions( if (query.kind === "attributeValues") { const atom = - query.scope === "resource" - ? getResourceAttributeValuesResultAtom({ - data: { ...window, attributeKey: query.attributeKey }, + query.source === "metrics" + ? getMetricAttributeValuesResultAtom({ + data: { + ...window, + attributeKey: query.attributeKey, + metricNames: query.metricNames, + metricType: query.metricType, + scope: query.scope === "resource" ? "resource" : "metric", + }, }) - : getSpanAttributeValuesResultAtom({ data: { ...window, attributeKey: query.attributeKey } }) + : query.scope === "resource" + ? 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/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/apps/web/src/lab/gcp-dashboard-fixture.json b/apps/web/src/lab/gcp-dashboard-fixture.json new file mode 100644 index 0000000000..d345c9855c --- /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.cloud_run.requests", + "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.gcp.metric.labels.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.cloud_run.requests", + "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.cloud_run.request.duration", + "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_s" + }, + "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.gcp.metric.labels.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.cloud_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.cloud_run.container.cpu.utilization", + "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.cloud_run.container.memory.utilization", + "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.cloud_functions.executions", + "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.cloud_functions.execution.duration", + "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_s" + }, + "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.gcp.metric.labels.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.cloud_functions.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.cloud_functions.memory.usage", + "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.gke.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.gcp.metric.labels.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.gke.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.gke.container.restarts", + "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.gcp.metric.labels.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.gke.container.memory.usage", + "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.gke.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.gcp.metric.labels.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.gke.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.gce.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.gce.memory.usage", + "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": "attr.network.io.direction = \"receive\" 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.gce.network.io", + "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": "attr.network.io.direction = \"transmit\" 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.gce.network.io", + "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": "attr.disk.io.direction = \"read\" 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.gce.disk.io", + "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": "attr.disk.io.direction = \"write\" 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.gce.disk.io", + "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.cloud_sql.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.cloud_sql.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 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.cloud_sql.connection.count", + "metricType": "gauge", + "isMonotonic": false + } + ], + "formulas": [], + "comparison": { + "mode": "none", + "includePercentChange": true + } + }, + "display": { + "title": "Connections by Instance", + "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-disk-operations", + "visualization": "chart", + "dataSource": { + "kind": "query", + "resultShape": "timeseries", + "queries": [ + { + "id": "sql-disk-operations", + "name": "Disk Operations by Instance", + "enabled": true, + "whereClause": "resource.cloud.account.id = \"$project\" AND resource.cloud.region = \"$region\"", + "aggregation": "sum", + "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.cloud_sql.disk.operations", + "metricType": "sum", + "isMonotonic": false + } + ], + "formulas": [], + "comparison": { + "mode": "none", + "includePercentChange": true + } + }, + "display": { + "title": "Disk Operations by Instance", + "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": "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.cloud_sql.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.cloud_sql.replication.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.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_messages", + "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.acked_messages", + "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_lettered_messages", + "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.publish_requests", + "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.load_balancing.requests", + "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.gcp.metric.labels.response_code_class"], + "having": "", + "orderBy": "", + "limit": "", + "legend": "", + "dataSource": "metrics", + "signalSource": "default", + "metricName": "gcp.load_balancing.requests", + "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.load_balancing.request.duration", + "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_s" + }, + "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.load_balancing.backend.request.duration", + "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_s" + }, + "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.cloud_run.request.duration", + "gcp.cloud_functions.execution.duration", + "gcp.gke.container.cpu.limit.utilization", + "gcp.gke.node.cpu.allocatable.utilization", + "gcp.gce.cpu.utilization", + "gcp.cloud_sql.cpu.utilization", + "gcp.pubsub.subscription.undelivered_messages", + "gcp.load_balancing.request.duration" + ], + "scope": "resource", + "attributeKey": "cloud.account.id" + } + }, + { + "name": "region", + "label": "Region", + "includeAll": true, + "type": "query", + "source": { + "kind": "metric", + "metricType": "gauge", + "metricNames": [ + "gcp.cloud_run.request.duration", + "gcp.cloud_functions.execution.duration", + "gcp.gke.container.cpu.limit.utilization", + "gcp.gke.node.cpu.allocatable.utilization", + "gcp.gce.cpu.utilization", + "gcp.cloud_sql.cpu.utilization" + ], + "scope": "resource", + "attributeKey": "cloud.region" + } + }, + { + "name": "workload", + "label": "Workload", + "includeAll": true, + "type": "query", + "source": { + "kind": "metric", + "metricType": "gauge", + "metricNames": [ + "gcp.cloud_run.request.duration", + "gcp.cloud_functions.execution.duration", + "gcp.gke.container.cpu.limit.utilization", + "gcp.gce.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..ee7da78837 --- /dev/null +++ b/apps/web/src/lab/gcp-dashboard-lab.tsx @@ -0,0 +1,212 @@ +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.gcp.metric.labels.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_s", [0.05, 2]], +]) + +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 bcf2c4a6b5..2a5aea5bb1 100644 --- a/apps/web/src/lab/registry.ts +++ b/apps/web/src/lab/registry.ts @@ -76,6 +76,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 4bcf571432..da766f030d 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -59,6 +59,7 @@ import { Route as LabCodeReviewReposRouteImport } from './routes/lab/code-review import { Route as LabErrorsRouteImport } from './routes/lab/errors' import { Route as LabFlowRouteImport } from './routes/lab/flow' import { Route as LabGcpConnectRouteImport } from './routes/lab/gcp-connect' +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' @@ -371,6 +372,11 @@ const LabGcpConnectRoute = LabGcpConnectRouteImport.update({ path: '/gcp-connect', 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', @@ -732,6 +738,7 @@ export interface FileRoutesByFullPath { '/lab/errors': typeof LabErrorsRoute '/lab/flow': typeof LabFlowRoute '/lab/gcp-connect': typeof LabGcpConnectRoute + '/lab/gcp-dashboard': typeof LabGcpDashboardRoute '/lab/infra': typeof LabInfraRoute '/lab/loaders': typeof LabLoadersRoute '/lab/logs': typeof LabLogsRoute @@ -844,6 +851,7 @@ export interface FileRoutesByTo { '/lab/errors': typeof LabErrorsRoute '/lab/flow': typeof LabFlowRoute '/lab/gcp-connect': typeof LabGcpConnectRoute + '/lab/gcp-dashboard': typeof LabGcpDashboardRoute '/lab/infra': typeof LabInfraRoute '/lab/loaders': typeof LabLoadersRoute '/lab/logs': typeof LabLogsRoute @@ -958,6 +966,7 @@ export interface FileRoutesById { '/lab/errors': typeof LabErrorsRoute '/lab/flow': typeof LabFlowRoute '/lab/gcp-connect': typeof LabGcpConnectRoute + '/lab/gcp-dashboard': typeof LabGcpDashboardRoute '/lab/infra': typeof LabInfraRoute '/lab/loaders': typeof LabLoadersRoute '/lab/logs': typeof LabLogsRoute @@ -1073,6 +1082,7 @@ export interface FileRouteTypes { | '/lab/errors' | '/lab/flow' | '/lab/gcp-connect' + | '/lab/gcp-dashboard' | '/lab/infra' | '/lab/loaders' | '/lab/logs' @@ -1185,6 +1195,7 @@ export interface FileRouteTypes { | '/lab/errors' | '/lab/flow' | '/lab/gcp-connect' + | '/lab/gcp-dashboard' | '/lab/infra' | '/lab/loaders' | '/lab/logs' @@ -1298,6 +1309,7 @@ export interface FileRouteTypes { | '/lab/errors' | '/lab/flow' | '/lab/gcp-connect' + | '/lab/gcp-dashboard' | '/lab/infra' | '/lab/loaders' | '/lab/logs' @@ -1805,6 +1817,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LabGcpConnectRouteImport 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' @@ -2246,6 +2265,7 @@ interface LabRouteRouteChildren { LabErrorsRoute: typeof LabErrorsRoute LabFlowRoute: typeof LabFlowRoute LabGcpConnectRoute: typeof LabGcpConnectRoute + LabGcpDashboardRoute: typeof LabGcpDashboardRoute LabInfraRoute: typeof LabInfraRoute LabLoadersRoute: typeof LabLoadersRoute LabLogsRoute: typeof LabLogsRoute @@ -2281,6 +2301,7 @@ const LabRouteRouteChildren: LabRouteRouteChildren = { LabErrorsRoute: LabErrorsRoute, LabFlowRoute: LabFlowRoute, LabGcpConnectRoute: LabGcpConnectRoute, + 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/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 cd4261c365..a379da95d6 100644 --- a/packages/backend/src/dashboard-templates/infrastructure/gcp.test.ts +++ b/packages/backend/src/dashboard-templates/infrastructure/gcp.test.ts @@ -77,4 +77,43 @@ 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 9b830d3f9a..ed914946d3 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, @@ -15,12 +15,14 @@ import type { TemplateDefinition, WidgetDef } from "@maple/backend/dashboard-tem // The Cloud Monitoring poller (GcpMetricsService) stores the metrics in `GCP_METRIC_GROUPS` at // one-minute resolution, so a widget takes its metric type and unit from that table (types sharing -// a name share both). Counters are -// delta sums (`sum`, not rate). Gauges are averaged, over the bucket and over the regions a +// a name share both). Counters are delta sums (`sum`, not rate). Gauges are averaged, over the bucket and over the regions a // workload runs in: `sum` would add up every minute. Distributions are p50/p95/p99 gauges keyed // 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)), ) @@ -35,8 +37,51 @@ const DISPLAY_UNITS = new Map([ const WORKLOAD = "service.name" const label = (name: string) => `resource.gcp.resource.labels.${name}` const metricLabel = (name: string) => `attr.gcp.metric.labels.${name}` +const NON_EVICTABLE = `${metricLabel("memory_type")} = "non-evictable"` +const ACTIVE = `${metricLabel("state")} = "active"` 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 @@ -46,202 +91,350 @@ 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.cloud_run.requests", - by: WORKLOAD, - }, - { - id: "run-errors", - title: "Cloud Run 5xx Responses by Service", - metric: "gcp.cloud_run.requests", - by: WORKLOAD, - where: `${metricLabel("response_code_class")} = "5xx"`, - display: CHART_DISPLAY_BAR, - }, - { - id: "run-latency", - title: "Cloud Run Request Latency p95 by Service", - metric: "gcp.cloud_run.request.duration", - by: WORKLOAD, - where: P95, - }, - { - id: "run-instances", - title: "Cloud Run Active Instances by Service", - metric: "gcp.cloud_run.container.instance.count", - by: WORKLOAD, - where: `${metricLabel("state")} = "active"`, - }, - { - id: "run-cpu", - title: "Cloud Run CPU Utilization p95 by Service", - metric: "gcp.cloud_run.container.cpu.utilization", - by: WORKLOAD, - where: P95, - }, - { - id: "run-memory", - title: "Cloud Run Memory Utilization p95 by Service", - metric: "gcp.cloud_run.container.memory.utilization", - by: WORKLOAD, - where: P95, - }, - - { - id: "functions-executions", - title: "Cloud Functions (1st gen) Executions by Function", - metric: "gcp.cloud_functions.executions", - by: WORKLOAD, - }, - { - id: "functions-execution-time", - title: "Cloud Functions (1st gen) Execution Time p95 by Function", - metric: "gcp.cloud_functions.execution.duration", - by: WORKLOAD, - where: P95, - }, - - { - id: "gke-cpu", - title: "GKE CPU Limit Utilization by Container", - metric: "gcp.gke.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.gke.container.memory.limit.utilization", - by: WORKLOAD, - where: `${metricLabel("memory_type")} = "non-evictable"`, - }, - { - id: "gke-restarts", - title: "GKE Container Restarts", - metric: "gcp.gke.container.restarts", - by: WORKLOAD, - display: CHART_DISPLAY_BAR, - }, - { - id: "gke-node-cpu", - title: "GKE Node CPU Utilization by Cluster", - metric: "gcp.gke.node.cpu.allocatable.utilization", - by: "resource.k8s.cluster.name", - }, - - { - id: "compute-cpu", - title: "Compute Engine CPU Utilization by Instance", - metric: "gcp.gce.cpu.utilization", - by: WORKLOAD, - }, - { - id: "compute-network-sent", - title: "Compute Engine Network Bytes Sent by Instance", - metric: "gcp.gce.network.io", - by: WORKLOAD, - where: `attr.network.io.direction = "transmit"`, - }, +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.cloud_sql.cpu.utilization", - by: label("database_id"), - }, - { - id: "sql-memory", - title: "Cloud SQL Memory Utilization by Instance", - metric: "gcp.cloud_sql.memory.utilization", - by: label("database_id"), - }, - { - id: "sql-connections", - title: "Cloud SQL Connections by Instance", - metric: "gcp.cloud_sql.connection.count", - 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.cloud_run.requests", + by: WORKLOAD, + }, + { + id: "run-errors", + title: "5xx Responses by Service", + metric: "gcp.cloud_run.requests", + by: WORKLOAD, + where: `${metricLabel("response_code_class")} = "5xx"`, + display: CHART_DISPLAY_BAR, + }, + { + id: "run-latency", + title: "Request Latency p95 by Service", + metric: "gcp.cloud_run.request.duration", + by: WORKLOAD, + where: P95, + }, + { + id: "run-instances", + title: "Active Instances by Service", + metric: "gcp.cloud_run.container.instance.count", + by: WORKLOAD, + where: ACTIVE, + }, + { + id: "run-cpu", + title: "CPU Utilization p95 by Service", + metric: "gcp.cloud_run.container.cpu.utilization", + by: WORKLOAD, + where: P95, + }, + { + id: "run-memory", + title: "Memory Utilization p95 by Service", + metric: "gcp.cloud_run.container.memory.utilization", + by: WORKLOAD, + where: P95, + }, + ], }, - { - id: "pubsub-backlog", - title: "Pub/Sub Unacknowledged Messages by Subscription", - metric: "gcp.pubsub.subscription.undelivered_messages", - by: label("subscription_id"), + id: "cloud-functions", + title: "Cloud Functions (1st gen)", + charts: [ + { + id: "functions-executions", + title: "Executions by Function", + metric: "gcp.cloud_functions.executions", + by: WORKLOAD, + }, + { + id: "functions-execution-time", + title: "Execution Time p95 by Function", + metric: "gcp.cloud_functions.execution.duration", + by: WORKLOAD, + where: P95, + }, + { + id: "functions-instances", + title: "Active Instances by Function", + metric: "gcp.cloud_functions.instance.count", + by: WORKLOAD, + where: ACTIVE, + }, + { + id: "functions-memory", + title: "Peak Memory per Execution p95 by Function", + metric: "gcp.cloud_functions.memory.usage", + 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.gke.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.gke.container.memory.limit.utilization", + by: WORKLOAD, + where: NON_EVICTABLE, + }, + { + id: "gke-restarts", + title: "Container Restarts", + metric: "gcp.gke.container.restarts", + by: WORKLOAD, + display: CHART_DISPLAY_BAR, + }, + { + id: "gke-memory-used", + title: "Memory in Use by Container", + metric: "gcp.gke.container.memory.usage", + by: WORKLOAD, + where: NON_EVICTABLE, + }, + { + id: "gke-node-cpu", + title: "Node CPU Utilization by Cluster", + metric: "gcp.gke.node.cpu.allocatable.utilization", + by: "resource.k8s.cluster.name", + }, + { + id: "gke-node-memory", + title: "Node Memory Utilization by Cluster", + metric: "gcp.gke.node.memory.allocatable.utilization", + by: "resource.k8s.cluster.name", + where: NON_EVICTABLE, + }, + ], }, - - // Global external Application Load Balancers. { - id: "lb-requests", - title: "Load Balancer Requests by URL Map", - metric: "gcp.load_balancing.requests", - by: label("url_map_name"), + id: "compute-engine", + title: "Compute Engine", + charts: [ + { + id: "compute-cpu", + title: "CPU Utilization by Instance", + metric: "gcp.gce.cpu.utilization", + by: WORKLOAD, + }, + { + id: "compute-memory", + title: "Memory in Use by Instance (E2)", + metric: "gcp.gce.memory.usage", + by: WORKLOAD, + }, + { + id: "compute-network-received", + title: "Network Bytes Received by Instance", + metric: "gcp.gce.network.io", + by: WORKLOAD, + where: `attr.network.io.direction = "receive"`, + }, + { + id: "compute-network-sent", + title: "Network Bytes Sent by Instance", + metric: "gcp.gce.network.io", + by: WORKLOAD, + where: `attr.network.io.direction = "transmit"`, + }, + { + id: "compute-disk-read", + title: "Disk Bytes Read by Instance", + metric: "gcp.gce.disk.io", + by: WORKLOAD, + where: `attr.disk.io.direction = "read"`, + }, + { + id: "compute-disk-write", + title: "Disk Bytes Written by Instance", + metric: "gcp.gce.disk.io", + by: WORKLOAD, + where: `attr.disk.io.direction = "write"`, + }, + ], }, { - id: "lb-response-classes", - title: "Load Balancer Requests by Response Class", - metric: "gcp.load_balancing.requests", - by: metricLabel("response_code_class"), + id: "cloud-sql", + title: "Cloud SQL", + charts: [ + { + id: "sql-cpu", + title: "CPU Utilization by Instance", + metric: "gcp.cloud_sql.cpu.utilization", + by: label("database_id"), + }, + { + id: "sql-memory", + title: "Memory Utilization by Instance", + metric: "gcp.cloud_sql.memory.utilization", + by: label("database_id"), + }, + { + id: "sql-connections", + title: "Connections by Instance", + metric: "gcp.cloud_sql.connection.count", + by: label("database_id"), + }, + { + id: "sql-disk-operations", + title: "Disk Operations by Instance", + metric: "gcp.cloud_sql.disk.operations", + by: label("database_id"), + }, + { + id: "sql-disk", + title: "Disk Utilization by Instance", + metric: "gcp.cloud_sql.disk.utilization", + by: label("database_id"), + }, + { + id: "sql-replica-lag", + title: "Replica Lag by Instance", + metric: "gcp.cloud_sql.replication.lag", + by: label("database_id"), + }, + ], }, { - id: "lb-total-latency", - title: "Load Balancer Total Latency p95 by URL Map", - metric: "gcp.load_balancing.request.duration", - 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.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_messages", + by: label("subscription_id"), + }, + { + id: "pubsub-acked", + title: "Messages Acknowledged by Subscription", + metric: "gcp.pubsub.subscription.acked_messages", + by: label("subscription_id"), + }, + { + id: "pubsub-dead-lettered", + title: "Dead-Lettered Messages by Subscription", + metric: "gcp.pubsub.subscription.dead_lettered_messages", + by: label("subscription_id"), + display: CHART_DISPLAY_BAR, + }, + { + id: "pubsub-publish-requests", + title: "Publish Requests by Topic", + metric: "gcp.pubsub.topic.publish_requests", + by: label("topic_id"), + }, + ], }, { - id: "lb-backend-latency", - title: "Load Balancer Backend Latency p95 by URL Map", - metric: "gcp.load_balancing.backend.request.duration", - 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.load_balancing.requests", + by: label("url_map_name"), + }, + { + id: "lb-response-classes", + title: "Requests by Response Class", + metric: "gcp.load_balancing.requests", + by: metricLabel("response_code_class"), + }, + { + id: "lb-total-latency", + title: "Total Latency p95 by URL Map", + metric: "gcp.load_balancing.request.duration", + by: label("url_map_name"), + where: P95, + }, + { + id: "lb-backend-latency", + title: "Backend Latency p95 by URL Map", + metric: "gcp.load_balancing.backend.request.duration", + 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: { @@ -256,7 +449,8 @@ 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", }, @@ -269,7 +463,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/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/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..f8f993dea2 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.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.cloud_run.container.instance.count', 'gcp.cloud_sql.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/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)", 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