From df2213453184a3a97f7a1843cd7798ab424beff1 Mon Sep 17 00:00:00 2001 From: Polliog Date: Mon, 22 Jun 2026 09:30:05 +0200 Subject: [PATCH 01/38] fix sigma compound modifier chains and |all quantifier --- .../src/modules/sigma/field-matcher.ts | 453 ++++++++++++------ .../tests/modules/sigma/field-matcher.test.ts | 130 ++++- 2 files changed, 417 insertions(+), 166 deletions(-) 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/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); }); }); From 55b8266dfd89723fd352705cb0526f0059207013 Mon Sep 17 00:00:00 2001 From: Polliog Date: Mon, 22 Jun 2026 09:41:35 +0200 Subject: [PATCH 02/38] add reservoir getServiceHealthStats with true window p95 --- .../src/buffered/reservoir-buffered.ts | 1 + packages/reservoir/src/client.ts | 10 ++++ .../reservoir/src/core/reservoir-interface.ts | 3 ++ packages/reservoir/src/core/storage-engine.ts | 8 +++ packages/reservoir/src/core/types.ts | 14 +++++ .../clickhouse/clickhouse-engine.test.ts | 47 ++++++++++++++++ .../engines/clickhouse/clickhouse-engine.ts | 53 +++++++++++++++++++ .../mongodb-engine-integration.test.ts | 48 +++++++++++++++++ .../src/engines/mongodb/mongodb-engine.ts | 45 ++++++++++++++++ .../timescale/timescale-engine.test.ts | 33 ++++++++++++ .../src/engines/timescale/timescale-engine.ts | 44 +++++++++++++++ packages/reservoir/src/index.ts | 1 + 12 files changed, 307 insertions(+) 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, From 02a873d28ead2e27b41d3aaf282da5fc40540a48 Mon Sep 17 00:00:00 2001 From: Polliog Date: Mon, 22 Jun 2026 09:46:49 +0200 Subject: [PATCH 03/38] service map p95 sourced from raw spans via reservoir --- .../backend/src/modules/traces/service.ts | 55 +++++-------------- .../src/tests/modules/traces/service.test.ts | 35 ++++++++---- 2 files changed, 38 insertions(+), 52 deletions(-) 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/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 () => { From f984ed85dfbf8c1cdca0fb68758f80517f1b3065 Mon Sep 17 00:00:00 2001 From: Polliog Date: Mon, 22 Jun 2026 09:51:45 +0200 Subject: [PATCH 04/38] changelog for sigma modifier chains and true p95 --- CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) 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. From 3704c7cf10b5c560989557fe4364be2ab5e743d2 Mon Sep 17 00:00:00 2001 From: Polliog Date: Thu, 25 Jun 2026 08:09:11 +0200 Subject: [PATCH 05/38] restyle trace and session id in log detail --- .../src/routes/dashboard/search/+page.svelte | 48 ++++++++++++------- 1 file changed, 32 insertions(+), 16 deletions(-) diff --git a/packages/frontend/src/routes/dashboard/search/+page.svelte b/packages/frontend/src/routes/dashboard/search/+page.svelte index b43b795c..7d58f86b 100644 --- a/packages/frontend/src/routes/dashboard/search/+page.svelte +++ b/packages/frontend/src/routes/dashboard/search/+page.svelte @@ -58,6 +58,8 @@ import Table2 from "@lucide/svelte/icons/table-2"; import WrapText from "@lucide/svelte/icons/wrap-text"; import Clock from "@lucide/svelte/icons/clock"; + import ArrowUpRight from "@lucide/svelte/icons/arrow-up-right"; + import Filter from "@lucide/svelte/icons/filter"; interface LogEntry { id?: string; @@ -1864,41 +1866,55 @@ {#if log.traceId} -
+
Trace ID: - {#if log.projectId} - View Trace → + {log.traceId} + + + {:else} + {/if}
{/if} {#if log.sessionId} -
+
Session ID:
{/if} From aeb85a2078f03e8e7d20cc1399c2f9f1ce152d2f Mon Sep 17 00:00:00 2001 From: Polliog Date: Thu, 25 Jun 2026 08:09:15 +0200 Subject: [PATCH 06/38] link error group logs to their trace --- .../backend/src/modules/exceptions/service.ts | 7 +++-- packages/frontend/src/lib/api/exceptions.ts | 1 + .../routes/dashboard/errors/[id]/+page.svelte | 29 ++++++++++++++----- 3 files changed, 27 insertions(+), 10 deletions(-) diff --git a/packages/backend/src/modules/exceptions/service.ts b/packages/backend/src/modules/exceptions/service.ts index 7fcb399d..8dd693c2 100644 --- a/packages/backend/src/modules/exceptions/service.ts +++ b/packages/backend/src/modules/exceptions/service.ts @@ -506,7 +506,7 @@ export class ExceptionService { occurrenceCount: number; limit?: number; offset?: number; - }): Promise<{ logs: Array<{ id: string; time: Date; service: string; message: string; metadata?: Record }>; total: number }> { + }): Promise<{ logs: Array<{ id: string; time: Date; service: string; message: string; traceId?: string; metadata?: Record }>; total: number }> { const limit = params.limit || 10; const offset = params.offset || 0; @@ -553,15 +553,16 @@ export class ExceptionService { ).flat(); // Build lookup map and return in the same order - const logMap = new Map(storedLogs.map((l: { id: string; time: Date; service: string; message: string; metadata?: any }) => [l.id, l])); + const logMap = new Map(storedLogs.map((l: { id: string; time: Date; service: string; message: string; traceId?: string; metadata?: any }) => [l.id, l])); const logs = logIds .map(id => logMap.get(id)) - .filter((l): l is { id: string; time: Date; service: string; message: string; metadata?: any } => Boolean(l)) + .filter((l): l is { id: string; time: Date; service: string; message: string; traceId?: string; metadata?: any } => Boolean(l)) .map(l => ({ id: l.id, time: l.time, service: l.service, message: l.message, + traceId: l.traceId, metadata: l.metadata, })); diff --git a/packages/frontend/src/lib/api/exceptions.ts b/packages/frontend/src/lib/api/exceptions.ts index b1065380..82319db7 100644 --- a/packages/frontend/src/lib/api/exceptions.ts +++ b/packages/frontend/src/lib/api/exceptions.ts @@ -26,6 +26,7 @@ export interface ErrorGroupLog { time: string | Date; service: string; message: string; + traceId?: string; metadata?: Record; } diff --git a/packages/frontend/src/routes/dashboard/errors/[id]/+page.svelte b/packages/frontend/src/routes/dashboard/errors/[id]/+page.svelte index b58f3251..3324ac34 100644 --- a/packages/frontend/src/routes/dashboard/errors/[id]/+page.svelte +++ b/packages/frontend/src/routes/dashboard/errors/[id]/+page.svelte @@ -26,6 +26,7 @@ import { StackTraceViewer, LanguageBadge, ErrorGroupStatusBadge } from '$lib/components/exceptions'; import Bug from '@lucide/svelte/icons/bug'; import ArrowLeft from '@lucide/svelte/icons/arrow-left'; + import ArrowUpRight from '@lucide/svelte/icons/arrow-up-right'; import Clock from '@lucide/svelte/icons/clock'; import Hash from '@lucide/svelte/icons/hash'; import TrendingUp from '@lucide/svelte/icons/trending-up'; @@ -432,13 +433,27 @@ {/if}
- +
+ {#if log.traceId} + + {/if} + +

{log.message}

From 1b57a910fad9c1b071ea3f78401091bf38ecb08f Mon Sep 17 00:00:00 2001 From: Polliog Date: Thu, 25 Jun 2026 08:09:18 +0200 Subject: [PATCH 07/38] changelog for trace links and log detail restyle --- CHANGELOG.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 55a60153..341ab4ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [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. +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. Plus a couple of frontend touch-ups: trace/session IDs in the log detail are theme-aware, and the error detail page links each occurrence to its trace. + +### Added +- **Per-occurrence trace links on the error detail page**: each log in an error group's Logs tab now shows a "View Trace" action when that log carries a trace context, opening the existing trace timeline. The error-group logs endpoint (`GET /api/v1/error-groups/:id/logs`) now surfaces the `traceId` it already loaded from storage and previously discarded; no schema change, no migration ### 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 @@ -16,6 +19,7 @@ Two correctness follow-ups from the multi-engine bug-hunt sweep (issue #255): Si ### 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 +- **Trace and session IDs in the log search detail are theme-aware**: the expanded log row rendered them as hardcoded light-mode pills (`bg-purple-100` / `bg-teal-100`) that looked washed out in dark mode. The trace ID is now a link that opens the trace timeline (primary accent) with a separate filter button, and the session ID is a dark-safe filter button; both derive their colors from the design tokens ## [1.0.2] - 2026-06-22 From 8444c09c0c6368cdda9235bfaa9d2886ead21a8a Mon Sep 17 00:00:00 2001 From: Polliog Date: Thu, 25 Jun 2026 08:09:41 +0200 Subject: [PATCH 08/38] update esbuild version to 0.25.12 in package.json and pnpm-lock.yaml --- package.json | 2 +- pnpm-lock.yaml | 222 ++++++++++++++++++++++++------------------------- 2 files changed, 112 insertions(+), 112 deletions(-) diff --git a/package.json b/package.json index 00232a3a..8418f2a6 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "license": "AGPL-3.0", "pnpm": { "overrides": { - "esbuild": ">=0.28.1", + "esbuild": ">=0.25.0 <0.26.0", "shell-quote": ">=1.8.4", "form-data": ">=4.0.6", "vite": ">=6.4.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fd835f8e..070008d6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,7 +5,7 @@ settings: excludeLinksFromLockfile: false overrides: - esbuild: '>=0.28.1' + esbuild: '>=0.25.0 <0.26.0' shell-quote: '>=1.8.4' form-data: '>=4.0.6' vite: '>=6.4.3' @@ -563,158 +563,158 @@ packages: '@epic-web/invariant@1.0.0': resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==} - '@esbuild/aix-ppc64@0.28.1': - resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.28.1': - resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.28.1': - resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.28.1': - resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.28.1': - resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.28.1': - resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.28.1': - resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.28.1': - resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.28.1': - resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.28.1': - resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.28.1': - resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.28.1': - resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.28.1': - resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.28.1': - resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.28.1': - resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.28.1': - resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.28.1': - resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.28.1': - resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.28.1': - resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.28.1': - resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.28.1': - resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.28.1': - resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.28.1': - resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.28.1': - resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.28.1': - resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.28.1': - resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -2006,8 +2006,8 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} - esbuild@0.28.1: - resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} engines: {node: '>=18'} hasBin: true @@ -3889,82 +3889,82 @@ snapshots: '@epic-web/invariant@1.0.0': {} - '@esbuild/aix-ppc64@0.28.1': + '@esbuild/aix-ppc64@0.25.12': optional: true - '@esbuild/android-arm64@0.28.1': + '@esbuild/android-arm64@0.25.12': optional: true - '@esbuild/android-arm@0.28.1': + '@esbuild/android-arm@0.25.12': optional: true - '@esbuild/android-x64@0.28.1': + '@esbuild/android-x64@0.25.12': optional: true - '@esbuild/darwin-arm64@0.28.1': + '@esbuild/darwin-arm64@0.25.12': optional: true - '@esbuild/darwin-x64@0.28.1': + '@esbuild/darwin-x64@0.25.12': optional: true - '@esbuild/freebsd-arm64@0.28.1': + '@esbuild/freebsd-arm64@0.25.12': optional: true - '@esbuild/freebsd-x64@0.28.1': + '@esbuild/freebsd-x64@0.25.12': optional: true - '@esbuild/linux-arm64@0.28.1': + '@esbuild/linux-arm64@0.25.12': optional: true - '@esbuild/linux-arm@0.28.1': + '@esbuild/linux-arm@0.25.12': optional: true - '@esbuild/linux-ia32@0.28.1': + '@esbuild/linux-ia32@0.25.12': optional: true - '@esbuild/linux-loong64@0.28.1': + '@esbuild/linux-loong64@0.25.12': optional: true - '@esbuild/linux-mips64el@0.28.1': + '@esbuild/linux-mips64el@0.25.12': optional: true - '@esbuild/linux-ppc64@0.28.1': + '@esbuild/linux-ppc64@0.25.12': optional: true - '@esbuild/linux-riscv64@0.28.1': + '@esbuild/linux-riscv64@0.25.12': optional: true - '@esbuild/linux-s390x@0.28.1': + '@esbuild/linux-s390x@0.25.12': optional: true - '@esbuild/linux-x64@0.28.1': + '@esbuild/linux-x64@0.25.12': optional: true - '@esbuild/netbsd-arm64@0.28.1': + '@esbuild/netbsd-arm64@0.25.12': optional: true - '@esbuild/netbsd-x64@0.28.1': + '@esbuild/netbsd-x64@0.25.12': optional: true - '@esbuild/openbsd-arm64@0.28.1': + '@esbuild/openbsd-arm64@0.25.12': optional: true - '@esbuild/openbsd-x64@0.28.1': + '@esbuild/openbsd-x64@0.25.12': optional: true - '@esbuild/openharmony-arm64@0.28.1': + '@esbuild/openharmony-arm64@0.25.12': optional: true - '@esbuild/sunos-x64@0.28.1': + '@esbuild/sunos-x64@0.25.12': optional: true - '@esbuild/win32-arm64@0.28.1': + '@esbuild/win32-arm64@0.25.12': optional: true - '@esbuild/win32-ia32@0.28.1': + '@esbuild/win32-ia32@0.25.12': optional: true - '@esbuild/win32-x64@0.28.1': + '@esbuild/win32-x64@0.25.12': optional: true '@exodus/bytes@1.15.1(@noble/hashes@1.8.0)': @@ -5347,34 +5347,34 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.4 - esbuild@0.28.1: + esbuild@0.25.12: optionalDependencies: - '@esbuild/aix-ppc64': 0.28.1 - '@esbuild/android-arm': 0.28.1 - '@esbuild/android-arm64': 0.28.1 - '@esbuild/android-x64': 0.28.1 - '@esbuild/darwin-arm64': 0.28.1 - '@esbuild/darwin-x64': 0.28.1 - '@esbuild/freebsd-arm64': 0.28.1 - '@esbuild/freebsd-x64': 0.28.1 - '@esbuild/linux-arm': 0.28.1 - '@esbuild/linux-arm64': 0.28.1 - '@esbuild/linux-ia32': 0.28.1 - '@esbuild/linux-loong64': 0.28.1 - '@esbuild/linux-mips64el': 0.28.1 - '@esbuild/linux-ppc64': 0.28.1 - '@esbuild/linux-riscv64': 0.28.1 - '@esbuild/linux-s390x': 0.28.1 - '@esbuild/linux-x64': 0.28.1 - '@esbuild/netbsd-arm64': 0.28.1 - '@esbuild/netbsd-x64': 0.28.1 - '@esbuild/openbsd-arm64': 0.28.1 - '@esbuild/openbsd-x64': 0.28.1 - '@esbuild/openharmony-arm64': 0.28.1 - '@esbuild/sunos-x64': 0.28.1 - '@esbuild/win32-arm64': 0.28.1 - '@esbuild/win32-ia32': 0.28.1 - '@esbuild/win32-x64': 0.28.1 + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 escalade@3.2.0: {} @@ -6634,7 +6634,7 @@ snapshots: tsx@4.21.0: dependencies: - esbuild: 0.28.1 + esbuild: 0.25.12 get-tsconfig: 4.13.0 optionalDependencies: fsevents: 2.3.3 @@ -6732,7 +6732,7 @@ snapshots: vite@6.4.3(@types/node@20.19.25)(jiti@1.21.7)(tsx@4.21.0): dependencies: - esbuild: 0.28.1 + esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 postcss: 8.5.10 @@ -6746,7 +6746,7 @@ snapshots: vite@6.4.3(@types/node@22.19.17)(jiti@1.21.7)(tsx@4.21.0): dependencies: - esbuild: 0.28.1 + esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 postcss: 8.5.10 From ec4dcf1e0545fb377011af60d617c5dae7000954 Mon Sep 17 00:00:00 2001 From: Polliog Date: Thu, 25 Jun 2026 09:21:40 +0200 Subject: [PATCH 09/38] fix log context dialog overflow and add metadata copy --- .../lib/components/LogContextDialog.svelte | 64 +++++++++++++------ 1 file changed, 44 insertions(+), 20 deletions(-) diff --git a/packages/frontend/src/lib/components/LogContextDialog.svelte b/packages/frontend/src/lib/components/LogContextDialog.svelte index fbd8ab62..146ada8a 100644 --- a/packages/frontend/src/lib/components/LogContextDialog.svelte +++ b/packages/frontend/src/lib/components/LogContextDialog.svelte @@ -5,8 +5,11 @@ import Spinner from '$lib/components/Spinner.svelte'; import { ExceptionDetailsDialog } from '$lib/components/exceptions'; import BreadcrumbTimeline from '$lib/components/BreadcrumbTimeline.svelte'; + import { copyToClipboard } from '$lib/utils/clipboard'; import AlertTriangle from '@lucide/svelte/icons/alert-triangle'; import ListTree from '@lucide/svelte/icons/list-tree'; + import Copy from '@lucide/svelte/icons/copy'; + import Check from '@lucide/svelte/icons/check'; interface LogEntry { id?: string; @@ -51,6 +54,18 @@ return level === 'error' || level === 'critical'; } + // Copy-to-clipboard feedback, keyed per metadata block + let copiedKey = $state(null); + async function copyMetadata(key: string, metadata: Record) { + const ok = await copyToClipboard(JSON.stringify(metadata, null, 2)); + if (ok) { + copiedKey = key; + setTimeout(() => { + if (copiedKey === key) copiedKey = null; + }, 2000); + } + } + function openExceptionDialog() { exceptionDialogOpen = true; } @@ -121,6 +136,30 @@ } +{#snippet metadataBlock(metadata: Record, key: string)} +
+ + View metadata + +
+ +
{JSON.stringify(metadata, null, 2)}
+
+
+{/snippet} + !isOpen && onClose()}> @@ -140,7 +179,7 @@ {error} {:else if contextLogs} -
+
{#if contextLogs.before.length > 0}
← {contextLogs.before.length} log(s) before @@ -163,12 +202,7 @@

{log.message}

{#if log.metadata && Object.keys(log.metadata).length > 0} -
- - View metadata - -
{JSON.stringify(log.metadata, null, 2)}
-
+ {@render metadataBlock(log.metadata, `before-${log.id ?? log.time}`)} {/if}
{/each} @@ -195,12 +229,7 @@

{selectedLog.message}

{#if selectedLog.metadata && Object.keys(selectedLog.metadata).length > 0} -
- - View metadata - -
{JSON.stringify(selectedLog.metadata, null, 2)}
-
+ {@render metadataBlock(selectedLog.metadata, 'selected')} {/if} {#if isErrorLevel(selectedLog.level) && selectedLog.id && organizationId}
@@ -227,7 +256,7 @@ {breadcrumbsOpen ? '▾' : '▸'} {#if breadcrumbsOpen} -
+

{log.message}

{#if log.metadata && Object.keys(log.metadata).length > 0} -
- - View metadata - -
{JSON.stringify(log.metadata, null, 2)}
-
+ {@render metadataBlock(log.metadata, `after-${log.id ?? log.time}`)} {/if}
{/each} From 5173d0e3b8dd3a7ef8860078badb6beb508f9e6e Mon Sep 17 00:00:00 2001 From: Polliog Date: Thu, 25 Jun 2026 09:21:43 +0200 Subject: [PATCH 10/38] search: metadata copy, breadcrumbs view, nested columns --- .../components/search/ColumnConfigMenu.svelte | 4 +- .../src/routes/dashboard/search/+page.svelte | 123 ++++++++++++++++-- 2 files changed, 116 insertions(+), 11 deletions(-) diff --git a/packages/frontend/src/lib/components/search/ColumnConfigMenu.svelte b/packages/frontend/src/lib/components/search/ColumnConfigMenu.svelte index 85025d09..bcdecbb7 100644 --- a/packages/frontend/src/lib/components/search/ColumnConfigMenu.svelte +++ b/packages/frontend/src/lib/components/search/ColumnConfigMenu.svelte @@ -58,13 +58,13 @@

Metadata columns

-

Add metadata keys to show as extra columns in the table.

+

Add metadata keys to show as extra columns in the table. Use dot notation to reach nested values (e.g. sdk.name).

()); + function toggleBreadcrumbs(index: number) { + const newSet = new Set(expandedBreadcrumbs); + if (newSet.has(index)) { + newSet.delete(index); + } else { + newSet.add(index); + } + expandedBreadcrumbs = newSet; + } + + type Breadcrumb = { + type: string; + category?: string; + message: string; + level?: string; + timestamp: number; + data?: Record; + }; + function getBreadcrumbs(log: LogEntry): Breadcrumb[] { + const bc = (log.metadata as Record | undefined)?.breadcrumbs; + return Array.isArray(bc) ? (bc as Breadcrumb[]) : []; + } + + // Resolve a metadata column value, supporting dot-notation paths into nested + // objects (e.g. "sdk.name"). Exact top-level keys win first, so flat keys that + // literally contain dots (e.g. "debug.trace_id") still resolve correctly. + function resolveMetadataPath( + metadata: Record | undefined, + path: string, + ): unknown { + if (!metadata) return undefined; + if (Object.prototype.hasOwnProperty.call(metadata, path)) return metadata[path]; + let current: any = metadata; + for (const part of path.split(".")) { + if (current === null || typeof current !== "object") return undefined; + current = current[part]; + } + return current; + } + + function formatMetadataCell(value: unknown): string { + if (typeof value === "object") return JSON.stringify(value); + return String(value); + } + function openContextDialog(log: LogEntry) { selectedLogForContext = log; contextDialogOpen = true; @@ -894,6 +946,18 @@ return level === 'error' || level === 'critical'; } + // Copy-to-clipboard feedback for metadata blocks, keyed per row + let copiedMetaKey = $state(null); + async function copyMetadata(key: string, metadata: unknown) { + const ok = await copyToClipboard(JSON.stringify(metadata, null, 2)); + if (ok) { + copiedMetaKey = key; + setTimeout(() => { + if (copiedMetaKey === key) copiedMetaKey = null; + }, 2000); + } + } + function getLevelColor(level: LogEntry["level"]): string { switch (level) { case "critical": @@ -1825,9 +1889,15 @@ >{log.message} {#each customColumns as col (col)} - - {#if log.metadata && log.metadata[col] !== undefined && log.metadata[col] !== null} - {String(log.metadata[col])} + {@const cellValue = resolveMetadataPath(log.metadata, col)} + + {#if cellValue !== undefined && cellValue !== null} + {formatMetadataCell(cellValue)} {:else} - {/if} @@ -1940,14 +2010,30 @@
{/if} {#if log.metadata} + {@const metaKey = `meta-${log.id ?? globalIndex}`}
Metadata: -
-
{JSON.stringify(
-                                    log.metadata,
-                                    null,
-                                    2,
-                                  )}
+
+ +
+
{JSON.stringify(
+                                      log.metadata,
+                                      null,
+                                      2,
+                                    )}
+
{/if} @@ -1964,6 +2050,25 @@
{/if} + {#if getBreadcrumbs(log).length > 0} + {@const crumbs = getBreadcrumbs(log)} +
+ + {#if expandedBreadcrumbs.has(globalIndex)} +
+ +
+ {/if} +
+ {/if}
From 63b54aff1ecdfa96a01a7803554915da1ec5f75a Mon Sep 17 00:00:00 2001 From: Polliog Date: Thu, 25 Jun 2026 09:21:48 +0200 Subject: [PATCH 11/38] changelog for log detail copy, breadcrumbs and nested columns --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 341ab4ed..d98cefd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,8 +12,12 @@ Two correctness follow-ups from the multi-engine bug-hunt sweep (issue #255): Si ### Added - **Per-occurrence trace links on the error detail page**: each log in an error group's Logs tab now shows a "View Trace" action when that log carries a trace context, opening the existing trace timeline. The error-group logs endpoint (`GET /api/v1/error-groups/:id/logs`) now surfaces the `traceId` it already loaded from storage and previously discarded; no schema change, no migration +- **Copy buttons on metadata blocks**: the log search expanded detail and the Log Context dialog now have a one-click copy on each metadata block (with copied feedback), so a log's metadata JSON can be grabbed without selecting it by hand +- **Breadcrumbs timeline in the log search detail**: when a log carries `metadata.breadcrumbs`, the expanded row now renders a collapsible "Breadcrumbs (N)" timeline (the same `BreadcrumbTimeline` view already used in the Log Context dialog) instead of leaving them buried in the raw metadata JSON +- **Nested metadata columns**: custom metadata columns in log search now accept dot-notation paths (e.g. `sdk.name`) to read into nested objects. Exact top-level keys still win first, so flat keys that contain dots (e.g. `debug.trace_id`) keep resolving; object/array values render as compact JSON, and the full value is available on hover ### Fixed +- **Log Context dialog no longer overflows on wide content**: a wide metadata `
` or breadcrumb entry stretched the whole dialog (the grid children had `min-width: auto`); the content now stays within the dialog and the wide block scrolls on its own axis
 - **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`)
 

From b1cf325c30eee75978f1b6d9e0495541e271d239 Mon Sep 17 00:00:00 2001
From: Polliog 
Date: Thu, 25 Jun 2026 09:38:48 +0200
Subject: [PATCH 12/38] fix redis leak: dont clobber bullmq cleanup defaults

---
 .../src/queue/adapters/bullmq-adapter.ts      | 43 +++++++++++++-----
 .../tests/queue/bullmq-job-options.test.ts    | 44 +++++++++++++++++++
 2 files changed, 75 insertions(+), 12 deletions(-)
 create mode 100644 packages/backend/src/tests/queue/bullmq-job-options.test.ts

diff --git a/packages/backend/src/queue/adapters/bullmq-adapter.ts b/packages/backend/src/queue/adapters/bullmq-adapter.ts
index e899d1b9..0a57ce9b 100644
--- a/packages/backend/src/queue/adapters/bullmq-adapter.ts
+++ b/packages/backend/src/queue/adapters/bullmq-adapter.ts
@@ -31,6 +31,36 @@ const DEFAULT_JOB_OPTIONS = {
   },
 };
 
+/**
+ * Build the per-job options passed to BullMQ's queue.add().
+ *
+ * CRITICAL: only include `removeOnComplete` / `removeOnFail` when the caller
+ * explicitly provides them. BullMQ merges per-job options OVER the queue's
+ * `defaultJobOptions` with Object.assign, which copies keys whose value is
+ * `undefined`. So passing `removeOnComplete: undefined` would clobber the
+ * DEFAULT_JOB_OPTIONS cleanup config, leaving BullMQ to keep every completed
+ * and failed job hash in Redis forever (the memory-leak root cause).
+ */
+export function buildBullJobOptions(options?: IJobOptions): Record {
+  const jobOptions: Record = {
+    delay: options?.delay,
+    // Default to 3 attempts to match the graphile adapter. Without this BullMQ
+    // would default to 1 (no retries), so the same job retried differently
+    // depending on the configured queue backend.
+    attempts: options?.maxAttempts ?? 3,
+    priority: options?.priority,
+    jobId: options?.jobKey,
+    repeat: options?.repeat,
+  };
+  if (options?.removeOnComplete !== undefined) {
+    jobOptions.removeOnComplete = options.removeOnComplete;
+  }
+  if (options?.removeOnFail !== undefined) {
+    jobOptions.removeOnFail = options.removeOnFail;
+  }
+  return jobOptions;
+}
+
 /**
  * Convert BullMQ Job to unified IJob interface
  */
@@ -62,18 +92,7 @@ export class BullMQQueueAdapter implements IQueueAdapter, ICronR
 
   async add(jobName: string, data: T, options?: IJobOptions): Promise> {
     const payload = attachContextToPayload(data);
-    const bullJob = await (this.queue as any).add(jobName, payload, {
-      delay: options?.delay,
-      // Default to 3 attempts to match the graphile adapter. Without this BullMQ
-      // would default to 1 (no retries), so the same job retried differently
-      // depending on the configured queue backend.
-      attempts: options?.maxAttempts ?? 3,
-      priority: options?.priority,
-      jobId: options?.jobKey,
-      repeat: options?.repeat,
-      removeOnComplete: options?.removeOnComplete,
-      removeOnFail: options?.removeOnFail,
-    });
+    const bullJob = await (this.queue as any).add(jobName, payload, buildBullJobOptions(options));
 
     return {
       id: bullJob.id || '',
diff --git a/packages/backend/src/tests/queue/bullmq-job-options.test.ts b/packages/backend/src/tests/queue/bullmq-job-options.test.ts
new file mode 100644
index 00000000..5366a1c9
--- /dev/null
+++ b/packages/backend/src/tests/queue/bullmq-job-options.test.ts
@@ -0,0 +1,44 @@
+import { describe, it, expect } from 'vitest';
+import { buildBullJobOptions } from '../../queue/adapters/bullmq-adapter.js';
+
+/**
+ * Regression test for the Redis memory leak: the BullMQ adapter must NOT pass
+ * `removeOnComplete`/`removeOnFail` when the caller omits them, otherwise the
+ * `undefined` values clobber the queue-level DEFAULT_JOB_OPTIONS during BullMQ's
+ * Object.assign merge and disable job cleanup (completed/failed job hashes pile
+ * up in Redis forever).
+ */
+describe('buildBullJobOptions', () => {
+  it('omits removeOnComplete/removeOnFail when no options are given, so queue defaults survive', () => {
+    const opts = buildBullJobOptions();
+
+    // The keys must be ABSENT (not present-with-undefined), so Object.assign over
+    // defaultJobOptions keeps the cleanup config.
+    expect('removeOnComplete' in opts).toBe(false);
+    expect('removeOnFail' in opts).toBe(false);
+  });
+
+  it('omits removeOnComplete/removeOnFail when options object lacks them', () => {
+    const opts = buildBullJobOptions({ maxAttempts: 5, priority: 2 });
+
+    expect('removeOnComplete' in opts).toBe(false);
+    expect('removeOnFail' in opts).toBe(false);
+    expect(opts.attempts).toBe(5);
+    expect(opts.priority).toBe(2);
+  });
+
+  it('defaults attempts to 3 to match the graphile adapter', () => {
+    expect(buildBullJobOptions().attempts).toBe(3);
+  });
+
+  it('passes through removeOnComplete/removeOnFail when explicitly provided', () => {
+    const opts = buildBullJobOptions({ removeOnComplete: false, removeOnFail: true });
+
+    expect(opts.removeOnComplete).toBe(false);
+    expect(opts.removeOnFail).toBe(true);
+  });
+
+  it('maps jobKey to jobId', () => {
+    expect(buildBullJobOptions({ jobKey: 'abc' }).jobId).toBe('abc');
+  });
+});

From 6a038fd24e3a4976fc494258876764572a983b8a Mon Sep 17 00:00:00 2001
From: Polliog 
Date: Thu, 25 Jun 2026 09:38:51 +0200
Subject: [PATCH 13/38] sigma cron: only update existing rules, no auto alerts

---
 .../tests/modules/sigma/sync-service.test.ts  | 33 +++++++++++++++++++
 packages/backend/src/worker.ts                | 22 ++++++++++++-
 2 files changed, 54 insertions(+), 1 deletion(-)

diff --git a/packages/backend/src/tests/modules/sigma/sync-service.test.ts b/packages/backend/src/tests/modules/sigma/sync-service.test.ts
index 0d9d40a7..640a951d 100644
--- a/packages/backend/src/tests/modules/sigma/sync-service.test.ts
+++ b/packages/backend/src/tests/modules/sigma/sync-service.test.ts
@@ -413,5 +413,38 @@ describe('SigmaSyncService - extra methods', () => {
 
       expect(result.imported).toBe(1);
     });
+
+    it('does NOT create an alert rule when autoCreateAlerts=false (sigma rules are independent)', async () => {
+      const { sigmahqClient } = await import('../../../modules/sigma/github-client.js');
+      (sigmahqClient.fetchRulesByCategory as ReturnType).mockResolvedValueOnce([
+        { path: 'rules/linux/noalert.yml', name: 'noalert.yml', category: 'linux', downloadUrl: 'http://x', sha: 'sha1' },
+      ]);
+      (sigmahqClient.fetchRule as ReturnType).mockResolvedValue(VALID_YAML);
+
+      const result = await service.syncFromSigmaHQ({
+        organizationId: orgId,
+        selection: { categories: ['linux'] },
+        autoCreateAlerts: false,
+      });
+
+      expect(result.imported).toBe(1);
+
+      // No alert_rules row should be created for this org (the cron path that
+      // passed autoCreateAlerts:true used to spawn an alert rule per sigma rule).
+      const alerts = await db
+        .selectFrom('alert_rules')
+        .select('id')
+        .where('organization_id', '=', orgId)
+        .execute();
+      expect(alerts).toHaveLength(0);
+
+      // ...and the imported sigma rule must not be linked to one.
+      const rule = await db
+        .selectFrom('sigma_rules')
+        .select('alert_rule_id')
+        .where('sigmahq_path', '=', 'rules/linux/noalert.yml')
+        .executeTakeFirst();
+      expect(rule?.alert_rule_id).toBeNull();
+    });
   });
 });
diff --git a/packages/backend/src/worker.ts b/packages/backend/src/worker.ts
index 3f47d691..c29c8924 100644
--- a/packages/backend/src/worker.ts
+++ b/packages/backend/src/worker.ts
@@ -641,9 +641,29 @@ async function syncSigmaRules() {
 
       for (const org of orgs) {
         try {
+          // Only re-sync the rules this org already imported, to refresh their
+          // detection content/commit from upstream. Do NOT fetch the whole
+          // SigmaHQ catalog (that path imports and enables thousands of new
+          // rules), and do NOT auto-create alert rules: Sigma rules are
+          // independent from alert rules.
+          const existingRules = await db
+            .selectFrom('sigma_rules')
+            .select('sigmahq_path')
+            .where('organization_id', '=', org.organization_id)
+            .where('sigmahq_path', 'is not', null)
+            .execute();
+          const rulePaths = existingRules
+            .map((r) => r.sigmahq_path)
+            .filter((p): p is string => Boolean(p));
+
+          if (rulePaths.length === 0) {
+            continue;
+          }
+
           const result = await sigmaSyncService.syncFromSigmaHQ({
             organizationId: org.organization_id,
-            autoCreateAlerts: true,
+            selection: { rules: rulePaths },
+            autoCreateAlerts: false,
             onLimitExceeded: 'skip-new',
           });
 

From 0112cae243eb794cb578431408f0c2ab2a348fb0 Mon Sep 17 00:00:00 2001
From: Polliog 
Date: Thu, 25 Jun 2026 09:38:56 +0200
Subject: [PATCH 14/38] changelog for redis leak and sigma sync fixes

---
 CHANGELOG.md | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index d98cefd9..1bfd8c3a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ## [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. Plus a couple of frontend touch-ups: trace/session IDs in the log detail are theme-aware, and the error detail page links each occurrence to its trace.
+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. This line also fixes two operational bugs: a Redis memory leak where completed/failed BullMQ jobs were never evicted, and a nightly SigmaHQ sync that re-imported the whole catalog as enabled and auto-created alert rules. Plus a few frontend touch-ups: theme-aware trace/session IDs in the log detail, per-occurrence trace links on the error page, metadata copy buttons, a breadcrumbs timeline and nested metadata columns in log search.
 
 ### Added
 - **Per-occurrence trace links on the error detail page**: each log in an error group's Logs tab now shows a "View Trace" action when that log carries a trace context, opening the existing trace timeline. The error-group logs endpoint (`GET /api/v1/error-groups/:id/logs`) now surfaces the `traceId` it already loaded from storage and previously discarded; no schema change, no migration
@@ -17,6 +17,8 @@ Two correctness follow-ups from the multi-engine bug-hunt sweep (issue #255): Si
 - **Nested metadata columns**: custom metadata columns in log search now accept dot-notation paths (e.g. `sdk.name`) to read into nested objects. Exact top-level keys still win first, so flat keys that contain dots (e.g. `debug.trace_id`) keep resolving; object/array values render as compact JSON, and the full value is available on hover
 
 ### Fixed
+- **Redis memory leak: completed/failed jobs were never evicted**: the BullMQ queue adapter defined sane `removeOnComplete`/`removeOnFail` cleanup defaults on the queue, but its `add()` then passed `removeOnComplete: undefined` / `removeOnFail: undefined` on every job. BullMQ merges per-job options over the queue defaults with `Object.assign`, which copies the `undefined` keys and so wiped the cleanup config, making BullMQ retain every completed and failed job hash (and its full payload) in Redis forever. With the high-volume ingestion jobs (`sigma-detection`, `log-pipeline`, `exception-parsing`) carrying whole log batches, Redis grew unbounded (multi-GB) while the dashboard still showed 0 waiting / 0 failed. `add()` now omits those keys unless the caller sets them, so the queue-level retention (keep 100 completed/1h, 50 failed/24h) applies
+- **Nightly SigmaHQ sync re-imported the entire catalog and auto-created alert rules**: the 2:30 AM cron called the sync with no rule selection, falling into the "fetch ALL rules" path that pulled the whole SigmaHQ catalog (~2000+ rules) and inserted them all as `enabled = true` (the `sigma_rules.enabled` column defaults to true and the insert never set it), so an org that had enabled 5-6 rules woke up with thousands active. The same cron passed `autoCreateAlerts: true`, which inserted an `alert_rules` row per synced Sigma rule, so Sigma rules appeared to "turn into" alert rules overnight. The cron now syncs only the rules the org already imported (by `sigmahq_path`) to refresh their detection content, and never auto-creates alert rules; Sigma rules stay independent. (Does not retroactively clean rules/alerts already created; a one-off cleanup is tracked separately)
 - **Log Context dialog no longer overflows on wide content**: a wide metadata `
` or breadcrumb entry stretched the whole dialog (the grid children had `min-width: auto`); the content now stays within the dialog and the wide block scrolls on its own axis
 - **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`)

From 51017ad7d797c00dfd2c37a34cd37d02f4845cf0 Mon Sep 17 00:00:00 2001
From: Polliog 
Date: Thu, 25 Jun 2026 09:45:10 +0200
Subject: [PATCH 15/38] fix error detail trend bars not rendering

---
 .../routes/dashboard/errors/[id]/+page.svelte | 24 ++++++++++---------
 1 file changed, 13 insertions(+), 11 deletions(-)

diff --git a/packages/frontend/src/routes/dashboard/errors/[id]/+page.svelte b/packages/frontend/src/routes/dashboard/errors/[id]/+page.svelte
index 3324ac34..d866a0df 100644
--- a/packages/frontend/src/routes/dashboard/errors/[id]/+page.svelte
+++ b/packages/frontend/src/routes/dashboard/errors/[id]/+page.svelte
@@ -188,7 +188,7 @@
 
 	function formatDate(dateStr: string | Date): string {
 		const date = typeof dateStr === 'string' ? new Date(dateStr) : dateStr;
-		return date.toLocaleDateString(undefined, {
+		return date.toLocaleDateString('en-US', {
 			month: 'short',
 			day: 'numeric',
 			year: 'numeric',
@@ -298,7 +298,7 @@
 			
- {group.occurrenceCount.toLocaleString()} + {group.occurrenceCount.toLocaleString('en-US')} occurrences
@@ -341,7 +341,7 @@ Stack Trace Trend - Logs ({logsTotal.toLocaleString()}) + Logs ({logsTotal.toLocaleString('en-US')}) @@ -381,14 +381,16 @@ {#each trend as bucket} {@const maxCount = Math.max(...trend.map((t) => t.count), 1)} {@const height = (bucket.count / maxCount) * 100} -
-
+
+
+
+
- {new Date(bucket.timestamp).toLocaleDateString(undefined, { weekday: 'short' })} + {new Date(bucket.timestamp).toLocaleDateString('en-US', { weekday: 'short' })}
{/each} @@ -467,7 +469,7 @@ Loading... {:else} - Load More ({(logsTotal - logs.length).toLocaleString()} remaining) + Load More ({(logsTotal - logs.length).toLocaleString('en-US')} remaining) {/if}
From 2eac0c588876fb0468b2b65765740f4609e18243 Mon Sep 17 00:00:00 2001 From: Polliog Date: Thu, 25 Jun 2026 09:45:14 +0200 Subject: [PATCH 16/38] pin user-facing dates and numbers to en-US locale --- .../src/lib/components/BreadcrumbTimeline.svelte | 2 +- .../components/CorrelationTimelineDialog.svelte | 4 ++-- .../lib/components/DetectionPackDialog.svelte | 2 +- .../DetectionPacksGalleryDialog.svelte | 2 +- .../src/lib/components/ExportLogsDialog.svelte | 16 ++++++++-------- .../src/lib/components/LogContextDialog.svelte | 2 +- .../lib/components/SigmaTreeMultiSelect.svelte | 2 +- .../admin/PlatformTimelineChart.svelte | 8 ++++---- .../alerts/preview/PreviewSamples.svelte | 6 +++--- .../alerts/preview/PreviewSummary.svelte | 2 +- .../alerts/preview/PreviewTimeline.svelte | 8 ++++---- .../panels/ActivityOverviewPanel.svelte | 4 ++-- .../panels/DetectionEventsPanel.svelte | 2 +- .../panels/LiveLogStreamPanel.svelte | 2 +- .../panels/MetricChartPanel.svelte | 2 +- .../panels/TimeSeriesPanel.svelte | 2 +- .../panels/TopNTablePanel.svelte | 2 +- .../panels/TraceLatencyPanel.svelte | 2 +- .../panels/TraceVolumePanel.svelte | 4 ++-- .../lib/components/dashboard/LogsChart.svelte | 4 ++-- .../dashboard/TopServicesWidget.svelte | 2 +- .../components/exceptions/ErrorGroupCard.svelte | 2 +- .../src/lib/components/metrics/MetricCard.svelte | 2 +- .../lib/components/metrics/SignalChart.svelte | 2 +- .../onboarding/steps/FirstLogStep.svelte | 2 +- .../siem/dashboard/TimelineWidget.svelte | 10 +++++----- .../siem/enrichment/IpReputationCard.svelte | 2 +- .../siem/incidents/DetectionEventsList.svelte | 6 +++--- .../siem/incidents/IncidentCard.svelte | 2 +- .../siem/incidents/IncidentCommentsThread.svelte | 2 +- .../incidents/IncidentHistoryTimeline.svelte | 2 +- packages/frontend/src/lib/utils/datetime.ts | 2 +- .../src/routes/dashboard/admin/+page.svelte | 4 ++-- .../dashboard/admin/organizations/+page.svelte | 2 +- .../admin/organizations/[id]/+page.svelte | 2 +- .../routes/dashboard/admin/projects/+page.svelte | 2 +- .../dashboard/admin/projects/[id]/+page.svelte | 2 +- .../dashboard/admin/system-health/+page.svelte | 2 +- .../routes/dashboard/admin/users/+page.svelte | 2 +- .../dashboard/admin/users/[id]/+page.svelte | 2 +- .../src/routes/dashboard/monitoring/+page.svelte | 14 +++++++------- .../dashboard/monitoring/[id]/+page.svelte | 8 ++++---- .../projects/[id]/sessions/+page.svelte | 2 +- .../[id]/sessions/[sessionId]/+page.svelte | 4 ++-- .../src/routes/dashboard/search/+page.svelte | 4 ++-- .../dashboard/security/incidents/+page.svelte | 4 ++-- .../security/incidents/[id]/+page.svelte | 4 ++-- .../dashboard/settings/audit-log/+page.svelte | 4 ++-- .../routes/dashboard/settings/usage/+page.svelte | 4 ++-- .../dashboard/settings/webhooks/+page.svelte | 2 +- 50 files changed, 91 insertions(+), 91 deletions(-) diff --git a/packages/frontend/src/lib/components/BreadcrumbTimeline.svelte b/packages/frontend/src/lib/components/BreadcrumbTimeline.svelte index d4a4be16..f1190a6a 100644 --- a/packages/frontend/src/lib/components/BreadcrumbTimeline.svelte +++ b/packages/frontend/src/lib/components/BreadcrumbTimeline.svelte @@ -50,7 +50,7 @@ } function formatAbsoluteTime(timestamp: number): string { - return new Date(timestamp).toLocaleTimeString(undefined, { + return new Date(timestamp).toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', diff --git a/packages/frontend/src/lib/components/CorrelationTimelineDialog.svelte b/packages/frontend/src/lib/components/CorrelationTimelineDialog.svelte index 7b1ce558..0775b051 100644 --- a/packages/frontend/src/lib/components/CorrelationTimelineDialog.svelte +++ b/packages/frontend/src/lib/components/CorrelationTimelineDialog.svelte @@ -67,13 +67,13 @@ } function formatTime(timestamp: string): string { - return new Date(timestamp).toLocaleString(); + return new Date(timestamp).toLocaleString('en-US'); } function formatShortTime(timestamp: string): string { const date = new Date(timestamp); // Include milliseconds manually since fractionalSecondDigits may not be supported - const timeStr = date.toLocaleTimeString(undefined, { + const timeStr = date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', diff --git a/packages/frontend/src/lib/components/DetectionPackDialog.svelte b/packages/frontend/src/lib/components/DetectionPackDialog.svelte index 51ab73cc..21656184 100644 --- a/packages/frontend/src/lib/components/DetectionPackDialog.svelte +++ b/packages/frontend/src/lib/components/DetectionPackDialog.svelte @@ -255,7 +255,7 @@ {pack.category} - {pack.rules.length.toLocaleString()} Sigma rules + {pack.rules.length.toLocaleString('en-US')} Sigma rules {#if pack.enabled && pack.generatedRulesCount > 0} diff --git a/packages/frontend/src/lib/components/DetectionPacksGalleryDialog.svelte b/packages/frontend/src/lib/components/DetectionPacksGalleryDialog.svelte index c46df9c5..2b1f094e 100644 --- a/packages/frontend/src/lib/components/DetectionPacksGalleryDialog.svelte +++ b/packages/frontend/src/lib/components/DetectionPacksGalleryDialog.svelte @@ -228,7 +228,7 @@ {pack.category} - {pack.rules.length.toLocaleString()} rules + {pack.rules.length.toLocaleString('en-US')} rules
diff --git a/packages/frontend/src/lib/components/ExportLogsDialog.svelte b/packages/frontend/src/lib/components/ExportLogsDialog.svelte index 1dd1d930..20f02979 100644 --- a/packages/frontend/src/lib/components/ExportLogsDialog.svelte +++ b/packages/frontend/src/lib/components/ExportLogsDialog.svelte @@ -74,7 +74,7 @@ const toDate = to ? new Date(to) : null; const formatDate = (d: Date) => { - return d.toLocaleString(undefined, { + return d.toLocaleString('en-US', { month: "short", day: "numeric", hour: "2-digit", @@ -124,7 +124,7 @@ async function handleExport() { if (exportLimit < 1 || exportLimit > maxExportable) { - toastStore.error(`Please enter a number between 1 and ${maxExportable.toLocaleString()}`); + toastStore.error(`Please enter a number between 1 and ${maxExportable.toLocaleString('en-US')}`); return; } @@ -179,7 +179,7 @@ downloadFile(csv, filename, "text/csv"); } - toastStore.success(`Exported ${allLogs.length.toLocaleString()} logs successfully`); + toastStore.success(`Exported ${allLogs.length.toLocaleString('en-US')} logs successfully`); open = false; } catch (error) { console.error("Export failed:", error); @@ -246,7 +246,7 @@ {/if}
Total Matching: - {totalLogs.toLocaleString()} logs + {totalLogs.toLocaleString('en-US')} logs
@@ -265,12 +265,12 @@ class="w-32" /> - / {maxExportable.toLocaleString()} max + / {maxExportable.toLocaleString('en-US')} max {#if totalLogs > MAX_EXPORT_LOGS}

- Maximum export limit is {MAX_EXPORT_LOGS.toLocaleString()} logs. + Maximum export limit is {MAX_EXPORT_LOGS.toLocaleString('en-US')} logs. Use time filters to narrow down your results.

{/if} @@ -341,7 +341,7 @@
Exporting... - {exportProgress.current.toLocaleString()} / {exportProgress.total.toLocaleString()} + {exportProgress.current.toLocaleString('en-US')} / {exportProgress.total.toLocaleString('en-US')}
@@ -364,7 +364,7 @@ Exporting... {:else} - Export {exportLimit.toLocaleString()} Logs + Export {exportLimit.toLocaleString('en-US')} Logs {/if} diff --git a/packages/frontend/src/lib/components/LogContextDialog.svelte b/packages/frontend/src/lib/components/LogContextDialog.svelte index 146ada8a..ddca78ca 100644 --- a/packages/frontend/src/lib/components/LogContextDialog.svelte +++ b/packages/frontend/src/lib/components/LogContextDialog.svelte @@ -115,7 +115,7 @@ } function formatTime(timestamp: string): string { - return new Date(timestamp).toLocaleString(); + return new Date(timestamp).toLocaleString('en-US'); } function getLevelColor(level: string): string { diff --git a/packages/frontend/src/lib/components/SigmaTreeMultiSelect.svelte b/packages/frontend/src/lib/components/SigmaTreeMultiSelect.svelte index b575d78f..ef5401e0 100644 --- a/packages/frontend/src/lib/components/SigmaTreeMultiSelect.svelte +++ b/packages/frontend/src/lib/components/SigmaTreeMultiSelect.svelte @@ -141,7 +141,7 @@ {#if totalSelected > 0}
- {totalSelected.toLocaleString()} item{totalSelected === 1 ? '' : 's'} selected + {totalSelected.toLocaleString('en-US')} item{totalSelected === 1 ? '' : 's'} selected
`; for (const p of params) { html += `
`; html += `${p.marker} ${p.seriesName}`; - html += `${Number(p.value).toLocaleString()}`; + html += `${Number(p.value).toLocaleString('en-US')}`; html += `
`; } return html; @@ -75,7 +75,7 @@ if (val % 1 !== 0) return ''; if (val >= 1000000) return `${(val / 1000000).toFixed(1)}M`; if (val >= 1000) return `${(val / 1000).toFixed(0)}k`; - return val.toLocaleString(); + return val.toLocaleString('en-US'); }, }, }, diff --git a/packages/frontend/src/lib/components/alerts/preview/PreviewSamples.svelte b/packages/frontend/src/lib/components/alerts/preview/PreviewSamples.svelte index 319335b7..a04204e6 100644 --- a/packages/frontend/src/lib/components/alerts/preview/PreviewSamples.svelte +++ b/packages/frontend/src/lib/components/alerts/preview/PreviewSamples.svelte @@ -44,7 +44,7 @@ function formatTime(dateStr: string): string { const d = new Date(dateStr); - return d.toLocaleString(undefined, { + return d.toLocaleString('en-US', { month: "short", day: "numeric", hour: "2-digit", @@ -55,7 +55,7 @@ function formatLogTime(dateStr: string): string { const d = new Date(dateStr); - return d.toLocaleTimeString(undefined, { + return d.toLocaleTimeString('en-US', { hour: "2-digit", minute: "2-digit", second: "2-digit", @@ -141,7 +141,7 @@ {#if incidents.length > 5}

- Showing 5 of {incidents.length.toLocaleString()} incidents + Showing 5 of {incidents.length.toLocaleString('en-US')} incidents

{/if} {/if} diff --git a/packages/frontend/src/lib/components/alerts/preview/PreviewSummary.svelte b/packages/frontend/src/lib/components/alerts/preview/PreviewSummary.svelte index 4a43b0ec..3c64e0d2 100644 --- a/packages/frontend/src/lib/components/alerts/preview/PreviewSummary.svelte +++ b/packages/frontend/src/lib/components/alerts/preview/PreviewSummary.svelte @@ -75,7 +75,7 @@
{#if totalIncidents > 0} - {totalIncidents.toLocaleString()} + {totalIncidents.toLocaleString('en-US')} incident{totalIncidents !== 1 ? "s" : ""} in the last {rangeDays} day{rangeDays !== 1 ? "s" : ""} diff --git a/packages/frontend/src/lib/components/alerts/preview/PreviewTimeline.svelte b/packages/frontend/src/lib/components/alerts/preview/PreviewTimeline.svelte index 62fc60f2..45418e11 100644 --- a/packages/frontend/src/lib/components/alerts/preview/PreviewTimeline.svelte +++ b/packages/frontend/src/lib/components/alerts/preview/PreviewTimeline.svelte @@ -78,7 +78,7 @@ // Format times for x-axis const times = dataPoints.map((d) => - d.time.toLocaleString(undefined, { + d.time.toLocaleString('en-US', { month: "short", day: "numeric", hour: "2-digit", @@ -139,7 +139,7 @@ ...axisStyle.axisLabel, formatter: (val: number) => { if (val % 1 !== 0) return ''; - return val.toLocaleString(); + return val.toLocaleString('en-US'); } }, }, @@ -229,8 +229,8 @@ class="h-[200px] md:h-[250px] w-full" >

- {incidents.length.toLocaleString()} incident{incidents.length !== 1 ? "s" : ""} detected - - dashed line shows threshold ({threshold.toLocaleString()} logs) + {incidents.length.toLocaleString('en-US')} incident{incidents.length !== 1 ? "s" : ""} detected + - dashed line shows threshold ({threshold.toLocaleString('en-US')} logs)

{/if} diff --git a/packages/frontend/src/lib/components/custom-dashboards/panels/ActivityOverviewPanel.svelte b/packages/frontend/src/lib/components/custom-dashboards/panels/ActivityOverviewPanel.svelte index 45bd0d41..fe5a5d04 100644 --- a/packages/frontend/src/lib/components/custom-dashboards/panels/ActivityOverviewPanel.svelte +++ b/packages/frontend/src/lib/components/custom-dashboards/panels/ActivityOverviewPanel.svelte @@ -56,9 +56,9 @@ function formatTimeLabel(time: string, bucket: 'hour' | 'day'): string { const d = new Date(time); if (bucket === 'day') { - return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); + return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); } - return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false }); + return d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }); } function buildOption(): echarts.EChartsOption { diff --git a/packages/frontend/src/lib/components/custom-dashboards/panels/DetectionEventsPanel.svelte b/packages/frontend/src/lib/components/custom-dashboards/panels/DetectionEventsPanel.svelte index ed2917ed..01ea5cf7 100644 --- a/packages/frontend/src/lib/components/custom-dashboards/panels/DetectionEventsPanel.svelte +++ b/packages/frontend/src/lib/components/custom-dashboards/panels/DetectionEventsPanel.svelte @@ -29,7 +29,7 @@ const typed = $derived(data as DetectionEventsData | null); function fmtTime(t: string): string { - return new Date(t).toLocaleString(undefined, { + return new Date(t).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', diff --git a/packages/frontend/src/lib/components/custom-dashboards/panels/LiveLogStreamPanel.svelte b/packages/frontend/src/lib/components/custom-dashboards/panels/LiveLogStreamPanel.svelte index ab44b6a4..55bbf40b 100644 --- a/packages/frontend/src/lib/components/custom-dashboards/panels/LiveLogStreamPanel.svelte +++ b/packages/frontend/src/lib/components/custom-dashboards/panels/LiveLogStreamPanel.svelte @@ -26,7 +26,7 @@ const typed = $derived(data as LiveLogStreamSnapshot | null); function formatTime(time: string): string { - return new Date(time).toLocaleTimeString(undefined, { + return new Date(time).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', diff --git a/packages/frontend/src/lib/components/custom-dashboards/panels/MetricChartPanel.svelte b/packages/frontend/src/lib/components/custom-dashboards/panels/MetricChartPanel.svelte index bc480f6d..e4011f43 100644 --- a/packages/frontend/src/lib/components/custom-dashboards/panels/MetricChartPanel.svelte +++ b/packages/frontend/src/lib/components/custom-dashboards/panels/MetricChartPanel.svelte @@ -31,7 +31,7 @@ const typed = $derived(data as MetricChartData | null); function fmtTime(t: string): string { - return new Date(t).toLocaleTimeString(undefined, { + return new Date(t).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false, diff --git a/packages/frontend/src/lib/components/custom-dashboards/panels/TimeSeriesPanel.svelte b/packages/frontend/src/lib/components/custom-dashboards/panels/TimeSeriesPanel.svelte index b138e616..6e6fae32 100644 --- a/packages/frontend/src/lib/components/custom-dashboards/panels/TimeSeriesPanel.svelte +++ b/packages/frontend/src/lib/components/custom-dashboards/panels/TimeSeriesPanel.svelte @@ -37,7 +37,7 @@ const typedData = $derived(data as TimeSeriesPanelData | null); function formatTimeLabel(time: string): string { - return new Date(time).toLocaleTimeString(undefined, { + return new Date(time).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false, diff --git a/packages/frontend/src/lib/components/custom-dashboards/panels/TopNTablePanel.svelte b/packages/frontend/src/lib/components/custom-dashboards/panels/TopNTablePanel.svelte index e7b71ec3..25d66c78 100644 --- a/packages/frontend/src/lib/components/custom-dashboards/panels/TopNTablePanel.svelte +++ b/packages/frontend/src/lib/components/custom-dashboards/panels/TopNTablePanel.svelte @@ -38,7 +38,7 @@

{row.key}

- {row.count.toLocaleString()} + {row.count.toLocaleString('en-US')} {config.dimension === 'service' ? 'logs' : 'occurrences'}

diff --git a/packages/frontend/src/lib/components/custom-dashboards/panels/TraceLatencyPanel.svelte b/packages/frontend/src/lib/components/custom-dashboards/panels/TraceLatencyPanel.svelte index cc8f281f..9d4b36c5 100644 --- a/packages/frontend/src/lib/components/custom-dashboards/panels/TraceLatencyPanel.svelte +++ b/packages/frontend/src/lib/components/custom-dashboards/panels/TraceLatencyPanel.svelte @@ -37,7 +37,7 @@ const typed = $derived(data as TraceLatencyData | null); function fmtTime(t: string): string { - return new Date(t).toLocaleTimeString(undefined, { + return new Date(t).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false, diff --git a/packages/frontend/src/lib/components/custom-dashboards/panels/TraceVolumePanel.svelte b/packages/frontend/src/lib/components/custom-dashboards/panels/TraceVolumePanel.svelte index 04d821ca..7596f7fc 100644 --- a/packages/frontend/src/lib/components/custom-dashboards/panels/TraceVolumePanel.svelte +++ b/packages/frontend/src/lib/components/custom-dashboards/panels/TraceVolumePanel.svelte @@ -33,9 +33,9 @@ function formatTimeLabel(time: string, bucket: 'hour' | 'day'): string { const d = new Date(time); if (bucket === 'day') { - return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); + return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); } - return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false }); + return d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }); } function buildOption(): echarts.EChartsOption { diff --git a/packages/frontend/src/lib/components/dashboard/LogsChart.svelte b/packages/frontend/src/lib/components/dashboard/LogsChart.svelte index 589b7b6a..914a9174 100644 --- a/packages/frontend/src/lib/components/dashboard/LogsChart.svelte +++ b/packages/frontend/src/lib/components/dashboard/LogsChart.svelte @@ -30,7 +30,7 @@ let chart: echarts.ECharts | null = null; function formatTimeLabel(time: string): string { - return new Date(time).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false }); + return new Date(time).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }); } function buildEventSeries(): echarts.SeriesOption[] { @@ -150,7 +150,7 @@ ...axisStyle.axisLabel, formatter: (value: number) => { if (value % 1 !== 0) return ''; - return value.toLocaleString(); + return value.toLocaleString('en-US'); } } }, diff --git a/packages/frontend/src/lib/components/dashboard/TopServicesWidget.svelte b/packages/frontend/src/lib/components/dashboard/TopServicesWidget.svelte index 0315de57..09f32821 100644 --- a/packages/frontend/src/lib/components/dashboard/TopServicesWidget.svelte +++ b/packages/frontend/src/lib/components/dashboard/TopServicesWidget.svelte @@ -38,7 +38,7 @@

{service.name}

-

{service.count.toLocaleString()} logs

+

{service.count.toLocaleString('en-US')} logs

{service.percentage.toFixed(2)}% diff --git a/packages/frontend/src/lib/components/exceptions/ErrorGroupCard.svelte b/packages/frontend/src/lib/components/exceptions/ErrorGroupCard.svelte index 8df76d5b..804ce747 100644 --- a/packages/frontend/src/lib/components/exceptions/ErrorGroupCard.svelte +++ b/packages/frontend/src/lib/components/exceptions/ErrorGroupCard.svelte @@ -18,7 +18,7 @@ function formatDate(dateStr: string | Date): string { const date = typeof dateStr === 'string' ? new Date(dateStr) : dateStr; - return date.toLocaleDateString(undefined, { + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', diff --git a/packages/frontend/src/lib/components/metrics/MetricCard.svelte b/packages/frontend/src/lib/components/metrics/MetricCard.svelte index eecc27c9..65642416 100644 --- a/packages/frontend/src/lib/components/metrics/MetricCard.svelte +++ b/packages/frontend/src/lib/components/metrics/MetricCard.svelte @@ -75,7 +75,7 @@ const tooltipStyle = getTooltipStyle(); const buckets = timeseries.timeseries.map(p => { const d = typeof p.bucket === 'string' ? new Date(p.bucket) : p.bucket; - return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false }); + return d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }); }); const values = timeseries.timeseries.map(p => p.value); diff --git a/packages/frontend/src/lib/components/metrics/SignalChart.svelte b/packages/frontend/src/lib/components/metrics/SignalChart.svelte index 0f740692..aa304747 100644 --- a/packages/frontend/src/lib/components/metrics/SignalChart.svelte +++ b/packages/frontend/src/lib/components/metrics/SignalChart.svelte @@ -117,7 +117,7 @@ boundaryGap: false, data: allBuckets.map(b => { const d = new Date(b); - return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false }); + return d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }); }), ...axisStyle, }, diff --git a/packages/frontend/src/lib/components/onboarding/steps/FirstLogStep.svelte b/packages/frontend/src/lib/components/onboarding/steps/FirstLogStep.svelte index 40059a7b..fb8a5a73 100644 --- a/packages/frontend/src/lib/components/onboarding/steps/FirstLogStep.svelte +++ b/packages/frontend/src/lib/components/onboarding/steps/FirstLogStep.svelte @@ -221,7 +221,7 @@
Time: - {new Date(receivedLog.time).toLocaleString()} + {new Date(receivedLog.time).toLocaleString('en-US')}
Level: diff --git a/packages/frontend/src/lib/components/siem/dashboard/TimelineWidget.svelte b/packages/frontend/src/lib/components/siem/dashboard/TimelineWidget.svelte index 651f0971..d83a80dd 100644 --- a/packages/frontend/src/lib/components/siem/dashboard/TimelineWidget.svelte +++ b/packages/frontend/src/lib/components/siem/dashboard/TimelineWidget.svelte @@ -21,18 +21,18 @@ const date = typeof timestamp === 'string' ? new Date(timestamp) : timestamp; if (timeRange === '24h') { - return date.toLocaleTimeString(undefined, { + return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false, }); } else if (timeRange === '7d') { - return date.toLocaleDateString(undefined, { + return date.toLocaleDateString('en-US', { weekday: 'short', day: 'numeric', }); } else { - return date.toLocaleDateString(undefined, { + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', }); @@ -50,7 +50,7 @@ const p = params as echarts.DefaultLabelFormatterCallbackParams[]; if (p && p.length > 0) { const date = new Date(data?.[p[0].dataIndex]?.timestamp || ''); - return `${date.toLocaleString(undefined)}
${p[0].value} detections`; + return `${date.toLocaleString('en-US')}
${p[0].value} detections`; } return ''; }, @@ -77,7 +77,7 @@ ...axisStyle.axisLabel, formatter: (value: number) => { if (value % 1 !== 0) return ''; - return value.toLocaleString(); + return value.toLocaleString('en-US'); } } }, series: [ diff --git a/packages/frontend/src/lib/components/siem/enrichment/IpReputationCard.svelte b/packages/frontend/src/lib/components/siem/enrichment/IpReputationCard.svelte index 72c6227e..cc42cb29 100644 --- a/packages/frontend/src/lib/components/siem/enrichment/IpReputationCard.svelte +++ b/packages/frontend/src/lib/components/siem/enrichment/IpReputationCard.svelte @@ -74,7 +74,7 @@ if (diffMins < 60) return `${diffMins}m ago`; if (diffHours < 24) return `${diffHours}h ago`; if (diffDays < 7) return `${diffDays}d ago`; - return date.toLocaleDateString(); + return date.toLocaleDateString('en-US'); } function toggleExpand(ip: string) { diff --git a/packages/frontend/src/lib/components/siem/incidents/DetectionEventsList.svelte b/packages/frontend/src/lib/components/siem/incidents/DetectionEventsList.svelte index 781e2a34..814eda1e 100644 --- a/packages/frontend/src/lib/components/siem/incidents/DetectionEventsList.svelte +++ b/packages/frontend/src/lib/components/siem/incidents/DetectionEventsList.svelte @@ -39,7 +39,7 @@ function formatTime(dateStr: string | Date): string { const date = typeof dateStr === 'string' ? new Date(dateStr) : dateStr; - return date.toLocaleTimeString(undefined, { + return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', @@ -48,7 +48,7 @@ function formatDate(dateStr: string | Date): string { const date = typeof dateStr === 'string' ? new Date(dateStr) : dateStr; - return date.toLocaleDateString(undefined, { + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', }); @@ -83,7 +83,7 @@ Detection Events - ({detections.length.toLocaleString()}) + ({detections.length.toLocaleString('en-US')}) diff --git a/packages/frontend/src/lib/components/siem/incidents/IncidentCard.svelte b/packages/frontend/src/lib/components/siem/incidents/IncidentCard.svelte index 9b3a490c..655a36bb 100644 --- a/packages/frontend/src/lib/components/siem/incidents/IncidentCard.svelte +++ b/packages/frontend/src/lib/components/siem/incidents/IncidentCard.svelte @@ -20,7 +20,7 @@ function formatDate(dateStr: string | Date): string { const date = typeof dateStr === 'string' ? new Date(dateStr) : dateStr; - return date.toLocaleDateString(undefined, { + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', diff --git a/packages/frontend/src/lib/components/siem/incidents/IncidentCommentsThread.svelte b/packages/frontend/src/lib/components/siem/incidents/IncidentCommentsThread.svelte index 97f3422d..0fe77011 100644 --- a/packages/frontend/src/lib/components/siem/incidents/IncidentCommentsThread.svelte +++ b/packages/frontend/src/lib/components/siem/incidents/IncidentCommentsThread.svelte @@ -25,7 +25,7 @@ function formatDate(dateStr: string | Date): string { const date = typeof dateStr === 'string' ? new Date(dateStr) : dateStr; - return date.toLocaleDateString(undefined, { + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', diff --git a/packages/frontend/src/lib/components/siem/incidents/IncidentHistoryTimeline.svelte b/packages/frontend/src/lib/components/siem/incidents/IncidentHistoryTimeline.svelte index ec524a86..24ab2124 100644 --- a/packages/frontend/src/lib/components/siem/incidents/IncidentHistoryTimeline.svelte +++ b/packages/frontend/src/lib/components/siem/incidents/IncidentHistoryTimeline.svelte @@ -65,7 +65,7 @@ function formatDate(dateStr: string | Date): string { const date = typeof dateStr === 'string' ? new Date(dateStr) : dateStr; - return date.toLocaleDateString(undefined, { + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', diff --git a/packages/frontend/src/lib/utils/datetime.ts b/packages/frontend/src/lib/utils/datetime.ts index d57c8d8e..9b39f4e7 100644 --- a/packages/frontend/src/lib/utils/datetime.ts +++ b/packages/frontend/src/lib/utils/datetime.ts @@ -30,7 +30,7 @@ export function formatDateTimeLong(date: Date | string): string { return 'Invalid date'; } - return dateObj.toLocaleString(undefined, { + return dateObj.toLocaleString('en-US', { year: 'numeric', month: 'short', day: 'numeric', diff --git a/packages/frontend/src/routes/dashboard/admin/+page.svelte b/packages/frontend/src/routes/dashboard/admin/+page.svelte index 2b9eb031..9f574a51 100644 --- a/packages/frontend/src/routes/dashboard/admin/+page.svelte +++ b/packages/frontend/src/routes/dashboard/admin/+page.svelte @@ -223,7 +223,7 @@
- {lastRefreshed.toLocaleTimeString()} + {lastRefreshed.toLocaleTimeString('en-US')}
{/if} diff --git a/packages/frontend/src/routes/dashboard/admin/organizations/+page.svelte b/packages/frontend/src/routes/dashboard/admin/organizations/+page.svelte index cc55087a..8634e861 100644 --- a/packages/frontend/src/routes/dashboard/admin/organizations/+page.svelte +++ b/packages/frontend/src/routes/dashboard/admin/organizations/+page.svelte @@ -109,7 +109,7 @@ function formatDate(dateString: string | Date) { const date = typeof dateString === 'string' ? new Date(dateString) : dateString; - return date.toLocaleString(); + return date.toLocaleString('en-US'); } onMount(() => { diff --git a/packages/frontend/src/routes/dashboard/admin/organizations/[id]/+page.svelte b/packages/frontend/src/routes/dashboard/admin/organizations/[id]/+page.svelte index 2a68e3bc..17b0e394 100644 --- a/packages/frontend/src/routes/dashboard/admin/organizations/[id]/+page.svelte +++ b/packages/frontend/src/routes/dashboard/admin/organizations/[id]/+page.svelte @@ -203,7 +203,7 @@ } function formatDate(dateString: string) { - return new Date(dateString).toLocaleString(); + return new Date(dateString).toLocaleString('en-US'); } function loadAll() { diff --git a/packages/frontend/src/routes/dashboard/admin/projects/+page.svelte b/packages/frontend/src/routes/dashboard/admin/projects/+page.svelte index 16ef0e65..7f9e4e04 100644 --- a/packages/frontend/src/routes/dashboard/admin/projects/+page.svelte +++ b/packages/frontend/src/routes/dashboard/admin/projects/+page.svelte @@ -100,7 +100,7 @@ function formatDate(dateStr: string | Date) { const date = typeof dateStr === 'string' ? new Date(dateStr) : dateStr; - return date.toLocaleDateString(undefined, { + return date.toLocaleDateString('en-US', { year: "numeric", month: "short", day: "numeric", diff --git a/packages/frontend/src/routes/dashboard/admin/projects/[id]/+page.svelte b/packages/frontend/src/routes/dashboard/admin/projects/[id]/+page.svelte index 258b0588..9a48205c 100644 --- a/packages/frontend/src/routes/dashboard/admin/projects/[id]/+page.svelte +++ b/packages/frontend/src/routes/dashboard/admin/projects/[id]/+page.svelte @@ -71,7 +71,7 @@ } function formatDate(date: string) { - return new Date(date).toLocaleString(undefined, { + return new Date(date).toLocaleString('en-US', { month: "short", day: "numeric", hour: "2-digit", diff --git a/packages/frontend/src/routes/dashboard/admin/system-health/+page.svelte b/packages/frontend/src/routes/dashboard/admin/system-health/+page.svelte index 6c4a9d0b..8024150c 100644 --- a/packages/frontend/src/routes/dashboard/admin/system-health/+page.svelte +++ b/packages/frontend/src/routes/dashboard/admin/system-health/+page.svelte @@ -183,7 +183,7 @@
- {lastRefreshed.toLocaleTimeString()} + {lastRefreshed.toLocaleTimeString('en-US')}
@@ -980,7 +980,7 @@ {monitor.status?.lastCheckedAt - ? new Date(monitor.status.lastCheckedAt).toLocaleString() + ? new Date(monitor.status.lastCheckedAt).toLocaleString('en-US') : '-'} @@ -1116,7 +1116,7 @@ -

{new Date(incident.createdAt).toLocaleString()}

+

{new Date(incident.createdAt).toLocaleString('en-US')}

{/each} @@ -1236,7 +1236,7 @@

{m.description}

{/if}

- {new Date(m.scheduledStart).toLocaleString()} - {new Date(m.scheduledEnd).toLocaleString()} + {new Date(m.scheduledStart).toLocaleString('en-US')} - {new Date(m.scheduledEnd).toLocaleString('en-US')}

{#if m.autoUpdateStatus}

Monitor alerts suppressed

diff --git a/packages/frontend/src/routes/dashboard/monitoring/[id]/+page.svelte b/packages/frontend/src/routes/dashboard/monitoring/[id]/+page.svelte index 96d26018..934830e2 100644 --- a/packages/frontend/src/routes/dashboard/monitoring/[id]/+page.svelte +++ b/packages/frontend/src/routes/dashboard/monitoring/[id]/+page.svelte @@ -81,7 +81,7 @@ function formatDate(d: string | null | undefined) { if (!d) return '-'; - return new Date(d).toLocaleString(); + return new Date(d).toLocaleString('en-US'); } function formatResponseTime(ms: number | null | undefined) { @@ -185,7 +185,7 @@

30-day uptime

- {recentUptime[0]?.bucket ? new Date(recentUptime[0].bucket).toLocaleDateString() : ''} – today + {recentUptime[0]?.bucket ? new Date(recentUptime[0].bucket).toLocaleDateString('en-US') : ''} – today
@@ -193,7 +193,7 @@
{/each}
@@ -309,7 +309,7 @@ {/if} - {new Date(result.time).toLocaleString()} + {new Date(result.time).toLocaleString('en-US')} {result.status === 'up' ? 'Up' : 'Down'} diff --git a/packages/frontend/src/routes/dashboard/projects/[id]/sessions/+page.svelte b/packages/frontend/src/routes/dashboard/projects/[id]/sessions/+page.svelte index 2b655d8b..dc057e6e 100644 --- a/packages/frontend/src/routes/dashboard/projects/[id]/sessions/+page.svelte +++ b/packages/frontend/src/routes/dashboard/projects/[id]/sessions/+page.svelte @@ -103,7 +103,7 @@ } function formatTimestamp(iso: string) { - return new Date(iso).toLocaleString(undefined, { + return new Date(iso).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', diff --git a/packages/frontend/src/routes/dashboard/projects/[id]/sessions/[sessionId]/+page.svelte b/packages/frontend/src/routes/dashboard/projects/[id]/sessions/[sessionId]/+page.svelte index 197fbab7..96ae8066 100644 --- a/packages/frontend/src/routes/dashboard/projects/[id]/sessions/[sessionId]/+page.svelte +++ b/packages/frontend/src/routes/dashboard/projects/[id]/sessions/[sessionId]/+page.svelte @@ -215,7 +215,7 @@ } function formatTime(iso: string): string { - return new Date(iso).toLocaleString(undefined, { + return new Date(iso).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', @@ -226,7 +226,7 @@ } function formatTimestamp(ts: number): string { - return new Date(ts).toLocaleTimeString(undefined, { + return new Date(ts).toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', diff --git a/packages/frontend/src/routes/dashboard/search/+page.svelte b/packages/frontend/src/routes/dashboard/search/+page.svelte index 5500eb3f..bb0f5c99 100644 --- a/packages/frontend/src/routes/dashboard/search/+page.svelte +++ b/packages/frontend/src/routes/dashboard/search/+page.svelte @@ -2082,7 +2082,7 @@
{#if totalLogs > 0} - Showing {((currentPage - 1) * pageSize + 1).toLocaleString()} to {Math.min(currentPage * pageSize, totalLogs).toLocaleString()} of {totalLogs.toLocaleString()} logs + Showing {((currentPage - 1) * pageSize + 1).toLocaleString('en-US')} to {Math.min(currentPage * pageSize, totalLogs).toLocaleString('en-US')} of {totalLogs.toLocaleString('en-US')} logs {:else} Showing {(currentPage - 1) * pageSize + 1} to {(currentPage - 1) * pageSize + logs.length} logs {/if} @@ -2191,7 +2191,7 @@
{:else} - Page {currentPage.toLocaleString()} + Page {currentPage.toLocaleString('en-US')} {/if}