Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion apps/api/src/routes/query-engine.http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 })
}),
Expand Down
17 changes: 17 additions & 0 deletions apps/api/src/routes/v2/error-issues.http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/routes/v2/v2-test-support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ export const Phase1ResourceStubsLayer = Layer.mergeAll(
}),
Layer.succeed(ErrorsService, {
listIssues: die,
countOpenIssuesByService: die,
getIssue: die,
transitionIssue: die,
claimIssue: die,
Expand Down
31 changes: 31 additions & 0 deletions apps/api/src/services/ErrorsService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down
38 changes: 38 additions & 0 deletions apps/api/src/services/ErrorsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,17 @@ export interface ErrorsServiceShape {
readonly sort?: "last_seen" | "severity"
},
) => Effect.Effect<ErrorIssuesListResponse, ErrorPersistenceError>
/**
* 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,
Expand Down Expand Up @@ -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<number>`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 })
Expand Down Expand Up @@ -2914,6 +2951,7 @@ const make: Effect.Effect<

return ErrorsService.of({
listIssues,
countOpenIssuesByService,
getIssue,
transitionIssue,
claimIssue,
Expand Down
38 changes: 31 additions & 7 deletions apps/web/src/api/warehouse/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -76,6 +79,7 @@ interface CoercedRow {
p95LatencyMs: number
p99LatencyMs: number
estimatedSpanCount: number
firstSeen: string
}

function coerceRow(raw: Record<string, unknown>): CoercedRow {
Expand All @@ -92,6 +96,7 @@ function coerceRow(raw: Record<string, unknown>): CoercedRow {
p95LatencyMs: Number(raw.p95LatencyMs ?? 0),
p99LatencyMs: Number(raw.p99LatencyMs ?? 0),
estimatedSpanCount: Number(raw.estimatedSpanCount ?? 0),
firstSeen: String(raw.firstSeen ?? ""),
}
}

Expand Down Expand Up @@ -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<string, { spanCount: number; errorCount: number; firstSeen: string }>()
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,
Expand Down
46 changes: 45 additions & 1 deletion apps/web/src/components/services/services-filter-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 = <K extends keyof typeof search>(key: K, value: (typeof search)[K]) => {
navigate({
search: (prev: Record<string, unknown>) => ({
Expand All @@ -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(() => <LoadingState />)
Expand All @@ -69,6 +86,33 @@ export function ServicesFilterSidebar() {
<FilterSidebarFrame waiting={result.waiting}>
<FilterSidebarHeader canClear={hasActiveFilters} onClear={clearAllFilters} />
<FilterSidebarBody>
{healthSummary !== undefined && (
<>
<FilterSection
title="Health"
options={(["unhealthy", "degraded", "healthy"] as const).map(
(level) => ({
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,
)
}}
/>
<Separator className="my-2" />
</>
)}

{(facets.environments.length ?? 0) > 0 && (
<>
<FilterSection
Expand Down
Loading
Loading