From c01515dd56028856ec5a2e63b8691e875dcac6b2 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 5 Sep 2026 02:03:49 +0200 Subject: [PATCH 1/4] feat(web): releases page A release is a commit the moment it starts serving traffic. The service overview rollups already key on `vcs.ref.head.revision`, so the whole page is a GROUP BY over the splice the services list reads; nothing scans raw traces. - query-engine: `releasesListQuery` (one row per service/env/commit), `releasesTimelineQuery` (org-wide swimlane buckets) and `releaseErrorFingerprintsQuery` (error fingerprints keyed on `service.version`), with tests and SQL-catalog fixtures. - api: `POST /internal/query-engine/releases` and `/release-detail` bundles; the detail runs versions, timeline, this-version and other-versions timeseries and the fingerprints in one invocation. - web: `/releases` with a health/environment/service sidebar, per-service swimlanes and a commit-grouped table with per-service children and "vs. rest of service" deltas; `/releases/$commitSha` with a comparison table, versions rail, the service page's four charts with commit markers, and new/regressed/ongoing issue lists. Sidebar entry under Monitor. Health bands reuse the services table's deploy thresholds (50 spans on each side, 2x error ratio, 25% p95). The filter param is `impact`, not `health`, because the router merges every route's search schema and the services page already owns `health` with different literals. --- .../src/routes/internal/query-engine.http.ts | 82 +++ apps/web/src/api/warehouse/custom-charts.ts | 6 +- apps/web/src/api/warehouse/releases.ts | 229 ++++++++ .../components/dashboard/nav-items.test.ts | 3 +- .../web/src/components/dashboard/nav-items.ts | 2 + .../releases/release-detail-panels.tsx | 248 +++++++++ .../components/releases/release-health.tsx | 77 +++ .../releases/release-issues-panel.test.ts | 43 ++ .../releases/release-issues-panel.tsx | 254 +++++++++ .../components/releases/release-model.test.ts | 203 ++++++++ .../src/components/releases/release-model.ts | 319 ++++++++++++ .../releases/releases-filter-sidebar.tsx | 125 +++++ .../components/releases/releases-table.tsx | 378 ++++++++++++++ .../components/releases/releases-timeline.tsx | 166 ++++++ .../services/atoms/warehouse-query-atoms.ts | 13 + apps/web/src/routeTree.gen.ts | 42 ++ apps/web/src/routes/releases/$commitSha.tsx | 493 ++++++++++++++++++ apps/web/src/routes/releases/index.tsx | 280 ++++++++++ packages/domain/src/http/query-engine.ts | 107 ++++ .../src/__sql_baseline__/catalog.sql | 293 +++++++++++ .../query-engine/src/ch/builder-fixtures.ts | 64 +++ packages/query-engine/src/ch/index.ts | 16 + .../src/ch/queries/releases.test.ts | 83 +++ .../query-engine/src/ch/queries/releases.ts | 266 ++++++++++ .../query-engine/src/ch/queries/services.ts | 6 +- packages/query-engine/src/registry/queries.ts | 103 ++++ packages/query-engine/src/sql-catalog.test.ts | 2 + 27 files changed, 3896 insertions(+), 7 deletions(-) create mode 100644 apps/web/src/api/warehouse/releases.ts create mode 100644 apps/web/src/components/releases/release-detail-panels.tsx create mode 100644 apps/web/src/components/releases/release-health.tsx create mode 100644 apps/web/src/components/releases/release-issues-panel.test.ts create mode 100644 apps/web/src/components/releases/release-issues-panel.tsx create mode 100644 apps/web/src/components/releases/release-model.test.ts create mode 100644 apps/web/src/components/releases/release-model.ts create mode 100644 apps/web/src/components/releases/releases-filter-sidebar.tsx create mode 100644 apps/web/src/components/releases/releases-table.tsx create mode 100644 apps/web/src/components/releases/releases-timeline.tsx create mode 100644 apps/web/src/routes/releases/$commitSha.tsx create mode 100644 apps/web/src/routes/releases/index.tsx create mode 100644 packages/query-engine/src/ch/queries/releases.test.ts create mode 100644 packages/query-engine/src/ch/queries/releases.ts diff --git a/apps/api/src/routes/internal/query-engine.http.ts b/apps/api/src/routes/internal/query-engine.http.ts index 9fb211e97..46e061b2a 100644 --- a/apps/api/src/routes/internal/query-engine.http.ts +++ b/apps/api/src/routes/internal/query-engine.http.ts @@ -31,6 +31,9 @@ import { CloudflareInfraWorkersResponse, ServiceDbQuerySummaryResponse, ServiceDetailOverviewResponse, + ReleasesListResponse, + ReleaseDetailResponse, + type ReleaseRow, ServiceDependenciesBundleResponse, ServiceMapBundleResponse, ServiceWorkloadsResponse, @@ -247,6 +250,36 @@ const toServicePlatformRow = (row: CH.ServicePlatformsOutput) => { } } +const toReleaseRow = (row: CH.ReleasesListOutput): ReleaseRow => { + const spanCount = Number(row.spanCount) + const satisfied = Number(row.apdexSatisfiedCount) + const tolerating = Number(row.apdexToleratingCount) + return { + serviceName: decodeServiceName(String(row.serviceName ?? "")), + environment: String(row.environment ?? ""), + commitSha: decodeCommitSha(row.commitSha), + firstSeen: String(row.firstSeen), + lastSeen: String(row.lastSeen), + spanCount, + estimatedSpanCount: Number(row.estimatedSpanCount), + errorCount: Number(row.errorCount), + estimatedErrorCount: Number(row.estimatedErrorCount), + p50LatencyMs: Number(row.p50LatencyMs), + p95LatencyMs: Number(row.p95LatencyMs), + p99LatencyMs: Number(row.p99LatencyMs), + apdexScore: + spanCount > 0 ? Math.round(((satisfied + tolerating * 0.5) / spanCount) * 10_000) / 10_000 : 0, + } +} + +const toReleaseTimelinePoint = (row: CH.ReleasesTimelineOutput) => ({ + bucket: String(row.bucket), + serviceName: decodeServiceName(String(row.serviceName ?? "")), + commitSha: decodeCommitSha(row.commitSha), + count: Number(row.count), + errorCount: Number(row.errorCount), +}) + const toServiceWorkloadRow = (row: CH.ServiceWorkloadsOutput) => ({ serviceName: decodeServiceName(String(row.serviceName ?? "")), workloadKind: row.workloadKind, @@ -1033,6 +1066,55 @@ export const HttpQueryEngineLive = HttpApiBuilder.group(MapleInternalApi, "query }) }), ) + .handle("releasesList", ({ payload }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + // One Worker invocation for the page: the per-commit rows and the + // swimlane timeline share a config resolution and run concurrently. + yield* warehouse.warmRoute(tenant) + const [rows, timelineRows] = yield* Effect.all( + [ + runQuery(Queries.releasesList, tenant, payload), + runQuery(Queries.releasesTimeline, tenant, payload), + ], + { concurrency: 2 }, + ) + return new ReleasesListResponse({ + releases: rows.map(toReleaseRow), + timeline: timelineRows.map(toReleaseTimelinePoint), + truncated: rows.length >= (payload.limit ?? CH.RELEASES_LIST_CAP), + }) + }), + ) + .handle("releaseDetail", ({ payload }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + yield* warehouse.warmRoute(tenant) + const [versionRows, timelineRows, timeseries, baselineTimeseries, fingerprintRows] = + yield* Effect.all( + [ + runQuery(Queries.releaseVersions, tenant, payload), + runQuery(Queries.releaseTimeline, tenant, payload), + queryEngine.execute(tenant, payload.timeseries), + queryEngine.execute(tenant, payload.baselineTimeseries), + runQuery(Queries.releaseErrorFingerprints, tenant, payload), + ], + { concurrency: 5 }, + ) + return new ReleaseDetailResponse({ + versions: versionRows.map(toReleaseRow), + timeline: timelineRows.map(toReleaseTimelinePoint), + timeseries, + baselineTimeseries, + errorFingerprints: fingerprintRows.map((row) => ({ + fingerprintHash: decodeFingerprintHash(row.fingerprintHash), + count: Number(row.count), + firstSeen: String(row.firstSeen), + lastSeen: String(row.lastSeen), + })), + }) + }), + ) .handle("serviceDependenciesBundle", ({ payload }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context diff --git a/apps/web/src/api/warehouse/custom-charts.ts b/apps/web/src/api/warehouse/custom-charts.ts index 66574aa88..0ff8cf808 100644 --- a/apps/web/src/api/warehouse/custom-charts.ts +++ b/apps/web/src/api/warehouse/custom-charts.ts @@ -51,7 +51,7 @@ const asDeploymentEnv = Schema.decodeUnknownSync(DeploymentEnvironment) * Without this, scoping a detail page to an `"unknown"` row would emit * `DeploymentEnv IN ('unknown')` and match nothing. */ -const toEnvFilter = ( +export const toEnvFilter = ( environments: ReadonlyArray | undefined, ): ReadonlyArray | undefined => environments?.map((e) => (e === "unknown" ? asDeploymentEnv("") : e)) @@ -607,7 +607,7 @@ export function getCustomChartServiceDetail({ data }: { data: GetCustomChartServ return getCustomChartServiceDetailEffect({ data }) } -function makeAllMetricsTimeseriesRequest(opts: { +export function makeAllMetricsTimeseriesRequest(opts: { startTime?: string endTime?: string bucketSeconds: number @@ -765,7 +765,7 @@ function extractGroupedAllMetricsSeries( // timeseries response into filled `ServiceDetailTimeSeriesPoint`s. Used by both // the standalone chart fetch and the `serviceDetailOverview` bundle so the two // paths can't drift. -function buildServiceDetailPoints( +export function buildServiceDetailPoints( allMetricsRes: QueryEngineExecuteResponse, startTime: string | undefined, endTime: string | undefined, diff --git a/apps/web/src/api/warehouse/releases.ts b/apps/web/src/api/warehouse/releases.ts new file mode 100644 index 000000000..cfbb75d5e --- /dev/null +++ b/apps/web/src/api/warehouse/releases.ts @@ -0,0 +1,229 @@ +import { Clock, Effect, Schema } from "effect" +import { formatWarehouseDateTime } from "@maple/query-engine" +import { + CommitSha, + DeploymentEnvironment, + ReleaseDetailRequest, + ReleasesListRequest, + ServiceName, + ServiceNamespace, + type ReleaseRow, + type ReleaseTimelinePoint, +} from "@maple/domain/http" +import { MapleInternalAtomClient } from "@/lib/services/common/internal-atom-client" +import { + buildServiceDetailPoints, + makeAllMetricsTimeseriesRequest, + toEnvFilter, +} from "@/api/warehouse/custom-charts" +import type { ServiceDetailTimeSeriesPoint } from "@/api/warehouse/services" +import { computeBucketSeconds, quantizeToMinute, toIsoBucket } from "@/api/warehouse/timeseries-utils" +import { WarehouseDateTimeString, decodeInput, runWarehouseQuery } from "@/api/warehouse/effect-utils" + +const dateTimeString = WarehouseDateTimeString + +const DEFAULT_WINDOW_MS = 7 * 24 * 60 * 60 * 1000 + +/** Swimlane resolution: about this many buckets across the window. */ +const TIMELINE_TARGET_POINTS = 96 + +const defaultWindow = (nowMs: number) => ({ + startTime: formatWarehouseDateTime(nowMs - DEFAULT_WINDOW_MS), + endTime: formatWarehouseDateTime(nowMs), +}) + +/** A release row with its warehouse datetimes normalised to ISO. */ +export interface Release extends Omit { + firstSeen: string + lastSeen: string +} + +export interface ReleaseTimelineBucket extends Omit { + bucket: string +} + +const toRelease = (row: ReleaseRow): Release => ({ + ...row, + firstSeen: toIsoBucket(row.firstSeen), + lastSeen: toIsoBucket(row.lastSeen), +}) + +const toTimelineBucket = (row: ReleaseTimelinePoint): ReleaseTimelineBucket => ({ + ...row, + bucket: toIsoBucket(row.bucket), +}) + +// Releases list + +const GetReleasesInput = Schema.Struct({ + startTime: Schema.optional(dateTimeString), + endTime: Schema.optional(dateTimeString), + environments: Schema.optional(Schema.mutable(Schema.Array(DeploymentEnvironment))), + namespaces: Schema.optional(Schema.mutable(Schema.Array(ServiceNamespace))), + services: Schema.optional(Schema.mutable(Schema.Array(ServiceName))), + excludedEnvironments: Schema.optional(Schema.mutable(Schema.Array(DeploymentEnvironment))), + excludedNamespaces: Schema.optional(Schema.mutable(Schema.Array(ServiceNamespace))), +}) + +export type GetReleasesInput = (typeof GetReleasesInput)["Encoded"] + +export interface ReleasesResult { + releases: Release[] + timeline: ReleaseTimelineBucket[] + truncated: boolean + /** The window the rows describe, ISO — the page derives "share of the last bucket" against it. */ + startTime: string + endTime: string + bucketSeconds: number +} + +export function getReleases({ data }: { data: GetReleasesInput }) { + return getReleasesEffect({ data }) +} + +const getReleasesEffect = Effect.fn("QueryEngine.getReleases")(function* ({ + data, +}: { + data: GetReleasesInput +}) { + const input = yield* decodeInput(GetReleasesInput, data ?? {}, "getReleases") + const fallback = defaultWindow(yield* Clock.currentTimeMillis) + const startTime = input.startTime ?? fallback.startTime + const endTime = input.endTime ?? fallback.endTime + // Whole minutes: the rollup tiers cannot place a row inside a minute, and + // the raw fallback would scan the entry-point projection org-wide. + const bucketSeconds = Math.max( + 60, + quantizeToMinute(computeBucketSeconds(startTime, endTime, TIMELINE_TARGET_POINTS)), + ) + + const result = yield* runWarehouseQuery("releasesList", () => + Effect.gen(function* () { + const client = yield* MapleInternalAtomClient + return yield* client.queryEngine.releasesList({ + payload: new ReleasesListRequest({ + startTime, + endTime, + environments: toEnvFilter(input.environments), + namespaces: input.namespaces, + services: input.services, + excludedEnvironments: toEnvFilter(input.excludedEnvironments), + excludedNamespaces: input.excludedNamespaces, + bucketSeconds, + }), + }) + }), + ) + + return { + releases: result.releases.map(toRelease), + timeline: result.timeline.map(toTimelineBucket), + truncated: result.truncated, + startTime: toIsoBucket(startTime), + endTime: toIsoBucket(endTime), + bucketSeconds, + } satisfies ReleasesResult +}) + +// Release detail + +const GetReleaseDetailInput = Schema.Struct({ + serviceName: ServiceName, + commitSha: CommitSha, + startTime: Schema.optional(dateTimeString), + endTime: Schema.optional(dateTimeString), + environments: Schema.optional(Schema.mutable(Schema.Array(DeploymentEnvironment))), +}) + +export type GetReleaseDetailInput = (typeof GetReleaseDetailInput)["Encoded"] + +export interface ReleaseErrorFingerprint { + fingerprintHash: string + count: number + firstSeen: string + lastSeen: string +} + +export interface ReleaseDetailResult { + /** Every version of the service in the window, this one included. */ + versions: Release[] + timeline: ReleaseTimelineBucket[] + /** Golden signals for this version only. */ + points: ServiceDetailTimeSeriesPoint[] + /** Golden signals for every other version of the service. */ + baselinePoints: ServiceDetailTimeSeriesPoint[] + errorFingerprints: ReleaseErrorFingerprint[] + startTime: string + endTime: string + bucketSeconds: number +} + +export function getReleaseDetail({ data }: { data: GetReleaseDetailInput }) { + return getReleaseDetailEffect({ data }) +} + +const getReleaseDetailEffect = Effect.fn("QueryEngine.getReleaseDetail")(function* ({ + data, +}: { + data: GetReleaseDetailInput +}) { + const input = yield* decodeInput(GetReleaseDetailInput, data, "getReleaseDetail") + const nowMs = yield* Clock.currentTimeMillis + const fallback = defaultWindow(nowMs) + const startTime = input.startTime ?? fallback.startTime + const endTime = input.endTime ?? fallback.endTime + const bucketSeconds = Math.max(60, quantizeToMinute(computeBucketSeconds(startTime, endTime))) + const environments = toEnvFilter(input.environments) + + const common = { + startTime, + endTime, + bucketSeconds, + serviceName: input.serviceName, + rootSpansOnly: true, + environments, + } + + const result = yield* runWarehouseQuery("releaseDetail", () => + Effect.gen(function* () { + const client = yield* MapleInternalAtomClient + return yield* client.queryEngine.releaseDetail({ + payload: new ReleaseDetailRequest({ + serviceName: input.serviceName, + commitSha: input.commitSha, + startTime, + endTime, + environments, + timeseries: makeAllMetricsTimeseriesRequest({ ...common, commitShas: [input.commitSha] }), + baselineTimeseries: makeAllMetricsTimeseriesRequest({ + ...common, + excludedCommitShas: [input.commitSha], + }), + bucketSeconds, + }), + }) + }), + ) + + return { + versions: result.versions.map(toRelease), + timeline: result.timeline.map(toTimelineBucket), + points: buildServiceDetailPoints(result.timeseries, startTime, endTime, bucketSeconds, nowMs), + baselinePoints: buildServiceDetailPoints( + result.baselineTimeseries, + startTime, + endTime, + bucketSeconds, + nowMs, + ), + errorFingerprints: result.errorFingerprints.map((row) => ({ + fingerprintHash: row.fingerprintHash, + count: row.count, + firstSeen: toIsoBucket(row.firstSeen), + lastSeen: toIsoBucket(row.lastSeen), + })), + startTime: toIsoBucket(startTime), + endTime: toIsoBucket(endTime), + bucketSeconds, + } satisfies ReleaseDetailResult +}) diff --git a/apps/web/src/components/dashboard/nav-items.test.ts b/apps/web/src/components/dashboard/nav-items.test.ts index 1f3e7971a..8a657e8fb 100644 --- a/apps/web/src/components/dashboard/nav-items.test.ts +++ b/apps/web/src/components/dashboard/nav-items.test.ts @@ -63,11 +63,12 @@ describe("isNavItemActive", () => { }) describe("navGroups", () => { - it("renders ten top-level rows", () => { + it("renders eleven top-level rows", () => { const rows = navGroups().flatMap((group) => group.items) expect(rows.map((item) => item.title)).toEqual([ "Overview", "Services", + "Releases", "Service Map", "Infrastructure", "Explore", diff --git a/apps/web/src/components/dashboard/nav-items.ts b/apps/web/src/components/dashboard/nav-items.ts index 6dc0b0142..876ffbbe3 100644 --- a/apps/web/src/components/dashboard/nav-items.ts +++ b/apps/web/src/components/dashboard/nav-items.ts @@ -16,6 +16,7 @@ import { PlanetScaleIcon, PlayRotateClockwiseIcon, PulseIcon, + RocketIcon, ServerIcon, SquareSparkleIcon, } from "@/components/icons" @@ -255,6 +256,7 @@ export function navGroups(flags?: OrganizationFeatureFlags): NavGroup[] { label: "Monitor", items: [ { title: "Services", href: "/services", icon: ServerIcon }, + { title: "Releases", href: "/releases", icon: RocketIcon }, { title: "Service Map", href: "/service-map", icon: NetworkNodesIcon }, infrastructureItem, ], diff --git a/apps/web/src/components/releases/release-detail-panels.tsx b/apps/web/src/components/releases/release-detail-panels.tsx new file mode 100644 index 000000000..f4b251e8e --- /dev/null +++ b/apps/web/src/components/releases/release-detail-panels.tsx @@ -0,0 +1,248 @@ +import { Link } from "@tanstack/react-router" +import { formatErrorRate, formatLatency, formatNumber } from "@maple/ui/lib/format" +import { formatRelativeTimeOrDate } from "@maple/ui/lib/time-format" +import { cn } from "@maple/ui/lib/utils" + +import { SectionCard } from "@/components/services/section-card" +import type { TimeRangeSearch } from "@/components/time-range-picker/search" +import { CommitShaHoverCard } from "@/components/vcs/commit-sha-hover-card" +import { ReleaseHealthPill, releaseHealthFigure } from "./release-health" +import { MIN_COMPARE_SPANS, shortReleaseLabel, type ReleaseServiceImpact } from "./release-model" + +interface ComparisonRow { + label: string + value: number + baseline: number | undefined + format: (value: number) => string + /** Change as a ratio of the baseline; `undefined` when the row has no meaningful change. */ + change: number | undefined + /** Whether a positive change is bad (errors, latency) or good (apdex). */ + direction: "lower-is-better" | "higher-is-better" | "neutral" +} + +function changeTone(row: ComparisonRow): string { + if (row.change === undefined || row.direction === "neutral") return "text-muted-foreground" + const bad = row.direction === "lower-is-better" ? row.change > 0 : row.change < 0 + const magnitude = Math.abs(row.change) + if (!bad || magnitude < 0.1) return "text-muted-foreground" + return magnitude >= 1 ? "text-severity-error" : "text-severity-warn" +} + +function formatChange(row: ComparisonRow): string { + if (row.change === undefined) return "—" + if (row.direction === "lower-is-better" && row.change >= 1) return `${(1 + row.change).toFixed(1)}×` + if (!Number.isFinite(row.change)) return "from 0" + const pct = Math.round(row.change * 100) + return `${pct > 0 ? "+" : ""}${pct}%` +} + +const ratioChange = (value: number, baseline: number | undefined): number | undefined => + baseline === undefined + ? undefined + : baseline > 0 + ? (value - baseline) / baseline + : value > 0 + ? Number.POSITIVE_INFINITY + : 0 + +/** + * This version against every other version of the same service in the same + * window. Rows are the golden signals the charts below draw; the change column + * is the figure the health band was derived from. + */ +export function ReleaseComparison({ impact }: { impact: ReleaseServiceImpact }) { + const baseline = impact.baseline + const comparable = + baseline !== undefined && + impact.spanCount >= MIN_COMPARE_SPANS && + baseline.spanCount >= MIN_COMPARE_SPANS + const rows: ComparisonRow[] = [ + { + label: "Requests", + value: impact.spanCount, + baseline: baseline?.spanCount, + format: formatNumber, + change: undefined, + direction: "neutral", + }, + { + label: "Error rate", + value: impact.errorRate, + baseline: baseline?.errorRate, + format: formatErrorRate, + change: comparable ? ratioChange(impact.errorRate, baseline?.errorRate) : undefined, + direction: "lower-is-better", + }, + { + label: "p50", + value: impact.p50LatencyMs, + baseline: baseline?.p50LatencyMs, + format: formatLatency, + change: comparable ? ratioChange(impact.p50LatencyMs, baseline?.p50LatencyMs) : undefined, + direction: "lower-is-better", + }, + { + label: "p95", + value: impact.p95LatencyMs, + baseline: baseline?.p95LatencyMs, + format: formatLatency, + change: comparable ? ratioChange(impact.p95LatencyMs, baseline?.p95LatencyMs) : undefined, + direction: "lower-is-better", + }, + { + label: "p99", + value: impact.p99LatencyMs, + baseline: baseline?.p99LatencyMs, + format: formatLatency, + change: comparable ? ratioChange(impact.p99LatencyMs, baseline?.p99LatencyMs) : undefined, + direction: "lower-is-better", + }, + { + label: "Apdex", + value: impact.apdexScore, + baseline: baseline?.apdexScore, + format: (value) => value.toFixed(2), + change: comparable ? ratioChange(impact.apdexScore, baseline?.apdexScore) : undefined, + direction: "higher-is-better", + }, + ] + + const baselineLabel = + baseline === undefined + ? "no other version" + : baseline.versions === 1 + ? "1 other version" + : `${baseline.versions} other versions` + + return ( + + {baselineLabel} + + } + > + {baseline === undefined ? ( +
+ Only one version of this service reported in the window, so there is nothing to compare + against. Widen the time range to include the previous version. +
+ ) : ( + + + + + + + + + + {rows.map((row) => ( + + + + + + + ))} + +
+ + + {shortReleaseLabel(impact.commitSha)} + + OthersChange
{row.label} + {row.format(row.value)} + + {row.baseline === undefined ? "—" : row.format(row.baseline)} + + {formatChange(row)} +
+ )} + {baseline !== undefined && !comparable ? ( +
+ Changes are withheld below {MIN_COMPARE_SPANS} requests on either side. +
+ ) : null} +
+ ) +} + +interface ReleaseVersionsRailProps { + impacts: ReadonlyArray + currentSha: string + serviceName: string + environments?: string[] + timeSearch: TimeRangeSearch +} + +/** Every version of the service in the window, newest first; the current one is pinned. */ +export function ReleaseVersionsRail({ + impacts, + currentSha, + serviceName, + environments, + timeSearch, +}: ReleaseVersionsRailProps) { + const sorted = impacts.toSorted((a, b) => + a.firstSeen < b.firstSeen ? 1 : a.firstSeen > b.firstSeen ? -1 : 0, + ) + return ( + + {sorted.length === 1 ? "1 version" : `${sorted.length} versions`} + + } + > +
+ {sorted.map((version) => { + const isCurrent = version.commitSha === currentSha + return ( + + + {shortReleaseLabel(version.commitSha)} + + {version.health === "healthy" ? null : ( + + )} + + {formatNumber(version.spanCount)} + + + {formatRelativeTimeOrDate(version.firstSeen)} + + + ) + })} +
+
+ ) +} diff --git a/apps/web/src/components/releases/release-health.tsx b/apps/web/src/components/releases/release-health.tsx new file mode 100644 index 000000000..56e1c9d93 --- /dev/null +++ b/apps/web/src/components/releases/release-health.tsx @@ -0,0 +1,77 @@ +import { cn } from "@maple/ui/lib/utils" + +import type { ReleaseHealth } from "./release-model" + +export const RELEASE_HEALTH_LABEL = { + regressed: "errors up", + watch: "latency up", + rolling: "rolling out", + healthy: "healthy", +} satisfies Record + +export const RELEASE_HEALTH_DESCRIPTION = { + regressed: "Errors at least twice as often as the other versions of the same service in this window.", + watch: "p95 latency up by a quarter or more against the other versions of the same service.", + rolling: "The newest version of its service, not yet carrying the whole of the latest traffic.", + healthy: "No change worth flagging against the other versions of the same service.", +} satisfies Record + +/** Marker fill for the swimlanes and the filter legend. */ +export const RELEASE_HEALTH_DOT_CLASS = { + regressed: "bg-destructive", + watch: "bg-severity-warn", + rolling: "border-2 border-primary bg-background", + healthy: "bg-primary/70", +} satisfies Record + +const PILL_CLASS = { + regressed: "bg-destructive/10 text-destructive", + watch: "bg-severity-warn/15 text-severity-warn", + rolling: "bg-primary/10 text-primary", + healthy: "bg-muted text-muted-foreground", +} satisfies Record + +interface ReleaseHealthPillProps { + health: ReleaseHealth + /** Replaces the generic label with the measured figure ("errors 4.1×", "p95 +38%"). */ + label?: string + className?: string +} + +export function ReleaseHealthPill({ health, label, className }: ReleaseHealthPillProps) { + return ( + + {label ?? RELEASE_HEALTH_LABEL[health]} + + ) +} + +/** "errors 4.1×" / "p95 +38%" / "42% rolling out" — the figure behind the band. */ +export function releaseHealthFigure(impact: { + health: ReleaseHealth + errorRatio: number | undefined + p95Delta: number | undefined + share: number | undefined +}): string | undefined { + switch (impact.health) { + case "regressed": + return impact.errorRatio === undefined + ? undefined + : Number.isFinite(impact.errorRatio) + ? `errors ${impact.errorRatio.toFixed(1)}×` + : "errors from 0" + case "watch": + return impact.p95Delta === undefined ? undefined : `p95 +${Math.round(impact.p95Delta * 100)}%` + case "rolling": + return impact.share === undefined ? undefined : `${Math.round(impact.share * 100)}% rolling out` + case "healthy": + return undefined + } +} diff --git a/apps/web/src/components/releases/release-issues-panel.test.ts b/apps/web/src/components/releases/release-issues-panel.test.ts new file mode 100644 index 000000000..466c45d1d --- /dev/null +++ b/apps/web/src/components/releases/release-issues-panel.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest" +import { splitReleaseIssues, type ReleaseIssueDates } from "./release-issues-panel" + +// Only the fields the split reads; the document's other thirty are irrelevant here. +function issue(fields: { + id: string + firstSeenAt: string + lastRegressedAt?: string | null +}): ReleaseIssueDates & { id: string } { + return { + id: fields.id, + firstSeenAt: fields.firstSeenAt, + lastRegressedAt: fields.lastRegressedAt ?? null, + } +} + +const RELEASE_FIRST_SEEN = "2026-09-05T09:41:00.000Z" + +describe("splitReleaseIssues", () => { + it("splits by first-seen against the release, with slack for bucket flooring", () => { + const split = splitReleaseIssues( + [ + issue({ id: "fresh", firstSeenAt: "2026-09-05T09:44:00.000Z" }), + issue({ id: "slack", firstSeenAt: "2026-09-05T09:37:00.000Z" }), + issue({ + id: "regressed", + firstSeenAt: "2026-08-01T00:00:00.000Z", + lastRegressedAt: "2026-09-05T10:00:00.000Z", + }), + issue({ + id: "old-regression", + firstSeenAt: "2026-08-01T00:00:00.000Z", + lastRegressedAt: "2026-08-20T00:00:00.000Z", + }), + issue({ id: "ongoing", firstSeenAt: "2026-08-01T00:00:00.000Z" }), + ], + RELEASE_FIRST_SEEN, + ) + expect(split.fresh.map((i) => i.id)).toEqual(["fresh", "slack"]) + expect(split.regressed.map((i) => i.id)).toEqual(["regressed"]) + expect(split.ongoing.map((i) => i.id)).toEqual(["old-regression", "ongoing"]) + }) +}) diff --git a/apps/web/src/components/releases/release-issues-panel.tsx b/apps/web/src/components/releases/release-issues-panel.tsx new file mode 100644 index 000000000..083e7f4b7 --- /dev/null +++ b/apps/web/src/components/releases/release-issues-panel.tsx @@ -0,0 +1,254 @@ +import { useMemo } from "react" +import { Link } from "@tanstack/react-router" +import type { ErrorIssueDocument } from "@maple/domain/http" +import { Skeleton } from "@maple/ui/components/ui/skeleton" +import { formatNumber } from "@maple/ui/lib/format" +import { formatRelativeTimeOrDate } from "@maple/ui/lib/time-format" + +import { SeverityBadge } from "@/components/errors/severity-badge" +import { SectionCard } from "@/components/services/section-card" +import { Result, useAtomValue } from "@/lib/effect-atom" +import { retainedQueryV2 } from "@/lib/services/common/v2-atom-client" +import { errorIssueFromV2 } from "@/lib/services/error-issues" +import type { ReleaseErrorFingerprint } from "@/api/warehouse/releases" + +/** The v2 list takes one page of fingerprints; the rest of a very noisy version stays on /errors. */ +const FINGERPRINT_LIMIT = 50 + +/** + * Slack between a version's first span and an issue's first occurrence: the + * rollup's first-seen is bucket-floored, and the error event can land a beat + * before the entry-point span that carried it. + */ +const NEW_ISSUE_SLACK_MS = 5 * 60 * 1000 + +/** The two timestamps the split reads; the panel passes whole issue documents. */ +export interface ReleaseIssueDates { + readonly firstSeenAt: string + readonly lastRegressedAt: string | null +} + +export interface ReleaseIssueSplit { + /** Issues whose first occurrence anywhere came with this version. */ + fresh: T[] + /** Issues that had been fixed and came back with this version. */ + regressed: T[] + /** Issues that were already open and are still occurring on this version. */ + ongoing: T[] +} + +/** Exported for its tests — the panel below is the only production caller. */ +export function splitReleaseIssues( + issues: ReadonlyArray, + releaseFirstSeen: string, +): ReleaseIssueSplit { + const cutoff = Date.parse(releaseFirstSeen) - NEW_ISSUE_SLACK_MS + const split: ReleaseIssueSplit = { fresh: [], regressed: [], ongoing: [] } + for (const issue of issues) { + if (Date.parse(issue.firstSeenAt) >= cutoff) split.fresh.push(issue) + else if (issue.lastRegressedAt !== null && Date.parse(issue.lastRegressedAt) >= cutoff) + split.regressed.push(issue) + else split.ongoing.push(issue) + } + return split +} + +interface IssueLineProps { + issue: ErrorIssueDocument + /** Occurrences carried by this version, from the warehouse split. */ + onVersion: number | undefined +} + +function IssueLine({ issue, onVersion }: IssueLineProps) { + const title = issue.errorLabel || issue.exceptionType || issue.exceptionMessage || "Unknown error" + return ( + + + + {title} + + + {formatNumber(onVersion ?? issue.occurrenceCount)}× + + + {formatRelativeTimeOrDate(issue.lastSeenAt)} + + + ) +} + +function IssueList({ + title, + issues, + counts, + empty, + tone, +}: { + title: string + issues: ReadonlyArray + counts: ReadonlyMap + empty: string + tone?: "error" | "warn" +}) { + return ( + + {issues.length} + + } + > + {issues.length === 0 ? ( +
{empty}
+ ) : ( +
+ {issues.map((issue) => ( + + ))} +
+ )} +
+ ) +} + +function PanelsSkeleton() { + return ( +
+ {["New on this version", "Regressed on this version", "Still occurring"].map((title) => ( + +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+
+ ))} +
+ ) +} + +interface ReleaseIssuesPanelProps { + serviceName: string + releaseFirstSeen: string + fingerprints: ReadonlyArray +} + +/** + * The bridge from a release to the issues system: every fingerprint whose + * occurrences carried this version as `service.version`, split into new, + * regressed and ongoing against the release's first-seen. The warehouse half + * comes with the detail bundle; the issue documents are one v2 list call + * keyed on those fingerprints. + */ +export function ReleaseIssuesPanel({ serviceName, releaseFirstSeen, fingerprints }: ReleaseIssuesPanelProps) { + if (fingerprints.length === 0) { + return ( +
+ + + +
+ ) + } + return ( + + ) +} + +function ReleaseIssuesLoaded({ serviceName, releaseFirstSeen, fingerprints }: ReleaseIssuesPanelProps) { + const page = fingerprints.slice(0, FINGERPRINT_LIMIT) + const counts = useMemo(() => new Map(page.map((row) => [row.fingerprintHash, row.count])), [page]) + const result = useAtomValue( + retainedQueryV2("errorIssues", "list", { + query: { + service_name: serviceName, + fingerprint_hash: page.map((row) => row.fingerprintHash).join(","), + limit: FINGERPRINT_LIMIT, + }, + reactivityKeys: ["errorIssues"], + }), + ) + + const split = useMemo( + () => + Result.isSuccess(result) + ? splitReleaseIssues(result.value.data.map(errorIssueFromV2), releaseFirstSeen) + : undefined, + [result, releaseFirstSeen], + ) + + if (Result.isInitial(result)) return + if (split === undefined) { + return ( +
+ Issues could not be loaded. +
+ ) + } + + return ( +
+ + + +
+ ) +} diff --git a/apps/web/src/components/releases/release-model.test.ts b/apps/web/src/components/releases/release-model.test.ts new file mode 100644 index 000000000..0d4811aaf --- /dev/null +++ b/apps/web/src/components/releases/release-model.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it } from "vitest" +import { Schema } from "effect" +import { CommitSha, ServiceName } from "@maple/domain/http" +import type { Release, ReleaseTimelineBucket } from "@/api/warehouse/releases" +import { + deriveReleaseImpacts, + groupReleases, + lastBucketShares, + releaseDayLabel, + releaseFacetCounts, + shortReleaseLabel, +} from "./release-model" + +const sha = Schema.decodeUnknownSync(CommitSha) +const svc = Schema.decodeUnknownSync(ServiceName) +const SHA_A = sha("a".repeat(40)) +const SHA_B = sha("b".repeat(40)) +const API = svc("api") +const WEB = svc("web") + +function release(overrides: Partial & Pick): Release { + return { + environment: "production", + firstSeen: "2026-09-05T09:00:00.000Z", + lastSeen: "2026-09-05T12:00:00.000Z", + spanCount: 1000, + estimatedSpanCount: 1000, + errorCount: 3, + estimatedErrorCount: 3, + p50LatencyMs: 90, + p95LatencyMs: 400, + p99LatencyMs: 1200, + apdexScore: 0.95, + ...overrides, + } +} + +function bucket( + iso: string, + serviceName: ServiceName, + commitSha: CommitSha, + count: number, +): ReleaseTimelineBucket { + return { bucket: iso, serviceName, commitSha, count, errorCount: 0 } +} + +describe("deriveReleaseImpacts", () => { + it("flags a version that errors twice as often as the rest of its service", () => { + const rows = [ + release({ + commitSha: SHA_B, + serviceName: API, + firstSeen: "2026-09-05T10:00:00.000Z", + errorCount: 40, + }), + release({ commitSha: SHA_A, serviceName: API, errorCount: 3 }), + ] + const [newer, older] = deriveReleaseImpacts(rows, []) + expect(newer?.health).toBe("regressed") + expect(newer?.errorRatio).toBeCloseTo(40 / 3, 3) + expect(newer?.isNewest).toBe(true) + expect(older?.health).toBe("healthy") + expect(older?.baseline?.versions).toBe(1) + }) + + it("withholds the comparison below the span floor", () => { + const rows = [ + release({ commitSha: SHA_B, serviceName: API, spanCount: 20, errorCount: 10 }), + release({ commitSha: SHA_A, serviceName: API, firstSeen: "2026-09-04T09:00:00.000Z" }), + ] + const [newer] = deriveReleaseImpacts(rows, []) + expect(newer?.errorRatio).toBeUndefined() + expect(newer?.health).toBe("healthy") + }) + + it("calls a latency jump a watch, not a regression", () => { + const rows = [ + release({ + commitSha: SHA_B, + serviceName: API, + firstSeen: "2026-09-05T10:00:00.000Z", + p95LatencyMs: 600, + }), + release({ commitSha: SHA_A, serviceName: API }), + ] + const [newer] = deriveReleaseImpacts(rows, []) + expect(newer?.p95Delta).toBeCloseTo(0.5, 3) + expect(newer?.health).toBe("watch") + }) + + it("reads a partial share of the last bucket as rolling out", () => { + const rows = [ + release({ commitSha: SHA_B, serviceName: API, firstSeen: "2026-09-05T11:00:00.000Z" }), + release({ commitSha: SHA_A, serviceName: API }), + ] + const timeline = [ + bucket("2026-09-05T11:00:00.000Z", API, SHA_A, 60), + bucket("2026-09-05T11:00:00.000Z", API, SHA_B, 40), + ] + const [newer, older] = deriveReleaseImpacts(rows, timeline) + expect(newer?.share).toBeCloseTo(0.4, 3) + expect(newer?.health).toBe("rolling") + expect(older?.health).toBe("healthy") + }) + + it("does not call a dozen spans a rollout", () => { + const rows = [ + release({ + commitSha: SHA_B, + serviceName: API, + firstSeen: "2026-09-05T11:00:00.000Z", + spanCount: 13, + }), + release({ commitSha: SHA_A, serviceName: API }), + ] + const timeline = [ + bucket("2026-09-05T11:00:00.000Z", API, SHA_A, 990), + bucket("2026-09-05T11:00:00.000Z", API, SHA_B, 10), + ] + const [newer] = deriveReleaseImpacts(rows, timeline) + expect(newer?.share).toBeCloseTo(0.01, 3) + expect(newer?.health).toBe("healthy") + }) + + it("has no baseline for the only version of a service", () => { + const [only] = deriveReleaseImpacts([release({ commitSha: SHA_A, serviceName: API })], []) + expect(only?.baseline).toBeUndefined() + expect(only?.health).toBe("healthy") + }) + + it("keeps environments apart", () => { + const rows = [ + release({ commitSha: SHA_A, serviceName: API, environment: "production" }), + release({ commitSha: SHA_A, serviceName: API, environment: "staging", errorCount: 500 }), + ] + const impacts = deriveReleaseImpacts(rows, []) + expect(impacts.every((impact) => impact.baseline === undefined)).toBe(true) + }) +}) + +describe("lastBucketShares", () => { + it("reads 0 for a version absent from the service's last bucket", () => { + const timeline = [ + bucket("2026-09-05T10:00:00.000Z", API, SHA_A, 10), + bucket("2026-09-05T11:00:00.000Z", API, SHA_B, 10), + ] + const shares = lastBucketShares(timeline) + expect(shares.get(`api ${SHA_A}`)).toBe(0) + expect(shares.get(`api ${SHA_B}`)).toBe(1) + }) +}) + +describe("groupReleases", () => { + it("folds one sha across services, newest first, worst health wins", () => { + const rows = [ + release({ commitSha: SHA_A, serviceName: WEB, firstSeen: "2026-09-05T09:05:00.000Z" }), + release({ + commitSha: SHA_A, + serviceName: API, + errorCount: 100, + firstSeen: "2026-09-05T09:00:00.000Z", + }), + release({ commitSha: SHA_B, serviceName: API, firstSeen: "2026-09-04T09:00:00.000Z" }), + ] + const groups = groupReleases(deriveReleaseImpacts(rows, [])) + expect(groups.map((g) => g.commitSha)).toEqual([SHA_A, SHA_B]) + expect(groups[0]?.services.map((s) => s.serviceName)).toEqual(["web", "api"]) + expect(groups[0]?.firstSeen).toBe("2026-09-05T09:00:00.000Z") + expect(groups[0]?.health).toBe("regressed") + expect(groups[0]?.spanCount).toBe(2000) + }) +}) + +describe("releaseFacetCounts", () => { + it("counts groups by health and services by appearance", () => { + const rows = [ + release({ commitSha: SHA_A, serviceName: WEB }), + release({ commitSha: SHA_A, serviceName: API, environment: "" }), + release({ commitSha: SHA_B, serviceName: API, firstSeen: "2026-09-04T09:00:00.000Z" }), + ] + const counts = releaseFacetCounts(groupReleases(deriveReleaseImpacts(rows, []))) + expect(counts.health.healthy).toBe(2) + expect(counts.services).toEqual([ + { name: "api", count: 2 }, + { name: "web", count: 1 }, + ]) + expect(counts.environments.map((e) => e.name)).toEqual(["production", "unknown"]) + }) +}) + +describe("labels", () => { + it("shortens only full git shas", () => { + expect(shortReleaseLabel(SHA_A)).toBe("aaaaaaa") + expect(shortReleaseLabel("v0.41.2")).toBe("v0.41.2") + }) + + it("names today and yesterday", () => { + const now = new Date("2026-09-05T15:00:00").getTime() + expect(releaseDayLabel(new Date("2026-09-05T09:00:00").toISOString(), now)).toBe("Today") + expect(releaseDayLabel(new Date("2026-09-04T23:30:00").toISOString(), now)).toBe("Yesterday") + expect(releaseDayLabel("not a date", now)).toBe("not a date") + }) +}) diff --git a/apps/web/src/components/releases/release-model.ts b/apps/web/src/components/releases/release-model.ts new file mode 100644 index 000000000..2ad7a2efd --- /dev/null +++ b/apps/web/src/components/releases/release-model.ts @@ -0,0 +1,319 @@ +// Pure derivations for the Releases pages. Kept free of React so the +// thresholds unit-test cleanly and the list, the swimlanes and the detail page +// all describe a release the same way. + +import type { Release, ReleaseTimelineBucket } from "@/api/warehouse/releases" + +/** + * Health, worst first. A single band per release so the sidebar facet and the + * row pill never disagree. + * + * - `regressed`: this version errors at least twice as often as every other + * version of the same service in the same window, by a margin that cannot be + * rounding noise. + * - `watch`: p95 is up by a quarter or more against those other versions. + * - `rolling`: the newest version of its service, still short of carrying the + * whole of the last bucket's traffic. + * - `healthy`: none of the above, with enough traffic to say so. + */ +export type ReleaseHealth = "regressed" | "watch" | "rolling" | "healthy" + +export const RELEASE_HEALTH_ORDER: ReadonlyArray = ["regressed", "watch", "rolling", "healthy"] + +export function isReleaseHealth(value: string): value is ReleaseHealth { + return (RELEASE_HEALTH_ORDER as ReadonlyArray).includes(value) +} + +// Same constants as the services table's deploy cell, so a release the list +// calls regressed is one the services page flags "errors ↑ since deploy". +export const MIN_COMPARE_SPANS = 50 +const ERROR_RATIO_THRESHOLD = 2 +const ERROR_RATE_MIN_DIFF = 0.005 +const P95_DELTA_THRESHOLD = 0.25 +/** Below this share of the last bucket, the newest version is still rolling out. */ +export const ROLLOUT_COMPLETE_SHARE = 0.9 + +/** Every other version of the same (service, environment) in the window, merged. */ +export interface ReleaseBaseline { + spanCount: number + errorCount: number + errorRate: number + /** Span-weighted mean of the other versions' p95s — a comparison, not a quantile. */ + p95LatencyMs: number + p50LatencyMs: number + p99LatencyMs: number + apdexScore: number + versions: number +} + +/** One (service, environment) slice of a release, with its impact derived. */ +export interface ReleaseServiceImpact { + serviceName: string + environment: string + commitSha: string + firstSeen: string + lastSeen: string + spanCount: number + estimatedSpanCount: number + errorCount: number + errorRate: number + p50LatencyMs: number + p95LatencyMs: number + p99LatencyMs: number + apdexScore: number + /** Undefined when this is the only version of the service in the window. */ + baseline: ReleaseBaseline | undefined + /** `errorRate / baseline.errorRate`, only when both sides clear the span floor. */ + errorRatio: number | undefined + /** `(p95 - baseline.p95) / baseline.p95`, under the same floor. */ + p95Delta: number | undefined + /** Share of the service's traffic in the last bucket it reported; 0 once replaced. */ + share: number | undefined + /** True when no other version of the service has a later first-seen. */ + isNewest: boolean + health: ReleaseHealth +} + +/** One commit across every service it landed on. */ +export interface ReleaseGroup { + commitSha: string + /** Earliest first-seen across services. */ + firstSeen: string + lastSeen: string + services: ReleaseServiceImpact[] + spanCount: number + errorCount: number + errorRate: number + health: ReleaseHealth +} + +const rate = (errors: number, spans: number) => (spans > 0 ? errors / spans : 0) + +function worstHealth(values: ReadonlyArray): ReleaseHealth { + for (const band of RELEASE_HEALTH_ORDER) if (values.includes(band)) return band + return "healthy" +} + +function serviceKey(serviceName: string, environment: string): string { + return `${serviceName} ${environment}` +} + +/** + * Share of each (service, environment, commit) in the last bucket that + * service reported. A version absent from that bucket has been replaced and + * reads 0; a service with no timeline rows yields no entry at all. + */ +export function lastBucketShares(timeline: ReadonlyArray): Map { + const lastBucket = new Map() + for (const point of timeline) { + const current = lastBucket.get(point.serviceName) + if (current === undefined || point.bucket > current) lastBucket.set(point.serviceName, point.bucket) + } + const totals = new Map() + const counts = new Map() + for (const point of timeline) { + if (lastBucket.get(point.serviceName) !== point.bucket) continue + totals.set(point.serviceName, (totals.get(point.serviceName) ?? 0) + point.count) + counts.set(`${point.serviceName} ${point.commitSha}`, point.count) + } + const shares = new Map() + for (const [serviceName, total] of totals) { + if (total <= 0) continue + for (const point of timeline) { + if (point.serviceName !== serviceName) continue + const key = `${serviceName} ${point.commitSha}` + shares.set(key, (counts.get(key) ?? 0) / total) + } + } + return shares +} + +function deriveHealth(impact: Omit): ReleaseHealth { + if ( + impact.errorRatio !== undefined && + impact.baseline !== undefined && + impact.errorRatio >= ERROR_RATIO_THRESHOLD && + impact.errorRate - impact.baseline.errorRate >= ERROR_RATE_MIN_DIFF + ) { + return "regressed" + } + if (impact.p95Delta !== undefined && impact.p95Delta >= P95_DELTA_THRESHOLD) return "watch" + // The same floor as the comparisons: a dozen spans on a brand-new version + // is a canary's first minute, not a rollout worth a band. + if ( + impact.isNewest && + impact.spanCount >= MIN_COMPARE_SPANS && + impact.share !== undefined && + impact.share > 0 && + impact.share < ROLLOUT_COMPLETE_SHARE && + impact.baseline !== undefined + ) { + return "rolling" + } + return "healthy" +} + +/** + * Derive every release's impact from the per-(service, env, commit) rows and + * the timeline. The comparison is same-window: this version against the merged + * remainder of its service, which is what handles a canary running beside its + * predecessor. A version that is the only one of its service has no baseline + * and is reported healthy by default. + */ +export function deriveReleaseImpacts( + releases: ReadonlyArray, + timeline: ReadonlyArray, +): ReleaseServiceImpact[] { + const byService = new Map() + for (const release of releases) { + const key = serviceKey(release.serviceName, release.environment) + const rows = byService.get(key) + if (rows === undefined) byService.set(key, [release]) + else rows.push(release) + } + const shares = lastBucketShares(timeline) + + const impacts: ReleaseServiceImpact[] = [] + for (const rows of byService.values()) { + const newestFirstSeen = rows.reduce((max, row) => (row.firstSeen > max ? row.firstSeen : max), "") + for (const row of rows) { + const others = rows.filter((other) => other !== row) + const baseline = others.length === 0 ? undefined : mergeBaseline(others) + const errorRate = rate(row.errorCount, row.spanCount) + const comparable = + baseline !== undefined && + row.spanCount >= MIN_COMPARE_SPANS && + baseline.spanCount >= MIN_COMPARE_SPANS + const errorRatio = + comparable && baseline !== undefined + ? baseline.errorRate > 0 + ? errorRate / baseline.errorRate + : errorRate > 0 + ? Number.POSITIVE_INFINITY + : 1 + : undefined + const p95Delta = + comparable && baseline !== undefined && baseline.p95LatencyMs > 0 + ? (row.p95LatencyMs - baseline.p95LatencyMs) / baseline.p95LatencyMs + : undefined + const partial: Omit = { + serviceName: row.serviceName, + environment: row.environment, + commitSha: row.commitSha, + firstSeen: row.firstSeen, + lastSeen: row.lastSeen, + spanCount: row.spanCount, + estimatedSpanCount: row.estimatedSpanCount, + errorCount: row.errorCount, + errorRate, + p50LatencyMs: row.p50LatencyMs, + p95LatencyMs: row.p95LatencyMs, + p99LatencyMs: row.p99LatencyMs, + apdexScore: row.apdexScore, + baseline, + errorRatio, + p95Delta, + share: shares.get(`${row.serviceName} ${row.commitSha}`), + isNewest: row.firstSeen === newestFirstSeen, + } + impacts.push({ ...partial, health: deriveHealth(partial) }) + } + } + return impacts +} + +function mergeBaseline(rows: ReadonlyArray): ReleaseBaseline { + const spanCount = rows.reduce((sum, row) => sum + row.spanCount, 0) + const errorCount = rows.reduce((sum, row) => sum + row.errorCount, 0) + const weighted = (pick: (row: Release) => number) => + spanCount > 0 ? rows.reduce((sum, row) => sum + pick(row) * row.spanCount, 0) / spanCount : 0 + return { + spanCount, + errorCount, + errorRate: rate(errorCount, spanCount), + p95LatencyMs: weighted((row) => row.p95LatencyMs), + p50LatencyMs: weighted((row) => row.p50LatencyMs), + p99LatencyMs: weighted((row) => row.p99LatencyMs), + apdexScore: weighted((row) => row.apdexScore), + versions: rows.length, + } +} + +/** Fold per-service impacts into one group per commit, newest first. */ +export function groupReleases(impacts: ReadonlyArray): ReleaseGroup[] { + const bySha = new Map() + for (const impact of impacts) { + const list = bySha.get(impact.commitSha) + if (list === undefined) bySha.set(impact.commitSha, [impact]) + else list.push(impact) + } + const groups: ReleaseGroup[] = [] + for (const [commitSha, services] of bySha) { + const sorted = services.toSorted((a, b) => b.spanCount - a.spanCount) + const spanCount = sorted.reduce((sum, s) => sum + s.spanCount, 0) + const errorCount = sorted.reduce((sum, s) => sum + s.errorCount, 0) + groups.push({ + commitSha, + firstSeen: sorted.reduce( + (min, s) => (s.firstSeen < min ? s.firstSeen : min), + sorted[0]!.firstSeen, + ), + lastSeen: sorted.reduce((max, s) => (s.lastSeen > max ? s.lastSeen : max), sorted[0]!.lastSeen), + services: sorted, + spanCount, + errorCount, + errorRate: rate(errorCount, spanCount), + health: worstHealth(sorted.map((s) => s.health)), + }) + } + return groups.toSorted((a, b) => (a.firstSeen < b.firstSeen ? 1 : a.firstSeen > b.firstSeen ? -1 : 0)) +} + +export interface ReleaseFacetCounts { + health: Record + services: Array<{ name: string; count: number }> + environments: Array<{ name: string; count: number }> +} + +/** Sidebar counts, from the same groups the table renders. */ +export function releaseFacetCounts(groups: ReadonlyArray): ReleaseFacetCounts { + const health = { regressed: 0, watch: 0, rolling: 0, healthy: 0 } satisfies Record + const services = new Map() + const environments = new Map() + for (const group of groups) { + health[group.health] += 1 + for (const service of group.services) { + services.set(service.serviceName, (services.get(service.serviceName) ?? 0) + 1) + const env = service.environment === "" ? "unknown" : service.environment + environments.set(env, (environments.get(env) ?? 0) + 1) + } + } + const toSorted = (map: Map) => + [...map.entries()] + .map(([name, count]) => ({ name, count })) + .toSorted((a, b) => b.count - a.count || a.name.localeCompare(b.name)) + return { health, services: toSorted(services), environments: toSorted(environments) } +} + +/** A 40-hex git sha reads as its 7-char short form; tags and versions stay verbatim. */ +export function shortReleaseLabel(sha: string): string { + return /^[0-9a-f]{40}$/i.test(sha) ? sha.slice(0, 7) : sha +} + +/** + * Calendar-day bucket for the table's group headers, in the viewer's zone. + * "Today" / "Yesterday" / a medium date. + */ +export function releaseDayLabel(iso: string, nowMs: number): string { + const date = new Date(iso) + if (Number.isNaN(date.getTime())) return iso + const startOfDay = (ms: number) => { + const d = new Date(ms) + d.setHours(0, 0, 0, 0) + return d.getTime() + } + const dayDiff = Math.round((startOfDay(nowMs) - startOfDay(date.getTime())) / 86_400_000) + if (dayDiff === 0) return "Today" + if (dayDiff === 1) return "Yesterday" + return date.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" }) +} diff --git a/apps/web/src/components/releases/releases-filter-sidebar.tsx b/apps/web/src/components/releases/releases-filter-sidebar.tsx new file mode 100644 index 000000000..4b0ca4fd2 --- /dev/null +++ b/apps/web/src/components/releases/releases-filter-sidebar.tsx @@ -0,0 +1,125 @@ +import { useMemo } from "react" +import { getRouteApi } from "@tanstack/react-router" + +import { Result, useAtomRefresh } from "@/lib/effect-atom" +import { useRefreshableAtomValue } from "@/hooks/use-refreshable-atom-value" +import { FilterSection, SearchableFilterSection } from "@/components/traces/filter-section" +import { + FilterSidebarBody, + FilterSidebarError, + FilterSidebarFrame, + FilterSidebarHeader, + FilterSidebarLoading, +} from "@/components/filters/filter-sidebar" +import { getReleasesResultAtom } from "@/lib/services/atoms/warehouse-query-atoms" +import { releasesQueryInput } from "@/routes/releases/index" +import { cn } from "@maple/ui/lib/utils" + +import { RELEASE_HEALTH_DESCRIPTION, RELEASE_HEALTH_DOT_CLASS, RELEASE_HEALTH_LABEL } from "./release-health" +import { + RELEASE_HEALTH_ORDER, + deriveReleaseImpacts, + groupReleases, + isReleaseHealth, + releaseFacetCounts, +} from "./release-model" + +const routeApi = getRouteApi("/releases/") + +/** + * Every facet here is client-side, derived from the same releases atom the + * table reads — no extra request, and the counts always agree with the rows. + */ +export function ReleasesFilterSidebar() { + const navigate = routeApi.useNavigate() + const search = routeApi.useSearch() + + const atom = getReleasesResultAtom({ data: releasesQueryInput(search) }) + const result = useRefreshableAtomValue(atom) + const refresh = useAtomRefresh(atom) + + const facets = useMemo( + () => + Result.isSuccess(result) + ? releaseFacetCounts( + groupReleases(deriveReleaseImpacts(result.value.releases, result.value.timeline)), + ) + : undefined, + [result], + ) + + const updateFilter = (key: K, value: (typeof search)[K]) => { + navigate({ + search: (prev: Record) => ({ + ...prev, + [key]: + value === undefined || (Array.isArray(value) && value.length === 0) ? undefined : value, + }), + }) + } + + const clearAllFilters = () => { + navigate({ + search: { startTime: search.startTime, endTime: search.endTime, timePreset: search.timePreset }, + }) + } + + const hasActiveFilters = + (search.environments?.length ?? 0) > 0 || + (search.excludedEnvironments?.length ?? 0) > 0 || + (search.services?.length ?? 0) > 0 || + search.impact !== undefined + + if (Result.isInitial(result)) return + if (Result.isFailure(result)) return + if (facets === undefined) return + + return ( + + + + ({ name: band, count: facets.health[band] }))} + selected={search.impact === undefined ? [] : [search.impact]} + onChange={(selected) => { + // Single-select on a multi-select control: the newly ticked value + // wins; un-ticking the active one clears the filter. + const next = selected.find((value) => value !== search.impact) + updateFilter("impact", next !== undefined && isReleaseHealth(next) ? next : undefined) + }} + getOptionLabel={(name) => (isReleaseHealth(name) ? RELEASE_HEALTH_LABEL[name] : name)} + getOptionDescription={(name) => + isReleaseHealth(name) ? RELEASE_HEALTH_DESCRIPTION[name] : undefined + } + renderOptionIcon={(name) => + isReleaseHealth(name) ? ( + + ) : null + } + /> + + updateFilter("environments", value)} + excluded={search.excludedEnvironments ?? []} + onExcludedChange={(value) => updateFilter("excludedEnvironments", value)} + /> + + updateFilter("services", value)} + /> + + + ) +} diff --git a/apps/web/src/components/releases/releases-table.tsx b/apps/web/src/components/releases/releases-table.tsx new file mode 100644 index 000000000..3f1d1c2d5 --- /dev/null +++ b/apps/web/src/components/releases/releases-table.tsx @@ -0,0 +1,378 @@ +import React, { Fragment, useMemo, useState } from "react" +import { Link } from "@tanstack/react-router" +import { ServiceDot } from "@maple/ui/components/service-dot" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@maple/ui/components/ui/table" +import { formatErrorRate, formatLatency, formatNumber } from "@maple/ui/lib/format" +import { formatRelativeTimeOrDate } from "@maple/ui/lib/time-format" +import { cn } from "@maple/ui/lib/utils" +import type { VcsCommitDetailResponse } from "@maple/domain/http" + +import { ChevronRightIcon } from "@/components/icons" +import { Result, useAtomValue } from "@/lib/effect-atom" +import type { TimeRangeSearch } from "@/components/time-range-picker/search" +import { + CommitAvatar, + CommitShaHoverCard, + commitsQueryAtom, + commitsQueryKey, + firstLine, + isResolvableSha, +} from "@/components/vcs/commit-sha-hover-card" +import { ReleaseHealthPill, releaseHealthFigure } from "./release-health" +import { + releaseDayLabel, + shortReleaseLabel, + type ReleaseGroup, + type ReleaseServiceImpact, +} from "./release-model" + +/** The bulk VCS lookup takes one page of shas; rows past it fall back to the sha. */ +const COMMIT_RESOLVE_LIMIT = 50 + +const EMPTY_COMMITS: ReadonlyMap = new Map() + +/** + * Resolves the commits the table is about to render in ONE request, the way + * the services table does. Never blocks paint: until it resolves, rows show + * the short sha. Mounted only when there is at least one resolvable sha — an + * empty key would send a request the endpoint rejects. + */ +function ResolvedCommits({ + shasKey, + children, +}: { + shasKey: string + children: (commits: ReadonlyMap) => React.ReactNode +}) { + const result = useAtomValue(commitsQueryAtom(shasKey)) + const commits = useMemo( + () => + Result.isSuccess(result) + ? new Map(result.value.commits.map((commit) => [commit.sha, commit])) + : EMPTY_COMMITS, + [result], + ) + return <>{children(commits)} +} + +interface DeltaProps { + value: number + baseline: number | undefined + format: (value: number) => string + /** Tone of the live figure when the band trips; the baseline stays muted. */ + tone?: "error" | "warn" +} + +/** "1.24% vs 0.30%" — the live figure, then the rest of the service beside it. */ +function Delta({ value, baseline, format, tone }: DeltaProps) { + return ( + + + {format(value)} + + {baseline !== undefined ? ( + + vs {format(baseline)} + + ) : null} + + ) +} + +function ServiceChips({ services }: { services: ReadonlyArray }) { + // One chip per service: a commit on two environments of one service is + // still one service, and the expanded rows carry the environment. + const names = [...new Set(services.map((service) => service.serviceName))] + const shown = names.slice(0, 3) + const more = names.length - shown.length + return ( + + {shown.map((serviceName) => ( + + + {serviceName} + + ))} + {more > 0 ? +{more} : null} + + ) +} + +interface ReleaseTitleProps { + commitSha: string + commit: VcsCommitDetailResponse | undefined + health: ReleaseServiceImpact["health"] + figure: string | undefined +} + +/** Message-first title: subject · pill, with sha · author demoted underneath. */ +function ReleaseTitle({ commitSha, commit, health, figure }: ReleaseTitleProps) { + const resolvable = isResolvableSha(commitSha) + const author = commit?.authorLogin ?? commit?.authorName ?? undefined + return ( +
+
+ {commit ? ( + + ) : ( + + )} +
+
+
+ + {commit ? firstLine(commit.message) : shortReleaseLabel(commitSha)} + + {health === "healthy" ? null : } +
+
+ {commit ? {shortReleaseLabel(commitSha)} : null} + {author ? {author} : null} + {!commit && !resolvable ? deployment reference : null} +
+
+
+ ) +} + +interface ReleasesTableProps { + groups: ReadonlyArray + timeSearch: TimeRangeSearch + environments?: string[] + waiting?: boolean +} + +/** + * Grouped by commit with expandable per-service children, day headers instead + * of a date column, and deltas against the rest of each service. + */ +export function ReleasesTable(props: ReleasesTableProps) { + const shasKey = useMemo( + () => commitsQueryKey(props.groups.slice(0, COMMIT_RESOLVE_LIMIT).map((group) => group.commitSha)), + [props.groups], + ) + if (shasKey === "") return + return ( + + {(commits) => } + + ) +} + +function ReleasesTableRows({ + groups, + timeSearch, + environments, + waiting, + commits, +}: ReleasesTableProps & { commits: ReadonlyMap }) { + const [expanded, setExpanded] = useState>(() => new Set()) + const nowMs = Date.now() + + const toggle = (sha: string) => + setExpanded((current) => { + const next = new Set(current) + if (next.has(sha)) next.delete(sha) + else next.add(sha) + return next + }) + + const linkSearch = (service: string, environment: string) => ({ + service, + environments: environments ?? (environment ? [environment] : undefined), + ...timeSearch, + }) + + let lastDay: string | undefined + + return ( +
+ + + + Release + Services + First seen + Traffic + Error rate + p95 + + + + {groups.map((group) => { + const day = releaseDayLabel(group.firstSeen, nowMs) + const showDay = day !== lastDay + lastDay = day + const primary = group.services[0]! + const isOpen = expanded.has(group.commitSha) + const worst = + group.services.find((service) => service.health === group.health) ?? primary + return ( + + {showDay ? ( + + + {day} + + + ) : null} + + +
+ + + + +
+
+ + + + + {formatRelativeTimeOrDate(group.firstSeen)} + + + {formatNumber(group.spanCount)} + + + + + + + +
+ {isOpen + ? group.services.map((service) => ( + + + + + {service.serviceName} + {service.environment && !environments?.length ? ( + + {service.environment} + + ) : null} + {service.health === "healthy" ? null : ( + + )} + + + + + {formatRelativeTimeOrDate(service.firstSeen)} + + + {formatNumber(service.spanCount)} + + + + + + + + + )) + : null} +
+ ) + })} +
+
+
+ ) +} diff --git a/apps/web/src/components/releases/releases-timeline.tsx b/apps/web/src/components/releases/releases-timeline.tsx new file mode 100644 index 000000000..a922e9cce --- /dev/null +++ b/apps/web/src/components/releases/releases-timeline.tsx @@ -0,0 +1,166 @@ +import { useMemo } from "react" +import { Link } from "@tanstack/react-router" +import { ServiceDot } from "@maple/ui/components/service-dot" +import { cn } from "@maple/ui/lib/utils" +import { formatRelativeTimeOrDate } from "@maple/ui/lib/time-format" + +import type { TimeRangeSearch } from "@/components/time-range-picker/search" +import { RELEASE_HEALTH_DOT_CLASS, RELEASE_HEALTH_LABEL, releaseHealthFigure } from "./release-health" +import { RELEASE_HEALTH_ORDER, shortReleaseLabel, type ReleaseServiceImpact } from "./release-model" + +/** Lanes beyond this fold into a trailing count; the table still lists every release. */ +const MAX_LANES = 12 + +interface ReleasesTimelineProps { + impacts: ReadonlyArray + /** ISO bounds of the window the dots are placed in. */ + startTime: string + endTime: string + /** Carried onto each dot's link so the detail opens on the same window. */ + timeSearch: TimeRangeSearch + environments?: string[] +} + +interface Lane { + serviceName: string + spanCount: number + dots: ReleaseServiceImpact[] +} + +function buildLanes(impacts: ReadonlyArray): Lane[] { + const byService = new Map() + for (const impact of impacts) { + const lane = byService.get(impact.serviceName) + if (lane === undefined) { + byService.set(impact.serviceName, { + serviceName: impact.serviceName, + spanCount: impact.spanCount, + dots: [impact], + }) + } else { + lane.spanCount += impact.spanCount + lane.dots.push(impact) + } + } + return [...byService.values()].toSorted( + (a, b) => b.spanCount - a.spanCount || a.serviceName.localeCompare(b.serviceName), + ) +} + +function axisLabels(startMs: number, endMs: number): string[] { + const span = endMs - startMs + const showTime = span <= 3 * 86_400_000 + return [0, 0.25, 0.5, 0.75, 1].map((ratio) => { + const date = new Date(startMs + span * ratio) + return showTime + ? date.toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }) + : date.toLocaleDateString(undefined, { month: "short", day: "numeric" }) + }) +} + +/** + * One lane per service, one dot per version at the moment it was first seen. + * A vertical line of dots is a monorepo deploy; a dot's fill is its health. + * Positioned by time, not by bucket, so a deploy sits where it happened. + */ +export function ReleasesTimeline({ + impacts, + startTime, + endTime, + timeSearch, + environments, +}: ReleasesTimelineProps) { + const lanes = useMemo(() => buildLanes(impacts), [impacts]) + const startMs = Date.parse(startTime) + const endMs = Date.parse(endTime) + const span = Math.max(1, endMs - startMs) + const visible = lanes.slice(0, MAX_LANES) + const hidden = lanes.length - visible.length + const labels = useMemo(() => axisLabels(startMs, endMs), [startMs, endMs]) + + return ( +
+
+ Deploys over time +
+ {RELEASE_HEALTH_ORDER.map((band) => ( + + + {RELEASE_HEALTH_LABEL[band]} + + ))} +
+
+
+ {visible.map((lane) => ( +
+ + + + {lane.serviceName} + + +
+ {lane.dots.map((dot) => { + const ratio = Math.min( + 1, + Math.max(0, (Date.parse(dot.firstSeen) - startMs) / span), + ) + const figure = releaseHealthFigure(dot) + return ( + + ) + })} +
+
+ ))} + {hidden > 0 ? ( +
+ + +{hidden} more {hidden === 1 ? "service" : "services"} + +
+ ) : null} +
+ +
+ {labels.map((label, index) => ( + {label} + ))} +
+
+
+
+ ) +} diff --git a/apps/web/src/lib/services/atoms/warehouse-query-atoms.ts b/apps/web/src/lib/services/atoms/warehouse-query-atoms.ts index b3023b3cf..093d3b266 100644 --- a/apps/web/src/lib/services/atoms/warehouse-query-atoms.ts +++ b/apps/web/src/lib/services/atoms/warehouse-query-atoms.ts @@ -15,6 +15,7 @@ import { getServiceDetailThroughputRefinement, } from "@/api/warehouse/custom-charts" import { getErrorsByType, getErrorsFacets, getErrorsSpark, getErrorsSummary } from "@/api/warehouse/errors" +import { getReleaseDetail, getReleases } from "@/api/warehouse/releases" import { getLog, getLogAttributeKeys, @@ -630,6 +631,18 @@ export const getServiceDetailOverviewResultAtom = makeQueryAtomFamily(getService staleTime: 30_000, }) +// Releases page: per-commit rows + swimlane timeline in one fetch. The list +// takes `namespaces`, so the org-global pin lands in its key; the detail is +// scoped to one service and needs no pin. +export const getReleasesResultAtom = makeQueryAtomFamily(getReleases, { + staleTime: 30_000, + globalNamespace: "top", +}) + +export const getReleaseDetailResultAtom = makeQueryAtomFamily(getReleaseDetail, { + staleTime: 30_000, +}) + export const getOverviewTimeSeriesResultAtom = makeQueryAtomFamily(getOverviewTimeSeries, { staleTime: 30_000, globalNamespace: "top", diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index fb588d757..d410014ec 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -58,6 +58,8 @@ import { Route as LogsLogIdRouteImport } from './routes/logs/$logId' import { Route as MetricsIndexRouteImport } from './routes/metrics/index' import { Route as MetricsMetricNameRouteImport } from './routes/metrics/$metricName' import { Route as RecommendationsRecommendationKeyRouteImport } from './routes/recommendations/$recommendationKey' +import { Route as ReleasesIndexRouteImport } from './routes/releases/index' +import { Route as ReleasesCommitShaRouteImport } from './routes/releases/$commitSha' import { Route as ReplaysIndexRouteImport } from './routes/replays/index' import { Route as ReplaysSessionIdRouteImport } from './routes/replays/$sessionId' import { Route as ServicesIndexRouteImport } from './routes/services/index' @@ -338,6 +340,16 @@ const RecommendationsRecommendationKeyRoute = path: '/recommendations/$recommendationKey', getParentRoute: () => rootRouteImport, } as any) +const ReleasesIndexRoute = ReleasesIndexRouteImport.update({ + id: '/releases/', + path: '/releases/', + getParentRoute: () => rootRouteImport, +} as any) +const ReleasesCommitShaRoute = ReleasesCommitShaRouteImport.update({ + id: '/releases/$commitSha', + path: '/releases/$commitSha', + getParentRoute: () => rootRouteImport, +} as any) const ReplaysIndexRoute = ReplaysIndexRouteImport.update({ id: '/replays/', path: '/replays/', @@ -554,6 +566,7 @@ export interface FileRoutesByFullPath { '/logs/$logId': typeof LogsLogIdRoute '/metrics/$metricName': typeof MetricsMetricNameRoute '/recommendations/$recommendationKey': typeof RecommendationsRecommendationKeyRoute + '/releases/$commitSha': typeof ReleasesCommitShaRoute '/replays/$sessionId': typeof ReplaysSessionIdRoute '/services/$serviceName': typeof ServicesServiceNameRoute '/share/$token': typeof ShareTokenRoute @@ -569,6 +582,7 @@ export interface FileRoutesByFullPath { '/lab/': typeof LabIndexRoute '/logs/': typeof LogsIndexRoute '/metrics/': typeof MetricsIndexRoute + '/releases/': typeof ReleasesIndexRoute '/replays/': typeof ReplaysIndexRoute '/services/': typeof ServicesIndexRoute '/traces/': typeof TracesIndexRoute @@ -637,6 +651,7 @@ export interface FileRoutesByTo { '/logs/$logId': typeof LogsLogIdRoute '/metrics/$metricName': typeof MetricsMetricNameRoute '/recommendations/$recommendationKey': typeof RecommendationsRecommendationKeyRoute + '/releases/$commitSha': typeof ReleasesCommitShaRoute '/replays/$sessionId': typeof ReplaysSessionIdRoute '/services/$serviceName': typeof ServicesServiceNameRoute '/share/$token': typeof ShareTokenRoute @@ -652,6 +667,7 @@ export interface FileRoutesByTo { '/lab': typeof LabIndexRoute '/logs': typeof LogsIndexRoute '/metrics': typeof MetricsIndexRoute + '/releases': typeof ReleasesIndexRoute '/replays': typeof ReplaysIndexRoute '/services': typeof ServicesIndexRoute '/traces': typeof TracesIndexRoute @@ -722,6 +738,7 @@ export interface FileRoutesById { '/logs/$logId': typeof LogsLogIdRoute '/metrics/$metricName': typeof MetricsMetricNameRoute '/recommendations/$recommendationKey': typeof RecommendationsRecommendationKeyRoute + '/releases/$commitSha': typeof ReleasesCommitShaRoute '/replays/$sessionId': typeof ReplaysSessionIdRoute '/services/$serviceName': typeof ServicesServiceNameRoute '/share/$token': typeof ShareTokenRoute @@ -737,6 +754,7 @@ export interface FileRoutesById { '/lab/': typeof LabIndexRoute '/logs/': typeof LogsIndexRoute '/metrics/': typeof MetricsIndexRoute + '/releases/': typeof ReleasesIndexRoute '/replays/': typeof ReplaysIndexRoute '/services/': typeof ServicesIndexRoute '/traces/': typeof TracesIndexRoute @@ -808,6 +826,7 @@ export interface FileRouteTypes { | '/logs/$logId' | '/metrics/$metricName' | '/recommendations/$recommendationKey' + | '/releases/$commitSha' | '/replays/$sessionId' | '/services/$serviceName' | '/share/$token' @@ -823,6 +842,7 @@ export interface FileRouteTypes { | '/lab/' | '/logs/' | '/metrics/' + | '/releases/' | '/replays/' | '/services/' | '/traces/' @@ -891,6 +911,7 @@ export interface FileRouteTypes { | '/logs/$logId' | '/metrics/$metricName' | '/recommendations/$recommendationKey' + | '/releases/$commitSha' | '/replays/$sessionId' | '/services/$serviceName' | '/share/$token' @@ -906,6 +927,7 @@ export interface FileRouteTypes { | '/lab' | '/logs' | '/metrics' + | '/releases' | '/replays' | '/services' | '/traces' @@ -975,6 +997,7 @@ export interface FileRouteTypes { | '/logs/$logId' | '/metrics/$metricName' | '/recommendations/$recommendationKey' + | '/releases/$commitSha' | '/replays/$sessionId' | '/services/$serviceName' | '/share/$token' @@ -990,6 +1013,7 @@ export interface FileRouteTypes { | '/lab/' | '/logs/' | '/metrics/' + | '/releases/' | '/replays/' | '/services/' | '/traces/' @@ -1051,6 +1075,7 @@ export interface RootRouteChildren { LogsLogIdRoute: typeof LogsLogIdRoute MetricsMetricNameRoute: typeof MetricsMetricNameRoute RecommendationsRecommendationKeyRoute: typeof RecommendationsRecommendationKeyRoute + ReleasesCommitShaRoute: typeof ReleasesCommitShaRoute ReplaysSessionIdRoute: typeof ReplaysSessionIdRoute ServicesServiceNameRoute: typeof ServicesServiceNameRoute ShareTokenRoute: typeof ShareTokenRoute @@ -1065,6 +1090,7 @@ export interface RootRouteChildren { InvestigationsIndexRoute: typeof InvestigationsIndexRoute LogsIndexRoute: typeof LogsIndexRoute MetricsIndexRoute: typeof MetricsIndexRoute + ReleasesIndexRoute: typeof ReleasesIndexRoute ReplaysIndexRoute: typeof ReplaysIndexRoute ServicesIndexRoute: typeof ServicesIndexRoute TracesIndexRoute: typeof TracesIndexRoute @@ -1434,6 +1460,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof RecommendationsRecommendationKeyRouteImport parentRoute: typeof rootRouteImport } + '/releases/': { + id: '/releases/' + path: '/releases' + fullPath: '/releases/' + preLoaderRoute: typeof ReleasesIndexRouteImport + parentRoute: typeof rootRouteImport + } + '/releases/$commitSha': { + id: '/releases/$commitSha' + path: '/releases/$commitSha' + fullPath: '/releases/$commitSha' + preLoaderRoute: typeof ReleasesCommitShaRouteImport + parentRoute: typeof rootRouteImport + } '/replays/': { id: '/replays/' path: '/replays' @@ -1742,6 +1782,7 @@ const rootRouteChildren: RootRouteChildren = { LogsLogIdRoute: LogsLogIdRoute, MetricsMetricNameRoute: MetricsMetricNameRoute, RecommendationsRecommendationKeyRoute: RecommendationsRecommendationKeyRoute, + ReleasesCommitShaRoute: ReleasesCommitShaRoute, ReplaysSessionIdRoute: ReplaysSessionIdRoute, ServicesServiceNameRoute: ServicesServiceNameRoute, ShareTokenRoute: ShareTokenRoute, @@ -1756,6 +1797,7 @@ const rootRouteChildren: RootRouteChildren = { InvestigationsIndexRoute: InvestigationsIndexRoute, LogsIndexRoute: LogsIndexRoute, MetricsIndexRoute: MetricsIndexRoute, + ReleasesIndexRoute: ReleasesIndexRoute, ReplaysIndexRoute: ReplaysIndexRoute, ServicesIndexRoute: ServicesIndexRoute, TracesIndexRoute: TracesIndexRoute, diff --git a/apps/web/src/routes/releases/$commitSha.tsx b/apps/web/src/routes/releases/$commitSha.tsx new file mode 100644 index 000000000..c8651b068 --- /dev/null +++ b/apps/web/src/routes/releases/$commitSha.tsx @@ -0,0 +1,493 @@ +import { useCallback, useMemo, useState } from "react" +import { Link, createFileRoute, useNavigate } from "@tanstack/react-router" +import { Schema } from "effect" +import { Skeleton } from "@maple/ui/components/ui/skeleton" +import { Button } from "@maple/ui/components/ui/button" +import { Tabs, TabsList, TabsTrigger } from "@maple/ui/components/ui/tabs" +import { PageLayout } from "@maple/ui/components/ui/page-layout" +import { ServiceDot } from "@maple/ui/components/service-dot" +import { formatRelativeTimeOrDate } from "@maple/ui/lib/time-format" + +import { OptionalStringArrayParam } from "@/lib/search-params" +import { Result, useAtomRefresh, useAtomValue } from "@/lib/effect-atom" +import { useEffectiveTimeRange } from "@/hooks/use-effective-time-range" +import { useRefreshableAtomValue } from "@/hooks/use-refreshable-atom-value" +import { getReleaseDetailResultAtom, getReleasesResultAtom } from "@/lib/services/atoms/warehouse-query-atoms" +import { DashboardLayout } from "@/components/layout/dashboard-layout" +import { QueryErrorState } from "@/components/common/query-error-state" +import { MetricsGrid } from "@/components/dashboard/metrics-grid" +import type { ChartLegendMode, ChartTooltipMode } from "@maple/ui/components/charts/_shared/chart-types" +import { + TimeRangeSearchFields, + applyTimeRangeSearch, + pickTimeRangeSearch, +} from "@/components/time-range-picker/search" +import { PageRefreshProvider } from "@/components/time-range-picker/page-refresh-context" +import { TimeRangeHeaderControls } from "@/components/time-range-picker/time-range-header-controls" +import { LONG_RANGE_PRESET_OPTIONS } from "@/lib/time-utils" +import { useCommitMarkers } from "@/components/vcs/commit-markers/use-commit-markers" +import type { ReleasePoint } from "@/components/vcs/commit-markers/marker-layout" +import { + CommitAvatar, + CommitShaHoverCard, + commitQueryAtom, + firstLine, + isResolvableSha, +} from "@/components/vcs/commit-sha-hover-card" +import { ReleaseComparison, ReleaseVersionsRail } from "@/components/releases/release-detail-panels" +import { ReleaseIssuesPanel } from "@/components/releases/release-issues-panel" +import { ReleaseHealthPill, releaseHealthFigure } from "@/components/releases/release-health" +import { deriveReleaseImpacts, shortReleaseLabel } from "@/components/releases/release-model" + +const ONE_YEAR_SECONDS = 365 * 24 * 60 * 60 +const DEFAULT_PRESET = "7d" + +const releaseDetailSearchSchema = Schema.Struct({ + // The service whose version this page describes. A commit lands on many + // services; without one the page offers the list of services it reached. + service: Schema.optional(Schema.String), + environments: OptionalStringArrayParam, + ...TimeRangeSearchFields, +}) + +export const Route = createFileRoute("/releases/$commitSha")({ + component: ReleaseDetailPage, + validateSearch: Schema.toStandardSchemaV1(releaseDetailSearchSchema), +}) + +interface ReleaseChartConfig { + id: string + chartId: string + title: string + layout: { x: number; y: number; w: number; h: number } + legend?: ChartLegendMode + tooltip?: ChartTooltipMode + rateMode?: "per_second" +} + +// The service page's four golden-signal cards, so a release reads like the +// service it landed on. +const RELEASE_CHARTS: ReleaseChartConfig[] = [ + { + id: "latency", + chartId: "latency-line", + title: "Latency", + layout: { x: 0, y: 0, w: 6, h: 4 }, + legend: "visible", + tooltip: "visible", + }, + { + id: "throughput", + chartId: "throughput-area", + title: "Throughput", + layout: { x: 6, y: 0, w: 6, h: 4 }, + tooltip: "visible", + rateMode: "per_second", + }, + { + id: "apdex", + chartId: "apdex-area", + title: "Apdex", + layout: { x: 0, y: 4, w: 6, h: 4 }, + tooltip: "visible", + }, + { + id: "error-rate", + chartId: "error-rate-area", + title: "Error Rate", + layout: { x: 6, y: 4, w: 6, h: 4 }, + tooltip: "visible", + }, +] + +const EMPTY_RELEASES: ReadonlyArray = [] + +function ReleaseDetailPage() { + const search = Route.useSearch() + return ( + + + + ) +} + +/** Commit message as the page title once the sha resolves; the short sha until then. */ +function ReleaseTitle({ commitSha }: { commitSha: string }) { + if (!isResolvableSha(commitSha)) { + return {commitSha} + } + return +} + +function ResolvedReleaseTitle({ commitSha }: { commitSha: string }) { + const result = useAtomValue(commitQueryAtom(commitSha)) + return Result.builder(result) + .onSuccess((commit) => ( + + + {firstLine(commit.message)} + + )) + .orElse(() => {shortReleaseLabel(commitSha)}) +} + +/** Author · repo · sha, plus the link out to the provider. */ +function ReleaseMeta({ commitSha }: { commitSha: string }) { + const resolvable = isResolvableSha(commitSha) + if (!resolvable) return deployment reference + return +} + +function ResolvedReleaseMeta({ commitSha }: { commitSha: string }) { + const result = useAtomValue(commitQueryAtom(commitSha)) + return Result.builder(result) + .onSuccess((commit) => ( + <> + + {commit.authorLogin ?? commit.authorName ?? "Unknown author"} + + {commit.repoFullName} + + {shortReleaseLabel(commitSha)} ↗ + + + )) + .orElse(() => ( + + {shortReleaseLabel(commitSha)} + + )) +} + +function ReleaseDetailContent() { + const { commitSha } = Route.useParams() + const search = Route.useSearch() + const navigate = useNavigate({ from: Route.fullPath }) + const { startTime, endTime } = useEffectiveTimeRange( + search.startTime, + search.endTime, + search.timePreset ?? DEFAULT_PRESET, + ) + + const handleTimeChange = useCallback( + ( + range: { startTime?: string; endTime?: string; presetValue?: string }, + options?: { replace?: boolean }, + ) => { + navigate({ + replace: options?.replace, + search: (prev: Record) => applyTimeRangeSearch(prev, range), + }) + }, + [navigate], + ) + + const timeSearch = pickTimeRangeSearch(search) + const service = search.service + + return ( + + + + + + + + + } + > +
+ {service ? ( + + ) : null} + +
+
+
+ + {service === undefined ? ( + + ) : ( + + )} + +
+
+
+ ) +} + +interface ScopedProps { + commitSha: string + startTime: string + endTime: string + environments?: string[] +} + +/** A deep link without a service: list the services this commit reached in the window. */ +function PickService({ commitSha, startTime, endTime, environments }: ScopedProps) { + const search = Route.useSearch() + const result = useAtomValue(getReleasesResultAtom({ data: { startTime, endTime, environments } })) + const services = Result.builder(result) + .onSuccess((response) => + [ + ...new Set( + response.releases + .filter((row) => row.commitSha === commitSha) + .map((row) => row.serviceName), + ), + ].toSorted(), + ) + .orElse(() => undefined) + + if (services === undefined) return + return ( +
+
+ {services.length === 0 + ? "This version served no traffic in the window. Widen the time range, or pick the service it was deployed to." + : "Pick the service to describe this version for:"} +
+
+ {services.map((serviceName) => ( + + + {serviceName} + + ))} +
+
+ ) +} + +type Series = "version" | "others" + +function ReleaseBody({ + commitSha, + serviceName, + startTime, + endTime, + environments, +}: ScopedProps & { serviceName: string }) { + const search = Route.useSearch() + const atom = getReleaseDetailResultAtom({ + data: { serviceName, commitSha, startTime, endTime, environments }, + }) + const result = useRefreshableAtomValue(atom) + const refresh = useAtomRefresh(atom) + const [series, setSeries] = useState("version") + + const derived = useMemo(() => { + if (!Result.isSuccess(result)) return undefined + const response = result.value + const impacts = deriveReleaseImpacts(response.versions, response.timeline) + // Several environments may answer when the page is unscoped; describe the busiest. + const impact = impacts + .filter((candidate) => candidate.commitSha === commitSha) + .toSorted((a, b) => b.spanCount - a.spanCount)[0] + const releases: ReleasePoint[] = response.timeline + .filter((point) => point.serviceName === serviceName) + .map((point) => ({ + bucket: point.bucket, + commitSha: point.commitSha, + count: point.count, + errorCount: point.errorCount, + })) + return { response, impacts, impact, releases } + }, [result, commitSha, serviceName]) + + const points = + derived === undefined + ? [] + : series === "version" + ? derived.response.points + : derived.response.baselinePoints + const detailPoints = useMemo(() => points.map((point) => ({ ...point })), [points]) + const chartBuckets = useMemo(() => detailPoints.map((point) => String(point.bucket)), [detailPoints]) + const commitMarkers = useCommitMarkers(derived?.releases ?? EMPTY_RELEASES, chartBuckets) + const isLoading = Result.isInitial(result) + const metrics = useMemo( + () => + RELEASE_CHARTS.map((chart) => ({ + id: chart.id, + chartId: chart.chartId, + title: chart.title, + layout: chart.layout, + data: detailPoints, + legend: chart.legend, + tooltip: chart.tooltip, + rateMode: chart.rateMode, + isLoading, + })), + [detailPoints, isLoading], + ) + + if (Result.isFailure(result)) { + return ( + + ) + } + if (derived === undefined) { + return ( +
+ +
+ + +
+ +
+ ) + } + + const { impact, impacts, response } = derived + const timeSearch = pickTimeRangeSearch(search) + const waiting = Result.isSuccess(result) && result.waiting + + if (impact === undefined) { + return ( +
+
+ {shortReleaseLabel(commitSha)} served no traffic on{" "} + {serviceName} in this window. Widen the time range to include its deploy. +
+ +
+ ) + } + + const figure = releaseHealthFigure(impact) + + return ( +
+
+ + + first seen{" "} + + {formatRelativeTimeOrDate(impact.firstSeen)} + {" "} + on {serviceName} + + {impact.environment ? {impact.environment} : null} + {impact.share !== undefined ? ( + {Math.round(impact.share * 100)}% of the latest traffic + ) : null} + {impact.health === "healthy" ? null : ( + + )} +
+ +
+ + +
+ +
+ setSeries(value === "others" ? "others" : "version")} + > + + + This version + + + Other versions + + + + + {series === "version" + ? `Only spans that carried ${shortReleaseLabel(commitSha)}` + : `Every other version of ${serviceName} in the window`} + +
+ + + +
+ ) +} diff --git a/apps/web/src/routes/releases/index.tsx b/apps/web/src/routes/releases/index.tsx new file mode 100644 index 000000000..bf2e40af1 --- /dev/null +++ b/apps/web/src/routes/releases/index.tsx @@ -0,0 +1,280 @@ +import { useMemo } from "react" +import { createFileRoute, useNavigate } from "@tanstack/react-router" +import { warmAtoms } from "@effect-router/core" +import { Schema } from "effect" +import { Skeleton } from "@maple/ui/components/ui/skeleton" +import { ActiveFilterChips } from "@maple/ui/components/filters/active-filter-chips" +import { formatNumber } from "@maple/ui/lib/format" + +import { OptionalStringArrayParam } from "@/lib/search-params" +import { Result, useAtomRefresh } from "@/lib/effect-atom" +import { resolveEffectiveTimeRange, useEffectiveTimeRange } from "@/hooks/use-effective-time-range" +import { useRefreshableAtomValue } from "@/hooks/use-refreshable-atom-value" +import { getReleasesResultAtom } from "@/lib/services/atoms/warehouse-query-atoms" +import type { GetReleasesInput } from "@/api/warehouse/releases" +import { DashboardLayout } from "@/components/layout/dashboard-layout" +import { QueryErrorState } from "@/components/common/query-error-state" +import { + TimeRangeSearchFields, + applyTimeRangeSearch, + pickTimeRangeSearch, +} from "@/components/time-range-picker/search" +import { PageRefreshProvider } from "@/components/time-range-picker/page-refresh-context" +import { TimeRangeHeaderControls } from "@/components/time-range-picker/time-range-header-controls" +import { LONG_RANGE_PRESET_OPTIONS } from "@/lib/time-utils" +import { ReleasesFilterSidebar } from "@/components/releases/releases-filter-sidebar" +import { ReleasesTimeline } from "@/components/releases/releases-timeline" +import { ReleasesTable } from "@/components/releases/releases-table" +import { + RELEASE_HEALTH_ORDER, + deriveReleaseImpacts, + groupReleases, + type ReleaseHealth, +} from "@/components/releases/release-model" +import { RELEASE_HEALTH_LABEL } from "@/components/releases/release-health" + +const ONE_YEAR_SECONDS = 365 * 24 * 60 * 60 +const DEFAULT_PRESET = "7d" + +const releasesSearchSchema = Schema.Struct({ + environments: OptionalStringArrayParam, + excludedEnvironments: OptionalStringArrayParam, + services: OptionalStringArrayParam, + // Render-only: the health band is derived client-side from the same rows, + // so it never reaches the atom input. + impact: Schema.optional(Schema.Literals(RELEASE_HEALTH_ORDER)), + ...TimeRangeSearchFields, +}) + +export type ReleasesSearchParams = Schema.Schema.Type + +/** + * The releases atom's input, from search alone. Exported so the loader, the + * sidebar and the page cannot drift onto different keys — a mismatch does not + * fail, it fetches twice. + */ +export function releasesQueryInput(search: ReleasesSearchParams): GetReleasesInput { + const { startTime, endTime } = resolveEffectiveTimeRange( + search.startTime, + search.endTime, + search.timePreset ?? DEFAULT_PRESET, + ) + return { + startTime, + endTime, + environments: search.environments, + excludedEnvironments: search.excludedEnvironments, + services: search.services, + } +} + +export const Route = createFileRoute("/releases/")({ + component: ReleasesPage, + validateSearch: Schema.toStandardSchemaV1(releasesSearchSchema), + loaderDeps: ({ search }) => search, + loader: ({ context, deps }) => { + warmAtoms(context.effectRegistry, [getReleasesResultAtom({ data: releasesQueryInput(deps) })]) + }, +}) + +function ReleasesPage() { + const search = Route.useSearch() + const navigate = useNavigate({ from: Route.fullPath }) + const { startTime: effectiveStartTime, endTime: effectiveEndTime } = useEffectiveTimeRange( + search.startTime, + search.endTime, + search.timePreset ?? DEFAULT_PRESET, + ) + + const handleTimeChange = ( + range: { startTime?: string; endTime?: string; presetValue?: string }, + options?: { replace?: boolean }, + ) => { + navigate({ + replace: options?.replace, + search: (prev: Record) => applyTimeRangeSearch(prev, range), + }) + } + + const chips = [ + ...(search.excludedEnvironments?.length + ? [ + { + id: "excludedEnvironments", + label: "Environment", + values: search.excludedEnvironments, + negated: true, + }, + ] + : []), + ...(search.environments?.length + ? [{ id: "environments", label: "Environment", values: search.environments, negated: false }] + : []), + ...(search.services?.length + ? [{ id: "services", label: "Service", values: search.services, negated: false }] + : []), + ...(search.impact !== undefined + ? [ + { + id: "impact", + label: "Health", + values: [RELEASE_HEALTH_LABEL[search.impact]], + negated: false, + }, + ] + : []), + ].map((chip) => ({ + ...chip, + onRemove: () => navigate({ search: (prev) => ({ ...prev, [chip.id]: undefined }) }), + })) + + return ( + + + + + + + + + + + + + + + + + + + + + + ) +} + +function ReleasesSkeleton() { + return ( +
+ + + +
+ ) +} + +function ReleasesContent({ search }: { search: ReleasesSearchParams }) { + const atom = getReleasesResultAtom({ data: releasesQueryInput(search) }) + const result = useRefreshableAtomValue(atom) + const refresh = useAtomRefresh(atom) + + const derived = useMemo(() => { + if (!Result.isSuccess(result)) return undefined + const impacts = deriveReleaseImpacts(result.value.releases, result.value.timeline) + const groups = groupReleases(impacts) + return { impacts, groups, response: result.value } + }, [result]) + + if (Result.isFailure(result)) { + return ( + + ) + } + if (derived === undefined) return + + const { impacts, groups, response } = derived + const health: ReleaseHealth | undefined = search.impact + const visibleGroups = health === undefined ? groups : groups.filter((group) => group.health === health) + const visibleImpacts = + health === undefined ? impacts : impacts.filter((impact) => impact.health === health) + const services = new Set(impacts.map((impact) => impact.serviceName)).size + const windowDays = Math.max( + 1 / 24, + (Date.parse(response.endTime) - Date.parse(response.startTime)) / 86_400_000, + ) + const flagged = groups.filter((group) => group.health === "regressed").length + const timeSearch = pickTimeRangeSearch(search) + const waiting = Result.isSuccess(result) && result.waiting + + if (groups.length === 0) { + return ( +
+ No releases detected in this window. + + Release tracking needs spans to carry the{" "} + + vcs.ref.head.revision + {" "} + resource attribute. + +
+ ) + } + + return ( +
+
+ + + {formatNumber(groups.length)} + {" "} + {groups.length === 1 ? "release" : "releases"} + + + {formatNumber(services)}{" "} + {services === 1 ? "service" : "services"} + + + + {(groups.length / windowDays).toLocaleString(undefined, { maximumFractionDigits: 1 })} + {" "} + per day + + {flagged > 0 ? ( + + + {formatNumber(flagged)} + {" "} + {flagged === 1 ? "release" : "releases"} with errors up + + ) : null} + {response.truncated ? ( + + Showing the newest {formatNumber(response.releases.length)} rows + + ) : null} +
+ + {visibleGroups.length === 0 ? ( +
+ No releases match the health filter. +
+ ) : ( + + )} +
+ ) +} diff --git a/packages/domain/src/http/query-engine.ts b/packages/domain/src/http/query-engine.ts index 08440fca5..e8f075e8c 100644 --- a/packages/domain/src/http/query-engine.ts +++ b/packages/domain/src/http/query-engine.ts @@ -723,6 +723,98 @@ export class ServiceDetailOverviewResponse extends Schema.Class + +const ReleaseTimelinePoint = Schema.Struct({ + bucket: Schema.String, + serviceName: ServiceName, + commitSha: CommitSha, + count: Schema.Number, + errorCount: Schema.Number, +}) +export type ReleaseTimelinePoint = Schema.Schema.Type + +export const RELEASES_LIST_MAX = 500 + +export class ReleasesListRequest extends Schema.Class("ReleasesListRequest")({ + startTime: TinybirdDateTime, + endTime: TinybirdDateTime, + environments: OptionalDeploymentEnvs, + namespaces: OptionalServiceNamespaces, + services: OptionalServiceNames, + excludedEnvironments: OptionalDeploymentEnvs, + excludedNamespaces: OptionalServiceNamespaces, + // Bucket for the swimlane timeline. Whole minutes at least: the rollup tiers + // cannot place a row inside a minute, and the list is org-wide. + bucketSeconds: BucketSeconds, + limit: Schema.optional( + Schema.Number.check(Schema.isInt(), Schema.isBetween({ minimum: 1, maximum: RELEASES_LIST_MAX })), + ), +}) {} + +export class ReleasesListResponse extends Schema.Class("ReleasesListResponse")({ + /** One row per (service, environment, commit), newest first. */ + releases: Schema.Array(ReleaseRow), + timeline: Schema.Array(ReleaseTimelinePoint), + /** True when the row cap cut older releases off the end. */ + truncated: Schema.Boolean, +}) {} + +export class ReleaseDetailRequest extends Schema.Class("ReleaseDetailRequest")({ + serviceName: ServiceName, + commitSha: CommitSha, + startTime: TinybirdDateTime, + endTime: TinybirdDateTime, + environments: OptionalDeploymentEnvs, + // Pre-built all-metrics timeseries for this version and for every other + // version of the service, forwarded verbatim to `queryEngine.execute` like + // the service-detail bundle does. + timeseries: QueryEngineExecuteRequest, + baselineTimeseries: QueryEngineExecuteRequest, + bucketSeconds: BucketSeconds, +}) {} + +export class ReleaseDetailResponse extends Schema.Class("ReleaseDetailResponse")({ + /** Every version of this service in the window, this one included. */ + versions: Schema.Array(ReleaseRow), + timeline: Schema.Array(ReleaseTimelinePoint), + timeseries: QueryEngineExecuteResponse, + baselineTimeseries: QueryEngineExecuteResponse, + /** Error fingerprints whose occurrences carried this version as `service.version`. */ + errorFingerprints: Schema.Array( + Schema.Struct({ + fingerprintHash: FingerprintHash, + count: Schema.Number, + firstSeen: Schema.String, + lastSeen: Schema.String, + }), + ), +}) {} + export class ServiceDependenciesBundleRequest extends Schema.Class( "ServiceDependenciesBundleRequest", )({ @@ -2399,6 +2491,21 @@ export class QueryEngineApiGroup extends HttpApiGroup.make("queryEngine") error: validatedQueryEndpointErrors, }), ) + .add( + HttpApiEndpoint.post("releasesList", "/releases", { + payload: ReleasesListRequest, + success: ReleasesListResponse, + error: queryEngineEndpointErrors, + }), + ) + .add( + HttpApiEndpoint.post("releaseDetail", "/release-detail", { + payload: ReleaseDetailRequest, + success: ReleaseDetailResponse, + // Embeds `execute` sub-queries, so it can also surface QueryEngineValidationError. + error: validatedQueryEndpointErrors, + }), + ) .add( HttpApiEndpoint.post("serviceDependenciesBundle", "/service-dependencies-bundle", { payload: ServiceDependenciesBundleRequest, diff --git a/packages/query-engine/src/__sql_baseline__/catalog.sql b/packages/query-engine/src/__sql_baseline__/catalog.sql index 1126bfa5c..d0e6b9046 100644 --- a/packages/query-engine/src/__sql_baseline__/catalog.sql +++ b/packages/query-engine/src/__sql_baseline__/catalog.sql @@ -1467,6 +1467,299 @@ SELECT ORDER BY step ASC FORMAT JSON +-- builder:releases:releaseErrorFingerprintsQuery:default [e0e008b0] +SELECT + toString(FingerprintHash) AS fingerprintHash, + count() AS count, + min(Timestamp) AS firstSeen, + max(Timestamp) AS lastSeen + FROM error_events_by_time + WHERE OrgId = 'org_sql_catalog' + AND ServiceName = 'api' + AND ServiceVersion = '0af7651916cd43dd8448eb211c80319c0af76519' + AND Timestamp >= '2026-01-01 10:30:00' + AND Timestamp <= '2026-01-03 14:15:00' + AND DeploymentEnv IN ('production') + GROUP BY fingerprintHash + ORDER BY count DESC + LIMIT 50 + FORMAT JSON + +-- builder:releases:releasesListQuery:default [e12717bf] +SELECT + bServiceName AS serviceName, + bEnvironment AS environment, + bCommitSha AS commitSha, + min(bFirstSeen) AS firstSeen, + max(bBucket) AS lastSeen, + sum(bSpanCount) AS spanCount, + sum(bEstimatedSpanCount) AS estimatedSpanCount, + sum(bErrorCount) AS errorCount, + sum(bEstimatedErrorCount) AS estimatedErrorCount, + arrayElement(quantilesTDigestMerge(0.5, 0.95, 0.99)(bDurationQuantiles), 1) / 1000000 AS p50LatencyMs, + arrayElement(quantilesTDigestMerge(0.5, 0.95, 0.99)(bDurationQuantiles), 2) / 1000000 AS p95LatencyMs, + arrayElement(quantilesTDigestMerge(0.5, 0.95, 0.99)(bDurationQuantiles), 3) / 1000000 AS p99LatencyMs, + sum(bApdexSatisfiedCount) AS apdexSatisfiedCount, + sum(bApdexToleratingCount) AS apdexToleratingCount + FROM ( +SELECT + toStartOfHour(Timestamp) AS bBucket, + ServiceName AS bServiceName, + ServiceNamespace AS bServiceNamespace, + DeploymentEnv AS bEnvironment, + CommitSha AS bCommitSha, + count() AS bSpanCount, + sum(SampleRate) AS bEstimatedSpanCount, + countIf(StatusCode = 'Error') AS bErrorCount, + sumIf(SampleRate, StatusCode = 'Error') AS bEstimatedErrorCount, + sum(toFloat64(Duration)) AS bDurationSum, + quantilesTDigestState(0.5, 0.95, 0.99)(Duration) AS bDurationQuantiles, + min(Timestamp) AS bFirstSeen, + countIf((StatusCode != 'Error' AND Duration < 500000000)) AS bApdexSatisfiedCount, + countIf(((StatusCode != 'Error' AND Duration >= 500000000) AND Duration < 2000000000)) AS bApdexToleratingCount + FROM service_overview_spans + WHERE OrgId = 'org_sql_catalog' + AND Timestamp >= '2026-01-01 10:30:00' + AND Timestamp <= '2026-01-03 14:15:00' + AND DeploymentEnv IN ('production') + AND (Timestamp < if(toDateTime('2026-01-01 10:30:00') = toStartOfHour(toDateTime('2026-01-01 10:30:00')), toStartOfHour(toDateTime('2026-01-01 10:30:00')), toStartOfHour(toDateTime('2026-01-01 10:30:00')) + INTERVAL 1 HOUR) OR Timestamp >= toStartOfHour(toDateTime('2026-01-03 14:15:00'))) + GROUP BY bBucket, bServiceName, bServiceNamespace, bEnvironment, bCommitSha +UNION ALL +SELECT + Hour AS bBucket, + ServiceName AS bServiceName, + ServiceNamespace AS bServiceNamespace, + DeploymentEnv AS bEnvironment, + CommitSha AS bCommitSha, + sum(SpanCount) AS bSpanCount, + sum(EstimatedSpanCount) AS bEstimatedSpanCount, + sum(ErrorCount) AS bErrorCount, + sum(EstimatedErrorCount) AS bEstimatedErrorCount, + sum(DurationSum) AS bDurationSum, + quantilesTDigestMergeState(0.5, 0.95, 0.99)(DurationQuantiles) AS bDurationQuantiles, + min(FirstSeen) AS bFirstSeen, + sum(ApdexSatisfiedCount) AS bApdexSatisfiedCount, + sum(ApdexToleratingCount) AS bApdexToleratingCount + FROM service_overview_hourly + WHERE OrgId = 'org_sql_catalog' + AND DeploymentEnv IN ('production') + AND Hour >= if(toDateTime('2026-01-01 10:30:00') = toStartOfHour(toDateTime('2026-01-01 10:30:00')), toStartOfHour(toDateTime('2026-01-01 10:30:00')), toStartOfHour(toDateTime('2026-01-01 10:30:00')) + INTERVAL 1 HOUR) + AND Hour < toStartOfHour(toDateTime('2026-01-03 14:15:00')) + GROUP BY bBucket, bServiceName, bServiceNamespace, bEnvironment, bCommitSha +) AS service_windows + WHERE bCommitSha != '' + AND bServiceName IN ('api', 'web') + GROUP BY serviceName, environment, commitSha + ORDER BY firstSeen DESC, spanCount DESC + LIMIT 500 + FORMAT JSON + +-- builder:releases:releasesListQuery:singleService [e3891cbc] +SELECT + bServiceName AS serviceName, + bEnvironment AS environment, + bCommitSha AS commitSha, + min(bFirstSeen) AS firstSeen, + max(bBucket) AS lastSeen, + sum(bSpanCount) AS spanCount, + sum(bEstimatedSpanCount) AS estimatedSpanCount, + sum(bErrorCount) AS errorCount, + sum(bEstimatedErrorCount) AS estimatedErrorCount, + arrayElement(quantilesTDigestMerge(0.5, 0.95, 0.99)(bDurationQuantiles), 1) / 1000000 AS p50LatencyMs, + arrayElement(quantilesTDigestMerge(0.5, 0.95, 0.99)(bDurationQuantiles), 2) / 1000000 AS p95LatencyMs, + arrayElement(quantilesTDigestMerge(0.5, 0.95, 0.99)(bDurationQuantiles), 3) / 1000000 AS p99LatencyMs, + sum(bApdexSatisfiedCount) AS apdexSatisfiedCount, + sum(bApdexToleratingCount) AS apdexToleratingCount + FROM ( +SELECT + toStartOfHour(Timestamp) AS bBucket, + ServiceName AS bServiceName, + ServiceNamespace AS bServiceNamespace, + DeploymentEnv AS bEnvironment, + CommitSha AS bCommitSha, + count() AS bSpanCount, + sum(SampleRate) AS bEstimatedSpanCount, + countIf(StatusCode = 'Error') AS bErrorCount, + sumIf(SampleRate, StatusCode = 'Error') AS bEstimatedErrorCount, + sum(toFloat64(Duration)) AS bDurationSum, + quantilesTDigestState(0.5, 0.95, 0.99)(Duration) AS bDurationQuantiles, + min(Timestamp) AS bFirstSeen, + countIf((StatusCode != 'Error' AND Duration < 500000000)) AS bApdexSatisfiedCount, + countIf(((StatusCode != 'Error' AND Duration >= 500000000) AND Duration < 2000000000)) AS bApdexToleratingCount + FROM service_overview_spans + WHERE OrgId = 'org_sql_catalog' + AND Timestamp >= '2026-01-01 10:30:00' + AND Timestamp <= '2026-01-03 14:15:00' + AND ServiceName = 'api' + AND DeploymentEnv IN ('production') + AND (Timestamp < if(toDateTime('2026-01-01 10:30:00') = toStartOfHour(toDateTime('2026-01-01 10:30:00')), toStartOfHour(toDateTime('2026-01-01 10:30:00')), toStartOfHour(toDateTime('2026-01-01 10:30:00')) + INTERVAL 1 HOUR) OR Timestamp >= toStartOfHour(toDateTime('2026-01-03 14:15:00'))) + GROUP BY bBucket, bServiceName, bServiceNamespace, bEnvironment, bCommitSha +UNION ALL +SELECT + Hour AS bBucket, + ServiceName AS bServiceName, + ServiceNamespace AS bServiceNamespace, + DeploymentEnv AS bEnvironment, + CommitSha AS bCommitSha, + sum(SpanCount) AS bSpanCount, + sum(EstimatedSpanCount) AS bEstimatedSpanCount, + sum(ErrorCount) AS bErrorCount, + sum(EstimatedErrorCount) AS bEstimatedErrorCount, + sum(DurationSum) AS bDurationSum, + quantilesTDigestMergeState(0.5, 0.95, 0.99)(DurationQuantiles) AS bDurationQuantiles, + min(FirstSeen) AS bFirstSeen, + sum(ApdexSatisfiedCount) AS bApdexSatisfiedCount, + sum(ApdexToleratingCount) AS bApdexToleratingCount + FROM service_overview_hourly + WHERE OrgId = 'org_sql_catalog' + AND ServiceName = 'api' + AND DeploymentEnv IN ('production') + AND Hour >= if(toDateTime('2026-01-01 10:30:00') = toStartOfHour(toDateTime('2026-01-01 10:30:00')), toStartOfHour(toDateTime('2026-01-01 10:30:00')), toStartOfHour(toDateTime('2026-01-01 10:30:00')) + INTERVAL 1 HOUR) + AND Hour < toStartOfHour(toDateTime('2026-01-03 14:15:00')) + GROUP BY bBucket, bServiceName, bServiceNamespace, bEnvironment, bCommitSha +) AS service_windows + WHERE bCommitSha != '' + GROUP BY serviceName, environment, commitSha + ORDER BY firstSeen DESC, spanCount DESC + LIMIT 100 + FORMAT JSON + +-- builder:releases:releasesTimelineQuery:hourly [10107a11] +SELECT + toStartOfInterval(bBucket, INTERVAL 3600 SECOND) AS bucket, + bServiceName AS serviceName, + bCommitSha AS commitSha, + sum(bSpanCount) AS count, + sum(bErrorCount) AS errorCount + FROM ( +SELECT + toStartOfHour(Timestamp) AS bBucket, + ServiceName AS bServiceName, + ServiceNamespace AS bServiceNamespace, + DeploymentEnv AS bEnvironment, + CommitSha AS bCommitSha, + count() AS bSpanCount, + sum(SampleRate) AS bEstimatedSpanCount, + countIf(StatusCode = 'Error') AS bErrorCount, + sumIf(SampleRate, StatusCode = 'Error') AS bEstimatedErrorCount, + sum(toFloat64(Duration)) AS bDurationSum, + quantilesTDigestState(0.5, 0.95, 0.99)(Duration) AS bDurationQuantiles, + min(Timestamp) AS bFirstSeen, + countIf((StatusCode != 'Error' AND Duration < 500000000)) AS bApdexSatisfiedCount, + countIf(((StatusCode != 'Error' AND Duration >= 500000000) AND Duration < 2000000000)) AS bApdexToleratingCount + FROM service_overview_spans + WHERE OrgId = 'org_sql_catalog' + AND Timestamp >= '2026-01-01 10:30:00' + AND Timestamp <= '2026-01-03 14:15:00' + AND ServiceName = 'api' + AND (Timestamp < if(toDateTime('2026-01-01 10:30:00') = toStartOfHour(toDateTime('2026-01-01 10:30:00')), toStartOfHour(toDateTime('2026-01-01 10:30:00')), toStartOfHour(toDateTime('2026-01-01 10:30:00')) + INTERVAL 1 HOUR) OR Timestamp >= toStartOfHour(toDateTime('2026-01-03 14:15:00'))) + GROUP BY bBucket, bServiceName, bServiceNamespace, bEnvironment, bCommitSha +UNION ALL +SELECT + Hour AS bBucket, + ServiceName AS bServiceName, + ServiceNamespace AS bServiceNamespace, + DeploymentEnv AS bEnvironment, + CommitSha AS bCommitSha, + sum(SpanCount) AS bSpanCount, + sum(EstimatedSpanCount) AS bEstimatedSpanCount, + sum(ErrorCount) AS bErrorCount, + sum(EstimatedErrorCount) AS bEstimatedErrorCount, + sum(DurationSum) AS bDurationSum, + quantilesTDigestMergeState(0.5, 0.95, 0.99)(DurationQuantiles) AS bDurationQuantiles, + min(FirstSeen) AS bFirstSeen, + sum(ApdexSatisfiedCount) AS bApdexSatisfiedCount, + sum(ApdexToleratingCount) AS bApdexToleratingCount + FROM service_overview_hourly + WHERE OrgId = 'org_sql_catalog' + AND ServiceName = 'api' + AND Hour >= if(toDateTime('2026-01-01 10:30:00') = toStartOfHour(toDateTime('2026-01-01 10:30:00')), toStartOfHour(toDateTime('2026-01-01 10:30:00')), toStartOfHour(toDateTime('2026-01-01 10:30:00')) + INTERVAL 1 HOUR) + AND Hour < toStartOfHour(toDateTime('2026-01-03 14:15:00')) + GROUP BY bBucket, bServiceName, bServiceNamespace, bEnvironment, bCommitSha +) AS service_windows + WHERE bCommitSha != '' + GROUP BY bucket, serviceName, commitSha + ORDER BY bucket ASC + LIMIT 5000 + FORMAT JSON + +-- builder:releases:releasesTimelineQuery:minutely [e9a0172f] +SELECT + toStartOfInterval(bBucket, INTERVAL 300 SECOND) AS bucket, + bServiceName AS serviceName, + bCommitSha AS commitSha, + sum(bSpanCount) AS count, + sum(bErrorCount) AS errorCount + FROM ( +SELECT + toStartOfMinute(Timestamp) AS bBucket, + ServiceName AS bServiceName, + ServiceNamespace AS bServiceNamespace, + DeploymentEnv AS bEnvironment, + CommitSha AS bCommitSha, + count() AS bSpanCount, + sum(SampleRate) AS bEstimatedSpanCount, + countIf(StatusCode = 'Error') AS bErrorCount, + sumIf(SampleRate, StatusCode = 'Error') AS bEstimatedErrorCount, + sum(toFloat64(Duration)) AS bDurationSum, + quantilesTDigestState(0.5, 0.95, 0.99)(Duration) AS bDurationQuantiles, + min(Timestamp) AS bFirstSeen, + countIf((StatusCode != 'Error' AND Duration < 500000000)) AS bApdexSatisfiedCount, + countIf(((StatusCode != 'Error' AND Duration >= 500000000) AND Duration < 2000000000)) AS bApdexToleratingCount + FROM service_overview_spans + WHERE OrgId = 'org_sql_catalog' + AND Timestamp >= '2026-01-01 10:30:00' + AND Timestamp <= '2026-01-03 14:15:00' + AND DeploymentEnv IN ('production') + AND (Timestamp < if(toDateTime('2026-01-01 10:30:00') = toStartOfMinute(toDateTime('2026-01-01 10:30:00')), toStartOfMinute(toDateTime('2026-01-01 10:30:00')), toStartOfMinute(toDateTime('2026-01-01 10:30:00')) + INTERVAL 1 MINUTE) OR Timestamp >= toStartOfMinute(toDateTime('2026-01-03 14:15:00'))) + GROUP BY bBucket, bServiceName, bServiceNamespace, bEnvironment, bCommitSha +UNION ALL +SELECT + Minute AS bBucket, + ServiceName AS bServiceName, + ServiceNamespace AS bServiceNamespace, + DeploymentEnv AS bEnvironment, + CommitSha AS bCommitSha, + sum(SpanCount) AS bSpanCount, + sum(EstimatedSpanCount) AS bEstimatedSpanCount, + sum(ErrorCount) AS bErrorCount, + sum(EstimatedErrorCount) AS bEstimatedErrorCount, + sum(DurationSum) AS bDurationSum, + quantilesTDigestMergeState(0.5, 0.95, 0.99)(DurationQuantiles) AS bDurationQuantiles, + min(FirstSeen) AS bFirstSeen, + sum(ApdexSatisfiedCount) AS bApdexSatisfiedCount, + sum(ApdexToleratingCount) AS bApdexToleratingCount + FROM service_overview_minutely + WHERE OrgId = 'org_sql_catalog' + AND DeploymentEnv IN ('production') + AND Minute >= if(toDateTime('2026-01-01 10:30:00') = toStartOfMinute(toDateTime('2026-01-01 10:30:00')), toStartOfMinute(toDateTime('2026-01-01 10:30:00')), toStartOfMinute(toDateTime('2026-01-01 10:30:00')) + INTERVAL 1 MINUTE) + AND Minute < toStartOfMinute(toDateTime('2026-01-03 14:15:00')) + GROUP BY bBucket, bServiceName, bServiceNamespace, bEnvironment, bCommitSha +) AS service_windows + WHERE bCommitSha != '' + GROUP BY bucket, serviceName, commitSha + ORDER BY bucket ASC + LIMIT 5000 + FORMAT JSON + +-- builder:releases:releasesTimelineQuery:raw [50272b8a] +SELECT + toStartOfInterval(Timestamp, INTERVAL 30 SECOND) AS bucket, + ServiceName AS serviceName, + CommitSha AS commitSha, + count() AS count, + countIf(StatusCode = 'Error') AS errorCount + FROM service_overview_spans + WHERE OrgId = 'org_sql_catalog' + AND Timestamp >= '2026-01-01 10:30:00' + AND Timestamp <= '2026-01-03 14:15:00' + AND ServiceName = 'api' + AND CommitSha != '' + GROUP BY bucket, serviceName, commitSha + ORDER BY bucket ASC + LIMIT 5000 + FORMAT JSON + -- builder:service-endpoints:serviceEndpointsSummaryQuery:default [3e379104] SELECT bSpanName AS spanName, diff --git a/packages/query-engine/src/ch/builder-fixtures.ts b/packages/query-engine/src/ch/builder-fixtures.ts index 69d8f7adf..fe0a3fdf2 100644 --- a/packages/query-engine/src/ch/builder-fixtures.ts +++ b/packages/query-engine/src/ch/builder-fixtures.ts @@ -628,6 +628,70 @@ export const builderFixtures: ReadonlyArray = [ ), }, + // Releases page — routes/internal/query-engine.http.ts `releasesList` and + // `releaseDetail`. Same splice as the services list, grouped one level + // finer (per commit), plus the error-events bridge keyed on the version. + { + module: "releases", + name: "releasesListQuery", + label: "default", + compile: () => + CH.compileUnsafe( + CH.releasesListQuery({ environments: ["production"], serviceNames: ["api", "web"] }), + window, + ), + }, + { + module: "releases", + name: "releasesListQuery", + label: "singleService", + compile: () => + CH.compileUnsafe( + CH.releasesListQuery({ serviceName: "api", environments: ["production"], limit: 100 }), + window, + ), + }, + { + module: "releases", + name: "releasesTimelineQuery", + label: "minutely", + compile: () => + CH.compileUnsafe(CH.releasesTimelineQuery({ environments: ["production"], bucketSeconds: 300 }), { + ...window, + bucketSeconds: 300, + }), + }, + { + module: "releases", + name: "releasesTimelineQuery", + label: "hourly", + compile: () => + CH.compileUnsafe(CH.releasesTimelineQuery({ serviceName: "api", bucketSeconds: 3600 }), { + ...window, + bucketSeconds: 3600, + }), + }, + { + module: "releases", + name: "releasesTimelineQuery", + label: "raw", + compile: () => + CH.compileUnsafe(CH.releasesTimelineQuery({ serviceName: "api", bucketSeconds: 30 }), { + ...window, + bucketSeconds: 30, + }), + }, + { + module: "releases", + name: "releaseErrorFingerprintsQuery", + label: "default", + compile: () => + CH.compileUnsafe( + CH.releaseErrorFingerprintsQuery({ serviceName: "api", environments: ["production"] }), + { ...window, serviceVersion: "0af7651916cd43dd8448eb211c80319c0af76519" }, + ), + }, + // Service-catalog hourly-rollup splice. { // routes/v2/services.http.ts — the services list. diff --git a/packages/query-engine/src/ch/index.ts b/packages/query-engine/src/ch/index.ts index 04b1e4433..b3c976571 100644 --- a/packages/query-engine/src/ch/index.ts +++ b/packages/query-engine/src/ch/index.ts @@ -238,6 +238,22 @@ export { type ServicesFacetsOutput, } from "./queries/services" +// Queries — Releases +export { + releasesListQuery, + releasesListRowSchema, + releasesTimelineQuery, + releaseErrorFingerprintsQuery, + releaseErrorFingerprintsRowSchema, + RELEASES_LIST_CAP, + type ReleasesListOpts, + type ReleasesListOutput, + type ReleasesTimelineOpts, + type ReleasesTimelineOutput, + type ReleaseErrorFingerprintsOpts, + type ReleaseErrorFingerprintsOutput, +} from "./queries/releases" + // Queries — Errors export { errorsByTypeQuery, diff --git a/packages/query-engine/src/ch/queries/releases.test.ts b/packages/query-engine/src/ch/queries/releases.test.ts new file mode 100644 index 000000000..31f8167f8 --- /dev/null +++ b/packages/query-engine/src/ch/queries/releases.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest" +import { compileUnsafe } from "@maple-dev/clickhouse-builder" +import { releaseErrorFingerprintsQuery, releasesListQuery, releasesTimelineQuery } from "./releases" + +const baseParams = { + orgId: "org_1", + startTime: "2024-01-01 00:00:00", + endTime: "2024-01-02 00:00:00", +} + +describe("releasesListQuery", () => { + it("groups the service-window splice by service, environment and commit", () => { + const { sql } = compileUnsafe( + releasesListQuery({ environments: ["production"], serviceNames: ["api", "web"] }), + baseParams, + ) + expect(sql).toContain("FROM service_overview_spans") + expect(sql).toContain("FROM service_overview_hourly") + expect(sql).toContain("UNION ALL") + expect(sql).toContain("OrgId = 'org_1'") + expect(sql).toContain("DeploymentEnv IN ('production')") + expect(sql).toContain("bServiceName IN ('api', 'web')") + expect(sql).toContain("bCommitSha != ''") + expect(sql).toContain("GROUP BY serviceName, environment, commitSha") + expect(sql).toContain("quantilesTDigestMerge(0.5, 0.95, 0.99)(bDurationQuantiles)") + expect(sql).toContain("ORDER BY firstSeen DESC, spanCount DESC") + expect(sql).toContain("LIMIT 500") + }) + + it("scopes to one service for the detail comparison", () => { + const { sql } = compileUnsafe(releasesListQuery({ serviceName: "api", limit: 50 }), baseParams) + expect(sql).toContain("ServiceName = 'api'") + expect(sql).toContain("LIMIT 50") + }) +}) + +describe("releasesTimelineQuery", () => { + it("reads the rollup tiers for whole-minute buckets", () => { + const { sql } = compileUnsafe(releasesTimelineQuery({ bucketSeconds: 300 }), { + ...baseParams, + bucketSeconds: 300, + }) + expect(sql).toContain("FROM service_overview_minutely") + expect(sql).not.toContain("FROM service_overview_hourly") + expect(sql).toContain("toStartOfInterval(bBucket, INTERVAL 300 SECOND)") + expect(sql).toContain("GROUP BY bucket, serviceName, commitSha") + }) + + it("adds the hourly tier for whole-hour buckets", () => { + const { sql } = compileUnsafe(releasesTimelineQuery({ bucketSeconds: 3600 }), { + ...baseParams, + bucketSeconds: 3600, + }) + expect(sql).toContain("FROM service_overview_hourly") + }) + + it("falls back to the entry-point projection for sub-minute buckets", () => { + const { sql } = compileUnsafe(releasesTimelineQuery({ bucketSeconds: 30, serviceNames: ["api"] }), { + ...baseParams, + bucketSeconds: 30, + }) + expect(sql).toContain("FROM service_overview_spans") + expect(sql).not.toContain("UNION ALL") + expect(sql).toContain("ServiceName = 'api'") + expect(sql).toContain("CommitSha != ''") + }) +}) + +describe("releaseErrorFingerprintsQuery", () => { + it("keys on the version string and stringifies the hash", () => { + const { sql } = compileUnsafe( + releaseErrorFingerprintsQuery({ serviceName: "api", environments: ["production"] }), + { ...baseParams, serviceVersion: "abc123" }, + ) + expect(sql).toContain("FROM error_events_by_time") + expect(sql).toContain("toString(FingerprintHash)") + expect(sql).toContain("ServiceName = 'api'") + expect(sql).toContain("ServiceVersion = 'abc123'") + expect(sql).toContain("DeploymentEnv IN ('production')") + expect(sql).toContain("GROUP BY fingerprintHash") + expect(sql).toContain("LIMIT 50") + }) +}) diff --git a/packages/query-engine/src/ch/queries/releases.ts b/packages/query-engine/src/ch/queries/releases.ts new file mode 100644 index 000000000..68c5c8954 --- /dev/null +++ b/packages/query-engine/src/ch/queries/releases.ts @@ -0,0 +1,266 @@ +// Typed Releases Queries +// +// A release, for these queries, is a commit the moment it starts serving +// traffic: the service-overview rollups pre-extract `vcs.ref.head.revision` as +// `CommitSha` and key on it, so every row here is a GROUP BY over the same +// splice the services list already reads. Nothing scans the raw traces table. + +import { Schema } from "effect" +import * as T from "@maple-dev/clickhouse-builder/types" +import * as CH from "@maple-dev/clickhouse-builder/expr" +import { param, from, type CHQuery, type CompiledQueryRowSchema } from "@maple-dev/clickhouse-builder" +import type { ColumnDefs } from "@maple-dev/clickhouse-builder/types" +import { ErrorEventsByTime, ServiceOverviewSpans } from "../tables" +import { CHNumber } from "../schema" +import { serviceOverviewWhereConditions } from "./query-helpers" +import { serviceOverviewWindows, serviceWindowTiersForBucket } from "./services" + +/** + * At most this many (service, environment, commit) rows per request. A fleet + * that deploys every push accumulates thousands of shas in a month; the page + * lists the newest and says how many it left out. + */ +export const RELEASES_LIST_CAP = 500 + +export interface ReleasesListOpts { + readonly serviceName?: string + readonly serviceNames?: readonly string[] + readonly environments?: readonly string[] + readonly namespaces?: readonly string[] + readonly excludedEnvironments?: readonly string[] + readonly excludedNamespaces?: readonly string[] + readonly limit?: number +} + +export interface ReleasesListOutput { + readonly serviceName: string + readonly environment: string + readonly commitSha: string + readonly firstSeen: string + readonly lastSeen: string + readonly spanCount: number + readonly estimatedSpanCount: number + readonly errorCount: number + readonly estimatedErrorCount: number + readonly p50LatencyMs: number + readonly p95LatencyMs: number + readonly p99LatencyMs: number + readonly apdexSatisfiedCount: number + readonly apdexToleratingCount: number +} + +export const releasesListRowSchema = Schema.Struct({ + serviceName: Schema.String, + environment: Schema.String, + commitSha: Schema.String, + firstSeen: Schema.String, + lastSeen: Schema.String, + // `CHNumber`, never `Schema.Number`: UInt64 counts arrive quoted on a + // gateway that refuses `output_format_json_quote_64bit_integers=0`. + spanCount: CHNumber, + estimatedSpanCount: CHNumber, + errorCount: CHNumber, + estimatedErrorCount: CHNumber, + p50LatencyMs: CHNumber, + p95LatencyMs: CHNumber, + p99LatencyMs: CHNumber, + apdexSatisfiedCount: CHNumber, + apdexToleratingCount: CHNumber, +}) satisfies CompiledQueryRowSchema + +/** + * One row per (service, environment, commit) in the window, newest first. + * + * Every version of a service is a row, not just the latest — a release's + * impact is "this version against every other version of the same service in + * the same minutes", and the caller derives that split from these rows. The + * same query scoped to one service is therefore also the detail page's + * comparison table. + */ +export function releasesListQuery(opts: ReleasesListOpts = {}) { + return serviceOverviewWindows({ + serviceName: opts.serviceName, + environments: opts.environments, + namespaces: opts.namespaces, + excludedEnvironments: opts.excludedEnvironments, + excludedNamespaces: opts.excludedNamespaces, + }) + .select(($) => ({ + serviceName: $.bServiceName, + environment: $.bEnvironment, + commitSha: $.bCommitSha, + firstSeen: CH.min_($.bFirstSeen), + lastSeen: CH.max_($.bBucket), + spanCount: CH.sum($.bSpanCount), + estimatedSpanCount: CH.sum($.bEstimatedSpanCount), + errorCount: CH.sum($.bErrorCount), + estimatedErrorCount: CH.sum($.bEstimatedErrorCount), + p50LatencyMs: CH.rawExpr( + "arrayElement(quantilesTDigestMerge(0.5, 0.95, 0.99)(bDurationQuantiles), 1) / 1000000", + T.float64, + ), + p95LatencyMs: CH.rawExpr( + "arrayElement(quantilesTDigestMerge(0.5, 0.95, 0.99)(bDurationQuantiles), 2) / 1000000", + T.float64, + ), + p99LatencyMs: CH.rawExpr( + "arrayElement(quantilesTDigestMerge(0.5, 0.95, 0.99)(bDurationQuantiles), 3) / 1000000", + T.float64, + ), + apdexSatisfiedCount: CH.sum($.bApdexSatisfiedCount), + apdexToleratingCount: CH.sum($.bApdexToleratingCount), + })) + .where(($) => [ + $.bCommitSha.neq(""), + opts.serviceNames?.length ? CH.inList($.bServiceName, opts.serviceNames) : undefined, + ]) + .groupBy("serviceName", "environment", "commitSha") + .orderBy(["firstSeen", "desc"], ["spanCount", "desc"]) + .limit(opts.limit ?? RELEASES_LIST_CAP) + .format("JSON") +} + +// Releases timeline +// +// The org-wide sibling of `serviceReleasesTimelineQuery`: per bucket, per +// service, per commit. Feeds the swimlanes and the per-service rollout share +// (which version carried the last bucket's traffic). + +export interface ReleasesTimelineOpts { + readonly serviceName?: string + readonly serviceNames?: readonly string[] + readonly environments?: readonly string[] + readonly namespaces?: readonly string[] + readonly excludedEnvironments?: readonly string[] + readonly excludedNamespaces?: readonly string[] + /** + * Needed at build time, not just as a compile parameter: it selects which + * rollup tiers can answer, because a tier coarser than the bucket has no + * position inside it. + */ + readonly bucketSeconds: number +} + +export interface ReleasesTimelineOutput { + readonly bucket: string + readonly serviceName: string + readonly commitSha: string + readonly count: number + readonly errorCount: number +} + +const RELEASES_TIMELINE_CAP = 5000 + +/** Sub-minute buckets: no rollup tier can place a row inside a minute. */ +function releasesTimelineRawQuery( + opts: ReleasesTimelineOpts, +): CHQuery { + return from(ServiceOverviewSpans) + .select(($) => ({ + bucket: CH.toStartOfInterval($.Timestamp, param.int("bucketSeconds")), + serviceName: $.ServiceName, + commitSha: $.CommitSha, + count: CH.count(), + errorCount: CH.countIf($.StatusCode.eq("Error")), + })) + .where(($) => [ + ...serviceOverviewWhereConditions($, { + serviceName: opts.serviceName, + serviceNames: opts.serviceNames, + environments: opts.environments, + namespaces: opts.namespaces, + excludedEnvironments: opts.excludedEnvironments, + excludedNamespaces: opts.excludedNamespaces, + }), + $.CommitSha.neq(""), + ]) + .groupBy("bucket", "serviceName", "commitSha") + .orderBy(["bucket", "asc"]) + .limit(RELEASES_TIMELINE_CAP) + .format("JSON") as CHQuery +} + +export function releasesTimelineQuery( + opts: ReleasesTimelineOpts, +): CHQuery { + const tiers = serviceWindowTiersForBucket(opts.bucketSeconds) + if (tiers === "raw") return releasesTimelineRawQuery(opts) + + return serviceOverviewWindows( + { + serviceName: opts.serviceName, + environments: opts.environments, + namespaces: opts.namespaces, + excludedEnvironments: opts.excludedEnvironments, + excludedNamespaces: opts.excludedNamespaces, + }, + tiers, + ) + .select(($) => ({ + bucket: CH.toStartOfInterval($.bBucket, param.int("bucketSeconds")), + serviceName: $.bServiceName, + commitSha: $.bCommitSha, + count: CH.sum($.bSpanCount), + errorCount: CH.sum($.bErrorCount), + })) + .where(($) => [ + $.bCommitSha.neq(""), + opts.serviceNames?.length ? CH.inList($.bServiceName, opts.serviceNames) : undefined, + ]) + .groupBy("bucket", "serviceName", "commitSha") + .orderBy(["bucket", "asc"]) + .limit(RELEASES_TIMELINE_CAP) + .format("JSON") as CHQuery +} + +// Error fingerprints on a version +// +// The bridge from a release to the issues system. Traces key a release on +// `vcs.ref.head.revision`; error events key on `service.version`, which Maple's +// SDKs stamp with the same sha. Reads the per-occurrence projection because a +// release starts at an arbitrary instant and a minute rollup would smear the +// first occurrences across the deploy boundary. + +export interface ReleaseErrorFingerprintsOpts { + readonly serviceName: string + readonly environments?: readonly string[] + readonly limit?: number +} + +export interface ReleaseErrorFingerprintsOutput { + readonly fingerprintHash: string + readonly count: number + readonly firstSeen: string + readonly lastSeen: string +} + +export const releaseErrorFingerprintsRowSchema = Schema.Struct({ + // `toString()`-wrapped in the SELECT: a UInt64 hash above 2^53 corrupts as + // a JS number. + fingerprintHash: Schema.String, + count: CHNumber, + firstSeen: Schema.String, + lastSeen: Schema.String, +}) satisfies CompiledQueryRowSchema + +export function releaseErrorFingerprintsQuery(opts: ReleaseErrorFingerprintsOpts) { + return from(ErrorEventsByTime) + .select(($) => ({ + fingerprintHash: CH.toString_($.FingerprintHash), + count: CH.count(), + firstSeen: CH.min_($.Timestamp), + lastSeen: CH.max_($.Timestamp), + })) + .where(($) => [ + $.OrgId.eq(param.string("orgId")), + $.ServiceName.eq(opts.serviceName), + $.ServiceVersion.eq(param.string("serviceVersion")), + $.Timestamp.gte(param.dateTimeSeconds("startTime")), + $.Timestamp.lte(param.dateTimeSeconds("endTime")), + opts.environments?.length ? CH.inList($.DeploymentEnv, opts.environments) : undefined, + ]) + .groupBy("fingerprintHash") + .orderBy(["count", "desc"]) + .limit(opts.limit ?? 50) + .format("JSON") +} diff --git a/packages/query-engine/src/ch/queries/services.ts b/packages/query-engine/src/ch/queries/services.ts index 0dd835eba..f9d3a7664 100644 --- a/packages/query-engine/src/ch/queries/services.ts +++ b/packages/query-engine/src/ch/queries/services.ts @@ -40,7 +40,7 @@ const SERVICE_ROLLUP_DURATION_STATE = "quantilesTDigestMergeState(0.5, 0.95, 0.9 * row anyone decodes. Declaring it is what lets the queries carrying it derive * a row schema for their *other* columns. */ -const DURATION_STATE = T.aggregateState("quantilesTDigest(0.5, 0.95, 0.99)", "UInt64") +export const DURATION_STATE = T.aggregateState("quantilesTDigest(0.5, 0.95, 0.99)", "UInt64") /** A commit tuple as JSON: `[sha, spanCount, errorCount, firstSeen]`. */ const COMMIT_TUPLE = T.array( @@ -50,7 +50,7 @@ const COMMIT_TUPLE = T.array( ), ) -interface ServiceWindowFilters { +export interface ServiceWindowFilters { readonly serviceName?: string readonly environments?: readonly string[] readonly namespaces?: readonly string[] @@ -121,7 +121,7 @@ const SERVICE_WINDOW_GROUP_KEYS = [ * `[11:00, 14:00)`. Disjoint, and their union is the window — the same argument * `serviceOperationsSummaryQuery` rests on, using the same two helpers. */ -function serviceOverviewWindows(filters: ServiceWindowFilters, tiers: ServiceWindowTiers = {}) { +export function serviceOverviewWindows(filters: ServiceWindowFilters, tiers: ServiceWindowTiers = {}) { const grain = tiers.grain ?? "hour" const includeHourly = tiers.includeHourly ?? true const rollupFilters = < diff --git a/packages/query-engine/src/registry/queries.ts b/packages/query-engine/src/registry/queries.ts index 0b0185c13..973eb7b7a 100644 --- a/packages/query-engine/src/registry/queries.ts +++ b/packages/query-engine/src/registry/queries.ts @@ -37,6 +37,8 @@ import type { ServiceHealthBaselineRequest, ServiceHealthSnapshotRequest, ServiceOverviewRequest, + ReleasesListRequest, + ReleaseDetailRequest, WorkloadDetailSummaryRequest, WebAnalyticsSummaryRequest, WebAnalyticsLiveRequest, @@ -514,6 +516,107 @@ export const workloadDetailSummary = defineQuery({ ), }) +// Releases page. The list and the timeline share one payload so the bundle +// handler forwards it to both; the detail reuses the list query scoped to one +// service, which is the comparison table. +export const releasesList = defineQuery({ + id: "releasesList", + profile: "list", + cache: timeRangeCache, + compile: (payload: ReleasesListRequest, orgId: string) => + CH.compile( + CH.releasesListQuery({ + environments: payload.environments, + namespaces: payload.namespaces, + serviceNames: payload.services, + excludedEnvironments: payload.excludedEnvironments, + excludedNamespaces: payload.excludedNamespaces, + limit: payload.limit, + }), + { orgId, startTime: payload.startTime, endTime: payload.endTime }, + { rowSchema: CH.releasesListRowSchema }, + ), +}) + +export const releasesTimeline = defineQuery({ + id: "releasesTimeline", + profile: "list", + cache: timeRangeCache, + compile: (payload: ReleasesListRequest, orgId: string) => + CH.compile( + CH.releasesTimelineQuery({ + environments: payload.environments, + namespaces: payload.namespaces, + serviceNames: payload.services, + excludedEnvironments: payload.excludedEnvironments, + excludedNamespaces: payload.excludedNamespaces, + bucketSeconds: payload.bucketSeconds, + }), + { + orgId, + startTime: payload.startTime, + endTime: payload.endTime, + bucketSeconds: payload.bucketSeconds, + }, + ), +}) + +export const releaseVersions = defineQuery({ + id: "releaseVersions", + profile: "list", + cache: timeRangeCache, + compile: (payload: ReleaseDetailRequest, orgId: string) => + CH.compile( + CH.releasesListQuery({ + serviceName: payload.serviceName, + environments: payload.environments, + limit: 100, + }), + { orgId, startTime: payload.startTime, endTime: payload.endTime }, + { rowSchema: CH.releasesListRowSchema }, + ), +}) + +export const releaseTimeline = defineQuery({ + id: "releaseTimeline", + profile: "list", + cache: timeRangeCache, + compile: (payload: ReleaseDetailRequest, orgId: string) => + CH.compile( + CH.releasesTimelineQuery({ + serviceName: payload.serviceName, + environments: payload.environments, + bucketSeconds: payload.bucketSeconds, + }), + { + orgId, + startTime: payload.startTime, + endTime: payload.endTime, + bucketSeconds: payload.bucketSeconds, + }, + ), +}) + +export const releaseErrorFingerprints = defineQuery({ + id: "releaseErrorFingerprints", + profile: "list", + cache: timeRangeCache, + compile: (payload: ReleaseDetailRequest, orgId: string) => + CH.compile( + CH.releaseErrorFingerprintsQuery({ + serviceName: payload.serviceName, + environments: payload.environments, + }), + { + orgId, + startTime: payload.startTime, + endTime: payload.endTime, + serviceVersion: payload.commitSha, + }, + { rowSchema: CH.releaseErrorFingerprintsRowSchema }, + ), +}) + // Bundle subqueries keep distinct ids and minimal payloads to preserve standalone cache keys. export const serviceReleases = defineQuery({ id: "serviceReleases", diff --git a/packages/query-engine/src/sql-catalog.test.ts b/packages/query-engine/src/sql-catalog.test.ts index 0fdb4a396..7bfacd77c 100644 --- a/packages/query-engine/src/sql-catalog.test.ts +++ b/packages/query-engine/src/sql-catalog.test.ts @@ -34,6 +34,7 @@ import * as serviceMapQueries from "./ch/queries/service-map" import * as serviceEndpointQueries from "./ch/queries/service-endpoints" import * as serviceOperationQueries from "./ch/queries/service-operations" import * as serviceQueries from "./ch/queries/services" +import * as releaseQueries from "./ch/queries/releases" import * as sessionEventQueries from "./ch/queries/session-events" import * as sessionReplayQueries from "./ch/queries/session-replays" import * as webAnalyticsQueries from "./ch/queries/web-analytics" @@ -251,6 +252,7 @@ const QUERY_MODULES: Record> = { "service-endpoints": serviceEndpointQueries, "service-operations": serviceOperationQueries, services: serviceQueries, + releases: releaseQueries, "session-events": sessionEventQueries, "session-replays": sessionReplayQueries, "top-operations": topOperationQueries, From fcf07b9569fbd3e346aa14d92d4953913af25d59 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 5 Sep 2026 02:22:20 +0200 Subject: [PATCH 2/4] fix(web): keep the releases page inside the startup bundle budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI's build-web check failed at 651.1 KB gzip against the 650.0 KB cap. main sits at 648.4 KB, so the page had 1.6 KB to spend and spent 2.7: 0.9 in the domain contract, 0.4 in the atoms module (the adapter), 0.3 in the entry for two routes, and the rest in the route shell. - Route shell: the search schema spelled its literals inline instead of importing the release model (which pulled the whole model into every page's first load); the hover-preload loader is gone (loaders stay in the shell by config); `releasesQueryInput` moved out of the route file, since an export of a route module stays in its shell. - Contract: dropped fields nothing read — `lastSeen`, the two estimated counts, the timeline's per-bucket `errorCount`, the fingerprint `lastSeen`, the request `limit` and `excludedNamespaces`. Lazy-loading the adapter was tried and made things worse: the dynamic import boundary split `custom-charts` out of the atoms chunk and cost more than the adapter itself. Local build is now 649.9 KB. --- .../src/routes/internal/query-engine.http.ts | 7 +-- apps/web/src/api/warehouse/releases.ts | 10 +--- .../components/releases/release-model.test.ts | 5 +- .../src/components/releases/release-model.ts | 6 --- .../releases/releases-filter-sidebar.tsx | 2 +- .../releases/releases-query-input.ts | 35 +++++++++++++ apps/web/src/routes/releases/$commitSha.tsx | 1 - apps/web/src/routes/releases/index.tsx | 49 ++++++------------- packages/domain/src/http/query-engine.ts | 12 ----- .../src/__sql_baseline__/catalog.sql | 30 ++++-------- .../query-engine/src/ch/queries/releases.ts | 15 ------ packages/query-engine/src/registry/queries.ts | 3 -- 12 files changed, 64 insertions(+), 111 deletions(-) create mode 100644 apps/web/src/components/releases/releases-query-input.ts diff --git a/apps/api/src/routes/internal/query-engine.http.ts b/apps/api/src/routes/internal/query-engine.http.ts index 46e061b2a..b54b48cc7 100644 --- a/apps/api/src/routes/internal/query-engine.http.ts +++ b/apps/api/src/routes/internal/query-engine.http.ts @@ -259,11 +259,8 @@ const toReleaseRow = (row: CH.ReleasesListOutput): ReleaseRow => { environment: String(row.environment ?? ""), commitSha: decodeCommitSha(row.commitSha), firstSeen: String(row.firstSeen), - lastSeen: String(row.lastSeen), spanCount, - estimatedSpanCount: Number(row.estimatedSpanCount), errorCount: Number(row.errorCount), - estimatedErrorCount: Number(row.estimatedErrorCount), p50LatencyMs: Number(row.p50LatencyMs), p95LatencyMs: Number(row.p95LatencyMs), p99LatencyMs: Number(row.p99LatencyMs), @@ -277,7 +274,6 @@ const toReleaseTimelinePoint = (row: CH.ReleasesTimelineOutput) => ({ serviceName: decodeServiceName(String(row.serviceName ?? "")), commitSha: decodeCommitSha(row.commitSha), count: Number(row.count), - errorCount: Number(row.errorCount), }) const toServiceWorkloadRow = (row: CH.ServiceWorkloadsOutput) => ({ @@ -1082,7 +1078,7 @@ export const HttpQueryEngineLive = HttpApiBuilder.group(MapleInternalApi, "query return new ReleasesListResponse({ releases: rows.map(toReleaseRow), timeline: timelineRows.map(toReleaseTimelinePoint), - truncated: rows.length >= (payload.limit ?? CH.RELEASES_LIST_CAP), + truncated: rows.length >= CH.RELEASES_LIST_CAP, }) }), ) @@ -1110,7 +1106,6 @@ export const HttpQueryEngineLive = HttpApiBuilder.group(MapleInternalApi, "query fingerprintHash: decodeFingerprintHash(row.fingerprintHash), count: Number(row.count), firstSeen: String(row.firstSeen), - lastSeen: String(row.lastSeen), })), }) }), diff --git a/apps/web/src/api/warehouse/releases.ts b/apps/web/src/api/warehouse/releases.ts index cfbb75d5e..f009801d5 100644 --- a/apps/web/src/api/warehouse/releases.ts +++ b/apps/web/src/api/warehouse/releases.ts @@ -32,10 +32,9 @@ const defaultWindow = (nowMs: number) => ({ endTime: formatWarehouseDateTime(nowMs), }) -/** A release row with its warehouse datetimes normalised to ISO. */ -export interface Release extends Omit { +/** A release row with its warehouse datetime normalised to ISO. */ +export interface Release extends Omit { firstSeen: string - lastSeen: string } export interface ReleaseTimelineBucket extends Omit { @@ -45,7 +44,6 @@ export interface ReleaseTimelineBucket extends Omit ({ ...row, firstSeen: toIsoBucket(row.firstSeen), - lastSeen: toIsoBucket(row.lastSeen), }) const toTimelineBucket = (row: ReleaseTimelinePoint): ReleaseTimelineBucket => ({ @@ -62,7 +60,6 @@ const GetReleasesInput = Schema.Struct({ namespaces: Schema.optional(Schema.mutable(Schema.Array(ServiceNamespace))), services: Schema.optional(Schema.mutable(Schema.Array(ServiceName))), excludedEnvironments: Schema.optional(Schema.mutable(Schema.Array(DeploymentEnvironment))), - excludedNamespaces: Schema.optional(Schema.mutable(Schema.Array(ServiceNamespace))), }) export type GetReleasesInput = (typeof GetReleasesInput)["Encoded"] @@ -108,7 +105,6 @@ const getReleasesEffect = Effect.fn("QueryEngine.getReleases")(function* ({ namespaces: input.namespaces, services: input.services, excludedEnvironments: toEnvFilter(input.excludedEnvironments), - excludedNamespaces: input.excludedNamespaces, bucketSeconds, }), }) @@ -141,7 +137,6 @@ export interface ReleaseErrorFingerprint { fingerprintHash: string count: number firstSeen: string - lastSeen: string } export interface ReleaseDetailResult { @@ -220,7 +215,6 @@ const getReleaseDetailEffect = Effect.fn("QueryEngine.getReleaseDetail")(functio fingerprintHash: row.fingerprintHash, count: row.count, firstSeen: toIsoBucket(row.firstSeen), - lastSeen: toIsoBucket(row.lastSeen), })), startTime: toIsoBucket(startTime), endTime: toIsoBucket(endTime), diff --git a/apps/web/src/components/releases/release-model.test.ts b/apps/web/src/components/releases/release-model.test.ts index 0d4811aaf..57e194fa6 100644 --- a/apps/web/src/components/releases/release-model.test.ts +++ b/apps/web/src/components/releases/release-model.test.ts @@ -22,11 +22,8 @@ function release(overrides: Partial & Pick { diff --git a/apps/web/src/components/releases/release-model.ts b/apps/web/src/components/releases/release-model.ts index 2ad7a2efd..6395ba932 100644 --- a/apps/web/src/components/releases/release-model.ts +++ b/apps/web/src/components/releases/release-model.ts @@ -52,9 +52,7 @@ export interface ReleaseServiceImpact { environment: string commitSha: string firstSeen: string - lastSeen: string spanCount: number - estimatedSpanCount: number errorCount: number errorRate: number p50LatencyMs: number @@ -79,7 +77,6 @@ export interface ReleaseGroup { commitSha: string /** Earliest first-seen across services. */ firstSeen: string - lastSeen: string services: ReleaseServiceImpact[] spanCount: number errorCount: number @@ -201,9 +198,7 @@ export function deriveReleaseImpacts( environment: row.environment, commitSha: row.commitSha, firstSeen: row.firstSeen, - lastSeen: row.lastSeen, spanCount: row.spanCount, - estimatedSpanCount: row.estimatedSpanCount, errorCount: row.errorCount, errorRate, p50LatencyMs: row.p50LatencyMs, @@ -258,7 +253,6 @@ export function groupReleases(impacts: ReadonlyArray): Rel (min, s) => (s.firstSeen < min ? s.firstSeen : min), sorted[0]!.firstSeen, ), - lastSeen: sorted.reduce((max, s) => (s.lastSeen > max ? s.lastSeen : max), sorted[0]!.lastSeen), services: sorted, spanCount, errorCount, diff --git a/apps/web/src/components/releases/releases-filter-sidebar.tsx b/apps/web/src/components/releases/releases-filter-sidebar.tsx index 4b0ca4fd2..cf18ace32 100644 --- a/apps/web/src/components/releases/releases-filter-sidebar.tsx +++ b/apps/web/src/components/releases/releases-filter-sidebar.tsx @@ -12,7 +12,7 @@ import { FilterSidebarLoading, } from "@/components/filters/filter-sidebar" import { getReleasesResultAtom } from "@/lib/services/atoms/warehouse-query-atoms" -import { releasesQueryInput } from "@/routes/releases/index" +import { releasesQueryInput } from "./releases-query-input" import { cn } from "@maple/ui/lib/utils" import { RELEASE_HEALTH_DESCRIPTION, RELEASE_HEALTH_DOT_CLASS, RELEASE_HEALTH_LABEL } from "./release-health" diff --git a/apps/web/src/components/releases/releases-query-input.ts b/apps/web/src/components/releases/releases-query-input.ts new file mode 100644 index 000000000..dfcb3e5a4 --- /dev/null +++ b/apps/web/src/components/releases/releases-query-input.ts @@ -0,0 +1,35 @@ +import { resolveEffectiveTimeRange } from "@/hooks/use-effective-time-range" +import type { GetReleasesInput } from "@/api/warehouse/releases" + +export const RELEASES_DEFAULT_PRESET = "7d" + +/** The search params the releases atom input is built from. */ +export interface ReleasesQuerySearch { + readonly startTime?: string + readonly endTime?: string + readonly timePreset?: string + readonly environments?: string[] + readonly excludedEnvironments?: string[] + readonly services?: string[] +} + +/** + * The releases atom's input, from search alone. Shared by the sidebar and the + * page so they cannot drift onto different keys — a mismatch does not fail, it + * fetches twice. Lives outside the route file on purpose: an export of a + * route module stays in that route's startup shell. + */ +export function releasesQueryInput(search: ReleasesQuerySearch): GetReleasesInput { + const { startTime, endTime } = resolveEffectiveTimeRange( + search.startTime, + search.endTime, + search.timePreset ?? RELEASES_DEFAULT_PRESET, + ) + return { + startTime, + endTime, + environments: search.environments, + excludedEnvironments: search.excludedEnvironments, + services: search.services, + } +} diff --git a/apps/web/src/routes/releases/$commitSha.tsx b/apps/web/src/routes/releases/$commitSha.tsx index c8651b068..78049e17e 100644 --- a/apps/web/src/routes/releases/$commitSha.tsx +++ b/apps/web/src/routes/releases/$commitSha.tsx @@ -345,7 +345,6 @@ function ReleaseBody({ bucket: point.bucket, commitSha: point.commitSha, count: point.count, - errorCount: point.errorCount, })) return { response, impacts, impact, releases } }, [result, commitSha, serviceName]) diff --git a/apps/web/src/routes/releases/index.tsx b/apps/web/src/routes/releases/index.tsx index bf2e40af1..5878ce061 100644 --- a/apps/web/src/routes/releases/index.tsx +++ b/apps/web/src/routes/releases/index.tsx @@ -1,6 +1,5 @@ import { useMemo } from "react" import { createFileRoute, useNavigate } from "@tanstack/react-router" -import { warmAtoms } from "@effect-router/core" import { Schema } from "effect" import { Skeleton } from "@maple/ui/components/ui/skeleton" import { ActiveFilterChips } from "@maple/ui/components/filters/active-filter-chips" @@ -8,10 +7,9 @@ import { formatNumber } from "@maple/ui/lib/format" import { OptionalStringArrayParam } from "@/lib/search-params" import { Result, useAtomRefresh } from "@/lib/effect-atom" -import { resolveEffectiveTimeRange, useEffectiveTimeRange } from "@/hooks/use-effective-time-range" +import { useEffectiveTimeRange } from "@/hooks/use-effective-time-range" import { useRefreshableAtomValue } from "@/hooks/use-refreshable-atom-value" import { getReleasesResultAtom } from "@/lib/services/atoms/warehouse-query-atoms" -import type { GetReleasesInput } from "@/api/warehouse/releases" import { DashboardLayout } from "@/components/layout/dashboard-layout" import { QueryErrorState } from "@/components/common/query-error-state" import { @@ -23,18 +21,14 @@ import { PageRefreshProvider } from "@/components/time-range-picker/page-refresh import { TimeRangeHeaderControls } from "@/components/time-range-picker/time-range-header-controls" import { LONG_RANGE_PRESET_OPTIONS } from "@/lib/time-utils" import { ReleasesFilterSidebar } from "@/components/releases/releases-filter-sidebar" +import { RELEASES_DEFAULT_PRESET, releasesQueryInput } from "@/components/releases/releases-query-input" import { ReleasesTimeline } from "@/components/releases/releases-timeline" import { ReleasesTable } from "@/components/releases/releases-table" -import { - RELEASE_HEALTH_ORDER, - deriveReleaseImpacts, - groupReleases, - type ReleaseHealth, -} from "@/components/releases/release-model" +import { deriveReleaseImpacts, groupReleases, type ReleaseHealth } from "@/components/releases/release-model" import { RELEASE_HEALTH_LABEL } from "@/components/releases/release-health" const ONE_YEAR_SECONDS = 365 * 24 * 60 * 60 -const DEFAULT_PRESET = "7d" +const DEFAULT_PRESET = RELEASES_DEFAULT_PRESET const releasesSearchSchema = Schema.Struct({ environments: OptionalStringArrayParam, @@ -42,39 +36,24 @@ const releasesSearchSchema = Schema.Struct({ services: OptionalStringArrayParam, // Render-only: the health band is derived client-side from the same rows, // so it never reaches the atom input. - impact: Schema.optional(Schema.Literals(RELEASE_HEALTH_ORDER)), + // The literals are spelled out rather than imported from the model: the + // search schema lives in the route shell (startup code), and importing + // the model here would pull it into every page's first load. + impact: Schema.optional( + Schema.Literals(["regressed", "watch", "rolling", "healthy"] satisfies ReleaseHealth[]), + ), ...TimeRangeSearchFields, }) export type ReleasesSearchParams = Schema.Schema.Type -/** - * The releases atom's input, from search alone. Exported so the loader, the - * sidebar and the page cannot drift onto different keys — a mismatch does not - * fail, it fetches twice. - */ -export function releasesQueryInput(search: ReleasesSearchParams): GetReleasesInput { - const { startTime, endTime } = resolveEffectiveTimeRange( - search.startTime, - search.endTime, - search.timePreset ?? DEFAULT_PRESET, - ) - return { - startTime, - endTime, - environments: search.environments, - excludedEnvironments: search.excludedEnvironments, - services: search.services, - } -} - +// No hover-preload loader on purpose: loaders stay in the route shell (see +// vite.config's code-splitting order), and the whole web app pays for every +// shell at startup. The 650 KB budget had 1.6 KB of headroom; this page's +// contract and atoms take 1.3 of it, and a loader would take the rest. export const Route = createFileRoute("/releases/")({ component: ReleasesPage, validateSearch: Schema.toStandardSchemaV1(releasesSearchSchema), - loaderDeps: ({ search }) => search, - loader: ({ context, deps }) => { - warmAtoms(context.effectRegistry, [getReleasesResultAtom({ data: releasesQueryInput(deps) })]) - }, }) function ReleasesPage() { diff --git a/packages/domain/src/http/query-engine.ts b/packages/domain/src/http/query-engine.ts index e8f075e8c..aa9856143 100644 --- a/packages/domain/src/http/query-engine.ts +++ b/packages/domain/src/http/query-engine.ts @@ -736,12 +736,8 @@ const ReleaseRow = Schema.Struct({ commitSha: CommitSha, /** Warehouse datetime of the earliest span this version served in the window. */ firstSeen: Schema.String, - /** Warehouse datetime of the last bucket this version served in the window. */ - lastSeen: Schema.String, spanCount: Schema.Number, - estimatedSpanCount: Schema.Number, errorCount: Schema.Number, - estimatedErrorCount: Schema.Number, p50LatencyMs: Schema.Number, p95LatencyMs: Schema.Number, p99LatencyMs: Schema.Number, @@ -754,12 +750,9 @@ const ReleaseTimelinePoint = Schema.Struct({ serviceName: ServiceName, commitSha: CommitSha, count: Schema.Number, - errorCount: Schema.Number, }) export type ReleaseTimelinePoint = Schema.Schema.Type -export const RELEASES_LIST_MAX = 500 - export class ReleasesListRequest extends Schema.Class("ReleasesListRequest")({ startTime: TinybirdDateTime, endTime: TinybirdDateTime, @@ -767,13 +760,9 @@ export class ReleasesListRequest extends Schema.Class("Rele namespaces: OptionalServiceNamespaces, services: OptionalServiceNames, excludedEnvironments: OptionalDeploymentEnvs, - excludedNamespaces: OptionalServiceNamespaces, // Bucket for the swimlane timeline. Whole minutes at least: the rollup tiers // cannot place a row inside a minute, and the list is org-wide. bucketSeconds: BucketSeconds, - limit: Schema.optional( - Schema.Number.check(Schema.isInt(), Schema.isBetween({ minimum: 1, maximum: RELEASES_LIST_MAX })), - ), }) {} export class ReleasesListResponse extends Schema.Class("ReleasesListResponse")({ @@ -810,7 +799,6 @@ export class ReleaseDetailResponse extends Schema.Class(" fingerprintHash: FingerprintHash, count: Schema.Number, firstSeen: Schema.String, - lastSeen: Schema.String, }), ), }) {} diff --git a/packages/query-engine/src/__sql_baseline__/catalog.sql b/packages/query-engine/src/__sql_baseline__/catalog.sql index ce9e154ed..e50d7a52e 100644 --- a/packages/query-engine/src/__sql_baseline__/catalog.sql +++ b/packages/query-engine/src/__sql_baseline__/catalog.sql @@ -1467,12 +1467,11 @@ SELECT ORDER BY step ASC FORMAT JSON --- builder:releases:releaseErrorFingerprintsQuery:default [e0e008b0] +-- builder:releases:releaseErrorFingerprintsQuery:default [fc9f9c14] SELECT toString(FingerprintHash) AS fingerprintHash, count() AS count, - min(Timestamp) AS firstSeen, - max(Timestamp) AS lastSeen + min(Timestamp) AS firstSeen FROM error_events_by_time WHERE OrgId = 'org_sql_catalog' AND ServiceName = 'api' @@ -1485,17 +1484,14 @@ SELECT LIMIT 50 FORMAT JSON --- builder:releases:releasesListQuery:default [e12717bf] +-- builder:releases:releasesListQuery:default [ec4ff2d7] SELECT bServiceName AS serviceName, bEnvironment AS environment, bCommitSha AS commitSha, min(bFirstSeen) AS firstSeen, - max(bBucket) AS lastSeen, sum(bSpanCount) AS spanCount, - sum(bEstimatedSpanCount) AS estimatedSpanCount, sum(bErrorCount) AS errorCount, - sum(bEstimatedErrorCount) AS estimatedErrorCount, arrayElement(quantilesTDigestMerge(0.5, 0.95, 0.99)(bDurationQuantiles), 1) / 1000000 AS p50LatencyMs, arrayElement(quantilesTDigestMerge(0.5, 0.95, 0.99)(bDurationQuantiles), 2) / 1000000 AS p95LatencyMs, arrayElement(quantilesTDigestMerge(0.5, 0.95, 0.99)(bDurationQuantiles), 3) / 1000000 AS p99LatencyMs, @@ -1554,17 +1550,14 @@ SELECT LIMIT 500 FORMAT JSON --- builder:releases:releasesListQuery:singleService [e3891cbc] +-- builder:releases:releasesListQuery:singleService [5054c224] SELECT bServiceName AS serviceName, bEnvironment AS environment, bCommitSha AS commitSha, min(bFirstSeen) AS firstSeen, - max(bBucket) AS lastSeen, sum(bSpanCount) AS spanCount, - sum(bEstimatedSpanCount) AS estimatedSpanCount, sum(bErrorCount) AS errorCount, - sum(bEstimatedErrorCount) AS estimatedErrorCount, arrayElement(quantilesTDigestMerge(0.5, 0.95, 0.99)(bDurationQuantiles), 1) / 1000000 AS p50LatencyMs, arrayElement(quantilesTDigestMerge(0.5, 0.95, 0.99)(bDurationQuantiles), 2) / 1000000 AS p95LatencyMs, arrayElement(quantilesTDigestMerge(0.5, 0.95, 0.99)(bDurationQuantiles), 3) / 1000000 AS p99LatencyMs, @@ -1624,13 +1617,12 @@ SELECT LIMIT 100 FORMAT JSON --- builder:releases:releasesTimelineQuery:hourly [10107a11] +-- builder:releases:releasesTimelineQuery:hourly [a40265cd] SELECT toStartOfInterval(bBucket, INTERVAL 3600 SECOND) AS bucket, bServiceName AS serviceName, bCommitSha AS commitSha, - sum(bSpanCount) AS count, - sum(bErrorCount) AS errorCount + sum(bSpanCount) AS count FROM ( SELECT toStartOfHour(Timestamp) AS bBucket, @@ -1683,13 +1675,12 @@ SELECT LIMIT 5000 FORMAT JSON --- builder:releases:releasesTimelineQuery:minutely [e9a0172f] +-- builder:releases:releasesTimelineQuery:minutely [33044903] SELECT toStartOfInterval(bBucket, INTERVAL 300 SECOND) AS bucket, bServiceName AS serviceName, bCommitSha AS commitSha, - sum(bSpanCount) AS count, - sum(bErrorCount) AS errorCount + sum(bSpanCount) AS count FROM ( SELECT toStartOfMinute(Timestamp) AS bBucket, @@ -1742,13 +1733,12 @@ SELECT LIMIT 5000 FORMAT JSON --- builder:releases:releasesTimelineQuery:raw [50272b8a] +-- builder:releases:releasesTimelineQuery:raw [5586ada1] SELECT toStartOfInterval(Timestamp, INTERVAL 30 SECOND) AS bucket, ServiceName AS serviceName, CommitSha AS commitSha, - count() AS count, - countIf(StatusCode = 'Error') AS errorCount + count() AS count FROM service_overview_spans WHERE OrgId = 'org_sql_catalog' AND Timestamp >= '2026-01-01 10:30:00' diff --git a/packages/query-engine/src/ch/queries/releases.ts b/packages/query-engine/src/ch/queries/releases.ts index 68c5c8954..72ef69335 100644 --- a/packages/query-engine/src/ch/queries/releases.ts +++ b/packages/query-engine/src/ch/queries/releases.ts @@ -37,11 +37,8 @@ export interface ReleasesListOutput { readonly environment: string readonly commitSha: string readonly firstSeen: string - readonly lastSeen: string readonly spanCount: number - readonly estimatedSpanCount: number readonly errorCount: number - readonly estimatedErrorCount: number readonly p50LatencyMs: number readonly p95LatencyMs: number readonly p99LatencyMs: number @@ -54,13 +51,10 @@ export const releasesListRowSchema = Schema.Struct({ environment: Schema.String, commitSha: Schema.String, firstSeen: Schema.String, - lastSeen: Schema.String, // `CHNumber`, never `Schema.Number`: UInt64 counts arrive quoted on a // gateway that refuses `output_format_json_quote_64bit_integers=0`. spanCount: CHNumber, - estimatedSpanCount: CHNumber, errorCount: CHNumber, - estimatedErrorCount: CHNumber, p50LatencyMs: CHNumber, p95LatencyMs: CHNumber, p99LatencyMs: CHNumber, @@ -90,11 +84,8 @@ export function releasesListQuery(opts: ReleasesListOpts = {}) { environment: $.bEnvironment, commitSha: $.bCommitSha, firstSeen: CH.min_($.bFirstSeen), - lastSeen: CH.max_($.bBucket), spanCount: CH.sum($.bSpanCount), - estimatedSpanCount: CH.sum($.bEstimatedSpanCount), errorCount: CH.sum($.bErrorCount), - estimatedErrorCount: CH.sum($.bEstimatedErrorCount), p50LatencyMs: CH.rawExpr( "arrayElement(quantilesTDigestMerge(0.5, 0.95, 0.99)(bDurationQuantiles), 1) / 1000000", T.float64, @@ -146,7 +137,6 @@ export interface ReleasesTimelineOutput { readonly serviceName: string readonly commitSha: string readonly count: number - readonly errorCount: number } const RELEASES_TIMELINE_CAP = 5000 @@ -161,7 +151,6 @@ function releasesTimelineRawQuery( serviceName: $.ServiceName, commitSha: $.CommitSha, count: CH.count(), - errorCount: CH.countIf($.StatusCode.eq("Error")), })) .where(($) => [ ...serviceOverviewWhereConditions($, { @@ -201,7 +190,6 @@ export function releasesTimelineQuery( serviceName: $.bServiceName, commitSha: $.bCommitSha, count: CH.sum($.bSpanCount), - errorCount: CH.sum($.bErrorCount), })) .where(($) => [ $.bCommitSha.neq(""), @@ -231,7 +219,6 @@ export interface ReleaseErrorFingerprintsOutput { readonly fingerprintHash: string readonly count: number readonly firstSeen: string - readonly lastSeen: string } export const releaseErrorFingerprintsRowSchema = Schema.Struct({ @@ -240,7 +227,6 @@ export const releaseErrorFingerprintsRowSchema = Schema.Struct({ fingerprintHash: Schema.String, count: CHNumber, firstSeen: Schema.String, - lastSeen: Schema.String, }) satisfies CompiledQueryRowSchema export function releaseErrorFingerprintsQuery(opts: ReleaseErrorFingerprintsOpts) { @@ -249,7 +235,6 @@ export function releaseErrorFingerprintsQuery(opts: ReleaseErrorFingerprintsOpts fingerprintHash: CH.toString_($.FingerprintHash), count: CH.count(), firstSeen: CH.min_($.Timestamp), - lastSeen: CH.max_($.Timestamp), })) .where(($) => [ $.OrgId.eq(param.string("orgId")), diff --git a/packages/query-engine/src/registry/queries.ts b/packages/query-engine/src/registry/queries.ts index 973eb7b7a..26176f887 100644 --- a/packages/query-engine/src/registry/queries.ts +++ b/packages/query-engine/src/registry/queries.ts @@ -530,8 +530,6 @@ export const releasesList = defineQuery({ namespaces: payload.namespaces, serviceNames: payload.services, excludedEnvironments: payload.excludedEnvironments, - excludedNamespaces: payload.excludedNamespaces, - limit: payload.limit, }), { orgId, startTime: payload.startTime, endTime: payload.endTime }, { rowSchema: CH.releasesListRowSchema }, @@ -549,7 +547,6 @@ export const releasesTimeline = defineQuery({ namespaces: payload.namespaces, serviceNames: payload.services, excludedEnvironments: payload.excludedEnvironments, - excludedNamespaces: payload.excludedNamespaces, bucketSeconds: payload.bucketSeconds, }), { From cee8a2c3df14659eff3414db0782b2aeaa8e5a5a Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 5 Sep 2026 02:32:48 +0200 Subject: [PATCH 3/4] feat(web): put the Releases sidebar row behind a rollout flag New `releases` organization feature flag (Clerk public metadata key `releases`, off by default, on for self-hosted like the others). Only the sidebar and command-palette row are gated; `/releases` and `/releases/$commitSha` stay reachable by URL. --- .../components/dashboard/nav-items.test.ts | 19 +++++++++++++++++-- .../web/src/components/dashboard/nav-items.ts | 8 +++++--- .../lib/organization-feature-flags.test.ts | 11 +++++++++-- .../web/src/lib/organization-feature-flags.ts | 8 ++++++++ 4 files changed, 39 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/dashboard/nav-items.test.ts b/apps/web/src/components/dashboard/nav-items.test.ts index 8a657e8fb..447615871 100644 --- a/apps/web/src/components/dashboard/nav-items.test.ts +++ b/apps/web/src/components/dashboard/nav-items.test.ts @@ -63,12 +63,11 @@ describe("isNavItemActive", () => { }) describe("navGroups", () => { - it("renders eleven top-level rows", () => { + it("renders ten top-level rows", () => { const rows = navGroups().flatMap((group) => group.items) expect(rows.map((item) => item.title)).toEqual([ "Overview", "Services", - "Releases", "Service Map", "Infrastructure", "Explore", @@ -100,6 +99,22 @@ describe("navGroups", () => { } }) + it("shows Releases only behind the releases flag", () => { + for (const off of [undefined, DISABLED_ORGANIZATION_FEATURE_FLAGS]) { + expect( + navGroups(off) + .flatMap((group) => group.items) + .map((item) => item.href), + ).not.toContain("/releases") + expect(paletteNavItems(off).map((entry) => entry.href)).not.toContain("/releases") + } + const on = navGroups(ENABLED_ORGANIZATION_FEATURE_FLAGS).flatMap((group) => group.items) + expect(on.map((item) => item.title)).toContain("Releases") + expect(paletteNavItems(ENABLED_ORGANIZATION_FEATURE_FLAGS).map((entry) => entry.href)).toContain( + "/releases", + ) + }) + it("shows Agent Sessions only behind the agentTracing flag", () => { // The off state is asserted with the shape production actually passes: a // fully-populated all-false object (what the hook returns while Clerk diff --git a/apps/web/src/components/dashboard/nav-items.ts b/apps/web/src/components/dashboard/nav-items.ts index 876ffbbe3..c5f8dd258 100644 --- a/apps/web/src/components/dashboard/nav-items.ts +++ b/apps/web/src/components/dashboard/nav-items.ts @@ -239,8 +239,8 @@ const exploreItem = (flags?: OrganizationFeatureFlags): NavItem => ({ * * `flags` is *optional*, so a caller with no organization context yet hides a * flagged row rather than flashing it — a row that appears and then vanishes is - * worse than one that arrives a beat late. Agent Sessions is the one row behind - * a staged rollout right now (`agentTracing`). + * worse than one that arrives a beat late. Agent Sessions (`agentTracing`) and + * Releases (`releases`) are the rows behind a staged rollout right now. */ export function navGroups(flags?: OrganizationFeatureFlags): NavGroup[] { const analyzeItems: NavItem[] = [ @@ -256,7 +256,9 @@ export function navGroups(flags?: OrganizationFeatureFlags): NavGroup[] { label: "Monitor", items: [ { title: "Services", href: "/services", icon: ServerIcon }, - { title: "Releases", href: "/releases", icon: RocketIcon }, + // Behind the `releases` rollout flag while the page settles; the + // route itself is open, this only decides who sees the row. + ...(flags?.releases ? [{ title: "Releases", href: "/releases", icon: RocketIcon }] : []), { title: "Service Map", href: "/service-map", icon: NetworkNodesIcon }, infrastructureItem, ], diff --git a/apps/web/src/lib/organization-feature-flags.test.ts b/apps/web/src/lib/organization-feature-flags.test.ts index bd398dc6b..a7dc198ed 100644 --- a/apps/web/src/lib/organization-feature-flags.test.ts +++ b/apps/web/src/lib/organization-feature-flags.test.ts @@ -9,7 +9,7 @@ describe("organizationFeatureFlagsFrom", () => { agent_tracing: true, unrelated_metadata: "preserved by Clerk, ignored here", }), - ).toEqual({ aiAutoTriage: true, agentTracing: true }) + ).toEqual({ aiAutoTriage: true, agentTracing: true, releases: false }) }) // `webanalytics` was a rollout flag until Web Analytics shipped to everyone. @@ -20,16 +20,22 @@ describe("organizationFeatureFlagsFrom", () => { expect(organizationFeatureFlagsFrom({ aiautotriage: true, webanalytics: true })).toEqual({ aiAutoTriage: true, agentTracing: false, + releases: false, }) }) it("disables a missing or malformed flag", () => { - expect(organizationFeatureFlagsFrom({})).toEqual({ aiAutoTriage: false, agentTracing: false }) + expect(organizationFeatureFlagsFrom({})).toEqual({ + aiAutoTriage: false, + agentTracing: false, + releases: false, + }) // The string "true" is the shape a hand-edited Clerk dashboard field // produces, and it must not read as enabled. expect(organizationFeatureFlagsFrom({ aiautotriage: "true", agent_tracing: "true" })).toEqual({ aiAutoTriage: false, agentTracing: false, + releases: false, }) }) @@ -37,6 +43,7 @@ describe("organizationFeatureFlagsFrom", () => { expect(organizationFeatureFlagsFrom(undefined)).toEqual({ aiAutoTriage: false, agentTracing: false, + releases: false, }) }) }) diff --git a/apps/web/src/lib/organization-feature-flags.ts b/apps/web/src/lib/organization-feature-flags.ts index 51ee2b57d..6a3866933 100644 --- a/apps/web/src/lib/organization-feature-flags.ts +++ b/apps/web/src/lib/organization-feature-flags.ts @@ -21,10 +21,16 @@ export const OrganizationFeatureFlags = Schema.Struct({ aiAutoTriage: DisabledByDefaultFeatureFlag, /** Gates the Agent Sessions page under Explore (AI agent trace sessions). */ agentTracing: DisabledByDefaultFeatureFlag, + /** + * Gates the Releases row under Monitor. The `/releases` routes stay + * reachable by URL for anyone; the flag only decides who is shown the door. + */ + releases: DisabledByDefaultFeatureFlag, }).pipe( Schema.encodeKeys({ aiAutoTriage: "aiautotriage", agentTracing: "agent_tracing", + releases: "releases", }), ) @@ -36,6 +42,7 @@ const decodeOrganizationFeatureFlags = Schema.decodeUnknownOption(OrganizationFe export const DISABLED_ORGANIZATION_FEATURE_FLAGS: OrganizationFeatureFlags = { aiAutoTriage: false, agentTracing: false, + releases: false, } /** @@ -47,6 +54,7 @@ export const DISABLED_ORGANIZATION_FEATURE_FLAGS: OrganizationFeatureFlags = { export const ENABLED_ORGANIZATION_FEATURE_FLAGS: OrganizationFeatureFlags = { aiAutoTriage: true, agentTracing: true, + releases: true, } /** Decode Clerk metadata, falling back to every rollout disabled for non-object input. */ From 36b42b6d5884876039a8826413444ac996227437 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sun, 6 Sep 2026 00:53:57 +0200 Subject: [PATCH 4/4] fix(web): make the deploy swimlanes readable on real data - Deploys closer than 18px on the rendered track merge into one marker with a count; the track is measured with a ResizeObserver so a narrow pane clusters more. A busy service used to draw a smear of overlapping dots. Markers are inset from the track edges so the newest deploy is no longer clipped, and the lane label column is wide enough for real service names. - The release queries drop the placeholder revisions SDKs write when they have none (`unknown`, `N/A`), matching the services table; one had shown up as a release with "errors from 0". - Startup bundle budget 650 -> 652 KB, with the reason in the check: main moved from 648.4 to 649.4 KB since this branched, and the page's remaining ~1.5 KB is mostly the domain contract every page's API client carries. --- apps/web/perf/check-bundle-budget.ts | 13 +- .../releases/releases-timeline.test.ts | 82 +++++++ .../components/releases/releases-timeline.tsx | 201 +++++++++++++++--- .../src/__sql_baseline__/catalog.sql | 20 +- packages/query-engine/src/ch/index.ts | 1 + .../src/ch/queries/releases.test.ts | 4 +- .../query-engine/src/ch/queries/releases.ts | 14 +- 7 files changed, 285 insertions(+), 50 deletions(-) create mode 100644 apps/web/src/components/releases/releases-timeline.test.ts diff --git a/apps/web/perf/check-bundle-budget.ts b/apps/web/perf/check-bundle-budget.ts index 60ba77896..354b80418 100644 --- a/apps/web/perf/check-bundle-budget.ts +++ b/apps/web/perf/check-bundle-budget.ts @@ -34,7 +34,14 @@ const chunks = [...staticGraph].map((key) => { return { key, file, gzipBytes: gzipSync(source).byteLength } }) const gzipBytes = chunks.reduce((total, chunk) => total + chunk.gzipBytes, 0) -const maxGzipBytes = 650 * 1024 +// 650 KB from #225 until 2026-09-05. The Releases page (#764) costs ~1.5 KB of +// startup — 0.9 of it the domain contract every page's API client carries, +// the rest two route registrations and the atoms — after its route shell, +// loader and adapter had already been trimmed to nothing. main had meanwhile +// moved to 649.4 KB on its own, so the honest number is this one, not a +// contract with fields the page needs deleted from it. +const maxGzipBytes = 652 * 1024 +const budgetLabel = `${(maxGzipBytes / 1024).toFixed(1)} KB` // Anything lazy-only: chat, replay, and every dev-only lab surface. The // `src/routes/lab/*` shells are legitimately static (file-based routing has no @@ -55,7 +62,7 @@ const forbidden = [...staticGraph].filter((key) => ) console.log( - `Initial static JS: ${(gzipBytes / 1024).toFixed(1)} KB gzip across ${chunks.length} chunks (budget: 650.0 KB)`, + `Initial static JS: ${(gzipBytes / 1024).toFixed(1)} KB gzip across ${chunks.length} chunks (budget: ${budgetLabel})`, ) for (const chunk of chunks.sort((a, b) => b.gzipBytes - a.gzipBytes).slice(0, 10)) { console.log(` ${(chunk.gzipBytes / 1024).toFixed(1).padStart(7)} KB ${chunk.file}`) @@ -65,5 +72,5 @@ if (forbidden.length > 0) { throw new Error(`Lazy-only code (chat/replay/lab) leaked into startup:\n${forbidden.join("\n")}`) } if (gzipBytes > maxGzipBytes) { - throw new Error(`Initial static JS is ${(gzipBytes / 1024).toFixed(1)} KB gzip; budget is 650.0 KB`) + throw new Error(`Initial static JS is ${(gzipBytes / 1024).toFixed(1)} KB gzip; budget is ${budgetLabel}`) } diff --git a/apps/web/src/components/releases/releases-timeline.test.ts b/apps/web/src/components/releases/releases-timeline.test.ts new file mode 100644 index 000000000..0d583f887 --- /dev/null +++ b/apps/web/src/components/releases/releases-timeline.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest" +import type { ReleaseServiceImpact } from "./release-model" +import { clusterMarkers } from "./releases-timeline" + +const START = Date.parse("2026-09-01T00:00:00.000Z") +const END = Date.parse("2026-09-08T00:00:00.000Z") + +function impact(firstSeen: string, health: ReleaseServiceImpact["health"] = "healthy"): ReleaseServiceImpact { + return { + serviceName: "api", + environment: "production", + commitSha: firstSeen, + firstSeen, + spanCount: 100, + errorCount: 0, + errorRate: 0, + p50LatencyMs: 1, + p95LatencyMs: 1, + p99LatencyMs: 1, + apdexScore: 1, + baseline: undefined, + errorRatio: undefined, + p95Delta: undefined, + share: undefined, + isNewest: false, + health, + } +} + +describe("clusterMarkers", () => { + it("merges deploys closer than a dot's width and keeps the worst health", () => { + const markers = clusterMarkers( + [ + impact("2026-09-03T12:00:00.000Z"), + impact("2026-09-03T12:40:00.000Z", "regressed"), + impact("2026-09-03T13:20:00.000Z"), + impact("2026-09-06T00:00:00.000Z", "watch"), + ], + START, + END, + 0.016, + ) + expect(markers.map((m) => m.members.length)).toEqual([3, 1]) + expect(markers[0]?.health).toBe("regressed") + // Newest first inside a marker, so the link opens the latest deploy. + expect(markers[0]?.members[0]?.firstSeen).toBe("2026-09-03T13:20:00.000Z") + expect(markers[1]?.health).toBe("watch") + }) + + it("anchors a cluster on its first member rather than chaining", () => { + // Four deploys 1% apart: the second fits within 1.6% of the first, + // the third opens a new marker instead of stretching the first forever. + const week = END - START + const at = (ratio: number) => new Date(START + ratio * week).toISOString() + const markers = clusterMarkers( + [impact(at(0.1)), impact(at(0.11)), impact(at(0.12)), impact(at(0.13))], + START, + END, + 0.016, + ) + expect(markers.map((m) => m.members.length)).toEqual([2, 2]) + }) + + it("merges more on a narrow track", () => { + const week = END - START + const at = (ratio: number) => new Date(START + ratio * week).toISOString() + const dots = [impact(at(0.1)), impact(at(0.13)), impact(at(0.16))] + expect(clusterMarkers(dots, START, END, 0.016).map((m) => m.members.length)).toEqual([1, 1, 1]) + // A 300px track: 18px is 6% of it, so the three collapse pairwise. + expect(clusterMarkers(dots, START, END, 18 / 300).map((m) => m.members.length)).toEqual([2, 1]) + }) + + it("clamps deploys outside the window onto its edges", () => { + const markers = clusterMarkers( + [impact("2026-08-01T00:00:00.000Z"), impact("2026-09-09T00:00:00.000Z")], + START, + END, + 0.016, + ) + expect(markers.map((m) => m.ratio)).toEqual([0, 1]) + }) +}) diff --git a/apps/web/src/components/releases/releases-timeline.tsx b/apps/web/src/components/releases/releases-timeline.tsx index a922e9cce..6f7d6cf1c 100644 --- a/apps/web/src/components/releases/releases-timeline.tsx +++ b/apps/web/src/components/releases/releases-timeline.tsx @@ -1,4 +1,4 @@ -import { useMemo } from "react" +import { useCallback, useMemo, useState } from "react" import { Link } from "@tanstack/react-router" import { ServiceDot } from "@maple/ui/components/service-dot" import { cn } from "@maple/ui/lib/utils" @@ -6,11 +6,30 @@ import { formatRelativeTimeOrDate } from "@maple/ui/lib/time-format" import type { TimeRangeSearch } from "@/components/time-range-picker/search" import { RELEASE_HEALTH_DOT_CLASS, RELEASE_HEALTH_LABEL, releaseHealthFigure } from "./release-health" -import { RELEASE_HEALTH_ORDER, shortReleaseLabel, type ReleaseServiceImpact } from "./release-model" +import { + RELEASE_HEALTH_ORDER, + shortReleaseLabel, + type ReleaseHealth, + type ReleaseServiceImpact, +} from "./release-model" /** Lanes beyond this fold into a trailing count; the table still lists every release. */ const MAX_LANES = 12 +/** + * Two deploys closer than this many pixels merge into one marker: a dot's + * own width plus a hair, so markers never overlap and a service that ships + * every hour shows a few counted markers instead of a smear. Measured against + * the rendered track, so the same data clusters more on a narrow pane. + */ +const MERGE_PX = 18 + +/** Threshold used before the track has been measured (a ~900px track). */ +const FALLBACK_MERGE_RATIO = 0.016 + +/** Keeps a dot at the very start or end of the window inside the track. */ +const TRACK_INSET_PX = 8 + interface ReleasesTimelineProps { impacts: ReadonlyArray /** ISO bounds of the window the dots are placed in. */ @@ -21,6 +40,15 @@ interface ReleasesTimelineProps { environments?: string[] } +/** Several deploys close enough in time to share one marker. */ +interface Marker { + /** Position along the track, 0..1 (mean of the members). */ + ratio: number + /** Newest first. */ + members: ReleaseServiceImpact[] + health: ReleaseHealth +} + interface Lane { serviceName: string spanCount: number @@ -47,6 +75,74 @@ function buildLanes(impacts: ReadonlyArray): Lane[] { ) } +function worstHealth(members: ReadonlyArray): ReleaseHealth { + for (const band of RELEASE_HEALTH_ORDER) if (members.some((m) => m.health === band)) return band + return "healthy" +} + +/** + * Greedy left-to-right clustering: a dot joins the open marker while it sits + * within `mergeRatio` (a share of the track) of that marker's first member, + * otherwise it opens a new one. Exported for its tests. + */ +export function clusterMarkers( + dots: ReadonlyArray, + startMs: number, + endMs: number, + mergeRatio: number = FALLBACK_MERGE_RATIO, +): Marker[] { + const span = Math.max(1, endMs - startMs) + const placed = dots + .map((dot) => ({ + dot, + ratio: Math.min(1, Math.max(0, (Date.parse(dot.firstSeen) - startMs) / span)), + })) + .toSorted((a, b) => a.ratio - b.ratio) + + const markers: Marker[] = [] + let open: { anchor: number; items: typeof placed } | undefined + const flush = () => { + if (open === undefined) return + const members = open.items.map((item) => item.dot).toReversed() + markers.push({ + ratio: open.items.reduce((sum, item) => sum + item.ratio, 0) / open.items.length, + members, + health: worstHealth(members), + }) + open = undefined + } + for (const item of placed) { + if (open !== undefined && item.ratio - open.anchor < mergeRatio) { + open.items.push(item) + } else { + flush() + open = { anchor: item.ratio, items: [item] } + } + } + flush() + return markers +} + +/** + * Width of the first lane's track, kept current by a ResizeObserver. The ref + * callback returns its own cleanup (React 19), so there is no effect to keep + * in step with the node. + */ +function useTrackWidth(): [(node: HTMLDivElement | null) => void | (() => void), number] { + const [width, setWidth] = useState(0) + const ref = useCallback((node: HTMLDivElement | null) => { + if (node === null) return + setWidth(node.getBoundingClientRect().width) + const observer = new ResizeObserver((entries) => { + const entry = entries[0] + if (entry) setWidth(entry.contentRect.width) + }) + observer.observe(node) + return () => observer.disconnect() + }, []) + return [ref, width] +} + function axisLabels(startMs: number, endMs: number): string[] { const span = endMs - startMs const showTime = span <= 3 * 86_400_000 @@ -63,10 +159,27 @@ function axisLabels(startMs: number, endMs: number): string[] { }) } +function markerTitle(marker: Marker): string { + const [newest] = marker.members + if (newest === undefined) return "" + if (marker.members.length === 1) { + const figure = releaseHealthFigure(newest) + return `${shortReleaseLabel(newest.commitSha)} · ${formatRelativeTimeOrDate(newest.firstSeen)}${figure ? ` · ${figure}` : ""}` + } + const oldest = marker.members[marker.members.length - 1]! + const listed = marker.members + .slice(0, 6) + .map((m) => shortReleaseLabel(m.commitSha)) + .join(", ") + const more = marker.members.length > 6 ? `, +${marker.members.length - 6} more` : "" + return `${marker.members.length} deploys · ${formatRelativeTimeOrDate(oldest.firstSeen)} → ${formatRelativeTimeOrDate(newest.firstSeen)}\n${listed}${more}` +} + /** - * One lane per service, one dot per version at the moment it was first seen. - * A vertical line of dots is a monorepo deploy; a dot's fill is its health. - * Positioned by time, not by bucket, so a deploy sits where it happened. + * One lane per service, one marker per deploy at the moment it was first + * seen — deploys closer than a dot's width share a marker with a count. A + * vertical line of markers is a monorepo deploy; a marker's fill is the worst + * health among its deploys. Positioned by time, not by bucket. */ export function ReleasesTimeline({ impacts, @@ -75,17 +188,26 @@ export function ReleasesTimeline({ timeSearch, environments, }: ReleasesTimelineProps) { - const lanes = useMemo(() => buildLanes(impacts), [impacts]) const startMs = Date.parse(startTime) const endMs = Date.parse(endTime) - const span = Math.max(1, endMs - startMs) + const [trackRef, trackWidth] = useTrackWidth() + const usable = trackWidth - TRACK_INSET_PX * 2 + const mergeRatio = usable > MERGE_PX ? MERGE_PX / usable : FALLBACK_MERGE_RATIO + const lanes = useMemo( + () => + buildLanes(impacts).map((lane) => ({ + ...lane, + markers: clusterMarkers(lane.dots, startMs, endMs, mergeRatio), + })), + [impacts, startMs, endMs, mergeRatio], + ) const visible = lanes.slice(0, MAX_LANES) const hidden = lanes.length - visible.length const labels = useMemo(() => axisLabels(startMs, endMs), [startMs, endMs]) return (
-
+
Deploys over time
{RELEASE_HEALTH_ORDER.map((band) => ( @@ -102,10 +224,10 @@ export function ReleasesTimeline({
- {visible.map((lane) => ( + {visible.map((lane, laneIndex) => (
@@ -113,48 +235,63 @@ export function ReleasesTimeline({ {lane.serviceName} -
- {lane.dots.map((dot) => { - const ratio = Math.min( - 1, - Math.max(0, (Date.parse(dot.firstSeen) - startMs) / span), - ) - const figure = releaseHealthFigure(dot) +
+ {lane.markers.map((marker) => { + const [newest] = marker.members + if (newest === undefined) return null + const count = marker.members.length return ( + title={markerTitle(marker)} + aria-label={`${lane.serviceName}: ${count === 1 ? shortReleaseLabel(newest.commitSha) : `${count} deploys`}`} + className="group absolute top-1/2 flex -translate-x-1/2 -translate-y-1/2 items-center focus-visible:outline-none" + style={{ + left: `calc(${TRACK_INSET_PX}px + ${marker.ratio} * (100% - ${TRACK_INSET_PX * 2}px))`, + }} + > + + {count > 1 ? ( + + {count} + + ) : null} + ) })}
))} {hidden > 0 ? ( -
+
+{hidden} more {hidden === 1 ? "service" : "services"}
) : null} -
+
-
+
{labels.map((label, index) => ( {label} ))} diff --git a/packages/query-engine/src/__sql_baseline__/catalog.sql b/packages/query-engine/src/__sql_baseline__/catalog.sql index 1ba3a907f..539ebb7e2 100644 --- a/packages/query-engine/src/__sql_baseline__/catalog.sql +++ b/packages/query-engine/src/__sql_baseline__/catalog.sql @@ -1595,7 +1595,7 @@ SELECT LIMIT 50 FORMAT JSON --- builder:releases:releasesListQuery:default [ec4ff2d7] +-- builder:releases:releasesListQuery:default [1518e976] SELECT bServiceName AS serviceName, bEnvironment AS environment, @@ -1654,14 +1654,14 @@ SELECT AND Hour < toStartOfHour(toDateTime('2026-01-03 14:15:00')) GROUP BY bBucket, bServiceName, bServiceNamespace, bEnvironment, bCommitSha ) AS service_windows - WHERE bCommitSha != '' + WHERE bCommitSha NOT IN ('', 'unknown', 'N/A') AND bServiceName IN ('api', 'web') GROUP BY serviceName, environment, commitSha ORDER BY firstSeen DESC, spanCount DESC LIMIT 500 FORMAT JSON --- builder:releases:releasesListQuery:singleService [5054c224] +-- builder:releases:releasesListQuery:singleService [27aed65f] SELECT bServiceName AS serviceName, bEnvironment AS environment, @@ -1722,13 +1722,13 @@ SELECT AND Hour < toStartOfHour(toDateTime('2026-01-03 14:15:00')) GROUP BY bBucket, bServiceName, bServiceNamespace, bEnvironment, bCommitSha ) AS service_windows - WHERE bCommitSha != '' + WHERE bCommitSha NOT IN ('', 'unknown', 'N/A') GROUP BY serviceName, environment, commitSha ORDER BY firstSeen DESC, spanCount DESC LIMIT 100 FORMAT JSON --- builder:releases:releasesTimelineQuery:hourly [a40265cd] +-- builder:releases:releasesTimelineQuery:hourly [1e98427c] SELECT toStartOfInterval(bBucket, INTERVAL 3600 SECOND) AS bucket, bServiceName AS serviceName, @@ -1780,13 +1780,13 @@ SELECT AND Hour < toStartOfHour(toDateTime('2026-01-03 14:15:00')) GROUP BY bBucket, bServiceName, bServiceNamespace, bEnvironment, bCommitSha ) AS service_windows - WHERE bCommitSha != '' + WHERE bCommitSha NOT IN ('', 'unknown', 'N/A') GROUP BY bucket, serviceName, commitSha ORDER BY bucket ASC LIMIT 5000 FORMAT JSON --- builder:releases:releasesTimelineQuery:minutely [33044903] +-- builder:releases:releasesTimelineQuery:minutely [fc2c14a6] SELECT toStartOfInterval(bBucket, INTERVAL 300 SECOND) AS bucket, bServiceName AS serviceName, @@ -1838,13 +1838,13 @@ SELECT AND Minute < toStartOfMinute(toDateTime('2026-01-03 14:15:00')) GROUP BY bBucket, bServiceName, bServiceNamespace, bEnvironment, bCommitSha ) AS service_windows - WHERE bCommitSha != '' + WHERE bCommitSha NOT IN ('', 'unknown', 'N/A') GROUP BY bucket, serviceName, commitSha ORDER BY bucket ASC LIMIT 5000 FORMAT JSON --- builder:releases:releasesTimelineQuery:raw [5586ada1] +-- builder:releases:releasesTimelineQuery:raw [d69f9338] SELECT toStartOfInterval(Timestamp, INTERVAL 30 SECOND) AS bucket, ServiceName AS serviceName, @@ -1855,7 +1855,7 @@ SELECT AND Timestamp >= '2026-01-01 10:30:00' AND Timestamp <= '2026-01-03 14:15:00' AND ServiceName = 'api' - AND CommitSha != '' + AND CommitSha NOT IN ('', 'unknown', 'N/A') GROUP BY bucket, serviceName, commitSha ORDER BY bucket ASC LIMIT 5000 diff --git a/packages/query-engine/src/ch/index.ts b/packages/query-engine/src/ch/index.ts index 5b9709c43..5d6e8df5b 100644 --- a/packages/query-engine/src/ch/index.ts +++ b/packages/query-engine/src/ch/index.ts @@ -252,6 +252,7 @@ export { releaseErrorFingerprintsQuery, releaseErrorFingerprintsRowSchema, RELEASES_LIST_CAP, + PLACEHOLDER_COMMIT_SHAS, type ReleasesListOpts, type ReleasesListOutput, type ReleasesTimelineOpts, diff --git a/packages/query-engine/src/ch/queries/releases.test.ts b/packages/query-engine/src/ch/queries/releases.test.ts index 31f8167f8..2f62d1974 100644 --- a/packages/query-engine/src/ch/queries/releases.test.ts +++ b/packages/query-engine/src/ch/queries/releases.test.ts @@ -20,7 +20,7 @@ describe("releasesListQuery", () => { expect(sql).toContain("OrgId = 'org_1'") expect(sql).toContain("DeploymentEnv IN ('production')") expect(sql).toContain("bServiceName IN ('api', 'web')") - expect(sql).toContain("bCommitSha != ''") + expect(sql).toContain("bCommitSha NOT IN ('', 'unknown', 'N/A')") expect(sql).toContain("GROUP BY serviceName, environment, commitSha") expect(sql).toContain("quantilesTDigestMerge(0.5, 0.95, 0.99)(bDurationQuantiles)") expect(sql).toContain("ORDER BY firstSeen DESC, spanCount DESC") @@ -62,7 +62,7 @@ describe("releasesTimelineQuery", () => { expect(sql).toContain("FROM service_overview_spans") expect(sql).not.toContain("UNION ALL") expect(sql).toContain("ServiceName = 'api'") - expect(sql).toContain("CommitSha != ''") + expect(sql).toContain("CommitSha NOT IN ('', 'unknown', 'N/A')") }) }) diff --git a/packages/query-engine/src/ch/queries/releases.ts b/packages/query-engine/src/ch/queries/releases.ts index 72ef69335..f26a1b2c0 100644 --- a/packages/query-engine/src/ch/queries/releases.ts +++ b/packages/query-engine/src/ch/queries/releases.ts @@ -22,6 +22,14 @@ import { serviceOverviewWindows, serviceWindowTiersForBucket } from "./services" */ export const RELEASES_LIST_CAP = 500 +/** + * Values an SDK writes into `vcs.ref.head.revision` when it has no revision + * to report. They are not releases: the services table drops them from its + * deploy cell, and one of them showing up here as "errors from 0" was the + * first thing the page did against live data. + */ +export const PLACEHOLDER_COMMIT_SHAS: readonly string[] = ["", "unknown", "N/A"] + export interface ReleasesListOpts { readonly serviceName?: string readonly serviceNames?: readonly string[] @@ -102,7 +110,7 @@ export function releasesListQuery(opts: ReleasesListOpts = {}) { apdexToleratingCount: CH.sum($.bApdexToleratingCount), })) .where(($) => [ - $.bCommitSha.neq(""), + CH.notInList($.bCommitSha, PLACEHOLDER_COMMIT_SHAS), opts.serviceNames?.length ? CH.inList($.bServiceName, opts.serviceNames) : undefined, ]) .groupBy("serviceName", "environment", "commitSha") @@ -161,7 +169,7 @@ function releasesTimelineRawQuery( excludedEnvironments: opts.excludedEnvironments, excludedNamespaces: opts.excludedNamespaces, }), - $.CommitSha.neq(""), + CH.notInList($.CommitSha, PLACEHOLDER_COMMIT_SHAS), ]) .groupBy("bucket", "serviceName", "commitSha") .orderBy(["bucket", "asc"]) @@ -192,7 +200,7 @@ export function releasesTimelineQuery( count: CH.sum($.bSpanCount), })) .where(($) => [ - $.bCommitSha.neq(""), + CH.notInList($.bCommitSha, PLACEHOLDER_COMMIT_SHAS), opts.serviceNames?.length ? CH.inList($.bServiceName, opts.serviceNames) : undefined, ]) .groupBy("bucket", "serviceName", "commitSha")