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
6 changes: 5 additions & 1 deletion apps/ai/src/mcp/lib/dashboard-schema-doc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -413,7 +413,11 @@ const queriesSection = (): string => {
"### `whereClause` is a custom grammar, not SQL",
"",
"Operators (the only ones): `=`, `!=`, `>`, `<`, `>=`, `<=`, `contains`, `!contains`,",
"`exists`, `!exists`. Clauses join with ` AND `; there is no `OR` and no parentheses.",
"`exists`, `!exists`. Clauses join with ` AND `. On `traces` and `logs`, attribute",
'clauses can be OR-ed inside one level of parentheses: `(attr.a = "1" OR attr.b !exists)`.',
"Every member must be an attribute on the same map (all span or all resource); named keys",
"like `service.name` cannot be OR-ed, and there is no AND or nesting inside a group. A",
"group counts as one filter toward the cap.",
"Values use double quotes. **There is no `IS NULL` / `IS NOT NULL`**: write `<key> exists`",
"or `<key> !exists`. `exists` means present *and* non-empty, because attributes live in",
"ClickHouse `Map` columns where a missing key reads back as `''`.",
Expand Down
20 changes: 18 additions & 2 deletions apps/web/src/api/warehouse/traces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
QueryEngineExecuteRequest,
TracesFacetDimension,
type AttributeFilter,
type AttributeFilterLeaf,
formatWarehouseDateTime,
} from "@maple/query-engine"
import { TraceId, SpanId } from "@maple/domain"
Expand Down Expand Up @@ -40,11 +41,18 @@ const toTraceId = Schema.decodeSync(TraceId)

const ContainsMatchMode = Schema.optional(Schema.Literals(["contains"]))

