Skip to content
22 changes: 13 additions & 9 deletions apps/api/src/routes/internal/query-engine.http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion apps/landing/src/content/docs/integrations/gcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
15 changes: 15 additions & 0 deletions apps/local-ui/src/hooks/use-local-service-map.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
})
Expand Down
2 changes: 2 additions & 0 deletions apps/local-ui/src/hooks/use-local-service-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/api/warehouse/metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof GetMetricAttributeValuesInputSchema>
Expand Down Expand Up @@ -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,
},
}),
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/api/warehouse/service-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ export interface ServiceDbQuerySummaryResponse {
topQueries: ReadonlyArray<ServiceDbTopQuery>
}

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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -30,6 +35,13 @@ const FACET_LABELS: Record<QueryFacet, string> = {
log_severity: "Log severity",
} satisfies Record<QueryFacet, string>

const METRIC_TYPE_LABELS = {
sum: "Sum",
gauge: "Gauge",
histogram: "Histogram",
exponential_histogram: "Exponential histogram",
} satisfies Record<QueryBuilderMetricType, string>

const TYPE_LABELS: Record<DashboardVariable["type"], string> = {
query: "Query",
custom: "Custom",
Expand All @@ -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 {
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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",
},
})
}
Expand All @@ -478,10 +515,12 @@ function QuerySourceFields({
))}
<SelectItem value="attribute:span">Span attribute…</SelectItem>
<SelectItem value="attribute:resource">Resource attribute…</SelectItem>
<SelectItem value="metric:metric">Metric attribute…</SelectItem>
<SelectItem value="metric:resource">Metric resource attribute…</SelectItem>
</SelectContent>
</Select>
</Field>
{source.kind === "attribute" && (
{source.kind !== "facet" && (
<Field className="items-stretch gap-1.5">
<FieldLabel className="text-xs">Attribute key</FieldLabel>
<Input
Expand All @@ -497,6 +536,50 @@ function QuerySourceFields({
/>
</Field>
)}
{source.kind === "metric" && (
<>
<Field className="items-stretch gap-1.5">
<FieldLabel className="text-xs">Metrics</FieldLabel>
<Input
size="sm"
value={source.metricNames.join(", ")}
placeholder="http.server.duration"
onChange={(event) =>
onChange({
...variable,
source: {
...source,
// Empty entries stay while typing ("a, "); validation flags them.
metricNames: event.target.value.split(",").map((name) => name.trim()),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Warning

Metric names input has no 20-item cap the dashboard schema enforces

F2 · Warning · correctness

V2DashboardVariableSource caps metricNames at 20 entries (packages/domain/src/http/v2/dashboards.ts:545; the widgets schema does the same), but this input splits on every comma with no cap and draftError only rejects empty names. A variable with 21 comma-separated names saves into a document the dashboard schema rejects, so the save fails with a length error that says nothing about the metric count.

Add a `> 20` check for `source.kind === "metric"` to `draftError`, beside the empty-name check at line 112, so the dialog refuses the draft before it reaches the schema.
🤖 Prompt to fix with an AI agent
In `apps/web/src/components/dashboard-builder/config/variables-manager-dialog.tsx:553`: Metric names input has no 20-item cap the dashboard schema enforces.

`V2DashboardVariableSource` caps `metricNames` at 20 entries (`packages/domain/src/http/v2/dashboards.ts:545`; the widgets schema does the same), but this input splits on every comma with no cap and `draftError` only rejects empty names. A variable with 21 comma-separated names saves into a document the dashboard schema rejects, so the save fails with a length error that says nothing about the metric count.

Suggested fix: Add a `> 20` check for `source.kind === "metric"` to `draftError`, beside the empty-name check at line 112, so the dialog refuses the draft before it reaches the schema.

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

},
})
}
/>
</Field>
<Field className="items-stretch gap-1.5">
<FieldLabel className="text-xs">Metric type</FieldLabel>
<Select
items={METRIC_TYPE_LABELS}
value={source.metricType}
onValueChange={(value) => {
const metricType = toQueryBuilderMetricType(value)
if (metricType) onChange({ ...variable, source: { ...source, metricType } })
}}
>
<SelectTrigger size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{QUERY_BUILDER_METRIC_TYPES.map((metricType) => (
<SelectItem key={metricType} value={metricType}>
{METRIC_TYPE_LABELS[metricType]}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
</>
)}
</div>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -41,7 +42,7 @@ export interface DashboardVariablesContextValue {
setValue: (name: string, value: string) => void
}

const DashboardVariablesContext = createContext<DashboardVariablesContextValue | null>(null)
export const DashboardVariablesContext = createContext<DashboardVariablesContextValue | null>(null)

const EMPTY_CONTEXT: DashboardVariablesContextValue = {
variables: [],
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/components/icons/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ export {
FolderOpenIcon,
GlobeIcon,
GlobePointerIcon,
GoogleCloudIcon,
GoogleCloudMonoIcon,
KubernetesIcon,
LineHeightIcon,
LinkIcon,
Expand Down Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/lab/bench/service-map-bench.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"]

Expand Down
Loading
Loading