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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions apps/api/src/routes/internal/query-engine.http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
SpanDetailResponse,
ErrorsByTypeResponse,
ErrorsTimeseriesResponse,
ErrorsSparkResponse,
ErrorsSummaryResponse,
ErrorDetailTracesResponse,
ErrorRateByServiceResponse,
Expand Down Expand Up @@ -395,6 +396,19 @@ export const HttpQueryEngineLive = HttpApiBuilder.group(MapleInternalApi, "query
})
}),
)
.handle("errorsSpark", ({ payload }) =>
Effect.gen(function* () {
const tenant = yield* CurrentTenant.Context
const rows = yield* runQuery(Queries.errorsSpark, tenant, payload)
return new ErrorsSparkResponse({
data: rows.map((row) => ({
fingerprintHash: decodeFingerprintHash(row.fingerprintHash),
bucket: String(row.bucket),
count: Number(row.count),
})),
})
}),
)
.handle("errorsSummary", ({ payload }) =>
Effect.gen(function* () {
const tenant = yield* CurrentTenant.Context
Expand Down
14 changes: 14 additions & 0 deletions apps/api/src/routes/v2/error-issues.http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,19 @@ const decodeCursor = (
)
}

/**
* `fingerprint_hash=1,2,3` -> `["1","2","3"]`, or `undefined` when the caller
* did not filter. A param that is present but yields nothing stays an empty
* array so it matches no issues, rather than silently widening to everything.
*/
function parseFingerprintHashes(raw: string | undefined): ReadonlyArray<string> | undefined {
if (raw === undefined) return undefined
return raw
.split(",")
.map((hash) => hash.trim())
.filter((hash) => hash.length > 0)
}

export const HttpV2ErrorIssuesLive = HttpApiBuilder.group(MapleApiV2, "errorIssues", (handlers) =>
Effect.gen(function* () {
const readModels = yield* ErrorIssueReadModelsService
Expand All @@ -128,6 +141,7 @@ export const HttpV2ErrorIssuesLive = HttpApiBuilder.group(MapleApiV2, "errorIssu
severity: query.severity,
kind: query.kind,
service: query.service_name,
fingerprintHashes: parseFingerprintHashes(query.fingerprint_hash),
deploymentEnv: query.deployment_environment,
startTime: query.start_time,
endTime: query.end_time,
Expand Down
29 changes: 29 additions & 0 deletions apps/api/src/services/errors/ErrorIssueReadModelsService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,35 @@ describe("ErrorIssueReadModelsService", () => {
}).pipe(Effect.provide(makeLayer(contexts)))
})

it.effect("restricts the list to an explicit fingerprint set", () => {
const contexts: Array<string> = []
return Effect.gen(function* () {
const readModels = yield* ErrorIssueReadModelsService
const now = yield* Clock.currentTimeMillis
const wanted = asIssueId(randomUUID())
const other = asIssueId(randomUUID())

yield* seedIssue(ORG, wanted, now, { fingerprintHash: "fp-wanted" })
yield* seedIssue(ORG, other, now + 1_000, { fingerprintHash: "fp-other" })

// The volume-ranked list ranks fingerprints in the warehouse first, then
// asks for exactly those issues — order is re-applied client-side, but the
// set has to be exact or the ranking is drawn against the wrong rows.
const listed = yield* readModels.listIssues(ORG, {
fingerprintHashes: ["fp-wanted"],
})
assert.deepStrictEqual(
listed.issues.map((issue) => issue.id),
[wanted],
)

// An empty set is a real filter that matches nothing, not an absent one
// that widens back to every issue in the org.
const none = yield* readModels.listIssues(ORG, { fingerprintHashes: [] })
assert.lengthOf(none.issues, 0)
}).pipe(Effect.provide(makeLayer(contexts)))
})

it.effect("preserves issue ownership and not-found semantics", () => {
const contexts: Array<string> = []
return Effect.gen(function* () {
Expand Down
5 changes: 5 additions & 0 deletions apps/api/src/services/errors/ErrorIssueReadModelsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,9 @@ export interface ErrorIssueReadModelsPublicApi {
readonly severity?: IssueSeverity | "unset"
readonly kind?: IssueKind
readonly service?: string
/** Restrict to these fingerprint hashes (volume-ranked lists ask for an
* explicit set). An empty array matches nothing, as it should. */
readonly fingerprintHashes?: ReadonlyArray<string>
/** Only issues whose fingerprint the warehouse observed in this
* deployment environment (within startTime/endTime, defaulting to the
* trailing 30d). Costs one warehouse round-trip; excludes alert-kind
Expand Down Expand Up @@ -183,6 +186,8 @@ const make: Effect.Effect<
else if (opts.severity) conditions.push(eq(errorIssues.severity, opts.severity))
if (opts.kind) conditions.push(eq(errorIssues.kind, opts.kind))
if (opts.service) conditions.push(eq(errorIssues.serviceName, opts.service))
if (opts.fingerprintHashes !== undefined)
conditions.push(inArray(errorIssues.fingerprintHash, opts.fingerprintHashes))

// `""` is a real filter (raw spans without a deployment env), so check
// for undefined rather than truthiness.
Expand Down
8 changes: 8 additions & 0 deletions apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -4541,6 +4541,14 @@
"type": "string"
}
},
{
"in": "query",
"name": "fingerprint_hash",
"required": false,
"schema": {
"type": "string"
}
},
{
"in": "query",
"name": "deployment_environment",
Expand Down
102 changes: 71 additions & 31 deletions apps/web/src/api/warehouse/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
ErrorsByTypeRequest,
ErrorsSummaryRequest,
ErrorDetailTracesRequest,
ErrorsTimeseriesRequest,
ErrorsSparkRequest,
FingerprintHash,
ServiceName,
} from "@maple/domain/http"
Expand All @@ -27,6 +27,8 @@ import {
const OptionalServiceArray = Schema.optional(Schema.mutable(Schema.Array(ServiceName)))
const OptionalDeploymentEnvArray = Schema.optional(Schema.mutable(Schema.Array(DeploymentEnvironment)))
const OptionalFingerprintHashArray = Schema.optional(Schema.mutable(Schema.Array(FingerprintHash)))
/** "Error Type" / "Version" sidebar facets — plain strings, not branded. */
const OptionalStringArray = Schema.optional(Schema.mutable(Schema.Array(Schema.String)))

export interface ErrorByType {
fingerprintHash: string
Expand All @@ -44,6 +46,8 @@ const GetErrorsByTypeInputSchema = Schema.Struct({
services: OptionalServiceArray,
deploymentEnvs: OptionalDeploymentEnvArray,
fingerprintHashes: OptionalFingerprintHashArray,
errorLabels: OptionalStringArray,
serviceVersions: OptionalStringArray,
limit: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))),
showSpam: Schema.optional(Schema.Boolean),
rootOnly: Schema.optional(Schema.Boolean),
Expand Down Expand Up @@ -74,6 +78,8 @@ const getErrorsByTypeEffect = Effect.fn("QueryEngine.getErrorsByType")(function*
services: input.services,
deploymentEnvs: input.deploymentEnvs,
fingerprintHashes: input.fingerprintHashes,
errorLabels: input.errorLabels,
serviceVersions: input.serviceVersions,
limit: input.limit,
}),
})
Expand All @@ -95,6 +101,8 @@ const GetErrorsFacetsInputSchema = Schema.Struct({
services: OptionalServiceArray,
deploymentEnvs: OptionalDeploymentEnvArray,
fingerprintHashes: OptionalFingerprintHashArray,
errorLabels: OptionalStringArray,
serviceVersions: OptionalStringArray,
showSpam: Schema.optional(Schema.Boolean),
rootOnly: Schema.optional(Schema.Boolean),
})
Expand Down Expand Up @@ -133,6 +141,8 @@ const getErrorsFacetsEffect = Effect.fn("QueryEngine.getErrorsFacets")(function*
services: input.services,
deploymentEnvs: input.deploymentEnvs,
fingerprintHashes: input.fingerprintHashes,
errorLabels: input.errorLabels,
serviceVersions: input.serviceVersions,
},
},
}),
Expand All @@ -142,24 +152,32 @@ const getErrorsFacetsEffect = Effect.fn("QueryEngine.getErrorsFacets")(function*
const services: FacetItem[] = []
const deploymentEnvs: FacetItem[] = []
const errorTypes: FacetItem[] = []
const serviceVersions: FacetItem[] = []

for (const row of facetsData) {
const item = { name: row.name, count: Number(row.count) }
// These strings must match the `facetType` literals in `errorsFacetsQuery`.
// They did not: the query emits "environment"/"error_type" and this read
// "deploymentEnv"/"errorType", so those two sections rendered with zero
// options and the sidebar looked like it had no environment filter at all.
switch (row.facetType) {
case "service":
services.push(item)
break
case "deploymentEnv":
case "environment":
deploymentEnvs.push(item)
break
case "errorType":
case "error_type":
errorTypes.push(item)
break
case "version":
serviceVersions.push(item)
break
}
}

return {
data: { services, deploymentEnvs, errorTypes },
data: { services, deploymentEnvs, errorTypes, serviceVersions },
}
})

Expand All @@ -169,6 +187,8 @@ const GetErrorsSummaryInputSchema = Schema.Struct({
services: OptionalServiceArray,
deploymentEnvs: OptionalDeploymentEnvArray,
fingerprintHashes: OptionalFingerprintHashArray,
errorLabels: OptionalStringArray,
serviceVersions: OptionalStringArray,
showSpam: Schema.optional(Schema.Boolean),
rootOnly: Schema.optional(Schema.Boolean),
})
Expand Down Expand Up @@ -198,6 +218,8 @@ const getErrorsSummaryEffect = Effect.fn("QueryEngine.getErrorsSummary")(functio
services: input.services,
deploymentEnvs: input.deploymentEnvs,
fingerprintHashes: input.fingerprintHashes,
errorLabels: input.errorLabels,
serviceVersions: input.serviceVersions,
}),
})
}),
Expand All @@ -207,16 +229,6 @@ const getErrorsSummaryEffect = Effect.fn("QueryEngine.getErrorsSummary")(functio
return { data: coerceErrorsSummary(result.data) }
})