const AttributeFilterInput = Schema.Struct({
const attributeFilterInputFields = {
key: Schema.String,
value: Schema.String,
matchMode: Schema.optional(Schema.Literals(["contains", "exists", "gt", "gte", "lt", "lte"])),
negated: Schema.optional(Schema.Boolean),
}
const AttributeFilterLeafInput = Schema.Struct(attributeFilterInputFields)

const AttributeFilterInput = Schema.Struct({
...attributeFilterInputFields,
/** The other members of an `(a OR b)` where-clause group. */
or: Schema.optional(Schema.Array(AttributeFilterLeafInput)),
})

const ListTracesInputSchema = Schema.Struct({
Expand Down Expand Up @@ -189,7 +197,7 @@ function httpAttributeFilter(key: string, values: readonly string[] | undefined)
}

/** An absent match mode is equality; `exists` is a presence check and carries no value. */
function toAttributeFilter(entry: typeof AttributeFilterInput.Type): AttributeFilter {
function toAttributeFilterLeaf(entry: typeof AttributeFilterLeafInput.Type): AttributeFilterLeaf {
const mode = entry.matchMode ?? "equals"
return {
key: entry.key,
Expand All @@ -199,6 +207,14 @@ function toAttributeFilter(entry: typeof AttributeFilterInput.Type): AttributeFi
}
}

function toAttributeFilter(entry: typeof AttributeFilterInput.Type): AttributeFilter {
const { or, ...first } = entry
return {
...toAttributeFilterLeaf(first),
...(or?.length ? { or: or.map(toAttributeFilterLeaf) } : undefined),
}
}

function buildAttributeFilters(input: ListTracesDecoded): AttributeFilter[] {
const filters: AttributeFilter[] = []

Expand Down
28 changes: 13 additions & 15 deletions apps/web/src/components/services/dependency-drill.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,32 +12,30 @@ describe("dependencyDrillWhereClause", () => {
])
})

it("drills a messaging edge named by its system to spans without a destination", () => {
// The rollup merges spans whose destination is literally `kafka` into the
// same edge as the fallback spans, so the drill reaches both halves.
it("drills both halves of an edge named after its system", () => {
expect(filtersOf("messaging", "kafka", "kafka").filters.attributeFilters).toEqual([
{ key: "messaging.system", value: "kafka" },
{ key: "messaging.destination.name", value: "", matchMode: "exists", negated: true },
{
key: "messaging.destination.name",
value: "kafka",
or: [{ key: "messaging.destination.name", value: "", matchMode: "exists", negated: true }],
},
])
})

// The rollup merges `destination = kafka` spans into the same edge as the
// fallback spans; without OR in the where-clause only the fallback half is
// reachable. Pinned so a parser that gains OR support flips this on purpose.
it("drills only the fallback spans when a destination shares its system's name", () => {
expect(filtersOf("messaging", "kafka", "kafka").filters.attributeFilters).toContainEqual({
key: "messaging.destination.name",
value: "",
matchMode: "exists",
negated: true,
})
})

it("drills an rpc service, or the legacy system key when the edge is named by it", () => {
expect(filtersOf("rpc", "checkout.Cart", "grpc").filters.attributeFilters).toEqual([
{ key: "rpc.service", value: "checkout.Cart" },
])
expect(filtersOf("rpc", "grpc", "grpc").filters.attributeFilters).toEqual([
{ key: "rpc.system", value: "grpc" },
{ key: "rpc.service", value: "", matchMode: "exists", negated: true },
{
key: "rpc.service",
value: "grpc",
or: [{ key: "rpc.service", value: "", matchMode: "exists", negated: true }],
},
])
})

Expand Down
25 changes: 12 additions & 13 deletions apps/web/src/components/services/dependency-drill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,15 @@ export type DependencyDrillKind = "service" | "database" | "messaging" | "rpc" |
* The traces page filters root spans unless `root_only = false`, and a client
* span is almost never a root, so every drill opens the span-level list. There
* is no span-kind filter (a `SpanKind = ...` clause becomes a span-attribute
* filter that matches nothing), and the parser drops `(a OR b)` groups, so each
* drill is one aliased key that the query engine matches under every spelling.
* filter that matches nothing). Target keys are aliased, so the query engine
* matches them under every semconv spelling.
*
* Targets mirror how the edge rollups name them: messaging and rpc fall back to
* the system when the destination or `rpc.service` is absent, so those drills
* also require the absence, otherwise they would match every destination of the
* system. The rpc system uses the legacy key because that is what the rollup
* reads today.
*
* Known gap: when a destination or rpc.service is literally named after its
* system, the rollup merges those spans and the fallback spans into one edge.
* Matching both needs an OR the where-clause parser does not support, so the
* drill shows only the fallback spans.
* the system when the destination or `rpc.service` is absent. An edge named
* after its system therefore holds the fallback spans, plus any spans whose
* destination or service is literally that name, so the drill matches both and
* nothing else of the system. The rpc system uses the legacy key because that
* is what the rollup reads today.
*/
export function dependencyDrillWhereClause(
kind: DependencyDrillKind,
Expand All @@ -37,11 +33,14 @@ export function dependencyDrillWhereClause(
return spans(`db.system.name = ${value}`)
case "messaging":
return namedBySystem
? spans(`messaging.system = ${value}`, "messaging.destination.name !exists")
? spans(
`messaging.system = ${value}`,
`(messaging.destination.name = ${value} OR messaging.destination.name !exists)`,
)
: spans(`messaging.destination.name = ${value}`)
case "rpc":
return namedBySystem
? spans(`rpc.system = ${value}`, "rpc.service !exists")
? spans(`rpc.system = ${value}`, `(rpc.service = ${value} OR rpc.service !exists)`)
: spans(`rpc.service = ${value}`)
case "http":
return spans(`server.address = ${value}`)
Expand Down
37 changes: 37 additions & 0 deletions apps/web/src/lib/traces/advanced-filter-sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -682,3 +682,40 @@ describe("applyWhereClause removals", () => {
expect(result.hasError).toBe(true)
})
})

describe("OR groups", () => {
it("parses an attribute group onto one entry with or alternatives", () => {
const { filters, warnings } = parseWhereClause(
'root_only = false AND (messaging.destination.name = "kafka" OR messaging.destination.name !exists)',
)
expect(warnings).toEqual([])
expect(filters.rootOnly).toBe(false)
expect(filters.attributeFilters).toEqual([
{
key: "messaging.destination.name",
value: "kafka",
or: [{ key: "messaging.destination.name", value: "", matchMode: "exists", negated: true }],
},
])
})

it("rejects a group that ORs a named field or mixes maps", () => {
for (const whereClause of [
'(service.name = "api" OR attr.x = "1")',
'(attr.x = "1" OR resource.y = "2")',
]) {
const { filters, warnings } = parseWhereClause(whereClause)
expect(filters.attributeFilters).toEqual([])
expect(filters.resourceAttributeFilters).toEqual([])
expect(filters.service).toBeUndefined()
expect(warnings).toHaveLength(1)
expect(warnings[0]).toContain("OR group ignored")
}
})

it("round-trips a group through toWhereClause", () => {
const whereClause = '(attr.a = "1" OR attr.b !exists)'
const { filters } = parseWhereClause(whereClause)
expect(toWhereClause(filters)).toBe(whereClause)
})
})
91 changes: 82 additions & 9 deletions apps/web/src/lib/traces/advanced-filter-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
parseWhereClause as parseWhereClauses,
quoteWhereValue,
type Operator,
type ParsedClause,
} from "@maple/domain/where-clause"
import { Match } from "effect"

Expand All @@ -14,13 +15,18 @@ import { Match } from "effect"
*/
export type AttributeMatchMode = "contains" | "exists" | "gt" | "gte" | "lt" | "lte"

interface AttributeFilterEntry {
interface AttributeFilterEntryLeaf {
key: string
value: string
matchMode?: AttributeMatchMode
negated?: boolean
}

interface AttributeFilterEntry extends AttributeFilterEntryLeaf {
/** The other members of an `(a OR b)` group; the entry matches when any member does. */
or?: readonly AttributeFilterEntryLeaf[]
}

export interface TracesSearchLike {
services?: string[]
spanNames?: string[]
Expand Down Expand Up @@ -118,7 +124,12 @@ export function attributeFilterOperator(entry: Pick<AttributeFilterEntry, "match
return entry.negated ? negative : positive
}

function formatAttributeClause(prefix: string, entry: AttributeFilterEntry): string {
/** An attribute filter entry as where-clause text; a group reads back as `(a OR b)`. */
export function formatAttributeClause(prefix: string, entry: AttributeFilterEntry): string {
if (entry.or?.length) {
const { or, ...first } = entry
return `(${[first, ...or].map((member) => formatAttributeClause(prefix, member)).join(" OR ")})`
}
const operator = attributeFilterOperator(entry)
if (entry.matchMode === "exists") return `${prefix}${entry.key} ${operator}`
return `${prefix}${entry.key} ${operator} ${quoteWhereValue(entry.value)}`
Expand All @@ -136,13 +147,19 @@ export function parseWhereClause(whereClause: string | undefined): {
}
}

const parsedClauses = parseWhereClauses(whereClause.trim())
const parsedClauses = parseWhereClauses(whereClause.trim(), { orGroups: true })
const clauses = parsedClauses.clauses
const warnings = parsedClauses.warnings.map((warning) => warning.message)

let parsed: ParsedWhereClauseFilters = { attributeFilters: [], resourceAttributeFilters: [] }

for (const clause of clauses) {
// Takes `parsed` and `warnings` as parameters (shadowing the outer ones) so an
// OR group member can be applied alone, with its warnings kept apart.
const applyClause = (
parsed: ParsedWhereClauseFilters,
clause: ParsedClause,
warnings: string[],
): ParsedWhereClauseFilters => {
const key = normalizeKey(clause.key)
const isContains = clause.operator === "contains"
const isNegated = clause.operator === "!="
Expand Down Expand Up @@ -177,12 +194,12 @@ export function parseWhereClause(whereClause: string | undefined): {

if (key.startsWith("attr.")) {
pushAttribute(parsed.attributeFilters, typedKey.slice(5).trim(), typedKey)
continue
return parsed
}

if (key.startsWith("resource.")) {
pushAttribute(parsed.resourceAttributeFilters, typedKey.slice(9).trim(), typedKey)
continue
return parsed
}

const unsupported = (supported: string) => {
Expand All @@ -196,15 +213,15 @@ export function parseWhereClause(whereClause: string | undefined): {
const isEqualityOperator = clause.operator === "=" || isNegated
if (CONTAINS_FIELD_KEYS.has(key) && !isEqualityOperator && !isContains) {
unsupported("=, != and contains")
continue
return parsed
}
if (EQUALITY_FIELD_KEYS.has(key) && !isEqualityOperator) {
unsupported("= and !=")
continue
return parsed
}
if (SCALAR_KEYS.has(key) && clause.operator !== "=") {
unsupported("=")
continue
return parsed
}

parsed = Match.value(key).pipe(
Expand Down Expand Up @@ -292,6 +309,62 @@ export function parseWhereClause(whereClause: string | undefined): {
return parsed
}),
)
return parsed
}

for (const clause of clauses) parsed = applyClause(parsed, clause, warnings)

// An `(a OR b)` group: each member goes through `applyClause` on its own, and
// must land as exactly one attribute entry on the same map. Named fields
// (service, span name, http method, ...) are single-valued params and cannot
// be OR-ed.
for (const group of parsedClauses.groups) {
const label = `(${group.map((c) => c.rawKey ?? c.key).join(" OR ")})`
const members: Array<{
map: "attributeFilters" | "resourceAttributeFilters"
entry: AttributeFilterEntry
}> = []
for (const clause of group) {
const memberWarnings: string[] = []
const alone = applyClause(
{ attributeFilters: [], resourceAttributeFilters: [] },
clause,
memberWarnings,
)
const setsOtherField = Object.entries(alone).some(
([field, value]) =>
field !== "attributeFilters" &&
field !== "resourceAttributeFilters" &&
value !== undefined,
)
const map =
alone.attributeFilters.length === 1 && alone.resourceAttributeFilters.length === 0
? "attributeFilters"
: alone.resourceAttributeFilters.length === 1 && alone.attributeFilters.length === 0
? "resourceAttributeFilters"
: undefined
const entry = map === undefined ? undefined : alone[map][0]
if (memberWarnings.length > 0 || setsOtherField || map === undefined || entry === undefined) {
members.length = 0
warnings.push(`OR group ignored: only attribute filters can be OR-ed: ${label}`)
break
}
members.push({ map, entry })
}
const [first, ...rest] = members
if (first === undefined) continue
if (rest.some((m) => m.map !== first.map)) {
warnings.push(`OR group ignored: its members mix span and resource attributes: ${label}`)
continue
}
if (parsed[first.map].length >= 5) {
warnings.push(`Maximum of 5 filters per attribute map; ignoring ${label}`)
continue
}
parsed = {
...parsed,
[first.map]: [...parsed[first.map], { ...first.entry, or: rest.map((m) => m.entry) }],
}
}

return {
Expand Down
17 changes: 17 additions & 0 deletions apps/web/src/lib/traces/trace-filter-chips.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,20 @@ describe("traceFilterChips", () => {
expect(chips.map((c) => c.label)).toEqual(["Environment", "Service", "HTTP Method"])
})
})

describe("traceFilterChips OR groups", () => {
it("shows a group as one chip and removes only that group", () => {
const group = {
key: "a",
value: "1",
or: [{ key: "b", value: "", matchMode: "exists" as const, negated: true }],
}
const plain = { key: "a", value: "1" }
const chips = traceFilterChips({ attributeFilters: [group, plain] })
expect(chips.map((c) => [c.label, c.values])).toEqual([
["Any of", ['(a = "1" OR b !exists)']],
["a", ["1"]],
])
expect(chips[0]?.remove({ attributeFilters: [group, plain] }).attributeFilters).toEqual([plain])
})
})
Loading
Loading