diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a17a7bc..55a60153 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +Two correctness follow-ups from the multi-engine bug-hunt sweep (issue #255): Sigma detection now honors full SigmaHQ field-modifier chains, and the service-map p95 is a true window percentile on every storage engine. No database migrations; drop-in upgrade. The storage-layer change was validated against real ClickHouse, MongoDB and TimescaleDB. + +### Fixed +- **Sigma compound field-modifier chains were silently truncated**: the matcher split a field key like `CommandLine|utf16le|base64offset|contains` on `|` but kept only the first modifier, so any rule using a transform-plus-comparator chain (or a PowerShell `-enc` style `utf16le|base64offset|contains`) matched incorrectly. The whole chain is now parsed and applied in order: transforms (`base64`, `base64offset`, `utf16le`/`utf16`/`utf16be`/`wide`, `windash`) rewrite the pattern, then the final comparator runs. Transforms follow the canonical SigmaHQ model (the pattern is encoded, e.g. the field is checked for `base64(value)`), which is what real SigmaHQ rules are authored against. Added `cidr` and numeric `gt`/`gte`/`lt`/`lte` comparators while reworking the parser +- **Sigma `|all` modifier had the wrong semantics**: it was implemented as "all whitespace-split words present in any order" rather than the SigmaHQ list quantifier. `|all` now flips the default OR over a value list into AND (every list element must match), and composes with modifier chains (e.g. `cmd|base64|contains|all`) + +### Changed +- **Service-map p95 is now a true window percentile across all engines**: the service dependency map previously reported `MAX(duration_p95_ms)` from the per-bucket spans continuous aggregate, which overestimates (a p95 is not derivable by combining per-bucket p95s) and was only ever produced on TimescaleDB. Per-service health stats now come from a new `reservoir.getServiceHealthStats` computed directly from raw spans over the requested window on every engine: `percentile_cont` on TimescaleDB, `quantile(0.95)` on ClickHouse, and `$percentile` on MongoDB (approximate t-digest, Mongo 7.0+). ClickHouse and MongoDB service maps now carry real call/error/latency/p95 figures where they previously had none. The `spans_hourly_stats` / `spans_daily_stats` aggregates are unchanged and still back the dashboards + ## [1.0.2] - 2026-06-22 A frontend correctness and security release from a comprehensive multi-agent frontend bug hunt (UI, logic, reactivity, leaks and security), plus a hardening of how the browser authenticates the live-streaming endpoints. The headline item is single-use stream tickets: the session token no longer travels in WebSocket/SSE URLs (where reverse proxies log it). One additive database migration (`049_stream_tickets`); otherwise a drop-in upgrade. diff --git a/packages/backend/src/modules/sigma/field-matcher.ts b/packages/backend/src/modules/sigma/field-matcher.ts index ead226bc..70f7b4ec 100644 --- a/packages/backend/src/modules/sigma/field-matcher.ts +++ b/packages/backend/src/modules/sigma/field-matcher.ts @@ -1,22 +1,62 @@ /** - * SigmaFieldMatcher - Field matching with wildcards and modifiers + * SigmaFieldMatcher - Field matching with wildcards and modifier chains * - * Supports: - * - Wildcards: * (any characters), ? (single character) - * - Modifiers: contains, startswith, endswith, base64, re (regex) - * - Case-insensitive matching + * Implements the SigmaHQ field-modifier model: + * - Transforms (rewrite the pattern, applied left to right): base64, + * base64offset, utf16le/utf16/utf16be/wide, windash + * - Comparators (final match operator): contains, startswith, endswith, re, + * cidr, gt, gte, lt, lte, exists. Default (none) is equals-with-wildcards. + * - Quantifier: all (over a value list, flips the default OR into AND) + * + * Whole modifier chains are honored (e.g. CommandLine|utf16le|base64offset|contains), + * not just the first modifier. */ -export type FieldModifier = 'contains' | 'startswith' | 'endswith' | 'base64' | 're' | 'all' | 'base64offset' | 'exists'; +export type FieldModifier = + | 'contains' + | 'startswith' + | 'endswith' + | 'base64' + | 'base64offset' + | 're' + | 'cidr' + | 'gt' + | 'gte' + | 'lt' + | 'lte' + | 'all' + | 'exists' + | 'utf16le' + | 'utf16' + | 'utf16be' + | 'wide' + | 'windash'; export interface FieldMatchOptions { caseSensitive?: boolean; modifier?: FieldModifier; } +type Comparator = 'contains' | 'startswith' | 'endswith' | 're' | 'cidr' | 'gt' | 'gte' | 'lt' | 'lte'; + +const TRANSFORMS = new Set(['base64', 'base64offset', 'utf16le', 'utf16', 'utf16be', 'wide', 'windash']); +const COMPARATORS = new Set(['contains', 'startswith', 'endswith', 're', 'cidr', 'gt', 'gte', 'lt', 'lte']); + +// Windows dash variants for the |windash modifier (ASCII hyphen, slash, and the +// unicode dashes accepted by Windows command parsers). +const WINDASH_CHARS = ['-', '/', '–', '—', '―']; + +// Warn at most once per unknown modifier token so a malformed rule never throws +// and never silently disappears. +const warnedModifiers = new Set(); + +/** Intermediate pattern representation while running the transform chain. */ +type Candidate = { text: string } | { bytes: Buffer }; + export class SigmaFieldMatcher { /** - * Match a field value against a pattern with optional modifiers + * Match a field value against a pattern with an optional single modifier. + * Kept for backward compatibility; chains are driven from matchSelection. */ static match( fieldValue: any, @@ -25,71 +65,201 @@ export class SigmaFieldMatcher { ): boolean { const { caseSensitive = false, modifier } = options; - // Handle null/undefined field values if (fieldValue === null || fieldValue === undefined) { return false; } - // Convert field value to string for matching - const valueStr = String(fieldValue); - // Handle arrays in pattern (OR logic - match if ANY pattern matches) if (Array.isArray(pattern)) { return pattern.some((p) => this.match(fieldValue, p, options)); } - // Convert pattern to string - const patternStr = String(pattern); - - // Apply modifier-specific matching if (modifier) { - return this.matchWithModifier(valueStr, patternStr, modifier, caseSensitive); + return this.applyModifierChain(fieldValue, pattern, [modifier], caseSensitive); } - // Default: exact match with optional wildcards - return this.matchWithWildcards(valueStr, patternStr, caseSensitive); + return this.matchWithWildcards(String(fieldValue), String(pattern), caseSensitive); } /** - * Match with field modifiers + * Match a Sigma selection block against log data. + * + * @param logData - Log entry data (flattened object) + * @param selection - Sigma selection block (field: value pairs) + * @param caseSensitive - Case-sensitive matching + * @returns true if ALL fields in selection match (AND logic) */ - private static matchWithModifier( - value: string, - pattern: string, - modifier: FieldModifier, + static matchSelection( + logData: Record, + selection: Record, + caseSensitive: boolean = false + ): boolean { + if (!selection || Object.keys(selection).length === 0) { + return false; + } + + return Object.entries(selection).every(([field, pattern]) => { + const { fieldName, modifiers } = this.parseFieldWithModifier(field); + const fieldValue = this.getNestedField(logData, fieldName); + + // |exists is a presence check, not a value match. + if (modifiers.includes('exists')) { + const exists = fieldValue !== null && fieldValue !== undefined; + return pattern === true ? exists : !exists; + } + + const requireAll = modifiers.includes('all'); + const chain = modifiers.filter((m) => m !== 'all'); + + if (Array.isArray(pattern)) { + // |all flips the default OR over a value list into AND. + return requireAll + ? pattern.every((p) => this.applyModifierChain(fieldValue, p, chain, caseSensitive)) + : pattern.some((p) => this.applyModifierChain(fieldValue, p, chain, caseSensitive)); + } + + return this.applyModifierChain(fieldValue, pattern, chain, caseSensitive); + }); + } + + /** + * Apply an ordered chain of modifiers (transforms + a final comparator) to a + * single scalar pattern. + */ + private static applyModifierChain( + fieldValue: any, + pattern: any, + modifiers: string[], caseSensitive: boolean ): boolean { - const compareValue = caseSensitive ? value : value.toLowerCase(); - const comparePattern = caseSensitive ? pattern : pattern.toLowerCase(); + if (fieldValue === null || fieldValue === undefined) { + return false; + } - switch (modifier) { - case 'contains': - return compareValue.includes(comparePattern); + const transforms: string[] = []; + let comparator: Comparator | undefined; + + for (const m of modifiers) { + if (TRANSFORMS.has(m)) { + transforms.push(m); + } else if (COMPARATORS.has(m as Comparator)) { + comparator = m as Comparator; // last comparator wins + } else if (m !== 'exists' && m !== 'all') { + if (!warnedModifiers.has(m)) { + warnedModifiers.add(m); + console.warn(`[SigmaFieldMatcher] Unknown field modifier ignored: ${m}`); + } + } + } - case 'startswith': - return compareValue.startsWith(comparePattern); + const candidates = this.expandTransforms(String(pattern), transforms); - case 'endswith': - return compareValue.endsWith(comparePattern); + // base64/base64offset are substring transforms in practice: imply contains + // when no explicit comparator follows them. + const hasEncoding = transforms.includes('base64') || transforms.includes('base64offset'); + const cmp = comparator ?? (hasEncoding ? 'contains' : undefined); - case 'base64': - return this.matchBase64(value, pattern, caseSensitive); + return candidates.some((c) => this.matchComparator(fieldValue, c, cmp, caseSensitive)); + } - case 'base64offset': - return this.matchBase64Offset(value, pattern, caseSensitive); + /** + * Run the pattern through the ordered transform list, fanning out into the set + * of candidate strings that the comparator should be tested against. + */ + private static expandTransforms(pattern: string, transforms: string[]): string[] { + let items: Candidate[] = [{ text: pattern }]; + + for (const t of transforms) { + const next: Candidate[] = []; + for (const item of items) { + const asText = 'text' in item ? item.text : item.bytes.toString('latin1'); + + switch (t) { + case 'utf16le': + case 'utf16': // treated as utf16le for matching purposes + case 'wide': + next.push({ bytes: Buffer.from(asText, 'utf16le') }); + break; + case 'utf16be': + next.push({ bytes: this.toUtf16be(asText) }); + break; + case 'windash': + for (const variant of this.windashVariants(asText)) { + next.push({ text: variant }); + } + break; + case 'base64': { + const buf = 'bytes' in item ? item.bytes : Buffer.from(item.text, 'utf8'); + next.push({ text: buf.toString('base64') }); + break; + } + case 'base64offset': { + const buf = 'bytes' in item ? item.bytes : Buffer.from(item.text, 'utf8'); + for (const v of this.base64Offsets(buf)) { + next.push({ text: v }); + } + break; + } + default: + next.push(item); + } + } + items = next; + } - case 're': - return this.matchRegex(value, pattern, caseSensitive); + return items.map((i) => ('text' in i ? i.text : i.bytes.toString('latin1'))); + } - case 'all': - // 'all' modifier means match all words in any order - return this.matchAllWords(value, pattern, caseSensitive); + /** Apply a single comparator between the field value and a candidate pattern. */ + private static matchComparator( + fieldValue: any, + candidate: string, + comparator: Comparator | undefined, + caseSensitive: boolean + ): boolean { + const valueStr = String(fieldValue); + switch (comparator) { + case undefined: + return this.matchWithWildcards(valueStr, candidate, caseSensitive); + case 'contains': + case 'startswith': + case 'endswith': + return this.matchStringOp(valueStr, candidate, comparator, caseSensitive); + case 're': + return this.matchRegex(valueStr, candidate, caseSensitive); + case 'cidr': + return this.matchCidr(valueStr, candidate); + case 'gt': + case 'gte': + case 'lt': + case 'lte': + return this.matchNumeric(fieldValue, candidate, comparator); default: return false; } } + /** contains / startswith / endswith */ + private static matchStringOp( + value: string, + pattern: string, + op: 'contains' | 'startswith' | 'endswith', + caseSensitive: boolean + ): boolean { + const v = caseSensitive ? value : value.toLowerCase(); + const p = caseSensitive ? pattern : pattern.toLowerCase(); + + switch (op) { + case 'contains': + return v.includes(p); + case 'startswith': + return v.startsWith(p); + case 'endswith': + return v.endsWith(p); + } + } + /** * Match with wildcards (* and ?) */ @@ -98,15 +268,13 @@ export class SigmaFieldMatcher { pattern: string, caseSensitive: boolean ): boolean { - const compareValue = caseSensitive ? value : value.toLowerCase(); const comparePattern = caseSensitive ? pattern : pattern.toLowerCase(); + const compareValue = caseSensitive ? value : value.toLowerCase(); - // If no wildcards, do exact match if (!comparePattern.includes('*') && !comparePattern.includes('?')) { return compareValue === comparePattern; } - // Convert wildcard pattern to regex const regexPattern = this.wildcardToRegex(comparePattern); const regex = new RegExp(`^${regexPattern}$`, caseSensitive ? '' : 'i'); @@ -119,57 +287,8 @@ export class SigmaFieldMatcher { private static wildcardToRegex(pattern: string): string { return pattern .replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape regex special chars - .replace(/\*/g, '.*') // * → .* - .replace(/\?/g, '.'); // ? → . - } - - /** - * Match base64-encoded values - */ - private static matchBase64( - value: string, - pattern: string, - caseSensitive: boolean - ): boolean { - try { - // Decode base64 value - const decoded = Buffer.from(value, 'base64').toString('utf-8'); - const compareValue = caseSensitive ? decoded : decoded.toLowerCase(); - const comparePattern = caseSensitive ? pattern : pattern.toLowerCase(); - - return compareValue.includes(comparePattern); - } catch (error) { - // Invalid base64, no match - return false; - } - } - - /** - * Match base64-encoded values at any offset - */ - private static matchBase64Offset( - value: string, - pattern: string, - caseSensitive: boolean - ): boolean { - // Try matching at different offsets (0, 1, 2 bytes) - for (let offset = 0; offset < 3; offset++) { - try { - const paddedValue = '='.repeat(offset) + value; - const decoded = Buffer.from(paddedValue, 'base64').toString('utf-8'); - const compareValue = caseSensitive ? decoded : decoded.toLowerCase(); - const comparePattern = caseSensitive ? pattern : pattern.toLowerCase(); - - if (compareValue.includes(comparePattern)) { - return true; - } - } catch { - // Invalid base64 at this offset, try next - continue; - } - } - - return false; + .replace(/\*/g, '.*') // * -> .* + .replace(/\?/g, '.'); // ? -> . } /** @@ -185,98 +304,125 @@ export class SigmaFieldMatcher { const regex = new RegExp(pattern, flags); return regex.test(value); } catch (error) { - // Invalid regex, no match console.warn(`[SigmaFieldMatcher] Invalid regex pattern: ${pattern}`, error); return false; } } - /** - * Match all words in any order - */ - private static matchAllWords( - value: string, - pattern: string, - caseSensitive: boolean - ): boolean { - const compareValue = caseSensitive ? value : value.toLowerCase(); - const words = (caseSensitive ? pattern : pattern.toLowerCase()).split(/\s+/); + /** IPv4 CIDR membership test. Non-IPv4 input or malformed CIDR -> no match. */ + private static matchCidr(value: string, cidr: string): boolean { + const slash = cidr.indexOf('/'); + if (slash === -1) { + // Bare address: treat as /32 equality. + const ip = this.ipv4ToInt(value); + const range = this.ipv4ToInt(cidr); + return ip !== null && range !== null && ip === range; + } - return words.every((word) => compareValue.includes(word)); + const range = this.ipv4ToInt(cidr.slice(0, slash)); + const bits = Number(cidr.slice(slash + 1)); + const ip = this.ipv4ToInt(value); + if (ip === null || range === null || !Number.isInteger(bits) || bits < 0 || bits > 32) { + return false; + } + + const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0; + return (ip & mask) === (range & mask); } - /** - * Match a Sigma selection block against log data - * - * @param logData - Log entry data (flattened object) - * @param selection - Sigma selection block (field: value pairs) - * @param caseSensitive - Case-sensitive matching - * @returns true if ALL fields in selection match (AND logic) - */ - static matchSelection( - logData: Record, - selection: Record, - caseSensitive: boolean = false - ): boolean { - // Empty selection matches nothing - if (!selection || Object.keys(selection).length === 0) { - return false; + private static ipv4ToInt(ip: string): number | null { + const parts = ip.trim().split('.'); + if (parts.length !== 4) return null; + let result = 0; + for (const part of parts) { + if (!/^\d{1,3}$/.test(part)) return null; + const n = Number(part); + if (n > 255) return null; + result = (result << 8) | n; } + return result >>> 0; + } - // ALL fields must match (AND logic within a selection) - return Object.entries(selection).every(([field, pattern]) => { - // Parse field modifiers (e.g., "fieldname|contains", "fieldname|re") - const { fieldName, modifier } = this.parseFieldWithModifier(field); + /** Numeric comparison (gt/gte/lt/lte). Non-numeric input -> no match. */ + private static matchNumeric(value: any, pattern: string, op: 'gt' | 'gte' | 'lt' | 'lte'): boolean { + const a = typeof value === 'number' ? value : Number(value); + const b = Number(pattern); + if (Number.isNaN(a) || Number.isNaN(b)) return false; + + switch (op) { + case 'gt': + return a > b; + case 'gte': + return a >= b; + case 'lt': + return a < b; + case 'lte': + return a <= b; + } + } - // Get field value from log (support nested fields with dot notation) - const fieldValue = this.getNestedField(logData, fieldName); + /** Encode a string as UTF-16BE bytes. */ + private static toUtf16be(text: string): Buffer { + const le = Buffer.from(text, 'utf16le'); + const be = Buffer.alloc(le.length); + for (let i = 0; i < le.length; i += 2) { + be[i] = le[i + 1]; + be[i + 1] = le[i]; + } + return be; + } - // Handle |exists modifier specially - if (modifier === 'exists') { - const exists = fieldValue !== null && fieldValue !== undefined; - // pattern should be true/false - return pattern === true ? exists : !exists; - } + /** Replace every ASCII hyphen with each Windows dash variant. */ + private static windashVariants(pattern: string): string[] { + if (!pattern.includes('-')) return [pattern]; + return WINDASH_CHARS.map((c) => pattern.split('-').join(c)); + } - // Match with modifier - return this.match(fieldValue, pattern, { caseSensitive, modifier }); - }); + /** + * SigmaHQ base64offset: produce the three encodings covering the possible + * byte alignments of the pattern inside a larger base64 blob. + */ + private static base64Offsets(buf: Buffer): string[] { + const startOffsets = [0, 2, 3]; + const endOffsets: Array = [null, -3, -2]; + const results: string[] = []; + + for (let i = 0; i < 3; i++) { + const prefixed = Buffer.concat([Buffer.alloc(i, 0x20), buf]); + const encoded = prefixed.toString('base64'); + const start = startOffsets[i]; + const end = endOffsets[i]; + results.push(end === null ? encoded.slice(start) : encoded.slice(start, end)); + } + + return results; } /** - * Parse field name with optional modifier - * Example: "CommandLine|contains" → { fieldName: "CommandLine", modifier: "contains" } + * Parse a field name with its (possibly chained) modifiers. + * Example: "CommandLine|utf16le|base64offset|contains" -> + * { fieldName: "CommandLine", modifiers: ["utf16le", "base64offset", "contains"] } */ private static parseFieldWithModifier(field: string): { fieldName: string; - modifier?: FieldModifier; + modifiers: string[]; } { const parts = field.split('|'); - - if (parts.length === 1) { - return { fieldName: parts[0] }; - } - - const fieldName = parts[0]; - const modifier = parts[1] as FieldModifier; - - return { fieldName, modifier }; + return { fieldName: parts[0], modifiers: parts.slice(1) }; } /** * Get nested field value using dot notation - * Example: "metadata.user.id" → logData.metadata.user.id + * Example: "metadata.user.id" -> logData.metadata.user.id */ private static getNestedField( obj: Record, path: string ): any { - // Support both dot notation and direct access if (path in obj) { return obj[path]; } - // Try nested access const parts = path.split('.'); let current: any = obj; @@ -284,7 +430,6 @@ export class SigmaFieldMatcher { if (current === null || current === undefined) { return undefined; } - current = current[part]; } diff --git a/packages/backend/src/modules/traces/service.ts b/packages/backend/src/modules/traces/service.ts index 32072ea1..d2de9df0 100644 --- a/packages/backend/src/modules/traces/service.ts +++ b/packages/backend/src/modules/traces/service.ts @@ -213,7 +213,7 @@ export class TracesService { const results = await Promise.allSettled([ reservoir.getServiceDependencies(projectId, effectiveFrom, effectiveTo), - this.getServiceHealthStats(projectId, effectiveFrom, effectiveTo, rangeHours), + this.getServiceHealthStats(projectId, effectiveFrom, effectiveTo), includeLogCorrelation ? this.getLogCoOccurrenceEdges(projectId, effectiveFrom, effectiveTo) : Promise.resolve([]), @@ -298,48 +298,19 @@ export class TracesService { projectId: string, from: Date, to: Date, - rangeHours: number, ): Promise { - if (reservoir.getEngineType() !== 'timescale') { - return []; - } - - const { sql } = await import('kysely'); - const table = rangeHours <= 48 ? 'spans_hourly_stats' as const : 'spans_daily_stats' as const; - - const result = await db - .selectFrom(table) - .select([ - 'service_name', - ]) - .select([ - db.fn.sum('span_count').as('total_calls'), - db.fn.sum('error_count').as('total_errors'), - // Weighted average: SUM(avg * count) / SUM(count) - sql`CASE WHEN SUM(span_count) > 0 - THEN SUM(COALESCE(duration_avg_ms, 0) * span_count) / SUM(span_count) - ELSE 0 END`.as('avg_latency_ms'), - // APPROXIMATION: this is the max of the per-bucket p95s, not a true window - // p95 (the hourly/daily aggregate stores only a per-bucket p95, which is - // not mergeable). It is an upper-bound estimate; a true p95 would require a - // mergeable quantile sketch (t-digest) in the continuous aggregate. - db.fn.max('duration_p95_ms').as('p95_latency_ms'), - ]) - .where('project_id', '=', projectId) - .where('bucket', '>=', from) - .where('bucket', '<=', to) - .groupBy('service_name') - .execute(); - - return result.map((r) => ({ - service_name: r.service_name, - total_calls: Number(r.total_calls ?? 0), - total_errors: Number(r.total_errors ?? 0), - error_rate: Number(r.total_calls) > 0 - ? Number(r.total_errors) / Number(r.total_calls) - : 0, - avg_latency_ms: Number(r.avg_latency_ms ?? 0), - p95_latency_ms: r.p95_latency_ms != null ? Number(r.p95_latency_ms) : null, + // True window p95 computed directly from raw spans by the storage engine + // (percentile_cont / quantile / $percentile), not a max of per-bucket p95s + // from a continuous aggregate. Works across every reservoir engine. + const stats = await reservoir.getServiceHealthStats(projectId, from, to); + + return stats.map((s) => ({ + service_name: s.serviceName, + total_calls: s.totalCalls, + total_errors: s.totalErrors, + error_rate: s.totalCalls > 0 ? s.totalErrors / s.totalCalls : 0, + avg_latency_ms: s.avgLatencyMs, + p95_latency_ms: s.p95LatencyMs, })); } diff --git a/packages/backend/src/tests/modules/sigma/field-matcher.test.ts b/packages/backend/src/tests/modules/sigma/field-matcher.test.ts index aba11be6..59e948ad 100644 --- a/packages/backend/src/tests/modules/sigma/field-matcher.test.ts +++ b/packages/backend/src/tests/modules/sigma/field-matcher.test.ts @@ -135,23 +135,129 @@ describe('Sigma Field Matcher', () => { }); }); - describe('Modifier: base64', () => { - it('should match base64-encoded content', () => { - const base64 = Buffer.from('malicious code').toString('base64'); - expect(SigmaFieldMatcher.match(base64, 'malicious', { modifier: 'base64' })).toBe(true); - expect(SigmaFieldMatcher.match(base64, 'benign', { modifier: 'base64' })).toBe(false); + describe('Modifier: base64 (SigmaHQ encode-pattern semantics)', () => { + // SigmaHQ: the pattern is base64-encoded and that encoding is matched + // against the field value (NOT: decode the field). A lone base64 modifier + // implies a substring (contains) match, as it is always used in practice. + it('should match when the field contains base64(pattern)', () => { + const enc = Buffer.from('malicious').toString('base64'); + expect(SigmaFieldMatcher.match(`prefix ${enc} suffix`, 'malicious', { modifier: 'base64' })).toBe(true); + expect(SigmaFieldMatcher.match('plain text no encoding', 'malicious', { modifier: 'base64' })).toBe(false); + }); + + it('should support base64|contains chains via matchSelection', () => { + const enc = Buffer.from('whoami').toString('base64'); // d2hvYW1p + expect( + SigmaFieldMatcher.matchSelection({ cmd: `powershell ${enc} extra` }, { 'cmd|base64|contains': 'whoami' }), + ).toBe(true); + expect( + SigmaFieldMatcher.matchSelection({ cmd: 'powershell whoami extra' }, { 'cmd|base64|contains': 'whoami' }), + ).toBe(false); + }); + }); + + describe('Modifier: all (SigmaHQ list quantifier)', () => { + // SigmaHQ: |all flips the default OR over a value list into AND. + it('should require every list element to match (AND) with |all', () => { + expect( + SigmaFieldMatcher.matchSelection({ cmd: 'foo bar baz' }, { 'cmd|contains|all': ['foo', 'baz'] }), + ).toBe(true); + expect( + SigmaFieldMatcher.matchSelection({ cmd: 'foo bar' }, { 'cmd|contains|all': ['foo', 'baz'] }), + ).toBe(false); }); - it('should handle invalid base64 gracefully', () => { - expect(SigmaFieldMatcher.match('not-base64!!!', 'test', { modifier: 'base64' })).toBe(false); + it('should keep OR semantics over a list without |all', () => { + expect( + SigmaFieldMatcher.matchSelection({ cmd: 'foo only' }, { 'cmd|contains': ['foo', 'baz'] }), + ).toBe(true); }); }); - describe('Modifier: all (all words)', () => { - it('should match if all words present in any order', () => { - expect(SigmaFieldMatcher.match('hello world test', 'hello test', { modifier: 'all' })).toBe(true); - expect(SigmaFieldMatcher.match('test hello world', 'hello test', { modifier: 'all' })).toBe(true); - expect(SigmaFieldMatcher.match('hello world', 'hello test', { modifier: 'all' })).toBe(false); + describe('Compound modifiers (SigmaHQ spec)', () => { + it('should match base64offset|contains regardless of byte alignment', () => { + // A real base64 blob in a field; the secret must be found at any of + // the 3 base64 alignment offsets. + const blob = Buffer.from('powershell -enc whoami extra payload').toString('base64'); + expect( + SigmaFieldMatcher.matchSelection({ cmd: blob }, { 'cmd|base64offset|contains': 'whoami' }), + ).toBe(true); + expect( + SigmaFieldMatcher.matchSelection({ cmd: blob }, { 'cmd|base64offset|contains': 'notthere' }), + ).toBe(false); + }); + + it('should match utf16le|base64offset|contains (PowerShell -enc style)', () => { + const enc = Buffer.from('whoami', 'utf16le').toString('base64'); + expect( + SigmaFieldMatcher.matchSelection( + { cmd: `powershell -enc ${enc}` }, + { 'cmd|utf16le|base64offset|contains': 'whoami' }, + ), + ).toBe(true); + }); + + it('should treat wide as an alias of utf16le', () => { + const enc = Buffer.from('whoami', 'utf16le').toString('base64'); + expect( + SigmaFieldMatcher.matchSelection( + { cmd: `x ${enc} y` }, + { 'cmd|wide|base64offset|contains': 'whoami' }, + ), + ).toBe(true); + }); + + it('should expand windash dash variants', () => { + expect( + SigmaFieldMatcher.matchSelection({ cmd: 'program /e /s' }, { 'cmd|windash|contains': '-e' }), + ).toBe(true); + // without windash the literal dash is required and not present + expect( + SigmaFieldMatcher.matchSelection({ cmd: 'program /e /s' }, { 'cmd|contains': '-e' }), + ).toBe(false); + }); + + it('should match IPv4 cidr ranges', () => { + expect(SigmaFieldMatcher.matchSelection({ src: '192.168.1.50' }, { 'src|cidr': '192.168.1.0/24' })).toBe(true); + expect(SigmaFieldMatcher.matchSelection({ src: '192.168.2.50' }, { 'src|cidr': '192.168.1.0/24' })).toBe(false); + expect(SigmaFieldMatcher.matchSelection({ src: '10.0.0.5' }, { 'src|cidr': '10.0.0.0/8' })).toBe(true); + }); + + it('should support numeric comparators gt/gte/lt/lte', () => { + expect(SigmaFieldMatcher.matchSelection({ n: 10 }, { 'n|gt': 5 })).toBe(true); + expect(SigmaFieldMatcher.matchSelection({ n: 10 }, { 'n|gt': 10 })).toBe(false); + expect(SigmaFieldMatcher.matchSelection({ n: 10 }, { 'n|gte': 10 })).toBe(true); + expect(SigmaFieldMatcher.matchSelection({ n: 10 }, { 'n|lt': 20 })).toBe(true); + expect(SigmaFieldMatcher.matchSelection({ n: 10 }, { 'n|lte': 10 })).toBe(true); + expect(SigmaFieldMatcher.matchSelection({ n: 'notnum' }, { 'n|gt': 5 })).toBe(false); + }); + + it('should not drop the comparator in a transform+comparator chain', () => { + const enc = Buffer.from('cmd.exe').toString('base64'); + // endswith comparator must be honored after the base64 transform + expect( + SigmaFieldMatcher.matchSelection({ p: `junk${enc}` }, { 'p|base64|endswith': 'cmd.exe' }), + ).toBe(true); + expect( + SigmaFieldMatcher.matchSelection({ p: `${enc}junk` }, { 'p|base64|endswith': 'cmd.exe' }), + ).toBe(false); + }); + + it('should combine |all with a transform+comparator chain', () => { + const a = Buffer.from('alpha').toString('base64'); + const b = Buffer.from('omega').toString('base64'); + expect( + SigmaFieldMatcher.matchSelection( + { cmd: `x ${a} y ${b} z` }, + { 'cmd|base64|contains|all': ['alpha', 'omega'] }, + ), + ).toBe(true); + expect( + SigmaFieldMatcher.matchSelection( + { cmd: `x ${a} y z` }, + { 'cmd|base64|contains|all': ['alpha', 'omega'] }, + ), + ).toBe(false); }); }); diff --git a/packages/backend/src/tests/modules/traces/service.test.ts b/packages/backend/src/tests/modules/traces/service.test.ts index efbfdb0f..2666a506 100644 --- a/packages/backend/src/tests/modules/traces/service.test.ts +++ b/packages/backend/src/tests/modules/traces/service.test.ts @@ -998,17 +998,30 @@ describe('TracesService', () => { expect(result.edges).toBeDefined(); }); - it('should set default values when health stats are empty', async () => { + it('should compute a true window p95 and avg latency from raw spans', async () => { const traceId = crypto.randomBytes(16).toString('hex'); const now = new Date(); + // Parent in svc-health (duration 100), plus a second svc-health span + // (duration 300) so avg/p95 are computed over the window, not defaulted. const parentSpan = await createTestSpan({ projectId: context.project.id, organizationId: context.organization.id, traceId, spanId: 'health-parent', - serviceName: 'svc-no-health', + serviceName: 'svc-health', startTime: now, + durationMs: 100, + }); + + await createTestSpan({ + projectId: context.project.id, + organizationId: context.organization.id, + traceId, + spanId: 'health-extra', + serviceName: 'svc-health', + startTime: new Date(now.getTime() + 5), + durationMs: 300, }); await createTestSpan({ @@ -1016,7 +1029,7 @@ describe('TracesService', () => { organizationId: context.organization.id, traceId, parentSpanId: parentSpan.span_id, - serviceName: 'svc-no-health-child', + serviceName: 'svc-health-child', startTime: new Date(now.getTime() + 10), }); @@ -1026,13 +1039,12 @@ describe('TracesService', () => { new Date(now.getTime() + 5000), ); - // Health stats won't be populated (continuous aggregates not refreshed in tests) - // So defaults should be applied - for (const node of result.nodes) { - expect(node.errorRate).toBe(0); - expect(node.avgLatencyMs).toBe(0); - expect(node.p95LatencyMs).toBeNull(); - } + const node = result.nodes.find((n) => n.name === 'svc-health'); + expect(node).toBeDefined(); + // avg of 100 and 300 is 200; p95 is a true window percentile, not null. + expect(node!.avgLatencyMs).toBeGreaterThan(0); + expect(node!.p95LatencyMs).not.toBeNull(); + expect(node!.p95LatencyMs!).toBeGreaterThanOrEqual(100); }); it('should handle multiple independent trace dependencies', async () => { @@ -1123,10 +1135,13 @@ describe('TracesService', () => { expect(nodeA).toBeDefined(); expect(nodeA?.callCount).toBe(0); expect(nodeA?.totalCalls).toBe(0); + // No spans for log-only services, so health stats default to null p95. + expect(nodeA?.p95LatencyMs).toBeNull(); expect(nodeB).toBeDefined(); expect(nodeB?.callCount).toBe(0); expect(nodeB?.totalCalls).toBe(0); + expect(nodeB?.p95LatencyMs).toBeNull(); }); it('should not add log edges below threshold (< 2 co-occurrences)', async () => { diff --git a/packages/reservoir/src/buffered/reservoir-buffered.ts b/packages/reservoir/src/buffered/reservoir-buffered.ts index 5f249cb2..f16dbcba 100644 --- a/packages/reservoir/src/buffered/reservoir-buffered.ts +++ b/packages/reservoir/src/buffered/reservoir-buffered.ts @@ -121,6 +121,7 @@ export class ReservoirBuffered implements IReservoir { getTraceById(...args: Parameters): ReturnType { return this.inner.getTraceById(...args); } getServiceDependencies(...args: Parameters): ReturnType { return this.inner.getServiceDependencies(...args); } getTraceServices(...args: Parameters): ReturnType { return this.inner.getTraceServices(...args); } + getServiceHealthStats(...args: Parameters): ReturnType { return this.inner.getServiceHealthStats(...args); } deleteSpansByTimeRange(...args: Parameters): ReturnType { return this.inner.deleteSpansByTimeRange(...args); } queryMetrics(...args: Parameters): ReturnType { return this.inner.queryMetrics(...args); } aggregateMetrics(...args: Parameters): ReturnType { return this.inner.aggregateMetrics(...args); } diff --git a/packages/reservoir/src/client.ts b/packages/reservoir/src/client.ts index e2ad236f..80551df2 100644 --- a/packages/reservoir/src/client.ts +++ b/packages/reservoir/src/client.ts @@ -29,6 +29,7 @@ import type { TraceQueryResult, IngestSpansResult, ServiceDependencyResult, + ServiceHealthStat, DeleteSpansByTimeRangeParams, MetricRecord, MetricQueryParams, @@ -211,6 +212,15 @@ export class Reservoir implements IReservoir { return this.engine.getTraceServices(projectId, from, to); } + async getServiceHealthStats( + projectId: string, + from?: Date, + to?: Date, + ): Promise { + this.ensureInitialized(); + return this.engine.getServiceHealthStats(projectId, from, to); + } + async deleteSpansByTimeRange(params: DeleteSpansByTimeRangeParams): Promise { this.ensureInitialized(); return this.engine.deleteSpansByTimeRange(params); diff --git a/packages/reservoir/src/core/reservoir-interface.ts b/packages/reservoir/src/core/reservoir-interface.ts index 89711310..11eabea9 100644 --- a/packages/reservoir/src/core/reservoir-interface.ts +++ b/packages/reservoir/src/core/reservoir-interface.ts @@ -28,6 +28,7 @@ import type { TraceQueryResult, IngestSpansResult, ServiceDependencyResult, + ServiceHealthStat, DeleteSpansByTimeRangeParams, MetricRecord, MetricQueryParams, @@ -85,6 +86,8 @@ export interface IReservoir { ): Promise; /** Distinct service names that appear in traces within the time range. */ getTraceServices(projectId: string, from?: Date, to?: Date): Promise; + /** Per-service health stats (calls, errors, avg + true window p95) from raw spans. */ + getServiceHealthStats(projectId: string, from?: Date, to?: Date): Promise; deleteSpansByTimeRange(params: DeleteSpansByTimeRangeParams): Promise; // Metrics diff --git a/packages/reservoir/src/core/storage-engine.ts b/packages/reservoir/src/core/storage-engine.ts index d4fb5ca4..239408c8 100644 --- a/packages/reservoir/src/core/storage-engine.ts +++ b/packages/reservoir/src/core/storage-engine.ts @@ -29,6 +29,7 @@ import type { TraceQueryResult, IngestSpansResult, ServiceDependencyResult, + ServiceHealthStat, DeleteSpansByTimeRangeParams, MetricRecord, MetricQueryParams, @@ -144,6 +145,13 @@ export abstract class StorageEngine { /** Distinct service names appearing in traces within the time range */ abstract getTraceServices(projectId: string, from?: Date, to?: Date): Promise; + /** Per-service health stats (calls, errors, avg + true window p95) from raw spans */ + abstract getServiceHealthStats( + projectId: string, + from?: Date, + to?: Date, + ): Promise; + /** Delete spans by time range */ abstract deleteSpansByTimeRange(params: DeleteSpansByTimeRangeParams): Promise; diff --git a/packages/reservoir/src/core/types.ts b/packages/reservoir/src/core/types.ts index 3db2ba7d..76aabf80 100644 --- a/packages/reservoir/src/core/types.ts +++ b/packages/reservoir/src/core/types.ts @@ -399,6 +399,20 @@ export interface ServiceDependencyResult { edges: ServiceDependency[]; } +/** + * Per-service health stats computed over a time window directly from raw spans. + * p95LatencyMs is a true window percentile (approximate on engines whose native + * quantile is approximate, e.g. MongoDB/ClickHouse t-digest), not a max of + * per-bucket percentiles. + */ +export interface ServiceHealthStat { + serviceName: string; + totalCalls: number; + totalErrors: number; + avgLatencyMs: number; + p95LatencyMs: number | null; +} + /** Parameters for deleting spans by time range */ export interface DeleteSpansByTimeRangeParams { projectId: string | string[]; diff --git a/packages/reservoir/src/engines/clickhouse/clickhouse-engine.test.ts b/packages/reservoir/src/engines/clickhouse/clickhouse-engine.test.ts index f21b5f6c..9e4af10e 100644 --- a/packages/reservoir/src/engines/clickhouse/clickhouse-engine.test.ts +++ b/packages/reservoir/src/engines/clickhouse/clickhouse-engine.test.ts @@ -997,6 +997,53 @@ describe('ClickHouseEngine (integration)', () => { }); }); + describe('getServiceHealthStats', () => { + beforeEach(async () => { + await client.command({ query: 'TRUNCATE TABLE IF EXISTS spans' }); + const spans = []; + for (let i = 1; i <= 100; i++) { + spans.push( + makeSpan({ + spanId: `health-${i}`, + traceId: 'trace-health', + serviceName: 'api', + startTime: new Date('2025-03-01T10:00:00Z'), + durationMs: i, + statusCode: i <= 10 ? 'ERROR' : 'OK', + }), + ); + } + await engine.ingestSpans(spans); + }); + + it('computes a true window p95 from raw spans', async () => { + const stats = await engine.getServiceHealthStats( + 'proj-1', + new Date('2025-03-01T00:00:00Z'), + new Date('2025-03-02T00:00:00Z'), + ); + + const api = stats.find((s) => s.serviceName === 'api'); + expect(api).toBeDefined(); + expect(api!.totalCalls).toBe(100); + expect(api!.totalErrors).toBe(10); + expect(api!.avgLatencyMs).toBeGreaterThan(45); + expect(api!.avgLatencyMs).toBeLessThan(56); + // True window p95 over durations 1..100 is ~95, not the MAX (=100). + expect(api!.p95LatencyMs!).toBeGreaterThanOrEqual(90); + expect(api!.p95LatencyMs!).toBeLessThanOrEqual(100); + }); + + it('returns no rows outside the window', async () => { + const stats = await engine.getServiceHealthStats( + 'proj-1', + new Date('2030-01-01T00:00:00Z'), + new Date('2030-01-02T00:00:00Z'), + ); + expect(stats).toHaveLength(0); + }); + }); + describe('deleteSpansByTimeRange', () => { beforeEach(async () => { await client.command({ query: 'TRUNCATE TABLE IF EXISTS spans' }); diff --git a/packages/reservoir/src/engines/clickhouse/clickhouse-engine.ts b/packages/reservoir/src/engines/clickhouse/clickhouse-engine.ts index 25fdd907..7aad1826 100644 --- a/packages/reservoir/src/engines/clickhouse/clickhouse-engine.ts +++ b/packages/reservoir/src/engines/clickhouse/clickhouse-engine.ts @@ -63,6 +63,7 @@ import type { TraceQueryResult, IngestSpansResult, ServiceDependencyResult, + ServiceHealthStat, ServiceDependency, DeleteSpansByTimeRangeParams, SpanKind, @@ -1179,6 +1180,58 @@ export class ClickHouseEngine extends StorageEngine { return { nodes, edges }; } + async getServiceHealthStats( + projectId: string, + from?: Date, + to?: Date, + ): Promise { + const queryParams: Record = { projectId }; + let timeFilter = ''; + + if (from) { + timeFilter += ` AND start_time >= {p_from:DateTime64(3)}`; + queryParams.p_from = toDateTime64(from); + } + if (to) { + timeFilter += ` AND start_time <= {p_to:DateTime64(3)}`; + queryParams.p_to = toDateTime64(to); + } + + // True window p95 from raw spans via quantile() (approximate t-digest, over + // the whole window - not a max of per-bucket percentiles). + const resultSet = await this.runQuery({ + query: ` + SELECT + service_name, + count() AS total_calls, + countIf(status_code = 'ERROR') AS total_errors, + avg(duration_ms) AS avg_latency_ms, + quantile(0.95)(duration_ms) AS p95_latency_ms + FROM spans + WHERE project_id = {projectId:String}${timeFilter} + GROUP BY service_name + `, + query_params: queryParams, + format: 'JSONEachRow', + }); + + const rows = await resultSet.json<{ + service_name: string; + total_calls: string; + total_errors: string; + avg_latency_ms: number; + p95_latency_ms: number | null; + }>(); + + return rows.map((r) => ({ + serviceName: r.service_name, + totalCalls: Number(r.total_calls), + totalErrors: Number(r.total_errors), + avgLatencyMs: Number(r.avg_latency_ms), + p95LatencyMs: r.p95_latency_ms != null ? Number(r.p95_latency_ms) : null, + })); + } + async deleteSpansByTimeRange(params: DeleteSpansByTimeRangeParams): Promise { const start = Date.now(); const pids = Array.isArray(params.projectId) ? params.projectId : [params.projectId]; diff --git a/packages/reservoir/src/engines/mongodb/mongodb-engine-integration.test.ts b/packages/reservoir/src/engines/mongodb/mongodb-engine-integration.test.ts index aad5628d..b67da269 100644 --- a/packages/reservoir/src/engines/mongodb/mongodb-engine-integration.test.ts +++ b/packages/reservoir/src/engines/mongodb/mongodb-engine-integration.test.ts @@ -979,6 +979,54 @@ describe('MongoDBEngine (integration)', () => { }); }); + describe('getServiceHealthStats', () => { + beforeEach(async () => { + const db = directClient.db(TEST_CONFIG.database); + await db.collection('spans').deleteMany({}); + const spans = []; + for (let i = 1; i <= 100; i++) { + spans.push( + makeSpan({ + spanId: `health-${i}`, + traceId: 'trace-health', + serviceName: 'api', + startTime: new Date('2025-03-01T10:00:00Z'), + durationMs: i, + statusCode: i <= 10 ? 'ERROR' : 'OK', + }), + ); + } + await engine.ingestSpans(spans); + }); + + it('computes a true window p95 from raw spans', async () => { + const stats = await engine.getServiceHealthStats( + 'proj-1', + new Date('2025-03-01T00:00:00Z'), + new Date('2025-03-02T00:00:00Z'), + ); + + const api = stats.find((s) => s.serviceName === 'api'); + expect(api).toBeDefined(); + expect(api!.totalCalls).toBe(100); + expect(api!.totalErrors).toBe(10); + expect(api!.avgLatencyMs).toBeGreaterThan(45); + expect(api!.avgLatencyMs).toBeLessThan(56); + // Approximate but a true window p95 over durations 1..100 (~95), not MAX. + expect(api!.p95LatencyMs!).toBeGreaterThanOrEqual(88); + expect(api!.p95LatencyMs!).toBeLessThanOrEqual(100); + }); + + it('returns no rows outside the window', async () => { + const stats = await engine.getServiceHealthStats( + 'proj-1', + new Date('2030-01-01T00:00:00Z'), + new Date('2030-01-02T00:00:00Z'), + ); + expect(stats).toHaveLength(0); + }); + }); + describe('deleteSpansByTimeRange', () => { beforeEach(async () => { await engine.ingestSpans([ diff --git a/packages/reservoir/src/engines/mongodb/mongodb-engine.ts b/packages/reservoir/src/engines/mongodb/mongodb-engine.ts index 1d9658d0..1819a46a 100644 --- a/packages/reservoir/src/engines/mongodb/mongodb-engine.ts +++ b/packages/reservoir/src/engines/mongodb/mongodb-engine.ts @@ -35,6 +35,7 @@ import type { TraceQueryResult, IngestSpansResult, ServiceDependencyResult, + ServiceHealthStat, ServiceDependency, DeleteSpansByTimeRangeParams, SpanKind, @@ -799,6 +800,50 @@ export class MongoDBEngine extends StorageEngine { return { nodes, edges }; } + async getServiceHealthStats( + projectId: string, + from?: Date, + to?: Date, + ): Promise { + const col = this.spansCol(); + + const match: Document = { project_id: projectId }; + if (from || to) { + const timeFilter: Document = {}; + if (from) timeFilter.$gte = from; + if (to) timeFilter.$lte = to; + match.start_time = timeFilter; + } + + // True window p95 from raw spans via $percentile (approximate t-digest, over + // the whole window - not a max of per-bucket percentiles). Requires Mongo 7.0+. + const pipeline: Document[] = [ + { $match: match }, + { + $group: { + _id: '$service_name', + total_calls: { $sum: 1 }, + total_errors: { $sum: { $cond: [{ $eq: ['$status_code', 'ERROR'] }, 1, 0] } }, + avg_latency_ms: { $avg: '$duration_ms' }, + p95_latency_ms: { $percentile: { input: '$duration_ms', p: [0.95], method: 'approximate' } }, + }, + }, + ]; + + const rows = await col.aggregate(pipeline, { ...ctxOpts() }).toArray(); + + return rows.map((r) => { + const p95 = Array.isArray(r.p95_latency_ms) ? r.p95_latency_ms[0] : r.p95_latency_ms; + return { + serviceName: String(r._id), + totalCalls: Number(r.total_calls ?? 0), + totalErrors: Number(r.total_errors ?? 0), + avgLatencyMs: Number(r.avg_latency_ms ?? 0), + p95LatencyMs: p95 != null ? Number(p95) : null, + }; + }); + } + async deleteSpansByTimeRange(params: DeleteSpansByTimeRangeParams): Promise { const start = Date.now(); const col = this.spansCol(); diff --git a/packages/reservoir/src/engines/timescale/timescale-engine.test.ts b/packages/reservoir/src/engines/timescale/timescale-engine.test.ts index 02c4472a..65236997 100644 --- a/packages/reservoir/src/engines/timescale/timescale-engine.test.ts +++ b/packages/reservoir/src/engines/timescale/timescale-engine.test.ts @@ -840,6 +840,39 @@ describe('TimescaleEngine', () => { }); }); + describe('getServiceHealthStats', () => { + it('computes per-service stats with a true window p95 from raw spans', async () => { + mockQuery.mockResolvedValueOnce({ + rows: [ + { service_name: 'api', total_calls: '100', total_errors: '5', avg_latency_ms: 120.5, p95_latency_ms: 480 }, + { service_name: 'db', total_calls: '40', total_errors: '0', avg_latency_ms: 12, p95_latency_ms: null }, + ], + }); + await engine.connect(); + + const result = await engine.getServiceHealthStats( + 'proj-1', + new Date('2024-01-01'), + new Date('2024-01-02'), + ); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + serviceName: 'api', + totalCalls: 100, + totalErrors: 5, + avgLatencyMs: 120.5, + p95LatencyMs: 480, + }); + expect(result[1].p95LatencyMs).toBeNull(); + + const sql = mockQuery.mock.calls[0][0] as string; + expect(sql).toContain('percentile_cont(0.95)'); + expect(sql).toContain('FROM public.spans'); + expect(sql).toContain('GROUP BY service_name'); + }); + }); + describe('deleteSpansByTimeRange', () => { it('deletes spans and orphaned traces', async () => { // DELETE spans diff --git a/packages/reservoir/src/engines/timescale/timescale-engine.ts b/packages/reservoir/src/engines/timescale/timescale-engine.ts index 648c030a..6d5c4bf1 100644 --- a/packages/reservoir/src/engines/timescale/timescale-engine.ts +++ b/packages/reservoir/src/engines/timescale/timescale-engine.ts @@ -36,6 +36,7 @@ import type { TraceQueryResult, IngestSpansResult, ServiceDependencyResult, + ServiceHealthStat, ServiceDependency, DeleteSpansByTimeRangeParams, SpanKind, @@ -887,6 +888,49 @@ export class TimescaleEngine extends StorageEngine { return { nodes, edges }; } + async getServiceHealthStats( + projectId: string, + from?: Date, + to?: Date, + ): Promise { + const s = this.schema; + const values: unknown[] = [projectId]; + let idx = 2; + let timeFilter = ''; + + if (from) { + timeFilter += ` AND start_time >= $${idx++}`; + values.push(from); + } + if (to) { + timeFilter += ` AND start_time <= $${idx++}`; + values.push(to); + } + + // True window p95 straight from raw spans via percentile_cont (not a max of + // per-bucket percentiles from a continuous aggregate). + const result = await this.runQuery( + `SELECT + service_name, + COUNT(*)::int AS total_calls, + SUM(CASE WHEN status_code = 'ERROR' THEN 1 ELSE 0 END)::int AS total_errors, + AVG(duration_ms)::double precision AS avg_latency_ms, + percentile_cont(0.95) WITHIN GROUP (ORDER BY duration_ms) AS p95_latency_ms + FROM ${s}.spans + WHERE project_id = $1${timeFilter} + GROUP BY service_name`, + values, + ); + + return result.rows.map((r) => ({ + serviceName: r.service_name as string, + totalCalls: Number(r.total_calls ?? 0), + totalErrors: Number(r.total_errors ?? 0), + avgLatencyMs: Number(r.avg_latency_ms ?? 0), + p95LatencyMs: r.p95_latency_ms != null ? Number(r.p95_latency_ms) : null, + })); + } + async deleteSpansByTimeRange(params: DeleteSpansByTimeRangeParams): Promise { const start = Date.now(); const s = this.schema; diff --git a/packages/reservoir/src/index.ts b/packages/reservoir/src/index.ts index b2a8adca..e7d4fd57 100644 --- a/packages/reservoir/src/index.ts +++ b/packages/reservoir/src/index.ts @@ -43,6 +43,7 @@ export type { IngestSpansResult, ServiceDependency, ServiceDependencyResult, + ServiceHealthStat, DeleteSpansByTimeRangeParams, MetricType, HistogramData,