From eb2807641dfe1a9641d1de36130287f2c8bfac1d Mon Sep 17 00:00:00 2001 From: Makisuo Date: Wed, 22 Jul 2026 00:54:58 +0200 Subject: [PATCH] feat(services): enrich the services list with health, baseline delta, issues, and last-deploy columns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Redesigns the /services table per the approved "dense table+" direction while keeping every data source fleet-level (no per-service fan-out): - serviceOverviewQuery gains a per-commit `firstSeen: min(Timestamp)` on the same scan; CommitBreakdown now carries errorCount + firstSeen. The serviceOverview cachedDirect policy is bumped to version 2 so pre-upgrade cached rows (missing the field) can't be served. - New GET /v2/error_issues/service_counts backed by ErrorsService.countOpenIssuesByService — one Postgres GROUP BY over actionable, error-kind, non-archived issues (name-scoped; env scoping would need the warehouse fingerprint intersection and is deliberately out). - New useServiceHealthSummary hook derives per-(service, env) health from the already-fetched alert incidents + anomalies; shared by the table (name dot, destructive left rail, footer tally, health filter) and the sidebar's new client-side Health facet. - P95 cell shows a tone-colored delta vs the trailing-7d baseline — first consumer of the existing fleet-level, hour-cached serviceHealthBaseline. - "Commit" becomes "Last deploy": commit message first (lazy per-distinct-sha lookup), sha · age demoted, rollout progress bar with breakdown tooltip, and an "errors ↑ since" state when the newest commit errors ≥2× the older ones. - Rows extracted into a memoized ServiceRow; baseline/issues/messages render progressively and never block first paint. Co-Authored-By: Claude Fable 5 --- apps/api/src/routes/query-engine.http.ts | 5 +- apps/api/src/routes/v2/error-issues.http.ts | 17 + apps/api/src/routes/v2/v2-test-support.ts | 1 + apps/api/src/services/ErrorsService.test.ts | 31 + apps/api/src/services/ErrorsService.ts | 38 + apps/web/src/api/warehouse/services.ts | 38 +- .../services/services-filter-sidebar.tsx | 46 +- .../components/services/services-table.tsx | 885 +++++++++++------- .../services/use-service-health-summary.ts | 81 ++ packages/domain/src/http/v2/error-issues.ts | 29 + packages/domain/src/http/v2/openapi.test.ts | 1 + .../src/ch/queries/services.test.ts | 1 + .../query-engine/src/ch/queries/services.ts | 5 + 13 files changed, 843 insertions(+), 335 deletions(-) create mode 100644 apps/web/src/components/services/use-service-health-summary.ts diff --git a/apps/api/src/routes/query-engine.http.ts b/apps/api/src/routes/query-engine.http.ts index 27adcad4b..ab5c2c439 100644 --- a/apps/api/src/routes/query-engine.http.ts +++ b/apps/api/src/routes/query-engine.http.ts @@ -70,7 +70,7 @@ import { } from "@maple/domain/http" import { Clock, Config, Effect, Match, Option, Schema } from "effect" import { QueryEngineService } from "../services/QueryEngineService" -import { makeExecuteRawSql } from "@maple/query-engine/runtime" +import { makeDirectRouteCachePolicy, makeExecuteRawSql } from "@maple/query-engine/runtime" import { WarehouseQueryService } from "../lib/WarehouseQueryService" import { traceCacheTtlSeconds } from "../lib/trace-detail-cache" import { @@ -463,6 +463,9 @@ export const HttpQueryEngineLive = HttpApiBuilder.group(MapleApi, "queryEngine", }), "serviceOverview query failed", ), + // v2: rows gained per-commit `firstSeen`; the version bump keeps + // pre-upgrade cached rows (missing the field) from being served. + makeDirectRouteCachePolicy({ ttlSeconds: 15, version: 2 }), ) return new ServiceOverviewResponse({ data: rows }) }), diff --git a/apps/api/src/routes/v2/error-issues.http.ts b/apps/api/src/routes/v2/error-issues.http.ts index b28f5ff48..ad88b2961 100644 --- a/apps/api/src/routes/v2/error-issues.http.ts +++ b/apps/api/src/routes/v2/error-issues.http.ts @@ -173,6 +173,23 @@ export const HttpV2ErrorIssuesLive = HttpApiBuilder.group(MapleApiV2, "errorIssu } }), ) + .handle("serviceCounts", () => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + const counts = yield* errors + .countOpenIssuesByService(tenant.orgId) + .pipe(mapPersistenceError) + return { + object: "list" as const, + data: counts.map((row) => ({ + service_name: row.serviceName, + open_count: row.openCount, + })), + has_more: false, + next_cursor: null, + } + }), + ) .handle("retrieve", ({ params, query }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context diff --git a/apps/api/src/routes/v2/v2-test-support.ts b/apps/api/src/routes/v2/v2-test-support.ts index 2f56ba842..0c8dcad97 100644 --- a/apps/api/src/routes/v2/v2-test-support.ts +++ b/apps/api/src/routes/v2/v2-test-support.ts @@ -99,6 +99,7 @@ export const Phase1ResourceStubsLayer = Layer.mergeAll( }), Layer.succeed(ErrorsService, { listIssues: die, + countOpenIssuesByService: die, getIssue: die, transitionIssue: die, claimIssue: die, diff --git a/apps/api/src/services/ErrorsService.test.ts b/apps/api/src/services/ErrorsService.test.ts index cd35ccc85..66dbf3b43 100644 --- a/apps/api/src/services/ErrorsService.test.ts +++ b/apps/api/src/services/ErrorsService.test.ts @@ -351,6 +351,37 @@ const seedIngestKey = (orgId: string) => ) }) +// --------------------------------------------------------------------------- +// countOpenIssuesByService +// --------------------------------------------------------------------------- + +describe("ErrorsService.countOpenIssuesByService", () => { + it.effect("groups actionable error issues by service, excluding done/alert/archived", () => + Effect.gen(function* () { + const errors = yield* ErrorsService + const now = new Date() + yield* seedIssue(asIssueId(randomUUID()), { serviceName: "checkout-api" }) + yield* seedIssue(asIssueId(randomUUID()), { + serviceName: "checkout-api", + workflowState: "in_progress", + }) + yield* seedIssue(asIssueId(randomUUID()), { serviceName: "ingest", workflowState: "todo" }) + // Non-actionable, alert-kind, archived, and empty-service rows are all excluded. + yield* seedIssue(asIssueId(randomUUID()), { serviceName: "checkout-api", workflowState: "done" }) + yield* seedIssue(asIssueId(randomUUID()), { serviceName: "alerting", kind: "alert" }) + yield* seedIssue(asIssueId(randomUUID()), { serviceName: "ingest", archivedAt: now }) + yield* seedIssue(asIssueId(randomUUID()), { serviceName: "" }) + + const counts = yield* errors.countOpenIssuesByService(ORG) + const byService = new Map(counts.map((row) => [row.serviceName, row.openCount])) + assert.strictEqual(byService.get("checkout-api"), 2) + assert.strictEqual(byService.get("ingest"), 1) + assert.isFalse(byService.has("alerting")) + assert.isFalse(byService.has("")) + }).pipe(Effect.provide(makeErrorsLayer())), + ) +}) + // --------------------------------------------------------------------------- // setSeverity // --------------------------------------------------------------------------- diff --git a/apps/api/src/services/ErrorsService.ts b/apps/api/src/services/ErrorsService.ts index 23020b7fa..b74cacfde 100644 --- a/apps/api/src/services/ErrorsService.ts +++ b/apps/api/src/services/ErrorsService.ts @@ -262,6 +262,17 @@ export interface ErrorsServiceShape { readonly sort?: "last_seen" | "severity" }, ) => Effect.Effect + /** + * Fleet-level open (actionable-state) error-issue counts grouped by service + * name. One Postgres GROUP BY over the org's actionable subset; alert-kind + * issues are excluded because their serviceName can be empty or synthetic. + */ + readonly countOpenIssuesByService: ( + orgId: OrgId, + ) => Effect.Effect< + ReadonlyArray<{ readonly serviceName: string; readonly openCount: number }>, + ErrorPersistenceError + > readonly getIssue: ( orgId: OrgId, issueId: ErrorIssueId, @@ -1198,6 +1209,32 @@ const make: Effect.Effect< }, ) + const countOpenIssuesByService: ErrorsServiceShape["countOpenIssuesByService"] = Effect.fn( + "ErrorsService.countOpenIssuesByService", + )(function* (orgId) { + yield* Effect.annotateCurrentSpan({ orgId }) + const rows = yield* dbExecute((db) => + db + .select({ + serviceName: errorIssues.serviceName, + openCount: sql`count(*)::int`, + }) + .from(errorIssues) + .where( + and( + eq(errorIssues.orgId, orgId), + inArray(errorIssues.workflowState, ACTIONABLE_WORKFLOW_STATES), + eq(errorIssues.kind, "error"), + isNull(errorIssues.archivedAt), + ), + ) + .groupBy(errorIssues.serviceName), + ) + const counts = rows.filter((row) => row.serviceName !== "") + yield* Effect.annotateCurrentSpan({ serviceCount: counts.length }) + return counts + }) + const getIssue: ErrorsServiceShape["getIssue"] = Effect.fn("ErrorsService.getIssue")( function* (orgId, issueId, opts) { yield* Effect.annotateCurrentSpan({ orgId, issueId }) @@ -2914,6 +2951,7 @@ const make: Effect.Effect< return ErrorsService.of({ listIssues, + countOpenIssuesByService, getIssue, transitionIssue, claimIssue, diff --git a/apps/web/src/api/warehouse/services.ts b/apps/web/src/api/warehouse/services.ts index 06f569df6..981ddf5a6 100644 --- a/apps/web/src/api/warehouse/services.ts +++ b/apps/web/src/api/warehouse/services.ts @@ -35,6 +35,9 @@ export interface CommitBreakdown { commitSha: string spanCount: number percentage: number + errorCount: number + /** Earliest span for this commit inside the queried window ("" when unknown). */ + firstSeen: string } export interface ServiceOverview { @@ -76,6 +79,7 @@ interface CoercedRow { p95LatencyMs: number p99LatencyMs: number estimatedSpanCount: number + firstSeen: string } function coerceRow(raw: Record): CoercedRow { @@ -92,6 +96,7 @@ function coerceRow(raw: Record): CoercedRow { p95LatencyMs: Number(raw.p95LatencyMs ?? 0), p99LatencyMs: Number(raw.p99LatencyMs ?? 0), estimatedSpanCount: Number(raw.estimatedSpanCount ?? 0), + firstSeen: String(raw.firstSeen ?? ""), } } @@ -151,13 +156,32 @@ function aggregateByServiceEnvironment(rows: CoercedRow[], durationSeconds: numb } } - const commits: CommitBreakdown[] = group - .map((r) => ({ - commitSha: r.commitSha, - spanCount: r.spanCount, - percentage: totalSpans > 0 ? Math.round((r.spanCount / totalSpans) * 100) : 0, - })) - .sort((a, b) => b.percentage - a.percentage) + // Merge namespace variants of the same commit so a sha never appears twice + // and its firstSeen/error totals cover every variant. + const commitTotals = new Map() + for (const r of group) { + const existing = commitTotals.get(r.commitSha) + if (existing) { + existing.spanCount += r.spanCount + existing.errorCount += r.errorCount + if (r.firstSeen !== "" && (existing.firstSeen === "" || r.firstSeen < existing.firstSeen)) { + existing.firstSeen = r.firstSeen + } + } else { + commitTotals.set(r.commitSha, { + spanCount: r.spanCount, + errorCount: r.errorCount, + firstSeen: r.firstSeen, + }) + } + } + const commits: CommitBreakdown[] = Array.from(commitTotals, ([commitSha, totals]) => ({ + commitSha, + spanCount: totals.spanCount, + percentage: totalSpans > 0 ? Math.round((totals.spanCount / totalSpans) * 100) : 0, + errorCount: totals.errorCount, + firstSeen: totals.firstSeen, + })).sort((a, b) => b.percentage - a.percentage) results.push({ serviceName: representative.serviceName, diff --git a/apps/web/src/components/services/services-filter-sidebar.tsx b/apps/web/src/components/services/services-filter-sidebar.tsx index db2a12563..36d1a6491 100644 --- a/apps/web/src/components/services/services-filter-sidebar.tsx +++ b/apps/web/src/components/services/services-filter-sidebar.tsx @@ -7,6 +7,10 @@ import { FilterSection } from "@/components/traces/filter-section" import { Route } from "@/routes/services/index" import { Separator } from "@maple/ui/components/ui/separator" import { getServicesFacetsResultAtom } from "@/lib/services/atoms/warehouse-query-atoms" +import { + isServiceHealth, + useServiceHealthSummary, +} from "@/components/services/use-service-health-summary" import { FilterSidebarBody, FilterSidebarError, @@ -37,6 +41,16 @@ export function ServicesFilterSidebar() { const facetsResult = useRefreshableAtomValue(facetsAtom) const refreshFacets = useAtomRefresh(facetsAtom) + // Client-side facet: derived from the same cached overview/incident/anomaly + // atoms the table subscribes to — no extra requests, counts always agree + // with the table's health badges. + const healthSummary = useServiceHealthSummary({ + startTime: effectiveStartTime, + endTime: effectiveEndTime, + environments: search.environments, + commitShas: search.commitShas, + }) + const updateFilter = (key: K, value: (typeof search)[K]) => { navigate({ search: (prev: Record) => ({ @@ -57,7 +71,10 @@ export function ServicesFilterSidebar() { }) } - const hasActiveFilters = (search.environments?.length ?? 0) > 0 || (search.commitShas?.length ?? 0) > 0 + const hasActiveFilters = + (search.environments?.length ?? 0) > 0 || + (search.commitShas?.length ?? 0) > 0 || + search.health !== undefined return Result.builder(facetsResult) .onInitial(() => ) @@ -69,6 +86,33 @@ export function ServicesFilterSidebar() { + {healthSummary !== undefined && ( + <> + ({ + name: level, + count: healthSummary.counts[level], + }), + )} + selected={search.health === undefined ? [] : [search.health]} + onChange={(selected) => { + // Single-select semantics on a multi-select control: the + // newly toggled value wins; re-unchecking clears the filter. + const next = selected.find((value) => value !== search.health) + updateFilter( + "health", + next !== undefined && isServiceHealth(next) + ? next + : undefined, + ) + }} + /> + + + )} + {(facets.environments.length ?? 0) > 0 && ( <> N/A +// --------------------------------------------------------------------------- +// Health lane +// --------------------------------------------------------------------------- + +const HEALTH_DOT_CLASS: Record = { + healthy: "bg-success", + degraded: "bg-severity-warn", + unhealthy: "bg-destructive", +} + +/** Quiet health marker next to the service name — rendered only when there is + * something to say (degraded/unhealthy); healthy rows stay unadorned. */ +function HealthDot({ health }: { health: ServiceHealth | undefined }) { + if (health === undefined || health === "healthy") return null + return ( + + ) +} + +// --------------------------------------------------------------------------- +// P95 baseline delta +// --------------------------------------------------------------------------- + +// Mirrors MIN_BASELINE_SPANS in service-health.ts: a baseline computed from +// fewer spans is noise, so the delta line is withheld entirely. +const MIN_BASELINE_SPANS = 100 + +interface BaselineDelta { + label: string + className: string +} + +function baselineDelta(p95LatencyMs: number, baseline: LatencyBaselineSignal | undefined): BaselineDelta | undefined { + if (baseline === undefined || baseline.spanCount < MIN_BASELINE_SPANS || baseline.p95LatencyMs <= 0) { + return undefined + } + const delta = (p95LatencyMs - baseline.p95LatencyMs) / baseline.p95LatencyMs + const pct = Math.round(delta * 100) + return { + label: `${pct > 0 ? "+" : ""}${pct}% vs 7d`, + className: + delta >= 1 ? "text-severity-error" : delta >= 0.25 ? "text-severity-warn" : "text-muted-foreground", } +} - if (commits.length === 1) { - const sha = commits[0].commitSha - return ( - - {truncateCommitSha(sha)} +// --------------------------------------------------------------------------- +// Last deploy cell +// --------------------------------------------------------------------------- + +// Below this many spans on either side of the split, the errors-since-deploy +// comparison is too noisy to flag. +const MIN_DEPLOY_COMPARE_SPANS = 50 +// A commit needs at least this share of traffic to count toward "mid-rollout". +const ROLLOUT_MIN_SHARE_PCT = 5 + +interface DeployCellInfo { + sha: string + firstSeen: string + rollout?: { percentage: number; others: CommitBreakdown[] } + errorsSince: boolean +} + +function deriveDeployInfo(commits: CommitBreakdown[]): DeployCellInfo | undefined { + const real = commits.filter((c) => c.commitSha && c.commitSha !== "N/A" && c.commitSha !== "unknown") + if (real.length === 0) return undefined + const dated = real.filter((c) => c.firstSeen !== "") + const pool = dated.length > 0 ? dated : real + const latest = pool.reduce((best, c) => (c.firstSeen > best.firstSeen ? c : best)) + const dominant = real.reduce((best, c) => (c.spanCount > best.spanCount ? c : best)) + + const older = real.filter((c) => c !== latest) + const olderSpans = older.reduce((sum, c) => sum + c.spanCount, 0) + const olderErrors = older.reduce((sum, c) => sum + c.errorCount, 0) + const latestRate = latest.spanCount > 0 ? latest.errorCount / latest.spanCount : 0 + const olderRate = olderSpans > 0 ? olderErrors / olderSpans : 0 + // "Errors ↑ since deploy": the newest commit errors at least twice as often + // as everything it is replacing, by a margin that can't be rounding noise. + const errorsSince = + latest.spanCount >= MIN_DEPLOY_COMPARE_SPANS && + olderSpans >= MIN_DEPLOY_COMPARE_SPANS && + latestRate >= olderRate * 2 && + latestRate - olderRate >= 0.005 + + const meaningful = real.filter((c) => c.percentage >= ROLLOUT_MIN_SHARE_PCT) + const rollout = + meaningful.length > 1 + ? { + percentage: dominant.percentage, + others: real.filter((c) => c !== dominant).toSorted((a, b) => b.percentage - a.percentage), + } + : undefined + + return { sha: latest.commitSha, firstSeen: latest.firstSeen, rollout, errorsSince } +} + +interface DeployLinesProps { + sha: string + firstSeen: string + /** Replaces the default `sha · age` meta line (rollout / errors-since state). */ + stateLine: React.ReactNode | undefined +} + +function deployMetaLine(text: string) { + return text === "" ? null : ( + {text} + ) +} + +/** + * Message-first deploy lines: subject line on top (once the deduped, cached + * per-sha lookup resolves), `sha · age` demoted underneath. While unresolved — + * or when the reference isn't a resolvable sha — the sha IS the headline, so + * the meta line carries only the age instead of repeating it. + */ +function ResolvedDeployLines({ sha, firstSeen, stateLine }: DeployLinesProps) { + const result = useAtomValue(commitQueryAtom(sha)) + const shortSha = truncateCommitSha(sha) + const age = firstSeen !== "" ? formatTimeAgo(firstSeen) : "" + const message = Result.isSuccess(result) ? firstLine(result.value.message) : "" + return ( + <> + + {message !== "" ? message : shortSha} - ) - } + {stateLine ?? deployMetaLine(message !== "" ? [shortSha, age].filter(Boolean).join(" · ") : age)} + + ) +} - // Several versions served traffic: keep the row ONE line — the dominant - // commit (hover-resolvable) plus a "+N" chip whose tooltip holds the full - // breakdown. Rendering every sha inline stacked the cell several rows tall - // and made the table unreadable for services mid-rollout. - const ordered = commits.toSorted((a, b) => b.percentage - a.percentage) - const [dominant, ...rest] = ordered +function DeployLines({ sha, firstSeen, stateLine }: DeployLinesProps) { + if (isResolvableSha(sha)) { + return + } + const age = firstSeen !== "" ? formatTimeAgo(firstSeen) : "" return ( -
- - {truncateCommitSha(dominant.commitSha)} + <> + + {truncateCommitSha(sha)} - - {dominant.percentage}% - - - - +{rest.length} - - -
- {rest.map((c) => ( -
- {truncateCommitSha(c.commitSha)} - {c.percentage}% -
- ))} -
-
-
-
+ {stateLine ?? deployMetaLine(age)} + ) } +const DeployCell = React.memo(function DeployCell({ commits }: { commits: CommitBreakdown[] }) { + const info = deriveDeployInfo(commits) + if (info === undefined) { + return N/A + } + const stateLine = info.errorsSince ? ( + + {info.firstSeen !== "" ? `${formatTimeAgo(info.firstSeen)} · ` : ""}errors ↑ since + + ) : info.rollout !== undefined ? ( + + + + + + + {info.rollout.percentage}% · +{info.rollout.others.length} + + + +
+ {info.rollout.others.map((c) => ( +
+ {truncateCommitSha(c.commitSha)} + {c.percentage}% +
+ ))} +
+
+
+ ) : undefined + return ( +
+ +
+ ) +}) + +// --------------------------------------------------------------------------- +// Environment group header +// --------------------------------------------------------------------------- + function EnvironmentBadge({ environment }: { environment: string }) { const getVariant = () => { switch (environment.toLowerCase()) { @@ -176,6 +347,162 @@ function EnvironmentBadge({ environment }: { environment: string }) { ) } +// --------------------------------------------------------------------------- +// Row +// --------------------------------------------------------------------------- + +interface ServiceRowProps { + service: ServiceOverview + series: ServiceTimeSeriesPoint[] | undefined + filters: ServicesSearchParams | undefined + health: ServiceHealth | undefined + baseline: LatencyBaselineSignal | undefined + issueCount: number | undefined + navigate: ReturnType +} + +const ServiceRow = React.memo(function ServiceRow({ + service, + series, + filters, + health, + baseline, + issueCount, + navigate, +}: ServiceRowProps) { + const throughputData = React.useMemo( + () => (series === undefined ? [] : series.map((p) => ({ value: p.throughput }))), + [series], + ) + const errorRateData = React.useMemo( + () => (series === undefined ? [] : series.map((p) => ({ value: p.errorRate }))), + [series], + ) + const delta = baselineDelta(service.p95LatencyMs, baseline) + const goToDetail = () => + navigate({ + to: "/services/$serviceName", + params: { serviceName: service.serviceName }, + search: serviceDetailSearch(filters, service.environment), + }) + + return ( + { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault() + goToDetail() + } + }} + > + + e.stopPropagation()} + title={service.serviceName} + > + + {service.serviceName} + + + {service.serviceNamespace ? ( +
{service.serviceNamespace}
+ ) : null} +
+ + {formatLatency(service.p50LatencyMs)} + + +
{formatLatency(service.p95LatencyMs)}
+ {delta !== undefined && ( +
{delta.label}
+ )} +
+ + {formatLatency(service.p99LatencyMs)} + + +
+ +
+ + {formatErrorRate(service.errorRate)} + +
+
+
+ + + + +
+ + {service.hasSampling ? "~" : ""} + {formatThroughput(service.throughput)} + + {service.hasSampling && ( + + ~{formatThroughput(service.tracedThroughput)} traced + + )} +
+
+ {service.hasSampling && ( + +

+ Estimated from {((1 / service.samplingWeight) * 100).toFixed(0)}% sampled traces + (x{service.samplingWeight.toFixed(0)} extrapolation) +

+
+ )} +
+
+ + {issueCount !== undefined && issueCount > 0 ? ( + + {issueCount} + + ) : ( + + )} + + + + +
+ ) +}) + interface ServicesTableProps { filters?: ServicesSearchParams } @@ -188,12 +515,13 @@ function LoadingState() { Service - P50 - P95 - P99 - Error Rate - Throughput - Commit + P50 + P95 + P99 + Error Rate + Throughput + Issues + Last deploy @@ -203,13 +531,13 @@ function LoadingState() { - + - + @@ -218,7 +546,10 @@ function LoadingState() { - + + + + ))} @@ -274,277 +605,169 @@ export function ServicesTable({ filters }: ServicesTableProps) { ) const healthFilter = filters?.health + // Kept in the blocking Result.all below so the health lane never flashes + // from "healthy" to "unhealthy" after first paint; the derivation itself + // lives in useServiceHealthSummary (shared with the filter sidebar). const { result: incidentsResult } = useAlertIncidentsList() const anomaliesResult = useAtomValue(openAnomalyIncidentsAtom) + const healthSummary = useServiceHealthSummary({ + startTime: effectiveStartTime, + endTime: effectiveEndTime, + environments: filters?.environments, + commitShas: filters?.commitShas, + }) + + // Progressive enrichment — neither blocks first paint. The baseline payload + // is hour-snapped upstream, so this atom refetches at most hourly; issue + // counts are one cheap Postgres GROUP BY. + const baselineResult = useAtomValue( + getServiceHealthBaselineResultAtom({ + data: { + rangeStartTime: effectiveStartTime, + environments: filters?.environments, + }, + }), + ) + const issueCountsResult = useAtomValue(openIssueCountsAtom) + + const baselineData = Result.isSuccess(baselineResult) ? baselineResult.value : undefined + const baselineMap = React.useMemo( + () => (baselineData === undefined ? undefined : buildBaselineMap(baselineData.data)), + [baselineData], + ) + const issueCountsData = Result.isSuccess(issueCountsResult) ? issueCountsResult.value : undefined + const issueCountByService = React.useMemo(() => { + if (issueCountsData === undefined) return undefined + return new Map(issueCountsData.data.map((row) => [row.service_name, row.open_count])) + }, [issueCountsData]) return Result.builder(Result.all([overviewResult, timeSeriesResult, anomaliesResult, incidentsResult])) .onInitial(() => ) .onError((error) => ) - .onSuccess( - ( - [overviewResponse, timeSeriesResponse, anomaliesResponse, incidentsResponse], - combinedResult, - ) => { - const timeSeriesMap = timeSeriesResponse.data - const openIncidents = incidentsResponse.incidents.filter( - (incident) => incident.status === "open", - ) - const services = healthFilter - ? overviewResponse.data.filter((service) => { - const alertCauses: ServiceHealthCause[] = openIncidents - .filter((incident) => incident.groupKey === service.serviceName) - .map((incident) => ({ severity: incident.severity, label: "Alert firing" })) - const anomalyCauses: ServiceHealthCause[] = anomaliesResponse.incidents - .filter( - (incident) => - incident.serviceName === service.serviceName && - incident.deploymentEnv === service.environment, - ) - .map((incident) => ({ severity: incident.severity, label: "Anomaly" })) - return ( - deriveServiceHealthFromCauses([...alertCauses, ...anomalyCauses]) === - healthFilter - ) - }) - : overviewResponse.data - - const groups = groupByEnvironment(services) + .onSuccess(([overviewResponse, timeSeriesResponse], combinedResult) => { + const timeSeriesMap = timeSeriesResponse.data + const healthFor = (service: ServiceOverview) => + healthSummary?.byRow.get(serviceHealthRowKey(service.serviceName, service.environment)) + const services = healthFilter + ? overviewResponse.data.filter((service) => healthFor(service) === healthFilter) + : overviewResponse.data + + const groups = groupByEnvironment(services) + + // Tally over the DISPLAYED rows so the footer agrees with the table + // under active filters. + let unhealthyCount = 0 + let degradedCount = 0 + for (const service of services) { + const health = healthFor(service) + if (health === "unhealthy") unhealthyCount += 1 + else if (health === "degraded") degradedCount += 1 + } + const rowFor = (service: ServiceOverview) => { + const serviceSeries = Object.hasOwn(timeSeriesMap, service.serviceName) + ? timeSeriesMap[service.serviceName] + : undefined return ( -
- {/* Desktop: full metrics table. Below md the fixed-width columns and - in-cell sparklines force horizontal scroll, so we swap to a list. */} -
- {/* Fixed layout: the metric columns keep their set widths and the - Service column absorbs whatever remains, truncating long names — - so the table always fits the viewport instead of scrolling - horizontally. */} - - + + ) + } + + return ( +
+ {/* Desktop: full metrics table. Below md the fixed-width columns and + in-cell sparklines force horizontal scroll, so we swap to a list. */} +
+ {/* Fixed layout: the metric columns keep their set widths and the + Service column absorbs whatever remains, truncating long names — + so the table always fits the viewport instead of scrolling + horizontally. */} +
+ + + {/* Explicit width so the fixed layout scales every column + proportionally — leaving Service auto would let the fixed + metric columns squeeze it to nothing on narrow viewports. */} + Service + P50 + P95 + P99 + Error Rate + + Throughput + + + Issues + + + Last deploy + + + + + {services.length === 0 ? ( - {/* Explicit width so the fixed layout scales every column - proportionally — leaving Service auto would let the fixed - metric columns squeeze it to nothing on narrow viewports. */} - Service - P50 - P95 - P99 - Error Rate - - Throughput - - Commit + + No services found + - - - {services.length === 0 ? ( - - - No services found - - - ) : ( - groups.map(([environment, envServices]) => ( - - - -
- - - {envServices.length}{" "} - {envServices.length === 1 - ? "service" - : "services"} - -
-
-
- {envServices.map((service: ServiceOverview) => { - const serviceSeries = Object.hasOwn( - timeSeriesMap, - service.serviceName, - ) - ? timeSeriesMap[service.serviceName] - : undefined - const throughputData = Array.isArray(serviceSeries) - ? serviceSeries.map((p) => ({ value: p.throughput })) - : [] - const errorRateData = Array.isArray(serviceSeries) - ? serviceSeries.map((p) => ({ value: p.errorRate })) - : [] - - return ( - - navigate({ - to: "/services/$serviceName", - params: { - serviceName: service.serviceName, - }, - search: serviceDetailSearch( - filters, - service.environment, - ), - }) - } - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault() - navigate({ - to: "/services/$serviceName", - params: { - serviceName: service.serviceName, - }, - search: serviceDetailSearch( - filters, - service.environment, - ), - }) - } - }} - > - - e.stopPropagation()} - title={service.serviceName} - > - - - {service.serviceName} - - - {service.serviceNamespace ? ( -
- {service.serviceNamespace} -
- ) : null} -
- - {formatLatency(service.p50LatencyMs)} - - - {formatLatency(service.p95LatencyMs)} - - - {formatLatency(service.p99LatencyMs)} - - -
- -
- - {formatErrorRate( - service.errorRate, - )} - -
-
-
- - - - -
- - {service.hasSampling - ? "~" - : ""} - {formatThroughput( - service.throughput, - )} - - {service.hasSampling && ( - - ~ - {formatThroughput( - service.tracedThroughput, - )}{" "} - traced - - )} -
-
- {service.hasSampling && ( - -

- Estimated from{" "} - {( - (1 / - service.samplingWeight) * - 100 - ).toFixed(0)} - % sampled traces (x - {service.samplingWeight.toFixed( - 0, - )}{" "} - extrapolation) -

-
- )} -
-
- - - -
- ) - })} -
- )) - )} -
-
-
+ ) : ( + groups.map(([environment, envServices]) => ( + + + +
+ + + {envServices.length}{" "} + {envServices.length === 1 + ? "service" + : "services"} + +
+
+
+ {envServices.map(rowFor)} +
+ )) + )} + + +
- {/* Mobile: stacked, tap-to-drill list. Grouped by environment to - match the desktop table; metrics collapse to a tight mono line. */} -
- {services.length === 0 ? ( -
- No services found -
- ) : ( - groups.map(([environment, envServices]) => ( -
-
- - - {envServices.length}{" "} - {envServices.length === 1 ? "service" : "services"} - -
- {envServices.map((service: ServiceOverview) => ( + {/* Mobile: stacked, tap-to-drill list. Grouped by environment to + match the desktop table; metrics collapse to a tight mono line. */} +
+ {services.length === 0 ? ( +
+ No services found +
+ ) : ( + groups.map(([environment, envServices]) => ( +
+
+ + + {envServices.length}{" "} + {envServices.length === 1 ? "service" : "services"} + +
+ {envServices.map((service: ServiceOverview) => { + const health = healthFor(service) + return ( {service.serviceName} +
{service.serviceNamespace ? (
@@ -598,19 +822,28 @@ export function ServicesTable({ filters }: ServicesTableProps) {
- ))} -
- )) - )} -
+ ) + })} + + )) + )} + -
+
+ Showing {services.length} {healthFilter ?? ""}{" "} {services.length === 1 ? "service" : "services"} -
+ + {(unhealthyCount > 0 || degradedCount > 0) && ( + + {unhealthyCount > 0 ? `${unhealthyCount} unhealthy` : null} + {unhealthyCount > 0 && degradedCount > 0 ? " · " : null} + {degradedCount > 0 ? `${degradedCount} degraded` : null} + + )}
- ) - }, - ) + + ) + }) .render() } diff --git a/apps/web/src/components/services/use-service-health-summary.ts b/apps/web/src/components/services/use-service-health-summary.ts new file mode 100644 index 000000000..ea3bfa3dc --- /dev/null +++ b/apps/web/src/components/services/use-service-health-summary.ts @@ -0,0 +1,81 @@ +import React from "react" +import { Result, useAtomValue } from "@/lib/effect-atom" +import { useAlertIncidentsList } from "@/hooks/use-alerts-list" +import { openAnomalyIncidentsAtom } from "@/lib/services/atoms/anomaly-atoms" +import { getServiceOverviewResultAtom } from "@/lib/services/atoms/warehouse-query-atoms" +import { + deriveServiceHealthFromCauses, + type ServiceHealth, + type ServiceHealthCause, +} from "@/components/dashboard/service-health" +import type { GetServiceOverviewInput } from "@/api/warehouse/services" + +export interface ServiceHealthSummary { + /** Keyed by {@link serviceHealthRowKey} — one entry per (service, environment) row. */ + byRow: Map + counts: Record +} + +export const serviceHealthRowKey = (serviceName: string, environment: string) => + `${serviceName}::${environment}` + +const HEALTH_LEVELS: readonly ServiceHealth[] = ["healthy", "degraded", "unhealthy"] + +export const isServiceHealth = (value: string): value is ServiceHealth => + (HEALTH_LEVELS as readonly string[]).includes(value) + +/** + * Fleet-level health for every (service, environment) row of the services list. + * + * Derived purely from data the page already loads — the one-shot service + * overview, the locally-synced alert-incident collection, and the single + * open-anomalies call — so subscribing here from several components shares the + * same cached atoms and costs no extra requests. Alert incidents match by + * service name only (an incident's groupKey carries no environment), so an + * alert-caused badge repeats across a service's environment rows; anomalies + * match per (service, environment). + */ +export function useServiceHealthSummary(input: GetServiceOverviewInput): ServiceHealthSummary | undefined { + const overviewResult = useAtomValue(getServiceOverviewResultAtom({ data: input })) + const { result: incidentsResult } = useAlertIncidentsList() + const anomaliesResult = useAtomValue(openAnomalyIncidentsAtom) + + const overview = Result.isSuccess(overviewResult) ? overviewResult.value : undefined + const incidents = Result.isSuccess(incidentsResult) ? incidentsResult.value : undefined + const anomalies = Result.isSuccess(anomaliesResult) ? anomaliesResult.value : undefined + + return React.useMemo(() => { + if (overview === undefined || incidents === undefined || anomalies === undefined) return undefined + + const alertCausesByService = new Map() + for (const incident of incidents.incidents) { + if (incident.status !== "open" || !incident.groupKey) continue + const causes = alertCausesByService.get(incident.groupKey) + const cause: ServiceHealthCause = { severity: incident.severity, label: "Alert firing" } + if (causes) causes.push(cause) + else alertCausesByService.set(incident.groupKey, [cause]) + } + + const anomalyCausesByRow = new Map() + for (const incident of anomalies.incidents) { + const key = serviceHealthRowKey(incident.serviceName, incident.deploymentEnv) + const causes = anomalyCausesByRow.get(key) + const cause: ServiceHealthCause = { severity: incident.severity, label: "Anomaly" } + if (causes) causes.push(cause) + else anomalyCausesByRow.set(key, [cause]) + } + + const byRow = new Map() + const counts: Record = { healthy: 0, degraded: 0, unhealthy: 0 } + for (const service of overview.data) { + const key = serviceHealthRowKey(service.serviceName, service.environment) + const health = deriveServiceHealthFromCauses([ + ...(alertCausesByService.get(service.serviceName) ?? []), + ...(anomalyCausesByRow.get(key) ?? []), + ]) + byRow.set(key, health) + counts[health] += 1 + } + return { byRow, counts } + }, [overview, incidents, anomalies]) +} diff --git a/packages/domain/src/http/v2/error-issues.ts b/packages/domain/src/http/v2/error-issues.ts index 6e1b93626..5f2622498 100644 --- a/packages/domain/src/http/v2/error-issues.ts +++ b/packages/domain/src/http/v2/error-issues.ts @@ -133,6 +133,21 @@ const ErrorIssueList = ListOf(V2ErrorIssue).annotate({ identifier: "ErrorIssueList", title: "Error issue list", }) + +export const V2ErrorIssueServiceCount = Schema.Struct({ + service_name: Schema.String, + open_count: Schema.Number, +}).annotate({ + identifier: "ErrorIssueServiceCount", + title: "Error issue service count", + description: "Number of open (actionable-state) error issues for one service.", +}) +export type V2ErrorIssueServiceCount = Schema.Schema.Type + +const ErrorIssueServiceCountList = ListOf(V2ErrorIssueServiceCount).annotate({ + identifier: "ErrorIssueServiceCountList", + title: "Error issue service count list", +}) const commonErrors = [V2InvalidRequestError, V2ServiceUnavailableError] as const export class V2ErrorIssuesApiGroup extends HttpApiGroup.make("errorIssues") @@ -150,6 +165,20 @@ export class V2ErrorIssuesApiGroup extends HttpApiGroup.make("errorIssues") }), ), ) + .add( + // Static path — must be registered before the `/:id` param route. + HttpApiEndpoint.get("serviceCounts", "/service_counts", { + success: ErrorIssueServiceCountList, + error: [...commonErrors], + }).annotateMerge( + OpenApi.annotations({ + identifier: "listErrorIssueServiceCounts", + summary: "List open issue counts by service", + description: + "Returns the number of open (actionable-state) error issues per service, in one call. Alert-kind issues are excluded. Requires `error_issues:read`.", + }), + ), + ) .add( HttpApiEndpoint.get("retrieve", "/:id", { params: { id: ErrorIssuePublicId }, diff --git a/packages/domain/src/http/v2/openapi.test.ts b/packages/domain/src/http/v2/openapi.test.ts index e5d420977..221810a6c 100644 --- a/packages/domain/src/http/v2/openapi.test.ts +++ b/packages/domain/src/http/v2/openapi.test.ts @@ -84,6 +84,7 @@ describe("MapleApiV2 OpenAPI", () => { "GET /v2/dashboards/{id}/versions", "GET /v2/dashboards/{id}/versions/{version_id}", "GET /v2/error_issues", + "GET /v2/error_issues/service_counts", "GET /v2/error_issues/{id}", "GET /v2/ingest_keys", "GET /v2/instrumentation/recommendations", diff --git a/packages/query-engine/src/ch/queries/services.test.ts b/packages/query-engine/src/ch/queries/services.test.ts index f0933a8b9..2dcd3486d 100644 --- a/packages/query-engine/src/ch/queries/services.test.ts +++ b/packages/query-engine/src/ch/queries/services.test.ts @@ -110,6 +110,7 @@ describe("serviceOverviewQuery", () => { expect(sql).toContain("quantile(0.5)(Duration) / 1000000 AS p50LatencyMs") expect(sql).toContain("quantile(0.95)(Duration) / 1000000 AS p95LatencyMs") expect(sql).toContain("quantile(0.99)(Duration) / 1000000 AS p99LatencyMs") + expect(sql).toContain("min(Timestamp) AS firstSeen") expect(sql).toContain("GROUP BY serviceName, serviceNamespace, environment, commitSha") expect(sql).toContain("ORDER BY throughput DESC") expect(sql).toContain("LIMIT 100") diff --git a/packages/query-engine/src/ch/queries/services.ts b/packages/query-engine/src/ch/queries/services.ts index c0f638e5b..3376c767e 100644 --- a/packages/query-engine/src/ch/queries/services.ts +++ b/packages/query-engine/src/ch/queries/services.ts @@ -38,6 +38,7 @@ export interface ServiceOverviewOutput { readonly p95LatencyMs: number readonly p99LatencyMs: number readonly estimatedSpanCount: number + readonly firstSeen: string } export interface ServiceCatalogOpts { @@ -113,6 +114,10 @@ export function serviceOverviewQuery(opts: ServiceOverviewOpts) { // rows or `1 / acceptanceProbability` for spans carrying a `th:` value. // Replaces the broken `sampledSpanCount * dominantWeight` approximation. estimatedSpanCount: CH.sum($.SampleRate), + // Earliest span per (service, env, commit) inside the window — the + // list page derives deploy age / errors-since-deploy from this, so it + // is window-clamped by construction. + firstSeen: CH.min_($.Timestamp), })) .where(($) => [ $.OrgId.eq(param.string("orgId")),