export interface ErrorDetailTrace {
traceId: string
startTime: Date
durationMicros: number
spanCount: number
services: string[]
rootSpanName: string
errorMessage: string
}

const GetErrorDetailTracesInputSchema = Schema.Struct({
fingerprintHash: FingerprintHash,
startTime: Schema.optional(WarehouseDateTimeString),
Expand Down Expand Up @@ -275,48 +287,76 @@ export interface ErrorsTimeseriesItem {
count: number
}

const GetErrorsTimeseriesInputSchema = Schema.Struct({
fingerprintHash: FingerprintHash,
/**
* Bucketed counts for many fingerprints at once, pivoted into one series per
* fingerprint. The warehouse returns tall rows; the list draws one sparkline
* per row, so the pivot happens once here rather than in every row component.
*
* Buckets are sparse — a fingerprint that was quiet for an hour has no row for
* it. Densifying is the caller's job, since only it knows the bucket grid.
*/
export interface ErrorsSparkSeries {
fingerprintHash: string
points: ReadonlyArray<ErrorsTimeseriesItem>
}

const GetErrorsSparkInputSchema = Schema.Struct({
fingerprintHashes: Schema.mutable(Schema.Array(FingerprintHash)),
startTime: Schema.optional(WarehouseDateTimeString),
endTime: Schema.optional(WarehouseDateTimeString),
services: OptionalServiceArray,
deploymentEnvs: OptionalDeploymentEnvArray,
errorLabels: OptionalStringArray,
serviceVersions: OptionalStringArray,
bucketSeconds: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))),
showSpam: Schema.optional(Schema.Boolean),
})

export type GetErrorsTimeseriesInput = (typeof GetErrorsTimeseriesInputSchema)["Encoded"]
export type GetErrorsSparkInput = (typeof GetErrorsSparkInputSchema)["Encoded"]

export function getErrorsTimeseries({ data }: { data: GetErrorsTimeseriesInput }) {
return getErrorsTimeseriesEffect({ data })
export function getErrorsSpark({ data }: { data: GetErrorsSparkInput }) {
return getErrorsSparkEffect({ data })
}

const getErrorsTimeseriesEffect = Effect.fn("QueryEngine.getErrorsTimeseries")(function* ({
const getErrorsSparkEffect = Effect.fn("QueryEngine.getErrorsSpark")(function* ({
data,
}: {
data: GetErrorsTimeseriesInput
data: GetErrorsSparkInput
}) {
const input = yield* decodeInput(GetErrorsTimeseriesInputSchema, data ?? {}, "getErrorsTimeseries")
const input = yield* decodeInput(GetErrorsSparkInputSchema, data ?? {}, "getErrorsSpark")
const fallback = defaultErrorsTimeRange(yield* Clock.currentTimeMillis)

const result = yield* runWarehouseQuery("errorsTimeseries", () =>
// No fingerprints means nothing to chart — skipping the round-trip matters
// here because the list renders before its issues have loaded.
if (input.fingerprintHashes.length === 0) return { data: [] as ErrorsSparkSeries[] }

const result = yield* runWarehouseQuery("errorsSpark", () =>
Effect.gen(function* () {
const client = yield* MapleInternalAtomClient
return yield* client.queryEngine.errorsTimeseries({
payload: new ErrorsTimeseriesRequest({
return yield* client.queryEngine.errorsSpark({
payload: new ErrorsSparkRequest({
startTime: input.startTime ?? fallback.startTime,
endTime: input.endTime ?? fallback.endTime,
fingerprintHash: input.fingerprintHash,
fingerprintHashes: input.fingerprintHashes,
services: input.services,
deploymentEnvs: input.deploymentEnvs,
errorLabels: input.errorLabels,
serviceVersions: input.serviceVersions,
bucketSeconds: input.bucketSeconds,
}),
})
}),
)

const byFingerprint = new Map<string, ErrorsTimeseriesItem[]>()
for (const raw of result.data) {
const hash = String(raw.fingerprintHash)
const points = byFingerprint.get(hash)
const point = { bucket: String(raw.bucket), count: Number(raw.count) }
if (points) points.push(point)
else byFingerprint.set(hash, [point])
}

return {
data: result.data.map((raw) => ({
bucket: String(raw.bucket),
count: Number(raw.count),
})),
data: [...byFingerprint].map(([fingerprintHash, points]) => ({ fingerprintHash, points })),
}
})
15 changes: 12 additions & 3 deletions apps/web/src/components/anomalies/related-anomalies-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import type { ErrorIssueId } from "@maple/domain/http"
import { Badge } from "@maple/ui/components/ui/badge"
import { cn } from "@maple/ui/lib/utils"

import { SectionHeader } from "@/components/layout/section-header"
import { retainedQueryV2 } from "@/lib/services/common/v2-atom-client"
import { anomalyIncidentFromV2 } from "@/lib/services/anomalies"
import { AnomalyRow } from "./anomaly-row"
Expand Down Expand Up @@ -35,8 +34,18 @@ export function RelatedAnomaliesSection({ issueId }: { issueId: ErrorIssueId })
})

return (
<section aria-labelledby="related-anomalies-heading">
<SectionHeader id="related-anomalies-heading" label="Related anomalies" />
<section aria-labelledby="related-anomalies-heading" className="flex shrink-0 flex-col gap-3.5">
<div className="flex items-baseline gap-2.5">
<h2
id="related-anomalies-heading"
className="font-display text-base font-semibold tracking-[-0.01em] text-foreground"
>
Related anomalies
</h2>
<span className="text-sm text-muted-foreground">
{sorted.length} {sorted.length === 1 ? "detector incident" : "detector incidents"}
</span>
</div>
<div className="overflow-hidden rounded-md border border-border/60 divide-y divide-border/40">
{sorted.map((incident) => (
<AnomalyRow key={incident.id} incident={incident} variant="compact" />
Expand Down
1 change: 0 additions & 1 deletion apps/web/src/components/attributes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
// existing `@/components/attributes` import path working.
export {
CopyableValue,
AttributesTable,
AttributesSection,
ResourceAttributesSection,
tryParseJson,
Expand Down
Loading
Loading